Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/repos/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -111,6 +113,7 @@ const BASE_REPOS: Omit<RepoConfig, "tag">[] = [
{
name: "demo-wallet",
url: "https://github.com/AztecProtocol/demo-wallet",
matchLatestIncrementalTag: true,
description: "Aztec demo wallet application",
searchPatterns: {
code: ["*.nr", "*.ts"],
Expand Down
86 changes: 80 additions & 6 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,63 @@ 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<string | null> {
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(
repoGit: SimpleGit,
tag: string,
log?: Logger,
repoName?: string,
config?: RepoConfig,
): Promise<string> {
const fetchArgs = (t: string): string[] => ["--depth=1", "origin", `refs/tags/${t}:refs/tags/${t}`];
try {
Expand All @@ -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 */
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -310,7 +371,20 @@ export async function needsReclone(config: RepoConfig): Promise<boolean> {
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)
Expand Down
120 changes: 120 additions & 0 deletions tests/utils/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mockGitInstance = {
log: vi.fn(),
raw: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
};

vi.mock("simple-git", () => ({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);

Expand Down
Loading