diff --git a/internal/git/git.go b/internal/git/git.go index 397f10f..945e4b8 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -149,12 +149,16 @@ func GetInitialCommit() (string, error) { return strings.TrimSpace(string(output)), nil } -// GetLatestTag returns the most recent tag matching the given prefix, sorted by semver. -// Returns empty string if no matching tags found. -func GetLatestTag(prefix string) (string, string, error) { +// GetLatestTag returns the most recent tag matching the given prefix, sorted by +// semver. Tag lookups run against dir so the caller's repository is read even +// when the process working directory points elsewhere; an empty dir falls back +// to the process working directory. Returns empty string if no matching tags +// found. +func GetLatestTag(dir, prefix string) (string, string, error) { // Get all tags matching prefix, sorted by version descending // --sort=-v:refname sorts by version in descending order cmd := exec.Command("git", "tag", "-l", prefix+"*", "--sort=-v:refname") + cmd.Dir = dir output, err := cmd.Output() if err != nil { return "", "", fmt.Errorf("git tag: %w", err) @@ -172,6 +176,7 @@ func GetLatestTag(prefix string) (string, string, error) { // First valid tag is the latest (git sorted descending by version). cmd = exec.Command("git", "rev-list", "-n", "1", tag) + cmd.Dir = dir output, err = cmd.Output() if err != nil { return tag, "", fmt.Errorf("git rev-list for tag: %w", err) @@ -393,8 +398,12 @@ func remoteRefAlreadyGone(out []byte) bool { // 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) { +// Tag lookups run against dir so the caller's repository is read even when the +// process working directory points elsewhere; an empty dir falls back to the +// process working directory. +func GetLatestReleaseTag(dir, prefix string) (string, string, error) { cmd := exec.Command("git", "tag", "-l", prefix+"*", "--sort=-v:refname") + cmd.Dir = dir output, err := cmd.Output() if err != nil { return "", "", fmt.Errorf("git tag: %w", err) @@ -416,6 +425,7 @@ func GetLatestReleaseTag(prefix string) (string, string, error) { if !strings.Contains(tag, "-rc.") { // Get the SHA for this tag cmd = exec.Command("git", "rev-list", "-n", "1", tag) + cmd.Dir = dir output, err = cmd.Output() if err != nil { return tag, "", fmt.Errorf("git rev-list for tag: %w", err) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 38845aa..a898c86 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -326,7 +326,7 @@ func tagHead(t *testing.T, name string) { } func TestGetLatestTag_IgnoresNonVersionTags(t *testing.T) { - newScratchRepo(t) + dir := newScratchRepo(t) commitFile(t, "a.txt", "one", "first commit") // Valid version tags plus non-version tags that sort newer by base version. @@ -335,7 +335,7 @@ func TestGetLatestTag_IgnoresNonVersionTags(t *testing.T) { tagHead(t, "v0.6.0-dryrun.1") // higher base version, not a cascade version tagHead(t, "vnightly") // foreign tag matching the prefix glob - got, sha, err := GetLatestTag("v") + got, sha, err := GetLatestTag(dir, "v") if err != nil { t.Fatalf("GetLatestTag() unexpected error: %v", err) } @@ -348,7 +348,7 @@ func TestGetLatestTag_IgnoresNonVersionTags(t *testing.T) { } func TestGetLatestReleaseTag_IgnoresNonVersionTags(t *testing.T) { - newScratchRepo(t) + dir := newScratchRepo(t) commitFile(t, "a.txt", "one", "first commit") tagHead(t, "v0.5.0") @@ -356,7 +356,7 @@ func TestGetLatestReleaseTag_IgnoresNonVersionTags(t *testing.T) { tagHead(t, "v0.6.0-dryrun.1") // not an -rc tag, but also not a valid version tagHead(t, "vnightly") - got, sha, err := GetLatestReleaseTag("v") + got, sha, err := GetLatestReleaseTag(dir, "v") if err != nil { t.Fatalf("GetLatestReleaseTag() unexpected error: %v", err) } @@ -375,7 +375,7 @@ func TestGetLatestReleaseTag_SkipsRCButKeepsValidRelease(t *testing.T) { tagHead(t, "v1.0.0") tagHead(t, "v1.0.1-rc.0") // valid prerelease, must be skipped for "release" - got, _, err := GetLatestReleaseTag("v") + got, _, err := GetLatestReleaseTag("", "v") if err != nil { t.Fatalf("GetLatestReleaseTag() unexpected error: %v", err) } @@ -384,6 +384,80 @@ func TestGetLatestReleaseTag_SkipsRCButKeepsValidRelease(t *testing.T) { } } +// initRepoAt initializes a git repository at dir, commits a file, and creates a +// lightweight tag pointing at the resulting commit, all via "git -C" so the +// process working directory is never changed. +func initRepoAt(t *testing.T, dir, tag string) { + t.Helper() + for _, args := range [][]string{ + {"-C", dir, "init"}, + {"-C", dir, "config", "user.email", "test@example.com"}, + {"-C", dir, "config", "user.name", "Test User"}, + {"-C", dir, "config", "commit.gpgsign", "false"}, + } { + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + for _, args := range [][]string{ + {"-C", dir, "add", "f.txt"}, + {"-C", dir, "commit", "-m", "seed"}, + {"-C", dir, "tag", tag}, + } { + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + } +} + +// TestGetLatestTag_ScopesToDir proves the lookup reads the repository at the +// given dir, not the process working directory. The cwd repo carries a decoy +// tag that sorts higher; a cwd-scoped lookup would return it. +func TestGetLatestTag_ScopesToDir(t *testing.T) { + newScratchRepo(t) // cwd is a repo with a higher-sorting decoy tag + commitFile(t, "a.txt", "one", "first commit") + tagHead(t, "v9.9.9") + + target := t.TempDir() + initRepoAt(t, target, "v1.2.3") + + got, sha, err := GetLatestTag(target, "v") + if err != nil { + t.Fatalf("GetLatestTag() unexpected error: %v", err) + } + if got != "v1.2.3" { + t.Errorf("GetLatestTag() = %q, want %q (must read the dir repo, not cwd)", got, "v1.2.3") + } + if sha == "" { + t.Errorf("GetLatestTag() returned empty SHA for %q", got) + } +} + +// TestGetLatestReleaseTag_ScopesToDir mirrors TestGetLatestTag_ScopesToDir for +// the release-tag lookup. +func TestGetLatestReleaseTag_ScopesToDir(t *testing.T) { + newScratchRepo(t) + commitFile(t, "a.txt", "one", "first commit") + tagHead(t, "v9.9.9") + + target := t.TempDir() + initRepoAt(t, target, "v1.2.3") + + got, sha, err := GetLatestReleaseTag(target, "v") + if err != nil { + t.Fatalf("GetLatestReleaseTag() unexpected error: %v", err) + } + if got != "v1.2.3" { + t.Errorf("GetLatestReleaseTag() = %q, want %q (must read the dir repo, not cwd)", got, "v1.2.3") + } + if sha == "" { + t.Errorf("GetLatestReleaseTag() returned empty SHA for %q", got) + } +} + func TestIsValidVersionTag(t *testing.T) { tests := []struct { tag string diff --git a/internal/orchestrate/orchestrator.go b/internal/orchestrate/orchestrator.go index daaedb1..7d9df8e 100644 --- a/internal/orchestrate/orchestrator.go +++ b/internal/orchestrate/orchestrator.go @@ -344,7 +344,7 @@ func (o *Orchestrator) calculateVersion() (string, error) { // If no state, check for latest RC tag if currentDevVersion == "" { - latestTag, _, err := git.GetLatestTag(tagPrefix) + latestTag, _, err := git.GetLatestTag(o.baseDir, tagPrefix) if err != nil { log.Warn("Failed to get latest tag: %v", err) } else if latestTag != "" { @@ -355,7 +355,7 @@ func (o *Orchestrator) calculateVersion() (string, error) { // Get latest published release (non-RC) as base version for version calculation // This ensures we continue from v1.0.0 → v1.0.1-rc.0, not restart at v0.1.0-rc.0 - latestRelease, releaseSHA, err := git.GetLatestReleaseTag(tagPrefix) + latestRelease, releaseSHA, err := git.GetLatestReleaseTag(o.baseDir, tagPrefix) if err != nil { log.Warn("Failed to get latest release tag: %v", err) } else if latestRelease != "" {