diff --git a/src/repos/config.ts b/src/repos/config.ts index 0801ced..3e1ae49 100644 --- a/src/repos/config.ts +++ b/src/repos/config.ts @@ -26,6 +26,8 @@ export interface RepoConfig { skipVersionTag?: boolean; /** Override specific sparse paths to come from a different branch instead of the tag */ sparsePathOverrides?: { paths: string[]; branch: string }[]; + /** 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") */ + matchLatestIncrementalTag?: boolean; } /** Default Aztec version (tag) to use - can be overridden via AZTEC_DEFAULT_VERSION env var */ @@ -111,6 +113,7 @@ const BASE_REPOS: Omit[] = [ { name: "demo-wallet", url: "https://github.com/AztecProtocol/demo-wallet", + matchLatestIncrementalTag: true, description: "Aztec demo wallet application", searchPatterns: { code: ["*.nr", "*.ts"], diff --git a/src/utils/git.ts b/src/utils/git.ts index a685f83..6b15179 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -18,8 +18,55 @@ function alternateTagName(tag: string): string { return tag.startsWith("v") ? tag.slice(1) : `v${tag}`; } +/** + * Find the latest incremental tag matching a base version via ls-remote. + * e.g., for base "4.2.0-rc.1" finds the highest "4.2.0-rc.1-N" tag. + * Tries both with and without v-prefix. + */ +async function findLatestIncrementalTag( + repoUrl: string, + baseTag: string, + log?: Logger, + repoName?: string, +): Promise { + const git = simpleGit(); + const bare = baseTag.startsWith("v") ? baseTag.slice(1) : baseTag; + const candidates = [`${bare}-*`, `v${bare}-*`]; + + for (const pattern of candidates) { + try { + const result = await git.listRemote(["--tags", repoUrl, `refs/tags/${pattern}`]); + if (!result.trim()) continue; + + const tags = result + .trim() + .split("\n") + .map((line) => { + const match = line.match(/refs\/tags\/(.+)$/); + return match ? match[1] : null; + }) + .filter((t): t is string => t !== null) + .sort((a, b) => { + const numA = parseInt(a.match(/-(\d+)$/)?.[1] || "0", 10); + const numB = parseInt(b.match(/-(\d+)$/)?.[1] || "0", 10); + return numB - numA; + }); + + if (tags.length > 0) { + log?.(`${repoName}: Found incremental tags: ${tags.join(", ")}`, "debug"); + return tags[0]; + } + } catch { + // pattern didn't match, try next + } + } + return null; +} + /** * Fetch a tag from origin, trying the alternate v-prefix variant on failure. + * If matchLatestIncrementalTag is set on the config, also tries finding + * the latest incremental tag (e.g., "4.2.0-rc.1-2" for "4.2.0-rc.1"). * Returns the resolved tag name that was successfully fetched. */ async function fetchTag( @@ -27,6 +74,7 @@ async function fetchTag( tag: string, log?: Logger, repoName?: string, + config?: RepoConfig, ): Promise { const fetchArgs = (t: string): string[] => ["--depth=1", "origin", `refs/tags/${t}:refs/tags/${t}`]; try { @@ -35,10 +83,23 @@ async function fetchTag( return tag; } catch { const alt = alternateTagName(tag); - log?.(`${repoName}: Tag "${tag}" not found, trying "${alt}"`, "info"); - await repoGit.fetch(fetchArgs(alt)); - return alt; + try { + log?.(`${repoName}: Tag "${tag}" not found, trying "${alt}"`, "info"); + await repoGit.fetch(fetchArgs(alt)); + return alt; + } catch { + if (!config?.matchLatestIncrementalTag) throw new Error(`Tag "${tag}" not found (also tried "${alt}")`); + } } + + // Incremental tag fallback: find latest tag matching baseVersion-N + log?.(`${repoName}: Exact tags not found, searching for incremental tags matching "${tag}"`, "info"); + const resolved = await findLatestIncrementalTag(config!.url, tag, log, repoName); + if (!resolved) throw new Error(`No tags found matching "${tag}" or its variants`); + + log?.(`${repoName}: Using incremental tag "${resolved}"`, "info"); + await repoGit.fetch(fetchArgs(resolved)); + return resolved; } /** Base directory for cloned repos */ @@ -148,7 +209,7 @@ 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!]); - const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name); + const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name, config); log?.(`${config.name}: Checking out tag`, "debug"); await repoGit.checkout(resolvedTag); } else { @@ -178,7 +239,7 @@ export async function cloneRepo( // Clone and checkout tag await git.clone(config.url, clonePath, ["--no-checkout"]); const repoGit = simpleGit({ baseDir: clonePath, progress: progressHandler }); - const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name); + const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name, config); log?.(`${config.name}: Checking out tag`, "debug"); await repoGit.checkout(resolvedTag); } else { @@ -310,7 +371,20 @@ export async function needsReclone(config: RepoConfig): Promise { if (config.tag) { const currentTag = await getRepoTag(config.name); if (currentTag === null) return true; - return currentTag !== config.tag && currentTag !== alternateTagName(config.tag); + if (currentTag === config.tag || currentTag === alternateTagName(config.tag)) return false; + // For incremental tags (e.g., "4.2.0-rc.1-2"), check if the current tag + // is a versioned variant and whether a newer one exists upstream + if (config.matchLatestIncrementalTag) { + const bare = config.tag.startsWith("v") ? config.tag.slice(1) : config.tag; + const currentBare = currentTag.startsWith("v") ? currentTag.slice(1) : currentTag; + if (currentBare.startsWith(bare + "-")) { + const latest = await findLatestIncrementalTag(config.url, config.tag); + if (!latest) return false; // can't reach remote, assume current is fine + const latestBare = latest.startsWith("v") ? latest.slice(1) : latest; + return currentBare !== latestBare; + } + } + return true; } // 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 959cb63..dd639de 100644 --- a/tests/utils/git.test.ts +++ b/tests/utils/git.test.ts @@ -9,6 +9,7 @@ const mockGitInstance = { log: vi.fn(), raw: vi.fn(), checkout: vi.fn(), + listRemote: vi.fn(), }; vi.mock("simple-git", () => ({ @@ -264,6 +265,54 @@ describe("cloneRepo", () => { expect(mockGitInstance.checkout).toHaveBeenCalledWith("2.0.0"); }); + it("non-sparse + tag: falls back to incremental tag when matchLatestIncrementalTag is set", async () => { + const incrementalConfig: RepoConfig = { + name: "demo-wallet", + url: "https://github.com/AztecProtocol/demo-wallet", + tag: "v4.2.0-aztecnr-rc.2", + matchLatestIncrementalTag: true, + description: "test", + }; + mockExistsSync.mockReturnValue(false); + mockGitInstance.clone.mockResolvedValue(undefined); + // Both exact and v-prefix alternate fail + mockGitInstance.fetch + .mockRejectedValueOnce(new Error("not found")) // v4.2.0-aztecnr-rc.2 + .mockRejectedValueOnce(new Error("not found")) // 4.2.0-aztecnr-rc.2 + .mockResolvedValueOnce(undefined); // resolved incremental tag + mockGitInstance.checkout.mockResolvedValue(undefined); + // ls-remote returns incremental tags + mockGitInstance.listRemote.mockResolvedValueOnce( + "abc123\trefs/tags/4.2.0-aztecnr-rc.2-0\n" + + "def456\trefs/tags/4.2.0-aztecnr-rc.2-1\n" + + "ghi789\trefs/tags/4.2.0-aztecnr-rc.2-2\n" + ); + + await cloneRepo(incrementalConfig); + + // Should have tried ls-remote and picked the highest + expect(mockGitInstance.listRemote).toHaveBeenCalled(); + expect(mockGitInstance.fetch).toHaveBeenCalledWith([ + "--depth=1", "origin", + "refs/tags/4.2.0-aztecnr-rc.2-2:refs/tags/4.2.0-aztecnr-rc.2-2", + ]); + expect(mockGitInstance.checkout).toHaveBeenCalledWith("4.2.0-aztecnr-rc.2-2"); + }); + + it("non-sparse + tag: throws when all tag strategies fail without matchLatestIncrementalTag", async () => { + const noFallbackConfig: RepoConfig = { + ...nonSparseConfig, + tag: "v99.0.0", + }; + mockExistsSync.mockReturnValue(false); + mockGitInstance.clone.mockResolvedValue(undefined); + mockGitInstance.fetch + .mockRejectedValueOnce(new Error("not found")) + .mockRejectedValueOnce(new Error("not found")); + + await expect(cloneRepo(noFallbackConfig)).rejects.toThrow("not found"); + }); + it("force=true clones to temp dir then swaps", async () => { // existsSync calls: // 1) needsReclone -> isRepoCloned(.git) -> false (needs reclone) @@ -557,6 +606,77 @@ describe("needsReclone", () => { expect(result).toBe(false); }); + it("returns false when at latest incremental tag and matchLatestIncrementalTag is set", async () => { + mockExistsSync.mockReturnValue(true); + // Repo is checked out at "4.2.0-aztecnr-rc.2-2" but config requests "v4.2.0-aztecnr-rc.2" + mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-2\n"); + // ls-remote confirms -2 is the latest + mockGitInstance.listRemote.mockResolvedValueOnce( + "abc123\trefs/tags/4.2.0-aztecnr-rc.2-0\n" + + "def456\trefs/tags/4.2.0-aztecnr-rc.2-1\n" + + "ghi789\trefs/tags/4.2.0-aztecnr-rc.2-2\n" + ); + + const result = await needsReclone({ + name: "test", + url: "https://github.com/test/test", + tag: "v4.2.0-aztecnr-rc.2", + matchLatestIncrementalTag: true, + description: "test", + }); + expect(result).toBe(false); + }); + + it("returns true when a newer incremental tag exists upstream", async () => { + mockExistsSync.mockReturnValue(true); + // Repo is checked out at "-0" but "-2" exists upstream + mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-0\n"); + mockGitInstance.listRemote.mockResolvedValueOnce( + "abc123\trefs/tags/4.2.0-aztecnr-rc.2-0\n" + + "def456\trefs/tags/4.2.0-aztecnr-rc.2-1\n" + + "ghi789\trefs/tags/4.2.0-aztecnr-rc.2-2\n" + ); + + const result = await needsReclone({ + name: "test", + url: "https://github.com/test/test", + tag: "v4.2.0-aztecnr-rc.2", + matchLatestIncrementalTag: true, + description: "test", + }); + expect(result).toBe(true); + }); + + it("returns false when remote check fails for incremental tag", async () => { + mockExistsSync.mockReturnValue(true); + mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-0\n"); + // ls-remote fails (network error) + mockGitInstance.listRemote.mockRejectedValue(new Error("network error")); + + const result = await needsReclone({ + name: "test", + url: "https://github.com/test/test", + tag: "v4.2.0-aztecnr-rc.2", + matchLatestIncrementalTag: true, + description: "test", + }); + // Can't reach remote, assume current is fine + expect(result).toBe(false); + }); + + it("returns true when current tag is an incremental variant but matchLatestIncrementalTag is not set", async () => { + mockExistsSync.mockReturnValue(true); + mockGitInstance.raw.mockResolvedValue("4.2.0-aztecnr-rc.2-2\n"); + + const result = await needsReclone({ + name: "test", + url: "test", + tag: "v4.2.0-aztecnr-rc.2", + description: "test", + }); + expect(result).toBe(true); + }); + it("returns false for branch-only config when cloned", async () => { mockExistsSync.mockReturnValue(true);