Skip to content

Commit 49f7efb

Browse files
authored
Merge pull request #17 from AztecProtocol/feat/demo-wallet-versioned-tags
feat: support incremental tag matching for demo-wallet
2 parents 101b452 + 4396e92 commit 49f7efb

File tree

3 files changed

+203
-6
lines changed

3 files changed

+203
-6
lines changed

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: 80 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,20 @@ 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 and whether a newer one exists upstream
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 + "-")) {
381+
const latest = await findLatestIncrementalTag(config.url, config.tag);
382+
if (!latest) return false; // can't reach remote, assume current is fine
383+
const latestBare = latest.startsWith("v") ? latest.slice(1) : latest;
384+
return currentBare !== latestBare;
385+
}
386+
}
387+
return true;
314388
}
315389

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

tests/utils/git.test.ts

Lines changed: 120 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,77 @@ describe("needsReclone", () => {
557606
expect(result).toBe(false);
558607
});
559608

609+
it("returns false when at latest incremental tag 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+
// ls-remote confirms -2 is the latest
614+
mockGitInstance.listRemote.mockResolvedValueOnce(
615+
"abc123\trefs/tags/4.2.0-aztecnr-rc.2-0\n" +
616+
"def456\trefs/tags/4.2.0-aztecnr-rc.2-1\n" +
617+
"ghi789\trefs/tags/4.2.0-aztecnr-rc.2-2\n"
618+
);
619+
620+
const result = await needsReclone({
621+
name: "test",
622+
url: "https://github.com/test/test",
623+
tag: "v4.2.0-aztecnr-rc.2",
624+
matchLatestIncrementalTag: true,
625+
description: "test",
626+
});
627+
expect(result).toBe(false);
628+
});
629+
630+
it("returns true when a newer incremental tag exists upstream", async () => {
631+
mockExistsSync.mockReturnValue(true);
632+
// Repo is checked out at "-0" but "-2" exists upstream
633+
mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-0\n");
634+
mockGitInstance.listRemote.mockResolvedValueOnce(
635+
"abc123\trefs/tags/4.2.0-aztecnr-rc.2-0\n" +
636+
"def456\trefs/tags/4.2.0-aztecnr-rc.2-1\n" +
637+
"ghi789\trefs/tags/4.2.0-aztecnr-rc.2-2\n"
638+
);
639+
640+
const result = await needsReclone({
641+
name: "test",
642+
url: "https://github.com/test/test",
643+
tag: "v4.2.0-aztecnr-rc.2",
644+
matchLatestIncrementalTag: true,
645+
description: "test",
646+
});
647+
expect(result).toBe(true);
648+
});
649+
650+
it("returns false when remote check fails for incremental tag", async () => {
651+
mockExistsSync.mockReturnValue(true);
652+
mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-0\n");
653+
// ls-remote fails (network error)
654+
mockGitInstance.listRemote.mockRejectedValue(new Error("network error"));
655+
656+
const result = await needsReclone({
657+
name: "test",
658+
url: "https://github.com/test/test",
659+
tag: "v4.2.0-aztecnr-rc.2",
660+
matchLatestIncrementalTag: true,
661+
description: "test",
662+
});
663+
// Can't reach remote, assume current is fine
664+
expect(result).toBe(false);
665+
});
666+
667+
it("returns true when current tag is an incremental variant but matchLatestIncrementalTag is not set", async () => {
668+
mockExistsSync.mockReturnValue(true);
669+
mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-2\n");
670+
671+
const result = await needsReclone({
672+
name: "test",
673+
url: "test",
674+
tag: "v4.2.0-aztecnr-rc.2",
675+
description: "test",
676+
});
677+
expect(result).toBe(true);
678+
});
679+
560680
it("returns false for branch-only config when cloned", async () => {
561681
mockExistsSync.mockReturnValue(true);
562682

0 commit comments

Comments
 (0)