package gitx import ( "os" "os/exec" "testing" ) // runGit drives git directly (not through Repo.run, which doesn't exist // yet at setup time) with the same kind of isolation Repo.run uses -- // GIT_CONFIG_NOSYSTEM and a throwaway HOME -- so the test behaves the same // on a machine with commit.gpgsign=true globally as it does in CI. func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", append([]string{"-C", dir, "-c", "commit.gpgsign=false"}, args...)...) cmd.Env = []string{ "PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com", } if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } } // newTestRepo builds a real repository with one empty commit, a branch // "feature/x" (a branch name containing a slash), a second branch // "release", and a tag "release/1.0". The last two coexist by construction // deliberately: refs/heads and refs/tags are separate namespaces, so // "release" (a branch) and "release/1.0" (a tag) can both exist even // though a branch "release" and a branch "release/1.0" could not -- git // refuses that, since a ref name is a path and "release" can't be both a // file and a directory. Opened through gitx.Open, because SplitRefPath // resolves against Refs(), which shells out to `git for-each-ref` -- there // is no faking that, it has to be a real repo with real refs. func newTestRepo(t *testing.T) *Repo { t.Helper() dir := t.TempDir() runGit(t, dir, "init", "-q", "-b", "main") runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "init") runGit(t, dir, "branch", "feature/x") runGit(t, dir, "branch", "release") runGit(t, dir, "tag", "release/1.0") r, err := Open(dir) if err != nil { t.Fatalf("Open: %v", err) } return r } // TestSplitRefPath guards the longest-prefix-wins rule that lets ref names // contain slashes. A naive split at the first "/" gets both of the two // interesting cases here wrong: it would send "feature/x/src/main.go" to // ref "feature" (not a ref at all) instead of branch "feature/x", and it // would send "release/1.0/docs/readme.md" to branch "release" instead of // the more specific tag "release/1.0" -- the real ambiguity SplitRefPath // exists to resolve, since both "release" and "release/1.0" are valid refs // here and only the longest match is the intended one. func TestSplitRefPath(t *testing.T) { r := newTestRepo(t) cases := []struct { name string rest string wantRef string wantPath string }{ {"single-segment branch with a path", "main/src/main.go", "main", "src/main.go"}, {"slashed branch name resolves in full, not just its first segment", "feature/x/src/main.go", "feature/x", "src/main.go"}, {"slashed tag wins over a shorter branch that's also a valid prefix", "release/1.0/docs/readme.md", "release/1.0", "docs/readme.md"}, {"shorter branch still resolves when no longer candidate matches", "release/README.md", "release", "README.md"}, {"slashed branch with no path", "feature/x", "feature/x", ""}, {"empty rest falls back to HEAD", "", "main", ""}, {"no ref matches: whole thing is a path at HEAD", "no/such/branch/file.go", "main", "no/such/branch/file.go"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ref, path := r.SplitRefPath(tc.rest) if ref != tc.wantRef || path != tc.wantPath { t.Errorf("SplitRefPath(%q) = (%q, %q), want (%q, %q)", tc.rest, ref, path, tc.wantRef, tc.wantPath) } }) } } // TestIsAncestor builds its own repo (rather than reusing newTestRepo, // whose branches all point at the same single commit) because it needs a // real linear ancestor/descendant pair plus a genuinely diverged pair: // "main" advances past the point where "other" branched off, then "other" // advances on its own, so main's tip and other's tip share c1 as a common // ancestor but are not ancestors of each other. func TestIsAncestor(t *testing.T) { dir := t.TempDir() runGit(t, dir, "init", "-q", "-b", "main") runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c1") r, err := Open(dir) if err != nil { t.Fatalf("Open: %v", err) } c1, err := r.Resolve("main") if err != nil { t.Fatalf("Resolve(main): %v", err) } runGit(t, dir, "branch", "other") runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c2") c2, err := r.Resolve("main") if err != nil { t.Fatalf("Resolve(main): %v", err) } runGit(t, dir, "checkout", "-q", "other") runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "c3-diverged") c3, err := r.Resolve("other") if err != nil { t.Fatalf("Resolve(other): %v", err) } cases := []struct { name string a, b string want bool }{ {"strict ancestor", c1, c2, true}, {"reversed order is not an ancestor relationship", c2, c1, false}, {"a commit is its own ancestor", c1, c1, true}, {"diverged commits are not ancestors either direction", c2, c3, false}, {"diverged commits, reverse direction", c3, c2, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got, err := r.IsAncestor(tc.a, tc.b) if err != nil { t.Fatalf("IsAncestor(%q, %q): %v", tc.a, tc.b, err) } if got != tc.want { t.Errorf("IsAncestor(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) } }) } t.Run("bogus revision is an error, not false", func(t *testing.T) { if _, err := r.IsAncestor("deadbeef", c1); err == nil { t.Error("IsAncestor with a bogus revision: got nil error, want non-nil") } }) }