From 433dff2102562532ab4f5b63f15fbf16f4c8490d Mon Sep 17 00:00:00 2001 From: Josh Crites Date: Mon, 30 Mar 2026 15:33:48 -0400 Subject: [PATCH] feat: try alternate v-prefix variant when fetching tags Some repos use tags without the "v" prefix (e.g. "4.2.0-aztecnr-rc.2") while others use it (e.g. "v4.0.0-devnet.2-patch.1"). When fetching a tag fails, automatically retry with the alternate v-prefix variant (add "v" if missing, strip "v" if present). Also makes needsReclone v-prefix-insensitive so mismatched prefixes don't trigger unnecessary re-clones. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/git.ts | 46 +++++++++++++++++++---- tests/utils/git.test.ts | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/src/utils/git.ts b/src/utils/git.ts index 40244fe..a685f83 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -10,6 +10,37 @@ import { RepoConfig } from "../repos/config.js"; export type Logger = (message: string, level?: "info" | "debug" | "warning" | "error") => void; +/** + * Get the alternate v-prefix variant of a tag. + * "v1.0.0" → "1.0.0", "1.0.0" → "v1.0.0" + */ +function alternateTagName(tag: string): string { + return tag.startsWith("v") ? tag.slice(1) : `v${tag}`; +} + +/** + * Fetch a tag from origin, trying the alternate v-prefix variant on failure. + * Returns the resolved tag name that was successfully fetched. + */ +async function fetchTag( + repoGit: SimpleGit, + tag: string, + log?: Logger, + repoName?: string, +): Promise { + const fetchArgs = (t: string): string[] => ["--depth=1", "origin", `refs/tags/${t}:refs/tags/${t}`]; + try { + log?.(`${repoName}: Fetching tag ${tag}`, "info"); + await repoGit.fetch(fetchArgs(tag)); + return tag; + } catch { + const alt = alternateTagName(tag); + log?.(`${repoName}: Tag "${tag}" not found, trying "${alt}"`, "info"); + await repoGit.fetch(fetchArgs(alt)); + return alt; + } +} + /** Base directory for cloned repos */ export const REPOS_DIR = join( process.env.AZTEC_MCP_REPOS_DIR || join(homedir(), ".aztec-mcp"), @@ -117,10 +148,9 @@ export async function cloneRepo( await repoGit.raw(["config", "gc.auto", "0"]); log?.(`${config.name}: Setting sparse checkout paths: ${config.sparse!.join(", ")}`, "debug"); await repoGit.raw(["sparse-checkout", "set", "--skip-checks", ...config.sparse!]); - log?.(`${config.name}: Fetching tag ${config.tag}`, "info"); - await repoGit.fetch(["--depth=1", "origin", `refs/tags/${config.tag}:refs/tags/${config.tag}`]); + const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name); log?.(`${config.name}: Checking out tag`, "debug"); - await repoGit.checkout(config.tag); + await repoGit.checkout(resolvedTag); } else { await git.clone(config.url, clonePath, [ "--filter=blob:none", @@ -148,10 +178,9 @@ export async function cloneRepo( // Clone and checkout tag await git.clone(config.url, clonePath, ["--no-checkout"]); const repoGit = simpleGit({ baseDir: clonePath, progress: progressHandler }); - log?.(`${config.name}: Fetching tag ${config.tag}`, "info"); - await repoGit.fetch(["--depth=1", "origin", `refs/tags/${config.tag}:refs/tags/${config.tag}`]); + const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name); log?.(`${config.name}: Checking out tag`, "debug"); - await repoGit.checkout(config.tag); + await repoGit.checkout(resolvedTag); } else { await git.clone(config.url, clonePath, [ "--depth=1", @@ -277,10 +306,11 @@ export async function needsReclone(config: RepoConfig): Promise { return !currentCommit?.startsWith(config.commit.substring(0, 7)); } - // If a tag is requested, check if we're at that tag + // If a tag is requested, check if we're at that tag (v-prefix insensitive) if (config.tag) { const currentTag = await getRepoTag(config.name); - return currentTag !== config.tag; + if (currentTag === null) return true; + return currentTag !== config.tag && currentTag !== alternateTagName(config.tag); } // For branches, we don't force re-clone (just update) diff --git a/tests/utils/git.test.ts b/tests/utils/git.test.ts index 0047acb..959cb63 100644 --- a/tests/utils/git.test.ts +++ b/tests/utils/git.test.ts @@ -150,6 +150,35 @@ describe("cloneRepo", () => { expect(mockGitInstance.checkout).toHaveBeenCalledWith("v1.0.0"); }); + it("sparse + tag: falls back to alternate v-prefix on fetch failure", async () => { + const noVConfig: RepoConfig = { + ...sparseConfig, + tag: "1.0.0", // no v prefix + }; + mockExistsSync.mockReturnValue(false); + mockGitInstance.clone.mockResolvedValue(undefined); + mockGitInstance.raw.mockResolvedValue(undefined); + // First fetch (without v) fails, second (with v) succeeds + mockGitInstance.fetch + .mockRejectedValueOnce(new Error("not found")) + .mockResolvedValueOnce(undefined); + mockGitInstance.checkout.mockResolvedValue(undefined); + + const result = await cloneRepo(noVConfig); + expect(result).toContain("Cloned aztec-packages"); + + // First attempt: refs/tags/1.0.0 + expect(mockGitInstance.fetch).toHaveBeenCalledWith([ + "--depth=1", "origin", "refs/tags/1.0.0:refs/tags/1.0.0", + ]); + // Fallback: refs/tags/v1.0.0 + expect(mockGitInstance.fetch).toHaveBeenCalledWith([ + "--depth=1", "origin", "refs/tags/v1.0.0:refs/tags/v1.0.0", + ]); + // Checkout uses the resolved tag name + expect(mockGitInstance.checkout).toHaveBeenCalledWith("v1.0.0"); + }); + it("sparse + commit: clones with sparse flags, fetches commit", async () => { const commitConfig: RepoConfig = { ...sparseConfig, @@ -211,6 +240,30 @@ describe("cloneRepo", () => { expect(sparseCheckoutCalls).toHaveLength(0); }); + it("non-sparse + tag: falls back to stripping v-prefix on fetch failure", async () => { + const vConfig: RepoConfig = { + ...nonSparseConfig, + tag: "v2.0.0", + }; + mockExistsSync.mockReturnValue(false); + mockGitInstance.clone.mockResolvedValue(undefined); + // First fetch (with v) fails, second (without v) succeeds + mockGitInstance.fetch + .mockRejectedValueOnce(new Error("not found")) + .mockResolvedValueOnce(undefined); + mockGitInstance.checkout.mockResolvedValue(undefined); + + await cloneRepo(vConfig); + + expect(mockGitInstance.fetch).toHaveBeenCalledWith([ + "--depth=1", "origin", "refs/tags/v2.0.0:refs/tags/v2.0.0", + ]); + expect(mockGitInstance.fetch).toHaveBeenCalledWith([ + "--depth=1", "origin", "refs/tags/2.0.0:refs/tags/2.0.0", + ]); + expect(mockGitInstance.checkout).toHaveBeenCalledWith("2.0.0"); + }); + it("force=true clones to temp dir then swaps", async () => { // existsSync calls: // 1) needsReclone -> isRepoCloned(.git) -> false (needs reclone) @@ -476,6 +529,34 @@ describe("needsReclone", () => { expect(result).toBe(true); }); + it("returns false when tag matches via v-prefix alternate", async () => { + mockExistsSync.mockReturnValue(true); + // Repo is checked out at "v1.0.0" but config requests "1.0.0" (no v) + mockGitInstance.raw.mockResolvedValue("v1.0.0\n"); + + const result = await needsReclone({ + name: "test", + url: "test", + tag: "1.0.0", + description: "test", + }); + expect(result).toBe(false); + }); + + it("returns false when tag matches via v-prefix stripped", async () => { + mockExistsSync.mockReturnValue(true); + // Repo is checked out at "1.0.0" but config requests "v1.0.0" + mockGitInstance.raw.mockResolvedValue("1.0.0\n"); + + const result = await needsReclone({ + name: "test", + url: "test", + tag: "v1.0.0", + description: "test", + }); + expect(result).toBe(false); + }); + it("returns false for branch-only config when cloned", async () => { mockExistsSync.mockReturnValue(true);