Skip to content

Commit 7783c45

Browse files
critesjoshclaude
andcommitted
feat: support incremental tag matching for demo-wallet
demo-wallet uses tags like "4.2.0-aztecnr-rc.2-0", "4.2.0-aztecnr-rc.2-1" instead of the exact Aztec version tag. When matchLatestIncrementalTag is set on a repo config and exact tag + v-prefix alternate both fail, fetchTag now queries remote tags via ls-remote and picks the highest incremental variant. needsReclone also recognizes incremental tags as matching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 101b452 commit 7783c45

3 files changed

Lines changed: 155 additions & 6 deletions

File tree

src/repos/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface RepoConfig {
2626
skipVersionTag?: boolean;
2727
/** Override specific sparse paths to come from a different branch instead of the tag */
2828
sparsePathOverrides?: { paths: string[]; branch: string }[];
29+
/** When true, if the exact tag isn't found, find the latest tag starting with the version (e.g., "4.2.0-rc.1-2" for version "4.2.0-rc.1") */
30+
matchLatestIncrementalTag?: boolean;
2931
}
3032

3133
/** Default Aztec version (tag) to use - can be overridden via AZTEC_DEFAULT_VERSION env var */
@@ -111,6 +113,7 @@ const BASE_REPOS: Omit<RepoConfig, "tag">[] = [
111113
{
112114
name: "demo-wallet",
113115
url: "https://github.com/AztecProtocol/demo-wallet",
116+
matchLatestIncrementalTag: true,
114117
description: "Aztec demo wallet application",
115118
searchPatterns: {
116119
code: ["*.nr", "*.ts"],

src/utils/git.ts

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,63 @@ function alternateTagName(tag: string): string {
1818
return tag.startsWith("v") ? tag.slice(1) : `v${tag}`;
1919
}
2020

21+
/**
22+
* Find the latest incremental tag matching a base version via ls-remote.
23+
* e.g., for base "4.2.0-rc.1" finds the highest "4.2.0-rc.1-N" tag.
24+
* Tries both with and without v-prefix.
25+
*/
26+
async function findLatestIncrementalTag(
27+
repoUrl: string,
28+
baseTag: string,
29+
log?: Logger,
30+
repoName?: string,
31+
): Promise<string | null> {
32+
const git = simpleGit();
33+
const bare = baseTag.startsWith("v") ? baseTag.slice(1) : baseTag;
34+
const candidates = [`${bare}-*`, `v${bare}-*`];
35+
36+
for (const pattern of candidates) {
37+
try {
38+
const result = await git.listRemote(["--tags", repoUrl, `refs/tags/${pattern}`]);
39+
if (!result.trim()) continue;
40+
41+
const tags = result
42+
.trim()
43+
.split("\n")
44+
.map((line) => {
45+
const match = line.match(/refs\/tags\/(.+)$/);
46+
return match ? match[1] : null;
47+
})
48+
.filter((t): t is string => t !== null)
49+
.sort((a, b) => {
50+
const numA = parseInt(a.match(/-(\d+)$/)?.[1] || "0", 10);
51+
const numB = parseInt(b.match(/-(\d+)$/)?.[1] || "0", 10);
52+
return numB - numA;
53+
});
54+
55+
if (tags.length > 0) {
56+
log?.(`${repoName}: Found incremental tags: ${tags.join(", ")}`, "debug");
57+
return tags[0];
58+
}
59+
} catch {
60+
// pattern didn't match, try next
61+
}
62+
}
63+
return null;
64+
}
65+
2166
/**
2267
* Fetch a tag from origin, trying the alternate v-prefix variant on failure.
68+
* If matchLatestIncrementalTag is set on the config, also tries finding
69+
* the latest incremental tag (e.g., "4.2.0-rc.1-2" for "4.2.0-rc.1").
2370
* Returns the resolved tag name that was successfully fetched.
2471
*/
2572
async function fetchTag(
2673
repoGit: SimpleGit,
2774
tag: string,
2875
log?: Logger,
2976
repoName?: string,
77+
config?: RepoConfig,
3078
): Promise<string> {
3179
const fetchArgs = (t: string): string[] => ["--depth=1", "origin", `refs/tags/${t}:refs/tags/${t}`];
3280
try {
@@ -35,10 +83,23 @@ async function fetchTag(
3583
return tag;
3684
} catch {
3785
const alt = alternateTagName(tag);
38-
log?.(`${repoName}: Tag "${tag}" not found, trying "${alt}"`, "info");
39-
await repoGit.fetch(fetchArgs(alt));
40-
return alt;
86+
try {
87+
log?.(`${repoName}: Tag "${tag}" not found, trying "${alt}"`, "info");
88+
await repoGit.fetch(fetchArgs(alt));
89+
return alt;
90+
} catch {
91+
if (!config?.matchLatestIncrementalTag) throw new Error(`Tag "${tag}" not found (also tried "${alt}")`);
92+
}
4193
}
94+
95+
// Incremental tag fallback: find latest tag matching baseVersion-N
96+
log?.(`${repoName}: Exact tags not found, searching for incremental tags matching "${tag}"`, "info");
97+
const resolved = await findLatestIncrementalTag(config!.url, tag, log, repoName);
98+
if (!resolved) throw new Error(`No tags found matching "${tag}" or its variants`);
99+
100+
log?.(`${repoName}: Using incremental tag "${resolved}"`, "info");
101+
await repoGit.fetch(fetchArgs(resolved));
102+
return resolved;
42103
}
43104

44105
/** Base directory for cloned repos */
@@ -148,7 +209,7 @@ export async function cloneRepo(
148209
await repoGit.raw(["config", "gc.auto", "0"]);
149210
log?.(`${config.name}: Setting sparse checkout paths: ${config.sparse!.join(", ")}`, "debug");
150211
await repoGit.raw(["sparse-checkout", "set", "--skip-checks", ...config.sparse!]);
151-
const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name);
212+
const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name, config);
152213
log?.(`${config.name}: Checking out tag`, "debug");
153214
await repoGit.checkout(resolvedTag);
154215
} else {
@@ -178,7 +239,7 @@ export async function cloneRepo(
178239
// Clone and checkout tag
179240
await git.clone(config.url, clonePath, ["--no-checkout"]);
180241
const repoGit = simpleGit({ baseDir: clonePath, progress: progressHandler });
181-
const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name);
242+
const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name, config);
182243
log?.(`${config.name}: Checking out tag`, "debug");
183244
await repoGit.checkout(resolvedTag);
184245
} else {
@@ -310,7 +371,15 @@ export async function needsReclone(config: RepoConfig): Promise<boolean> {
310371
if (config.tag) {
311372
const currentTag = await getRepoTag(config.name);
312373
if (currentTag === null) return true;
313-
return currentTag !== config.tag && currentTag !== alternateTagName(config.tag);
374+
if (currentTag === config.tag || currentTag === alternateTagName(config.tag)) return false;
375+
// For incremental tags (e.g., "4.2.0-rc.1-2"), check if the current tag
376+
// is a versioned variant of the requested tag
377+
if (config.matchLatestIncrementalTag) {
378+
const bare = config.tag.startsWith("v") ? config.tag.slice(1) : config.tag;
379+
const currentBare = currentTag.startsWith("v") ? currentTag.slice(1) : currentTag;
380+
if (currentBare.startsWith(bare + "-")) return false;
381+
}
382+
return true;
314383
}
315384

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

tests/utils/git.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const mockGitInstance = {
99
log: vi.fn(),
1010
raw: vi.fn(),
1111
checkout: vi.fn(),
12+
listRemote: vi.fn(),
1213
};
1314

1415
vi.mock("simple-git", () => ({
@@ -264,6 +265,54 @@ describe("cloneRepo", () => {
264265
expect(mockGitInstance.checkout).toHaveBeenCalledWith("2.0.0");
265266
});
266267

268+
it("non-sparse + tag: falls back to incremental tag when matchLatestIncrementalTag is set", async () => {
269+
const incrementalConfig: RepoConfig = {
270+
name: "demo-wallet",
271+
url: "https://github.com/AztecProtocol/demo-wallet",
272+
tag: "v4.2.0-aztecnr-rc.2",
273+
matchLatestIncrementalTag: true,
274+
description: "test",
275+
};
276+
mockExistsSync.mockReturnValue(false);
277+
mockGitInstance.clone.mockResolvedValue(undefined);
278+
// Both exact and v-prefix alternate fail
279+
mockGitInstance.fetch
280+
.mockRejectedValueOnce(new Error("not found")) // v4.2.0-aztecnr-rc.2
281+
.mockRejectedValueOnce(new Error("not found")) // 4.2.0-aztecnr-rc.2
282+
.mockResolvedValueOnce(undefined); // resolved incremental tag
283+
mockGitInstance.checkout.mockResolvedValue(undefined);
284+
// ls-remote returns incremental tags
285+
mockGitInstance.listRemote.mockResolvedValueOnce(
286+
"abc123\trefs/tags/4.2.0-aztecnr-rc.2-0\n" +
287+
"def456\trefs/tags/4.2.0-aztecnr-rc.2-1\n" +
288+
"ghi789\trefs/tags/4.2.0-aztecnr-rc.2-2\n"
289+
);
290+
291+
await cloneRepo(incrementalConfig);
292+
293+
// Should have tried ls-remote and picked the highest
294+
expect(mockGitInstance.listRemote).toHaveBeenCalled();
295+
expect(mockGitInstance.fetch).toHaveBeenCalledWith([
296+
"--depth=1", "origin",
297+
"refs/tags/4.2.0-aztecnr-rc.2-2:refs/tags/4.2.0-aztecnr-rc.2-2",
298+
]);
299+
expect(mockGitInstance.checkout).toHaveBeenCalledWith("4.2.0-aztecnr-rc.2-2");
300+
});
301+
302+
it("non-sparse + tag: throws when all tag strategies fail without matchLatestIncrementalTag", async () => {
303+
const noFallbackConfig: RepoConfig = {
304+
...nonSparseConfig,
305+
tag: "v99.0.0",
306+
};
307+
mockExistsSync.mockReturnValue(false);
308+
mockGitInstance.clone.mockResolvedValue(undefined);
309+
mockGitInstance.fetch
310+
.mockRejectedValueOnce(new Error("not found"))
311+
.mockRejectedValueOnce(new Error("not found"));
312+
313+
await expect(cloneRepo(noFallbackConfig)).rejects.toThrow("not found");
314+
});
315+
267316
it("force=true clones to temp dir then swaps", async () => {
268317
// existsSync calls:
269318
// 1) needsReclone -> isRepoCloned(.git) -> false (needs reclone)
@@ -557,6 +606,34 @@ describe("needsReclone", () => {
557606
expect(result).toBe(false);
558607
});
559608

609+
it("returns false when current tag is an incremental variant and matchLatestIncrementalTag is set", async () => {
610+
mockExistsSync.mockReturnValue(true);
611+
// Repo is checked out at "4.2.0-aztecnr-rc.2-2" but config requests "v4.2.0-aztecnr-rc.2"
612+
mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-2\n");
613+
614+
const result = await needsReclone({
615+
name: "test",
616+
url: "test",
617+
tag: "v4.2.0-aztecnr-rc.2",
618+
matchLatestIncrementalTag: true,
619+
description: "test",
620+
});
621+
expect(result).toBe(false);
622+
});
623+
624+
it("returns true when current tag is an incremental variant but matchLatestIncrementalTag is not set", async () => {
625+
mockExistsSync.mockReturnValue(true);
626+
mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-2\n");
627+
628+
const result = await needsReclone({
629+
name: "test",
630+
url: "test",
631+
tag: "v4.2.0-aztecnr-rc.2",
632+
description: "test",
633+
});
634+
expect(result).toBe(true);
635+
});
636+
560637
it("returns false for branch-only config when cloned", async () => {
561638
mockExistsSync.mockReturnValue(true);
562639

0 commit comments

Comments
 (0)