diff --git a/internal/git/git.go b/internal/git/git.go index 8a39f16f..f92e8619 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -2,6 +2,7 @@ package git import ( "bytes" + "errors" "fmt" "os/exec" "strings" @@ -182,6 +183,69 @@ func CommitAndPush(filePath, message string) error { return nil } +// IsAncestor reports whether ancestor is an ancestor of descendant by running +// "git merge-base --is-ancestor". An exit code of 0 means true, an exit code of 1 +// means false, and any other exit code or execution failure is returned as an error. +// +// Both commits must be present in the local object store. In a shallow clone the +// relevant history may be missing, so callers that rely on this must ensure full +// history is fetched (for example fetch-depth: 0). +func IsAncestor(ancestor, descendant string) (bool, error) { + cmd := exec.Command("git", "merge-base", "--is-ancestor", ancestor, descendant) + err := cmd.Run() + if err == nil { + return true, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if exitErr.ExitCode() == 1 { + return false, nil + } + } + + return false, fmt.Errorf("git merge-base --is-ancestor: %w", err) +} + +// BranchExists reports whether the remote-tracking ref refs/remotes// +// exists by running "git rev-parse --verify". An exit code of 0 means the ref +// exists, a non-zero exit code means it does not, and an execution failure is +// returned as an error. +// +// This checks remote-tracking refs, so the remote must have been fetched first. +// A shallow or partial fetch that omits the branch will cause this to report false. +func BranchExists(remote, name string) (bool, error) { + ref := fmt.Sprintf("refs/remotes/%s/%s", remote, name) + cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", ref) + err := cmd.Run() + if err == nil { + return true, nil + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + + return false, fmt.Errorf("git rev-parse --verify %s: %w", ref, err) +} + +// RemoteBranchSHA returns the SHA of the remote-tracking ref +// refs/remotes// by running "git rev-parse". The returned SHA is +// whitespace-trimmed. An error is returned if the ref cannot be resolved. +// +// This resolves a remote-tracking ref, so the remote must have been fetched first. +// A shallow or partial fetch that omits the branch will cause this to fail. +func RemoteBranchSHA(remote, name string) (string, error) { + ref := fmt.Sprintf("refs/remotes/%s/%s", remote, name) + cmd := exec.Command("git", "rev-parse", ref) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git rev-parse %s: %w", ref, err) + } + return strings.TrimSpace(string(output)), nil +} + // GetLatestReleaseTag returns the most recent non-prerelease tag (no -rc suffix). // This is used to find the base version for calculating next release versions. func GetLatestReleaseTag(prefix string) (string, string, error) { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 1b06ca9d..73316930 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,10 +1,69 @@ package git import ( + "os" + "os/exec" + "path/filepath" "reflect" + "strings" "testing" ) +// newScratchRepo initializes a git repository in a temp directory, changes the +// working directory to it for the duration of the test, and returns the repo path. +// The original working directory is restored via t.Cleanup. +func newScratchRepo(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + + runGit(t, "init") + runGit(t, "config", "user.email", "test@example.com") + runGit(t, "config", "user.name", "Test User") + runGit(t, "config", "commit.gpgsign", "false") + + return dir +} + +// runGit runs a git command in the current working directory and fails the test on error. +func runGit(t *testing.T, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +// commitFile writes a file, stages it, commits with the given message, and +// returns the resulting commit SHA. +func commitFile(t *testing.T, name, content, message string) string { + t.Helper() + if err := os.WriteFile(filepath.Join(".", name), []byte(content), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + runGit(t, "add", name) + runGit(t, "commit", "-m", message) + + out, err := exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + return strings.TrimSpace(string(out)) +} + func TestParseCommits(t *testing.T) { tests := []struct { name string @@ -120,3 +179,142 @@ func TestParseLines(t *testing.T) { }) } } + +func TestIsAncestor_TrueFalseAndError(t *testing.T) { + newScratchRepo(t) + + first := commitFile(t, "a.txt", "one", "first commit") + second := commitFile(t, "b.txt", "two", "second commit") + + tests := []struct { + name string + ancestor string + descendant string + want bool + wantErr bool + }{ + {name: "is ancestor", ancestor: first, descendant: second, want: true}, + {name: "not ancestor", ancestor: second, descendant: first, want: false}, + {name: "bad sha errors", ancestor: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", descendant: second, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := IsAncestor(tt.ancestor, tt.descendant) + if tt.wantErr { + if err == nil { + t.Fatalf("IsAncestor() expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("IsAncestor() unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("IsAncestor() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBranchExists(t *testing.T) { + dir := newScratchRepo(t) + + commitFile(t, "a.txt", "one", "first commit") + runGit(t, "branch", "-M", "main") + runGit(t, "branch", "feature") + + // Create a second repo that uses the scratch repo as its origin remote. + clone := t.TempDir() + runGit(t, "clone", dir, clone) + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(clone); err != nil { + t.Fatalf("chdir clone: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + runGit(t, "fetch", "origin") + + tests := []struct { + name string + remote string + branch string + want bool + }{ + {name: "existing branch", remote: "origin", branch: "feature", want: true}, + {name: "missing branch", remote: "origin", branch: "nope", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := BranchExists(tt.remote, tt.branch) + if err != nil { + t.Fatalf("BranchExists() unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("BranchExists() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRemoteBranchSHA(t *testing.T) { + dir := newScratchRepo(t) + + wantSHA := commitFile(t, "a.txt", "one", "first commit") + runGit(t, "branch", "-M", "main") + runGit(t, "branch", "feature") + + clone := t.TempDir() + runGit(t, "clone", dir, clone) + + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(clone); err != nil { + t.Fatalf("chdir clone: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(orig); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + runGit(t, "fetch", "origin") + + tests := []struct { + name string + remote string + branch string + want string + wantErr bool + }{ + {name: "existing branch", remote: "origin", branch: "feature", want: wantSHA}, + {name: "missing branch", remote: "origin", branch: "nope", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := RemoteBranchSHA(tt.remote, tt.branch) + if tt.wantErr { + if err == nil { + t.Fatalf("RemoteBranchSHA() expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("RemoteBranchSHA() unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("RemoteBranchSHA() = %q, want %q", got, tt.want) + } + }) + } +}