Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package git

import (
"bytes"
"errors"
"fmt"
"os/exec"
"strings"
Expand Down Expand Up @@ -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/<remote>/<name>
// 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/<remote>/<name> 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) {
Expand Down
198 changes: 198 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
})
}
}
Loading