From f72fb30fc6a238ca3cdb23166c56c0a6a1476812 Mon Sep 17 00:00:00 2001 From: Dylan Arbour Date: Sun, 11 Oct 2026 14:29:35 -0400 Subject: [PATCH 1/2] Improve test coverage --- main_test.go | 274 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 256 insertions(+), 18 deletions(-) diff --git a/main_test.go b/main_test.go index 024d44c..62f7d8a 100644 --- a/main_test.go +++ b/main_test.go @@ -1,12 +1,41 @@ package main import ( + "io" + "os" + "regexp" "strings" "testing" "github.com/arbourd/git-sync/internal/gittest" ) +var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// runSync calls sync() from the current directory and returns its error along +// with everything it printed to stdout, ANSI colors stripped. +func runSync(t *testing.T) (string, error) { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + orig := os.Stdout + os.Stdout = w + defer func() { os.Stdout = orig }() + + done := make(chan string) + go func() { + b, _ := io.ReadAll(r) + done <- string(b) + }() + + syncErr := sync() + w.Close() + return ansi.ReplaceAllString(<-done, ""), syncErr +} + func TestSync_FastForwardNonCurrentBranch(t *testing.T) { remote := gittest.NewBare(t) local := remote.Clone(t) @@ -20,14 +49,19 @@ func TestSync_FastForwardNonCurrentBranch(t *testing.T) { local.Git(t, "fetch", "origin") local.Git(t, "checkout", "-b", "feature", "--track", "origin/feature") local.Checkout(t, "main") + oldSHA := local.Git(t, "rev-parse", "refs/heads/feature") newSHA := peer.Commit(t, "feature more") peer.Push(t, "origin", "feature") local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v", err) + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "Updated branch feature (was " + oldSHA[:7] + ")"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) } if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != newSHA { @@ -50,14 +84,19 @@ func TestSync_FastForwardCurrentBranch(t *testing.T) { local.Git(t, "fetch", "origin") local.Git(t, "checkout", "-b", "feature", "--track", "origin/feature") + oldSHA := local.Git(t, "rev-parse", "HEAD") newSHA := peer.Commit(t, "feature more") peer.Push(t, "origin", "feature") local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v", err) + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "Updated branch feature (was " + oldSHA[:7] + ")"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) } if got := local.Git(t, "rev-parse", "HEAD"); got != newSHA { @@ -76,8 +115,12 @@ func TestSync_IdenticalBranchIsNoOp(t *testing.T) { local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v", err) + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if out != "" { + t.Fatalf("output = %q, want none", out) } if got := local.Git(t, "rev-parse", "refs/heads/main"); got != wantSHA { @@ -103,13 +146,20 @@ func TestSync_DeletedUpstreamMergedBranchIsDeleted(t *testing.T) { local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v", err) + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "Deleted branch feature (was "; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) } if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "main" { t.Fatalf("current branch = %q, want %q (should check out default branch)", got, "main") } + if got := local.Git(t, "for-each-ref", "refs/remotes/origin/feature"); got != "" { + t.Fatalf("refs/remotes/origin/feature = %q, want pruned", got) + } branches := local.Git(t, "for-each-ref", "--format=%(refname:short)", "refs/heads/") if strings.Contains(branches, "feature") { @@ -135,13 +185,20 @@ func TestSync_DeletedUpstreamUnmergedBranchWarnsAndKeeps(t *testing.T) { local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v", err) + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "warning: 'feature' was deleted on origin, but appears not merged into 'main'"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) } if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != featureSHA { t.Fatalf("local refs/heads/feature = %q, want unchanged %q", got, featureSHA) } + if got := local.Git(t, "for-each-ref", "refs/remotes/origin/feature"); got != "" { + t.Fatalf("refs/remotes/origin/feature = %q, want pruned", got) + } } func TestSync_UntrackedBranchWithMatchingRemoteIsSynced(t *testing.T) { @@ -160,8 +217,12 @@ func TestSync_UntrackedBranchWithMatchingRemoteIsSynced(t *testing.T) { local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v", err) + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "Updated branch feature"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) } if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != newSHA { @@ -178,8 +239,12 @@ func TestSync_UntrackedBranchWithoutMatchingRemoteIsUntouched(t *testing.T) { local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v", err) + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if out != "" { + t.Fatalf("output = %q, want none", out) } if got := local.Git(t, "rev-parse", "refs/heads/solo"); got != wantSHA { @@ -209,8 +274,8 @@ func TestSync_ForceMovedTagIsIgnored(t *testing.T) { local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v, want nil (force-moved tags should not fail sync)", err) + if out, err := runSync(t); err != nil { + t.Fatalf("sync() error: %v, want nil (force-moved tags should not fail sync)\noutput: %s", err, out) } if got := local.Git(t, "rev-parse", "refs/remotes/origin/main"); got != newSHA { @@ -230,11 +295,184 @@ func TestSync_UnpushedCommitsWarnsAndKeeps(t *testing.T) { local.Chdir(t) - if err := sync(); err != nil { - t.Fatalf("sync() error: %v", err) + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "warning: 'feature' seems to contain unpushed commits"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) } if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != unpushedSHA { t.Fatalf("local refs/heads/feature = %q, want unchanged %q", got, unpushedSHA) } } + +func TestSync_DivergedBranchWarnsAndKeeps(t *testing.T) { + remote := gittest.NewBare(t) + peer := remote.Clone(t) + local := remote.Clone(t) + + peer.CreateBranch(t, "feature") + peer.Push(t, "origin", "feature") + + local.Git(t, "fetch", "origin") + local.Git(t, "checkout", "-b", "feature", "--track", "origin/feature") + localSHA := local.Commit(t, "local work") + + peer.Checkout(t, "feature") + peer.Commit(t, "remote work") + peer.Push(t, "origin", "feature") + + local.Chdir(t) + + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "warning: 'feature' seems to contain unpushed commits"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) + } + + if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != localSHA { + t.Fatalf("local refs/heads/feature = %q, want unchanged %q", got, localSHA) + } + if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "feature" { + t.Fatalf("current branch = %q, want %q", got, "feature") + } +} + +func TestSync_DeletedUpstreamUnmergedCurrentBranchStays(t *testing.T) { + remote := gittest.NewBare(t) + peer := remote.Clone(t) + local := remote.Clone(t) + + local.CreateBranch(t, "feature") + local.Checkout(t, "feature") + featureSHA := local.Commit(t, "feature work") + local.Push(t, "-u", "origin", "feature") + + peer.Git(t, "fetch", "origin") + peer.Push(t, "origin", "--delete", "feature") + + local.Chdir(t) + + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "warning: 'feature' was deleted on origin, but appears not merged into 'main'"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) + } + + if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "feature" { + t.Fatalf("current branch = %q, want %q (should not switch away from unmerged work)", got, "feature") + } + if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != featureSHA { + t.Fatalf("local refs/heads/feature = %q, want unchanged %q", got, featureSHA) + } +} + +func TestSync_MultipleBranchesAreHandledIndependently(t *testing.T) { + remote := gittest.NewBare(t) + peer := remote.Clone(t) + local := remote.Clone(t) + + peer.CreateBranch(t, "behind") + peer.CreateBranch(t, "merged") + peer.CreateBranch(t, "ahead") + peer.Push(t, "origin", "behind") + peer.Push(t, "origin", "merged") + peer.Push(t, "origin", "ahead") + + local.Git(t, "fetch", "origin") + local.Git(t, "branch", "--track", "behind", "origin/behind") + local.Git(t, "branch", "--track", "merged", "origin/merged") + local.Git(t, "branch", "--track", "ahead", "origin/ahead") + local.CreateBranch(t, "solo") + soloSHA := local.Git(t, "rev-parse", "refs/heads/solo") + + local.Checkout(t, "ahead") + aheadSHA := local.Commit(t, "unpushed") + local.Checkout(t, "main") + + peer.Checkout(t, "behind") + behindSHA := peer.Commit(t, "behind more") + peer.Push(t, "origin", "behind") + peer.Push(t, "origin", "--delete", "merged") + + local.Chdir(t) + + out, err := runSync(t) + if err != nil { + t.Fatalf("sync() error: %v\noutput: %s", err, out) + } + if want := "Updated branch behind"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) + } + if want := "Deleted branch merged"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) + } + if want := "warning: 'ahead' seems to contain unpushed commits"; !strings.Contains(out, want) { + t.Fatalf("output = %q, want it to contain %q", out, want) + } + + if got := local.Git(t, "rev-parse", "refs/heads/behind"); got != behindSHA { + t.Fatalf("local refs/heads/behind = %q, want %q", got, behindSHA) + } + if got := local.Git(t, "for-each-ref", "refs/heads/merged"); got != "" { + t.Fatalf("local refs/heads/merged = %q, want deleted", got) + } + if got := local.Git(t, "rev-parse", "refs/heads/ahead"); got != aheadSHA { + t.Fatalf("local refs/heads/ahead = %q, want unchanged %q", got, aheadSHA) + } + if got := local.Git(t, "rev-parse", "refs/heads/solo"); got != soloSHA { + t.Fatalf("local refs/heads/solo = %q, want unchanged %q", got, soloSHA) + } +} + +func TestSync_NotAGitRepository(t *testing.T) { + gittest.NewBare(t) // isolates the git environment + gittest.Chdir(t, t.TempDir()) + + out, err := runSync(t) + if err == nil || !strings.Contains(err.Error(), "not a git repository") { + t.Fatalf("sync() error = %v, want %q\noutput: %s", err, "not a git repository", out) + } +} + +func TestSync_NoRemoteFails(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Git(t, "remote", "remove", "origin") + + wantSHA := local.Git(t, "rev-parse", "refs/heads/main") + local.Chdir(t) + + if out, err := runSync(t); err == nil { + t.Fatalf("sync() error = nil, want error for repo without a remote\noutput: %s", out) + } + if got := local.Git(t, "rev-parse", "refs/heads/main"); got != wantSHA { + t.Fatalf("local refs/heads/main = %q, want unchanged %q", got, wantSHA) + } +} + +func TestSync_FetchFailureReturnsErrorAndKeepsBranches(t *testing.T) { + remote := gittest.NewBare(t) + peer := remote.Clone(t) + local := remote.Clone(t) + + peer.Commit(t, "main more") + peer.Push(t, "origin", "main") + + wantSHA := local.Git(t, "rev-parse", "refs/heads/main") + local.Git(t, "remote", "set-url", "origin", local.Dir+"/does-not-exist") + local.Chdir(t) + + if out, err := runSync(t); err == nil { + t.Fatalf("sync() error = nil, want fetch error\noutput: %s", out) + } + if got := local.Git(t, "rev-parse", "refs/heads/main"); got != wantSHA { + t.Fatalf("local refs/heads/main = %q, want unchanged %q", got, wantSHA) + } +} From fb620379d642a642c3d88359e66f6d444aec56f5 Mon Sep 17 00:00:00 2001 From: Dylan Arbour Date: Sun, 11 Oct 2026 14:45:48 -0400 Subject: [PATCH 2/2] Refactor sync into smaller functions and expand tests Split sync() into a syncer with update/prune helpers, move git calls into gitw, and add tests for the new gitw functions and sync output and error paths. DeleteBranch now force-deletes since prune already verifies the branch is merged into the remote default branch. --- internal/gitw/gitw.go | 70 ++++++++++++ internal/gitw/gitw_test.go | 204 +++++++++++++++++++++++++++++++++ main.go | 227 ++++++++++++++++++++----------------- 3 files changed, 400 insertions(+), 101 deletions(-) diff --git a/internal/gitw/gitw.go b/internal/gitw/gitw.go index ab160ba..6239f17 100644 --- a/internal/gitw/gitw.go +++ b/internal/gitw/gitw.go @@ -7,8 +7,11 @@ import ( "strings" "github.com/ldez/go-git-cmd-wrapper/v2/branch" + "github.com/ldez/go-git-cmd-wrapper/v2/checkout" "github.com/ldez/go-git-cmd-wrapper/v2/config" + "github.com/ldez/go-git-cmd-wrapper/v2/fetch" "github.com/ldez/go-git-cmd-wrapper/v2/git" + "github.com/ldez/go-git-cmd-wrapper/v2/merge" "github.com/ldez/go-git-cmd-wrapper/v2/revparse" "github.com/ldez/go-git-cmd-wrapper/v2/types" ) @@ -150,6 +153,73 @@ func IsGitDir() bool { return err == nil } +// Fetch fetches and prunes remote without touching tags. +// +// Tags are skipped because a force-moved tag makes fetch.pruneTags hard-fail. +func Fetch(remote string) error { + // git fetch --prune --progress --no-tags --no-prune-tags + noPruneTags := func(g *types.Cmd) { g.AddOptions("--no-prune-tags") } + out, err := git.Fetch(fetch.Prune, fetch.Progress, fetch.NoTags, noPruneTags, fetch.Remote(remote)) + if err != nil { + return errors.New(out) + } + return nil +} + +// Upstream returns the full upstream ref of branch (like refs/remotes/origin/main). +// It errors if the branch has no upstream, including when it was deleted on the remote. +func Upstream(branch string) (string, error) { + // git rev-parse --symbolic-full-name @{upstream} + out, err := git.RevParse(revparse.SymbolicFullName, revparse.Args(branch+"@{upstream}")) + if err != nil { + return "", errors.New(out) + } + return strings.TrimSpace(out), nil +} + +// FastForwardCurrent fast-forwards the checked out branch to ref. +func FastForwardCurrent(ref string) error { + // git merge --ff-only --quiet + out, err := git.Merge(merge.FfOnly, merge.Quiet, merge.Commits(ref)) + if err != nil { + return errors.New(out) + } + return nil +} + +// UpdateRef points ref at target. +func UpdateRef(ref, target string) error { + // git update-ref + out, err := git.Raw("update-ref", func(g *types.Cmd) { + g.AddOptions(ref) + g.AddOptions(target) + }) + if err != nil { + return errors.New(out) + } + return nil +} + +// Checkout switches to the named branch. +func Checkout(name string) error { + // git checkout --quiet + out, err := git.Checkout(checkout.Quiet, checkout.Branch(name)) + if err != nil { + return errors.New(out) + } + return nil +} + +// DeleteBranch force-deletes the named local branch. +func DeleteBranch(name string) error { + // git branch -D + out, err := git.Branch(branch.DeleteForce, branch.BranchName(name)) + if err != nil { + return errors.New(out) + } + return nil +} + func outputLines(output string) []string { output = strings.TrimSuffix(output, "\n") if output == "" { diff --git a/internal/gitw/gitw_test.go b/internal/gitw/gitw_test.go index de38c92..0ade5c6 100644 --- a/internal/gitw/gitw_test.go +++ b/internal/gitw/gitw_test.go @@ -154,3 +154,207 @@ func TestNewRange_Identical(t *testing.T) { t.Fatal("IsIdentical() = false, want true") } } + +func TestFetch(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + peer := remote.Clone(t) + local.Chdir(t) + + sha := peer.Commit(t, "upstream work") + peer.Push(t, "origin", "main") + + if err := gitw.Fetch("origin"); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if got := local.Git(t, "rev-parse", "refs/remotes/origin/main"); got != sha { + t.Fatalf("origin/main = %q, want %q", got, sha) + } +} + +func TestFetch_PrunesDeletedBranches(t *testing.T) { + remote := gittest.NewBare(t) + peer := remote.Clone(t) + peer.CreateBranch(t, "feature") + peer.Push(t, "origin", "feature") + local := remote.Clone(t) + local.Chdir(t) + + peer.Push(t, "origin", "--delete", "feature") + + if err := gitw.Fetch("origin"); err != nil { + t.Fatalf("Fetch() error: %v", err) + } + if got := local.Git(t, "for-each-ref", "refs/remotes/origin/feature"); got != "" { + t.Fatalf("refs/remotes/origin/feature still exists: %q", got) + } +} + +func TestFetch_UnknownRemote(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + if err := gitw.Fetch("nope"); err == nil { + t.Fatal("Fetch() error = nil, want error for unknown remote") + } +} + +func TestUpstream(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + got, err := gitw.Upstream("main") + if err != nil { + t.Fatalf("Upstream() error: %v", err) + } + if want := "refs/remotes/origin/main"; got != want { + t.Fatalf("Upstream() = %q, want %q", got, want) + } +} + +func TestUpstream_NoUpstream(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + local.CreateBranch(t, "feature") + + if got, err := gitw.Upstream("feature"); err == nil { + t.Fatalf("Upstream() = %q, want error for branch without upstream", got) + } +} + +func TestFastForwardCurrent(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + peer := remote.Clone(t) + local.Chdir(t) + + sha := peer.Commit(t, "upstream work") + peer.Push(t, "origin", "main") + local.Git(t, "fetch", "origin") + + if err := gitw.FastForwardCurrent("refs/remotes/origin/main"); err != nil { + t.Fatalf("FastForwardCurrent() error: %v", err) + } + if got := local.Git(t, "rev-parse", "HEAD"); got != sha { + t.Fatalf("HEAD = %q, want %q", got, sha) + } +} + +func TestFastForwardCurrent_Diverged(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + peer := remote.Clone(t) + local.Chdir(t) + + peer.Commit(t, "upstream work") + peer.Push(t, "origin", "main") + local.Git(t, "fetch", "origin") + sha := local.Commit(t, "local work") + + if err := gitw.FastForwardCurrent("refs/remotes/origin/main"); err == nil { + t.Fatal("FastForwardCurrent() error = nil, want error for diverged branch") + } + if got := local.Git(t, "rev-parse", "HEAD"); got != sha { + t.Fatalf("HEAD = %q, want unchanged %q", got, sha) + } +} + +func TestUpdateRef(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + local.CreateBranch(t, "feature") + local.Checkout(t, "feature") + sha := local.Commit(t, "feature work") + local.Checkout(t, "main") + + if err := gitw.UpdateRef("refs/heads/main", "refs/heads/feature"); err != nil { + t.Fatalf("UpdateRef() error: %v", err) + } + if got := local.Git(t, "rev-parse", "refs/heads/main"); got != sha { + t.Fatalf("main = %q, want %q", got, sha) + } +} + +func TestUpdateRef_UnknownTarget(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + if err := gitw.UpdateRef("refs/heads/main", "refs/heads/does-not-exist"); err == nil { + t.Fatal("UpdateRef() error = nil, want error for unknown target") + } +} + +func TestCheckout(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + local.CreateBranch(t, "feature") + + if err := gitw.Checkout("feature"); err != nil { + t.Fatalf("Checkout() error: %v", err) + } + if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "feature" { + t.Fatalf("HEAD = %q, want %q", got, "feature") + } +} + +func TestCheckout_UnknownBranch(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + if err := gitw.Checkout("does-not-exist"); err == nil { + t.Fatal("Checkout() error = nil, want error for unknown branch") + } +} + +func TestDeleteBranch(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + local.CreateBranch(t, "feature") + + if err := gitw.DeleteBranch("feature"); err != nil { + t.Fatalf("DeleteBranch() error: %v", err) + } + if got := local.Git(t, "for-each-ref", "refs/heads/feature"); got != "" { + t.Fatalf("refs/heads/feature still exists: %q", got) + } +} + +func TestDeleteBranch_Unmerged(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + local.CreateBranch(t, "feature") + local.Checkout(t, "feature") + local.Commit(t, "unmerged work") + local.Checkout(t, "main") + + if err := gitw.DeleteBranch("feature"); err != nil { + t.Fatalf("DeleteBranch() error: %v", err) + } + if got := local.Git(t, "for-each-ref", "refs/heads/feature"); got != "" { + t.Fatalf("refs/heads/feature still exists: %q", got) + } +} + +func TestDeleteBranch_Current(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + if err := gitw.DeleteBranch("main"); err == nil { + t.Fatal("DeleteBranch() error = nil, want error for the checked out branch") + } +} diff --git a/main.go b/main.go index 5c62472..d91f203 100644 --- a/main.go +++ b/main.go @@ -7,26 +7,38 @@ import ( "strings" "github.com/arbourd/git-sync/internal/gitw" - "github.com/ldez/go-git-cmd-wrapper/v2/branch" - "github.com/ldez/go-git-cmd-wrapper/v2/checkout" - "github.com/ldez/go-git-cmd-wrapper/v2/fetch" - "github.com/ldez/go-git-cmd-wrapper/v2/git" - "github.com/ldez/go-git-cmd-wrapper/v2/merge" - "github.com/ldez/go-git-cmd-wrapper/v2/revparse" - "github.com/ldez/go-git-cmd-wrapper/v2/types" +) + +const ( + green = "\033[32m" + lightGreen = "\033[32;1m" + red = "\033[31m" + lightRed = "\033[31;1m" + resetColor = "\033[0m" ) func main() { - err := sync() - if err != nil { - fmt.Printf("fatal: %s\n", strings.TrimPrefix(strings.TrimSpace(err.Error()), "fatal: ")) + if err := sync(); err != nil { + fmt.Printf("fatal: %s\n", formatError(err)) os.Exit(1) } } +// formatError trims err and strips a leading "fatal: ". +func formatError(err error) string { + return strings.TrimPrefix(strings.TrimSpace(err.Error()), "fatal: ") +} + +// syncer holds state shared across branches. +type syncer struct { + remote string + defaultBranch string + currentBranch string +} + func sync() error { - if gitDir := gitw.IsGitDir(); !gitDir { - return fmt.Errorf("not a git repository") + if !gitw.IsGitDir() { + return errors.New("not a git repository") } remote, err := gitw.RemoteFromHead() @@ -38,14 +50,15 @@ func sync() error { if err != nil { return err } - fullDefaultBranch := fmt.Sprintf("refs/remotes/%s/%s", remote, defaultBranch) - currentBranch := gitw.CurrentBranch() - // git fetch --prune --progress --no-tags --no-prune-tags - noPruneTags := func(g *types.Cmd) { g.AddOptions("--no-prune-tags") } - out, err := git.Fetch(fetch.Prune, fetch.Progress, fetch.NoTags, noPruneTags, fetch.Remote(remote)) - if err != nil { - return errors.New(out) + s := &syncer{ + remote: remote, + defaultBranch: defaultBranch, + currentBranch: gitw.CurrentBranch(), + } + + if err := gitw.Fetch(remote); err != nil { + return err } branches, err := gitw.LocalBranches() @@ -53,98 +66,110 @@ func sync() error { return err } - var green, - lightGreen, - red, - lightRed, - resetColor string + tracking, err := gitw.BranchesWithRemotes() + if err != nil { + return err + } + + for _, branch := range branches { + upstream, gone := s.upstream(branch, tracking[branch] == s.remote) + + var err error + switch { + case upstream != "": + err = s.update(branch, upstream) + case gone: + err = s.prune(branch) + } + if err != nil { + return err + } + } - colorize := true - if colorize { - green = "\033[32m" - lightGreen = "\033[32;1m" - red = "\033[31m" - lightRed = "\033[31;1m" - resetColor = "\033[0m" + return nil +} + +// upstream returns the remote ref branch should match. +// gone is true if a tracked branch's upstream was deleted. +func (s *syncer) upstream(branch string, tracked bool) (upstream string, gone bool) { + if tracked { + upstream, err := gitw.Upstream(branch) + return upstream, err != nil } - branchToRemote, err := gitw.BranchesWithRemotes() + candidate := fmt.Sprintf("refs/remotes/%s/%s", s.remote, branch) + if gitw.HasFile(candidate) { + return candidate, false + } + return "", false +} + +// update fast-forwards branch to upstream, or warns if it has local commits. +func (s *syncer) update(branch, upstream string) error { + ref := localRef(branch) + diff, err := gitw.NewRange(ref, upstream) if err != nil { return err } - for _, wbranch := range branches { - fullBranch := fmt.Sprintf("refs/heads/%s", wbranch) - remoteBranch := fmt.Sprintf("refs/remotes/%s/%s", remote, wbranch) - gone := false - - if branchToRemote[wbranch] == remote { - if upstream, err := git.RevParse(revparse.SymbolicFullName, revparse.Args(fmt.Sprintf("%s@{upstream}", wbranch))); err == nil { - remoteBranch = strings.TrimSpace(upstream) - } else { - remoteBranch = "" - gone = true - } - } else if !gitw.HasFile(strings.Split(remoteBranch, "/")...) { - remoteBranch = "" - } + switch { + case diff.IsIdentical(): + return nil + case !diff.IsAncestor(): + fmt.Printf("warning: '%s' seems to contain unpushed commits\n", branch) + return nil + } + + if branch == s.currentBranch { + err = gitw.FastForwardCurrent(upstream) + } else { + err = gitw.UpdateRef(ref, upstream) + } + if err != nil { + return err + } + + fmt.Printf("%sUpdated branch %s%s%s (was %s).\n", green, lightGreen, branch, resetColor, shortSHA(diff.A)) + return nil +} + +// prune deletes branch if it is merged into the default branch. +func (s *syncer) prune(branch string) error { + defaultRef := fmt.Sprintf("refs/remotes/%s/%s", s.remote, s.defaultBranch) + diff, err := gitw.NewRange(localRef(branch), defaultRef) + if err != nil { + return err + } + + if !diff.IsAncestor() { + fmt.Printf("warning: '%s' was deleted on %s, but appears not merged into '%s'\n", branch, s.remote, s.defaultBranch) + return nil + } - if remoteBranch != "" { - diff, err := gitw.NewRange(fullBranch, remoteBranch) - if err != nil { - return err - } - - if diff.IsIdentical() { - continue - } else if diff.IsAncestor() { - if wbranch == currentBranch { - // git merge --ff-only --quiet - out, err := git.Merge(merge.FfOnly, merge.Quiet, merge.Commits(remoteBranch)) - if err != nil { - return errors.New(out) - } - } else { - // git update-ref - out, err := git.Raw("update-ref", func(g *types.Cmd) { - g.AddOptions(fullBranch) - g.AddOptions(remoteBranch) - }) - if err != nil { - return errors.New(out) - } - } - fmt.Printf("%sUpdated branch %s%s%s (was %s).\n", green, lightGreen, wbranch, resetColor, diff.A[0:7]) - } else { - fmt.Printf("warning: '%s' seems to contain unpushed commits\n", wbranch) - } - } else if gone { - diff, err := gitw.NewRange(fullBranch, fullDefaultBranch) - if err != nil { - return err - } - - if diff.IsAncestor() { - if wbranch == currentBranch { - // git checkout --quiet - out, err := git.Checkout(checkout.Quiet, checkout.Branch(defaultBranch)) - if err != nil { - return errors.New(out) - } - currentBranch = defaultBranch - } - - // git branch -D - out, err := git.Branch(branch.Delete, branch.BranchName(wbranch)) - if err != nil { - return errors.New(out) - } - fmt.Printf("%sDeleted branch %s%s%s (was %s).\n", red, lightRed, wbranch, resetColor, diff.A[0:7]) - } else { - fmt.Printf("warning: '%s' was deleted on %s, but appears not merged into '%s'\n", wbranch, remote, defaultBranch) - } + // Can't delete the checked out branch. + if branch == s.currentBranch { + if err := gitw.Checkout(s.defaultBranch); err != nil { + return err } + s.currentBranch = s.defaultBranch + } + + if err := gitw.DeleteBranch(branch); err != nil { + return err } + fmt.Printf("%sDeleted branch %s%s%s (was %s).\n", red, lightRed, branch, resetColor, shortSHA(diff.A)) return nil } + +func localRef(branch string) string { + return "refs/heads/" + branch +} + +// shortSHA returns the first 7 characters of rev. +func shortSHA(rev string) string { + if len(rev) <= 7 { + return rev + } + return rev[:7] +}