package main import ( "io" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) type syncFixture struct { root string remote string work string other string } func makeSyncFixture(t *testing.T) syncFixture { t.Helper() root := t.TempDir() remote := filepath.Join(root, "remote.git") seed := filepath.Join(root, "seed") work := filepath.Join(root, "work") other := filepath.Join(root, "other") if err := os.MkdirAll(seed, 0o755); err != nil { t.Fatal(err) } git(t, root, "init", "--bare", "-q", "--initial-branch=main", remote) git(t, seed, "init", "-q", "--initial-branch=main") configureTestIdentity(t, seed) writeTestFile(t, filepath.Join(seed, "tracked.txt"), "initial\n") git(t, seed, "add", "tracked.txt") git(t, seed, "commit", "-qm", "initial") git(t, seed, "remote", "add", "origin", remote) git(t, seed, "push", "-qu", "origin", "main") git(t, root, "clone", "-q", remote, work) git(t, root, "clone", "-q", remote, other) configureTestIdentity(t, work) configureTestIdentity(t, other) git(t, work, "checkout", "-qb", "feature") writeTestFile(t, filepath.Join(work, "feature.txt"), "feature\n") git(t, work, "add", "feature.txt") git(t, work, "commit", "-qm", "feature") git(t, work, "push", "-qu", "origin", "feature") return syncFixture{root: root, remote: remote, work: work, other: other} } func configureTestIdentity(t *testing.T, path string) { t.Helper() git(t, path, "config", "user.name", "Test") git(t, path, "config", "user.email", "test@example.com") } func writeTestFile(t *testing.T, path, contents string) { t.Helper() if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatal(err) } } func advanceRemoteBranch(t *testing.T, fixture syncFixture, branch, filename string) { t.Helper() git(t, fixture.other, "fetch", "-q", "origin") git(t, fixture.other, "checkout", "-qB", branch, "origin/"+branch) writeTestFile(t, filepath.Join(fixture.other, filename), branch+" update\n") git(t, fixture.other, "add", filename) git(t, fixture.other, "commit", "-qm", "advance "+branch) git(t, fixture.other, "push", "-q", "origin", branch) } func gitText(t *testing.T, path string, args ...string) string { t.Helper() cmd := append([]string{"-C", path}, args...) output, err := exec.Command("git", cmd...).CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, output) } return strings.TrimSpace(string(output)) } func TestDirtyFeatureBranchStillAllowsInactiveMainUpdate(t *testing.T) { fixture := makeSyncFixture(t) writeTestFile(t, filepath.Join(fixture.work, "local-notes.txt"), "unfinished work\n") advanceRemoteBranch(t, fixture, "main", "main-remote.txt") beforeFeature := gitText(t, fixture.work, "rev-parse", "feature") report, err := syncRepository(fixture.work, time.Minute, true, nil) if err != nil { t.Fatalf("syncRepository(): %v\n%s", err, report.Message) } if got, want := gitText(t, fixture.work, "rev-parse", "main"), gitText(t, fixture.work, "rev-parse", "origin/main"); got != want { t.Fatalf("inactive main was not updated: got %s, want %s", got, want) } if got := gitText(t, fixture.work, "rev-parse", "feature"); got != beforeFeature { t.Fatalf("dirty checked-out feature branch moved: got %s, want %s", got, beforeFeature) } if got := gitText(t, fixture.work, "branch", "--show-current"); got != "feature" { t.Fatalf("checked-out branch = %q, want feature", got) } if _, err := os.Stat(filepath.Join(fixture.work, "local-notes.txt")); err != nil { t.Fatalf("dirty file was lost: %v", err) } assertBranchAction(t, report.Branches, "main", "updated") } func TestDirtyCheckedOutBranchIsProtectedWhenRemoteAdvances(t *testing.T) { fixture := makeSyncFixture(t) writeTestFile(t, filepath.Join(fixture.work, "feature.txt"), "unfinished feature edit\n") advanceRemoteBranch(t, fixture, "feature", "feature-remote.txt") before := gitText(t, fixture.work, "rev-parse", "feature") report, err := syncRepository(fixture.work, time.Minute, true, nil) if err != nil { t.Fatalf("syncRepository(): %v\n%s", err, report.Message) } if got := gitText(t, fixture.work, "rev-parse", "feature"); got != before { t.Fatalf("dirty checked-out branch moved: got %s, want %s", got, before) } if upstream := gitText(t, fixture.work, "rev-parse", "origin/feature"); upstream == before { t.Fatal("fixture did not fetch the advanced remote feature branch") } contents, err := os.ReadFile(filepath.Join(fixture.work, "feature.txt")) if err != nil || string(contents) != "unfinished feature edit\n" { t.Fatalf("dirty edit changed: contents=%q err=%v", contents, err) } assertBranchAction(t, report.Branches, "feature", "protected") } func TestCleanCheckedOutBranchFastForwards(t *testing.T) { fixture := makeSyncFixture(t) advanceRemoteBranch(t, fixture, "feature", "feature-remote.txt") report, err := syncRepository(fixture.work, time.Minute, false, nil) if err != nil { t.Fatalf("syncRepository(): %v\n%s", err, report.Message) } if got, want := gitText(t, fixture.work, "rev-parse", "feature"), gitText(t, fixture.work, "rev-parse", "origin/feature"); got != want { t.Fatalf("checked-out feature was not fast-forwarded: got %s, want %s", got, want) } if _, err := os.Stat(filepath.Join(fixture.work, "feature-remote.txt")); err != nil { t.Fatalf("fast-forwarded file is missing from worktree: %v", err) } assertBranchAction(t, report.Branches, "feature", "updated") } func TestDivergentBranchIsWarnedAndLeftUnchanged(t *testing.T) { fixture := makeSyncFixture(t) writeTestFile(t, filepath.Join(fixture.work, "local-only.txt"), "local commit\n") git(t, fixture.work, "add", "local-only.txt") git(t, fixture.work, "commit", "-qm", "local feature work") localOID := gitText(t, fixture.work, "rev-parse", "feature") advanceRemoteBranch(t, fixture, "feature", "remote-only.txt") report, err := syncRepository(fixture.work, time.Minute, false, nil) if err != nil { t.Fatalf("syncRepository(): %v\n%s", err, report.Message) } if got := gitText(t, fixture.work, "rev-parse", "feature"); got != localOID { t.Fatalf("divergent feature branch moved: got %s, want %s", got, localOID) } assertBranchAction(t, report.Branches, "feature", "warning") } func TestMergedInactiveBranchWithDeletedUpstreamIsDeleted(t *testing.T) { fixture := makeSyncFixture(t) git(t, fixture.work, "branch", "old", "main") git(t, fixture.work, "push", "-qu", "origin", "old") git(t, fixture.work, "push", "-q", "origin", "--delete", "old") report, err := syncRepository(fixture.work, time.Minute, false, nil) if err != nil { t.Fatalf("syncRepository(): %v\n%s", err, report.Message) } if err := exec.Command("git", "-C", fixture.work, "show-ref", "--verify", "--quiet", "refs/heads/old").Run(); err == nil { t.Fatal("merged branch with deleted configured upstream still exists") } assertBranchAction(t, report.Branches, "old", "deleted") } func TestBranchCheckedOutInAnotherWorktreeIsProtected(t *testing.T) { fixture := makeSyncFixture(t) mainWorktree := filepath.Join(fixture.root, "main-worktree") git(t, fixture.work, "worktree", "add", "-q", mainWorktree, "main") before := gitText(t, fixture.work, "rev-parse", "main") advanceRemoteBranch(t, fixture, "main", "main-remote.txt") report, err := syncRepository(fixture.work, time.Minute, false, nil) if err != nil { t.Fatalf("syncRepository(): %v\n%s", err, report.Message) } if got := gitText(t, fixture.work, "rev-parse", "main"); got != before { t.Fatalf("branch checked out in another worktree moved: got %s, want %s", got, before) } assertBranchAction(t, report.Branches, "main", "protected") } func assertBranchAction(t *testing.T, results []branchSyncResult, branch, action string) { t.Helper() for _, result := range results { if result.Branch == branch && result.Action == action { return } } t.Fatalf("missing %s action for branch %s in %+v", action, branch, results) } // A local branch with neither a configured upstream nor a same-named branch on // the remote cannot be fast-forwarded. It used to be dropped from the report // entirely, so the run claimed "Already up to date" while doing nothing at all. func TestLocalBranchWithoutUpstreamIsReportedNotSilentlyDropped(t *testing.T) { fixture := makeSyncFixture(t) git(t, fixture.work, "branch", "stray", "main") strayBefore := gitText(t, fixture.work, "rev-parse", "stray") advanceRemoteBranch(t, fixture, "main", "main-remote.txt") report, err := syncRepository(fixture.work, time.Minute, false, nil) if err != nil { t.Fatalf("syncRepository(): %v\n%s", err, report.Message) } assertBranchAction(t, report.Branches, "stray", "untracked") if report.Message == "Already up to date." { t.Fatalf("report claimed to be up to date while a branch was left alone: %q", report.Message) } // The untracked branch must genuinely be left untouched, even though a // sibling branch in the same run did move. if got := gitText(t, fixture.work, "rev-parse", "stray"); got != strayBefore { t.Fatalf("stray moved: got %s, want %s", got, strayBefore) } // A branch it *can* fix is still fixed in the same run. if got, want := gitText(t, fixture.work, "rev-parse", "main"), gitText(t, fixture.work, "rev-parse", "origin/main"); got != want { t.Fatalf("inactive main was not updated: got %s, want %s", got, want) } } func TestRemoteBranchWithoutLocalCounterpartIsCreated(t *testing.T) { fixture := makeSyncFixture(t) git(t, fixture.work, "push", "-qu", "origin", "main:refs/heads/brand-new") report, err := syncRepositoryWith(fixture.work, time.Minute, false, nil, syncOptions{CreateMissing: true}) if err != nil { t.Fatalf("syncRepositoryWith(): %v\n%s", err, report.Message) } assertBranchAction(t, report.Branches, "brand-new", "created") if got, want := gitText(t, fixture.work, "rev-parse", "brand-new"), gitText(t, fixture.work, "rev-parse", "origin/main"); got != want { t.Fatalf("created branch is at the wrong commit: got %s, want %s", got, want) } if upstream := gitText(t, fixture.work, "rev-parse", "--abbrev-ref", "brand-new@{upstream}"); upstream != "origin/brand-new" { t.Fatalf("created branch upstream = %q, want origin/brand-new", upstream) } // Creating a branch must never move the branch that is checked out. if got := gitText(t, fixture.work, "branch", "--show-current"); got != "feature" { t.Fatalf("checked-out branch = %q, want feature", got) } } // Like hub, stack-sync does not create remote-only branches unless asked. func TestRemoteBranchIsNotCreatedByDefault(t *testing.T) { fixture := makeSyncFixture(t) git(t, fixture.work, "push", "-qu", "origin", "main:refs/heads/brand-new") if _, err := syncRepositoryWith(fixture.work, time.Minute, false, nil, syncOptions{CreateMissing: false}); err != nil { t.Fatalf("syncRepositoryWith(): %v", err) } if err := exec.Command("git", "-C", fixture.work, "show-ref", "--verify", "--quiet", "refs/heads/brand-new").Run(); err == nil { t.Fatal("brand-new was created even though CreateMissing is false") } // The zero value of syncOptions must also mean "do not create". if _, err := syncRepository(fixture.work, time.Minute, false, nil); err != nil { t.Fatalf("syncRepository(): %v", err) } if err := exec.Command("git", "-C", fixture.work, "show-ref", "--verify", "--quiet", "refs/heads/brand-new").Run(); err == nil { t.Fatal("brand-new was created by the default syncRepository()") } } // The flag is opt-in. Guard against the default silently flipping back on, which // previously happened because the value was resolved before flags were parsed. func TestCreateMissingFlagIsOptIn(t *testing.T) { opts, err := parseFlags("sync", nil, io.Discard) if err != nil { t.Fatalf("parseFlags(): %v", err) } if opts.createMissing { t.Fatal("--create-missing must default to false") } opts, err = parseFlags("sync", []string{"--create-missing"}, io.Discard) if err != nil { t.Fatalf("parseFlags(): %v", err) } if !opts.createMissing { t.Fatal("--create-missing did not enable branch creation") } } // git shortens refs/remotes/origin/HEAD to the shortest unambiguous form, which // is plain "origin". Treating that as a branch name produced a bogus // "create branch origin" failure. func TestRemoteHeadSymrefIsNotListedAsABranch(t *testing.T) { fixture := makeSyncFixture(t) git(t, fixture.work, "fetch", "-q", "origin") branches, err := remoteTrackingBranches(t.Context(), fixture.work, "origin") if err != nil { t.Fatalf("remoteTrackingBranches(): %v", err) } var names []string for _, branch := range branches { names = append(names, branch.name) if branch.name == "origin" || branch.name == "HEAD" { t.Fatalf("remote HEAD symref leaked into the branch list as %q: %v", branch.name, names) } } if len(names) == 0 { t.Fatal("expected the remote's real branches to be listed") } } // refs/remotes//HEAD is a local convenience symref and is missing from // plenty of real clones. Without resolving the default branch another way, merged // branches with a deleted upstream were kept with a vague warning. func TestRemoteDefaultBranchFallsBackToAskingTheRemote(t *testing.T) { fixture := makeSyncFixture(t) git(t, fixture.work, "remote", "set-head", "origin", "-d") if err := exec.Command("git", "-C", fixture.work, "symbolic-ref", "--quiet", "--verify", "refs/remotes/origin/HEAD").Run(); err == nil { t.Skip("git kept the remote HEAD symref; fallback not exercised") } ref, branch, err := remoteDefaultBranch(t.Context(), fixture.work, "origin") if err != nil { t.Fatalf("remoteDefaultBranch(): %v", err) } if branch != "main" { t.Fatalf("default branch = %q, want main", branch) } if ref == "" { t.Fatal("remoteDefaultBranch() returned no ref") } } func TestMergedBranchIsDeletedWhenRemoteHeadSymrefIsMissing(t *testing.T) { fixture := makeSyncFixture(t) git(t, fixture.work, "branch", "old", "main") git(t, fixture.work, "push", "-qu", "origin", "old") git(t, fixture.work, "push", "-q", "origin", "--delete", "old") git(t, fixture.work, "remote", "set-head", "origin", "-d") report, err := syncRepository(fixture.work, time.Minute, false, nil) if err != nil { t.Fatalf("syncRepository(): %v\n%s", err, report.Message) } if err := exec.Command("git", "-C", fixture.work, "show-ref", "--verify", "--quiet", "refs/heads/old").Run(); err == nil { t.Fatalf("merged branch was kept without origin/HEAD: %s", report.Message) } assertBranchAction(t, report.Branches, "old", "deleted") }