Skip to content

Commit 9d99007

Browse files
authored
Merge pull request #15 from AztecProtocol/feat/resilient-tag-v-prefix
feat: try alternate v-prefix variant when fetching tags
2 parents cfb2e28 + 433dff2 commit 9d99007

File tree

2 files changed

+119
-8
lines changed

2 files changed

+119
-8
lines changed

src/utils/git.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,37 @@ import { RepoConfig } from "../repos/config.js";
1010

1111
export type Logger = (message: string, level?: "info" | "debug" | "warning" | "error") => void;
1212

13+
/**
14+
* Get the alternate v-prefix variant of a tag.
15+
* "v1.0.0" → "1.0.0", "1.0.0" → "v1.0.0"
16+
*/
17+
function alternateTagName(tag: string): string {
18+
return tag.startsWith("v") ? tag.slice(1) : `v${tag}`;
19+
}
20+
21+
/**
22+
* Fetch a tag from origin, trying the alternate v-prefix variant on failure.
23+
* Returns the resolved tag name that was successfully fetched.
24+
*/
25+
async function fetchTag(
26+
repoGit: SimpleGit,
27+
tag: string,
28+
log?: Logger,
29+
repoName?: string,
30+
): Promise<string> {
31+
const fetchArgs = (t: string): string[] => ["--depth=1", "origin", `refs/tags/${t}:refs/tags/${t}`];
32+
try {
33+
log?.(`${repoName}: Fetching tag ${tag}`, "info");
34+
await repoGit.fetch(fetchArgs(tag));
35+
return tag;
36+
} catch {
37+
const alt = alternateTagName(tag);
38+
log?.(`${repoName}: Tag "${tag}" not found, trying "${alt}"`, "info");
39+
await repoGit.fetch(fetchArgs(alt));
40+
return alt;
41+
}
42+
}
43+
1344
/** Base directory for cloned repos */
1445
export const REPOS_DIR = join(
1546
process.env.AZTEC_MCP_REPOS_DIR || join(homedir(), ".aztec-mcp"),
@@ -117,10 +148,9 @@ export async function cloneRepo(
117148
await repoGit.raw(["config", "gc.auto", "0"]);
118149
log?.(`${config.name}: Setting sparse checkout paths: ${config.sparse!.join(", ")}`, "debug");
119150
await repoGit.raw(["sparse-checkout", "set", "--skip-checks", ...config.sparse!]);
120-
log?.(`${config.name}: Fetching tag ${config.tag}`, "info");
121-
await repoGit.fetch(["--depth=1", "origin", `refs/tags/${config.tag}:refs/tags/${config.tag}`]);
151+
const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name);
122152
log?.(`${config.name}: Checking out tag`, "debug");
123-
await repoGit.checkout(config.tag);
153+
await repoGit.checkout(resolvedTag);
124154
} else {
125155
await git.clone(config.url, clonePath, [
126156
"--filter=blob:none",
@@ -148,10 +178,9 @@ export async function cloneRepo(
148178
// Clone and checkout tag
149179
await git.clone(config.url, clonePath, ["--no-checkout"]);
150180
const repoGit = simpleGit({ baseDir: clonePath, progress: progressHandler });
151-
log?.(`${config.name}: Fetching tag ${config.tag}`, "info");
152-
await repoGit.fetch(["--depth=1", "origin", `refs/tags/${config.tag}:refs/tags/${config.tag}`]);
181+
const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name);
153182
log?.(`${config.name}: Checking out tag`, "debug");
154-
await repoGit.checkout(config.tag);
183+
await repoGit.checkout(resolvedTag);
155184
} else {
156185
await git.clone(config.url, clonePath, [
157186
"--depth=1",
@@ -277,10 +306,11 @@ export async function needsReclone(config: RepoConfig): Promise<boolean> {
277306
return !currentCommit?.startsWith(config.commit.substring(0, 7));
278307
}
279308

280-
// If a tag is requested, check if we're at that tag
309+
// If a tag is requested, check if we're at that tag (v-prefix insensitive)
281310
if (config.tag) {
282311
const currentTag = await getRepoTag(config.name);
283-
return currentTag !== config.tag;
312+
if (currentTag === null) return true;
313+
return currentTag !== config.tag && currentTag !== alternateTagName(config.tag);
284314
}
285315

286316
// For branches, we don't force re-clone (just update)

tests/utils/git.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,35 @@ describe("cloneRepo", () => {
150150
expect(mockGitInstance.checkout).toHaveBeenCalledWith("v1.0.0");
151151
});
152152

153+
it("sparse + tag: falls back to alternate v-prefix on fetch failure", async () => {
154+
const noVConfig: RepoConfig = {
155+
...sparseConfig,
156+
tag: "1.0.0", // no v prefix
157+
};
158+
mockExistsSync.mockReturnValue(false);
159+
mockGitInstance.clone.mockResolvedValue(undefined);
160+
mockGitInstance.raw.mockResolvedValue(undefined);
161+
// First fetch (without v) fails, second (with v) succeeds
162+
mockGitInstance.fetch
163+
.mockRejectedValueOnce(new Error("not found"))
164+
.mockResolvedValueOnce(undefined);
165+
mockGitInstance.checkout.mockResolvedValue(undefined);
166+
167+
const result = await cloneRepo(noVConfig);
168+
expect(result).toContain("Cloned aztec-packages");
169+
170+
// First attempt: refs/tags/1.0.0
171+
expect(mockGitInstance.fetch).toHaveBeenCalledWith([
172+
"--depth=1", "origin", "refs/tags/1.0.0:refs/tags/1.0.0",
173+
]);
174+
// Fallback: refs/tags/v1.0.0
175+
expect(mockGitInstance.fetch).toHaveBeenCalledWith([
176+
"--depth=1", "origin", "refs/tags/v1.0.0:refs/tags/v1.0.0",
177+
]);
178+
// Checkout uses the resolved tag name
179+
expect(mockGitInstance.checkout).toHaveBeenCalledWith("v1.0.0");
180+
});
181+
153182
it("sparse + commit: clones with sparse flags, fetches commit", async () => {
154183
const commitConfig: RepoConfig = {
155184
...sparseConfig,
@@ -211,6 +240,30 @@ describe("cloneRepo", () => {
211240
expect(sparseCheckoutCalls).toHaveLength(0);
212241
});
213242

243+
it("non-sparse + tag: falls back to stripping v-prefix on fetch failure", async () => {
244+
const vConfig: RepoConfig = {
245+
...nonSparseConfig,
246+
tag: "v2.0.0",
247+
};
248+
mockExistsSync.mockReturnValue(false);
249+
mockGitInstance.clone.mockResolvedValue(undefined);
250+
// First fetch (with v) fails, second (without v) succeeds
251+
mockGitInstance.fetch
252+
.mockRejectedValueOnce(new Error("not found"))
253+
.mockResolvedValueOnce(undefined);
254+
mockGitInstance.checkout.mockResolvedValue(undefined);
255+
256+
await cloneRepo(vConfig);
257+
258+
expect(mockGitInstance.fetch).toHaveBeenCalledWith([
259+
"--depth=1", "origin", "refs/tags/v2.0.0:refs/tags/v2.0.0",
260+
]);
261+
expect(mockGitInstance.fetch).toHaveBeenCalledWith([
262+
"--depth=1", "origin", "refs/tags/2.0.0:refs/tags/2.0.0",
263+
]);
264+
expect(mockGitInstance.checkout).toHaveBeenCalledWith("2.0.0");
265+
});
266+
214267
it("force=true clones to temp dir then swaps", async () => {
215268
// existsSync calls:
216269
// 1) needsReclone -> isRepoCloned(.git) -> false (needs reclone)
@@ -476,6 +529,34 @@ describe("needsReclone", () => {
476529
expect(result).toBe(true);
477530
});
478531

532+
it("returns false when tag matches via v-prefix alternate", async () => {
533+
mockExistsSync.mockReturnValue(true);
534+
// Repo is checked out at "v1.0.0" but config requests "1.0.0" (no v)
535+
mockGitInstance.raw.mockResolvedValue("v1.0.0\n");
536+
537+
const result = await needsReclone({
538+
name: "test",
539+
url: "test",
540+
tag: "1.0.0",
541+
description: "test",
542+
});
543+
expect(result).toBe(false);
544+
});
545+
546+
it("returns false when tag matches via v-prefix stripped", async () => {
547+
mockExistsSync.mockReturnValue(true);
548+
// Repo is checked out at "1.0.0" but config requests "v1.0.0"
549+
mockGitInstance.raw.mockResolvedValue("1.0.0\n");
550+
551+
const result = await needsReclone({
552+
name: "test",
553+
url: "test",
554+
tag: "v1.0.0",
555+
description: "test",
556+
});
557+
expect(result).toBe(false);
558+
});
559+
479560
it("returns false for branch-only config when cloned", async () => {
480561
mockExistsSync.mockReturnValue(true);
481562

0 commit comments

Comments
 (0)