From 2df96081394f8d7e27f108efac491cda300a1501 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 30 Jun 2026 21:46:42 -0400 Subject: [PATCH] fix(git): scope GetLatest* tag lookups to the repo dir GetLatestTag and GetLatestReleaseTag built git commands with no working directory, so they read the process cwd instead of the caller's repository whenever the two differ. Add a dir parameter that sets cmd.Dir on the tag and rev-list commands, matching the orchestrator's existing gitOutput/gitRun pattern, and pass o.baseDir from the callers. Signed-off-by: Joshua Temple --- internal/git/git.go | 18 ++++-- internal/git/git_test.go | 84 ++++++++++++++++++++++++++-- internal/orchestrate/orchestrator.go | 4 +- 3 files changed, 95 insertions(+), 11 deletions(-) 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 != "" {