diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a144b88..3682f90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,9 @@ jobs: go-version-file: go.mod - run: go mod download + - run: go vet ./... - run: go build . + - run: go test ./... -race release: if: startsWith(github.ref, 'refs/tags/v') diff --git a/internal/gittest/gittest.go b/internal/gittest/gittest.go new file mode 100644 index 0000000..3663ac6 --- /dev/null +++ b/internal/gittest/gittest.go @@ -0,0 +1,129 @@ +// Package gittest builds disposable, isolated git repositories for tests. +package gittest + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Repo is a real, disposable git repository used for tests. +type Repo struct { + Dir string +} + +// NewBare creates a bare repository with a single initial commit on "main". +func NewBare(t testing.TB) *Repo { + t.Helper() + isolate(t) + + dir := t.TempDir() + runIn(t, dir, "init", "--bare", "-b", "main") + + // Bare repos can't be committed into directly, so seed via a throwaway clone. + seed := t.TempDir() + runIn(t, seed, "init", "-b", "main") + runIn(t, seed, "commit", "--allow-empty", "-m", "initial commit") + runIn(t, seed, "push", dir, "main") + + return &Repo{Dir: dir} +} + +// Clone clones r into a fresh temp directory, tracked as "origin". +func (r *Repo) Clone(t testing.TB) *Repo { + t.Helper() + isolate(t) + + dir := t.TempDir() + runIn(t, dir, "clone", r.Dir, ".") + + return &Repo{Dir: dir} +} + +// Commit creates an empty commit with msg and returns its full SHA. +func (r *Repo) Commit(t testing.TB, msg string) string { + t.Helper() + runIn(t, r.Dir, "commit", "--allow-empty", "-m", msg) + return strings.TrimSpace(runIn(t, r.Dir, "rev-parse", "HEAD")) +} + +// CreateBranch creates a new branch at HEAD without switching to it. +func (r *Repo) CreateBranch(t testing.TB, name string) { + t.Helper() + runIn(t, r.Dir, "branch", name) +} + +// Checkout switches the working tree to the given branch. +func (r *Repo) Checkout(t testing.TB, name string) { + t.Helper() + runIn(t, r.Dir, "checkout", "--quiet", name) +} + +// Push runs `git push` with the given args in r. +func (r *Repo) Push(t testing.TB, args ...string) { + t.Helper() + runIn(t, r.Dir, append([]string{"push"}, args...)...) +} + +// Git runs an arbitrary git command in r and returns trimmed stdout. +func (r *Repo) Git(t testing.TB, args ...string) string { + t.Helper() + return strings.TrimSpace(runIn(t, r.Dir, args...)) +} + +// Chdir makes dir the process's cwd for the rest of the test. +func Chdir(t testing.TB, dir string) { + t.Helper() + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("gittest: os.Getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("gittest: os.Chdir(%s): %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("gittest: restore os.Chdir(%s): %v", orig, err) + } + }) +} + +// Chdir makes r.Dir the process's cwd for the rest of the test. +func (r *Repo) Chdir(t testing.TB) { + t.Helper() + Chdir(t, r.Dir) +} + +// isolate pins git's environment to per-test temp locations. +func isolate(t testing.TB) { + t.Helper() + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_AUTHOR_NAME", "git-sync tests") + t.Setenv("GIT_AUTHOR_EMAIL", "git-sync-tests@example.com") + t.Setenv("GIT_COMMITTER_NAME", "git-sync tests") + t.Setenv("GIT_COMMITTER_EMAIL", "git-sync-tests@example.com") + t.Setenv("GIT_TERMINAL_PROMPT", "0") +} + +func runIn(t testing.TB, dir string, args ...string) string { + t.Helper() + + var stdout, stderr strings.Builder + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + output := stdout.String() + stderr.String() + t.Fatalf("gittest: git %s (in %s): %v\n%s", strings.Join(args, " "), dir, err, output) + } + return stdout.String() +} diff --git a/internal/gittest/gittest_test.go b/internal/gittest/gittest_test.go new file mode 100644 index 0000000..be4cd33 --- /dev/null +++ b/internal/gittest/gittest_test.go @@ -0,0 +1,86 @@ +package gittest_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/arbourd/git-sync/internal/gittest" +) + +func TestNewBareAndClone(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + + if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "main" { + t.Fatalf("current branch = %q, want %q", got, "main") + } + if got := local.Git(t, "remote"); got != "origin" { + t.Fatalf("remote = %q, want %q", got, "origin") + } + if got := local.Git(t, "symbolic-ref", "refs/remotes/origin/HEAD"); got != "refs/remotes/origin/main" { + t.Fatalf("origin/HEAD = %q, want %q", got, "refs/remotes/origin/main") + } +} + +func TestCommitCreateBranchCheckout(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + + local.CreateBranch(t, "feature") + local.Checkout(t, "feature") + sha := local.Commit(t, "feature work") + + if got := local.Git(t, "rev-parse", "HEAD"); got != sha { + t.Fatalf("HEAD = %q, want %q", got, sha) + } + if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "feature" { + t.Fatalf("current branch = %q, want %q", got, "feature") + } +} + +func TestPush(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + + local.CreateBranch(t, "feature") + local.Checkout(t, "feature") + sha := local.Commit(t, "feature work") + local.Push(t, "origin", "feature") + + if got := remote.Git(t, "rev-parse", "refs/heads/feature"); got != sha { + t.Fatalf("remote refs/heads/feature = %q, want %q", got, sha) + } +} + +func TestChdir(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd: %v", err) + } + + local.Chdir(t) + + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd: %v", err) + } + + wantDir, err := filepath.EvalSymlinks(local.Dir) + if err != nil { + t.Fatalf("filepath.EvalSymlinks(%s): %v", local.Dir, err) + } + gotDir, err := filepath.EvalSymlinks(cwd) + if err != nil { + t.Fatalf("filepath.EvalSymlinks(%s): %v", cwd, err) + } + if gotDir != wantDir { + t.Fatalf("cwd = %q, want %q", gotDir, wantDir) + } + if orig == cwd { + t.Fatalf("cwd did not change from %q", orig) + } +} diff --git a/gitw/gitw.go b/internal/gitw/gitw.go similarity index 86% rename from gitw/gitw.go rename to internal/gitw/gitw.go index bfede65..ab160ba 100644 --- a/gitw/gitw.go +++ b/internal/gitw/gitw.go @@ -3,8 +3,6 @@ package gitw import ( "errors" "fmt" - "os" - "path/filepath" "regexp" "strings" @@ -15,6 +13,7 @@ import ( "github.com/ldez/go-git-cmd-wrapper/v2/types" ) +// NewRange resolves a and b to a Range. func NewRange(a, b string) (*Range, error) { // git rev-parse --quiet out, err := git.RevParse(revparse.Quiet, revparse.Args(a, b)) @@ -30,15 +29,18 @@ func NewRange(a, b string) (*Range, error) { return &Range{lines[0], lines[1]}, nil } +// Range is a pair of resolved revisions. type Range struct { A string B string } +// IsIdentical reports whether A and B are the same revision. func (r *Range) IsIdentical() bool { return strings.EqualFold(r.A, r.B) } +// IsAncestor reports whether A is an ancestor of B. func (r *Range) IsAncestor() bool { // git merge-base --is-ancestor _, err := git.Raw("merge-base", func(g *types.Cmd) { @@ -49,23 +51,13 @@ func (r *Range) IsAncestor() bool { return err == nil } +// HasFile reports whether the ref named by joining segments with "/" exists. func HasFile(segments ...string) bool { - // The blessed way to resolve paths within git dir since Git 2.5.0 - // git rev-parse -q --git-path - out, err := git.RevParse(revparse.Quiet, revparse.GitPath(filepath.Join(segments...))) - if err != nil { - return false - } + ref := strings.Join(segments, "/") - lines := outputLines(out) - if len(lines) != 1 { - return false - } - - if _, err := os.Stat(lines[0]); err != nil { - return false - } - return true + // git rev-parse --quiet --verify + _, err := git.RevParse(revparse.Quiet, revparse.Verify, revparse.Args(ref)) + return err == nil } // RemoteFromHead gets the remote where the HEAD is. @@ -85,8 +77,7 @@ func RemoteFromHead() (string, error) { return match[1], nil } -// BranchesWithRemotes uses the Git config to determine which branches also exist on the remotes. -// Returns a list of branches. +// BranchesWithRemotes returns a map of local branches to their remote. func BranchesWithRemotes() (map[string]string, error) { // git config --get-regexp 'branch.*.remote' out, err := git.Config(config.GetRegexp("branch.*.remote", "")) diff --git a/internal/gitw/gitw_test.go b/internal/gitw/gitw_test.go new file mode 100644 index 0000000..de38c92 --- /dev/null +++ b/internal/gitw/gitw_test.go @@ -0,0 +1,156 @@ +package gitw_test + +import ( + "testing" + + "github.com/arbourd/git-sync/internal/gitw" + "github.com/arbourd/git-sync/internal/gittest" +) + +func TestIsGitDir(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + if !gitw.IsGitDir() { + t.Fatal("IsGitDir() = false, want true inside a git repo") + } +} + +func TestIsGitDir_NotGitDir(t *testing.T) { + gittest.Chdir(t, t.TempDir()) + + if gitw.IsGitDir() { + t.Fatal("IsGitDir() = true, want false outside a git repo") + } +} + +func TestRemoteFromHead(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + got, err := gitw.RemoteFromHead() + if err != nil { + t.Fatalf("RemoteFromHead() error: %v", err) + } + if got != "origin" { + t.Fatalf("RemoteFromHead() = %q, want %q", got, "origin") + } +} + +func TestDefaultBranch(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + got, err := gitw.DefaultBranch("origin") + if err != nil { + t.Fatalf("DefaultBranch() error: %v", err) + } + if got != "main" { + t.Fatalf("DefaultBranch() = %q, want %q", got, "main") + } +} + +func TestCurrentBranch(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + local.CreateBranch(t, "feature") + local.Checkout(t, "feature") + + if got := gitw.CurrentBranch(); got != "feature" { + t.Fatalf("CurrentBranch() = %q, want %q", got, "feature") + } +} + +func TestLocalBranches(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + local.CreateBranch(t, "feature-a") + local.CreateBranch(t, "feature-b") + + got, err := gitw.LocalBranches() + if err != nil { + t.Fatalf("LocalBranches() error: %v", err) + } + + want := map[string]bool{"main": true, "feature-a": true, "feature-b": true} + if len(got) != len(want) { + t.Fatalf("LocalBranches() = %v, want branches %v", got, want) + } + for _, b := range got { + if !want[b] { + t.Fatalf("LocalBranches() contained unexpected branch %q", b) + } + } +} + +func TestBranchesWithRemotes(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + got, err := gitw.BranchesWithRemotes() + if err != nil { + t.Fatalf("BranchesWithRemotes() error: %v", err) + } + if got["main"] != "origin" { + t.Fatalf(`BranchesWithRemotes()["main"] = %q, want "origin"`, got["main"]) + } +} + +func TestHasFile(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + if !gitw.HasFile("refs", "remotes", "origin", "main") { + t.Fatal(`HasFile("refs","remotes","origin","main") = false, want true`) + } + if gitw.HasFile("refs", "remotes", "origin", "does-not-exist") { + t.Fatal(`HasFile("refs","remotes","origin","does-not-exist") = true, want false`) + } +} + +func TestNewRange(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") + + rng, err := gitw.NewRange("refs/heads/main", "refs/heads/feature") + if err != nil { + t.Fatalf("NewRange() error: %v", err) + } + if rng.B != sha { + t.Fatalf("NewRange().B = %q, want %q", rng.B, sha) + } + if rng.IsIdentical() { + t.Fatal("IsIdentical() = true, want false") + } + if !rng.IsAncestor() { + t.Fatal("IsAncestor() = false, want true (main is an ancestor of feature)") + } +} + +func TestNewRange_Identical(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + local.Chdir(t) + + rng, err := gitw.NewRange("refs/heads/main", "refs/remotes/origin/main") + if err != nil { + t.Fatalf("NewRange() error: %v", err) + } + if !rng.IsIdentical() { + t.Fatal("IsIdentical() = false, want true") + } +} diff --git a/main.go b/main.go index 8d1ca09..cdcf2d7 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/arbourd/git-sync/gitw" + "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" diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..c8a9bf4 --- /dev/null +++ b/main_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "strings" + "testing" + + "github.com/arbourd/git-sync/internal/gittest" +) + +func TestSync_FastForwardNonCurrentBranch(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + peer := remote.Clone(t) + + peer.CreateBranch(t, "feature") + peer.Checkout(t, "feature") + peer.Commit(t, "feature start") + peer.Push(t, "origin", "feature") + + local.Git(t, "fetch", "origin") + local.Git(t, "checkout", "-b", "feature", "--track", "origin/feature") + local.Checkout(t, "main") + + 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) + } + + if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != newSHA { + t.Fatalf("local refs/heads/feature = %q, want %q", got, newSHA) + } + if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "main" { + t.Fatalf("current branch = %q, want %q (should not switch)", got, "main") + } +} + +func TestSync_FastForwardCurrentBranch(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + peer := remote.Clone(t) + + peer.CreateBranch(t, "feature") + peer.Checkout(t, "feature") + peer.Commit(t, "feature start") + peer.Push(t, "origin", "feature") + + local.Git(t, "fetch", "origin") + local.Git(t, "checkout", "-b", "feature", "--track", "origin/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) + } + + if got := local.Git(t, "rev-parse", "HEAD"); got != newSHA { + t.Fatalf("local HEAD = %q, want %q", got, newSHA) + } + if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "feature" { + t.Fatalf("current branch = %q, want %q", got, "feature") + } +} + +func TestSync_IdenticalBranchIsNoOp(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + + wantSHA := local.Git(t, "rev-parse", "refs/heads/main") + + local.Chdir(t) + + if err := sync(); err != nil { + t.Fatalf("sync() error: %v", err) + } + + if got := local.Git(t, "rev-parse", "refs/heads/main"); got != wantSHA { + t.Fatalf("local refs/heads/main = %q, want unchanged %q", got, wantSHA) + } + if got := local.Git(t, "rev-parse", "--abbrev-ref", "HEAD"); got != "main" { + t.Fatalf("current branch = %q, want %q", got, "main") + } +} + +func TestSync_DeletedUpstreamMergedBranchIsDeleted(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") + + peer.Push(t, "origin", "--delete", "feature") + + local.Chdir(t) + + if err := sync(); err != nil { + t.Fatalf("sync() error: %v", err) + } + + 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") + } + + branches := local.Git(t, "for-each-ref", "--format=%(refname:short)", "refs/heads/") + if strings.Contains(branches, "feature") { + t.Fatalf("local branches = %q, want %q deleted", branches, "feature") + } +} + +func TestSync_DeletedUpstreamUnmergedBranchWarnsAndKeeps(t *testing.T) { + remote := gittest.NewBare(t) + peer := remote.Clone(t) + local := remote.Clone(t) + + peer.CreateBranch(t, "feature") + peer.Checkout(t, "feature") + featureSHA := peer.Commit(t, "feature work") + peer.Push(t, "origin", "feature") + + local.Git(t, "fetch", "origin") + local.Git(t, "checkout", "-b", "feature", "--track", "origin/feature") + local.Checkout(t, "main") + + peer.Push(t, "origin", "--delete", "feature") + + local.Chdir(t) + + if err := sync(); err != nil { + t.Fatalf("sync() error: %v", err) + } + + 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_UntrackedBranchWithMatchingRemoteIsSynced(t *testing.T) { + remote := gittest.NewBare(t) + peer := remote.Clone(t) + local := remote.Clone(t) + + peer.CreateBranch(t, "feature") + peer.Checkout(t, "feature") + newSHA := peer.Commit(t, "feature work") + peer.Push(t, "origin", "feature") + + local.Git(t, "fetch", "origin") + // Untracked: branchToRemote is unset, forcing sync() to fall back to HasFile. + local.CreateBranch(t, "feature") + + local.Chdir(t) + + if err := sync(); err != nil { + t.Fatalf("sync() error: %v", err) + } + + if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != newSHA { + t.Fatalf("local refs/heads/feature = %q, want %q", got, newSHA) + } +} + +func TestSync_UntrackedBranchWithoutMatchingRemoteIsUntouched(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + + local.CreateBranch(t, "solo") + wantSHA := local.Git(t, "rev-parse", "refs/heads/solo") + + local.Chdir(t) + + if err := sync(); err != nil { + t.Fatalf("sync() error: %v", err) + } + + if got := local.Git(t, "rev-parse", "refs/heads/solo"); got != wantSHA { + t.Fatalf("local refs/heads/solo = %q, want unchanged %q", got, wantSHA) + } +} + +func TestSync_UnpushedCommitsWarnsAndKeeps(t *testing.T) { + remote := gittest.NewBare(t) + local := remote.Clone(t) + + local.CreateBranch(t, "feature") + local.Checkout(t, "feature") + local.Push(t, "-u", "origin", "feature") + + unpushedSHA := local.Commit(t, "local only work") + + local.Chdir(t) + + if err := sync(); err != nil { + t.Fatalf("sync() error: %v", err) + } + + if got := local.Git(t, "rev-parse", "refs/heads/feature"); got != unpushedSHA { + t.Fatalf("local refs/heads/feature = %q, want unchanged %q", got, unpushedSHA) + } +}