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
46 changes: 38 additions & 8 deletions src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,37 @@ import { RepoConfig } from "../repos/config.js";

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

/**
* Get the alternate v-prefix variant of a tag.
* "v1.0.0" → "1.0.0", "1.0.0" → "v1.0.0"
*/
function alternateTagName(tag: string): string {
return tag.startsWith("v") ? tag.slice(1) : `v${tag}`;
}

/**
* Fetch a tag from origin, trying the alternate v-prefix variant on failure.
* Returns the resolved tag name that was successfully fetched.
*/
async function fetchTag(
repoGit: SimpleGit,
tag: string,
log?: Logger,
repoName?: string,
): Promise<string> {
const fetchArgs = (t: string): string[] => ["--depth=1", "origin", `refs/tags/${t}:refs/tags/${t}`];
try {
log?.(`${repoName}: Fetching tag ${tag}`, "info");
await repoGit.fetch(fetchArgs(tag));
return tag;
} catch {
const alt = alternateTagName(tag);
log?.(`${repoName}: Tag "${tag}" not found, trying "${alt}"`, "info");
await repoGit.fetch(fetchArgs(alt));
return alt;
}
}

/** Base directory for cloned repos */
export const REPOS_DIR = join(
process.env.AZTEC_MCP_REPOS_DIR || join(homedir(), ".aztec-mcp"),
Expand Down Expand Up @@ -117,10 +148,9 @@ 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!]);
log?.(`${config.name}: Fetching tag ${config.tag}`, "info");
await repoGit.fetch(["--depth=1", "origin", `refs/tags/${config.tag}:refs/tags/${config.tag}`]);
const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name);
log?.(`${config.name}: Checking out tag`, "debug");
await repoGit.checkout(config.tag);
await repoGit.checkout(resolvedTag);
} else {
await git.clone(config.url, clonePath, [
"--filter=blob:none",
Expand Down Expand Up @@ -148,10 +178,9 @@ export async function cloneRepo(
// Clone and checkout tag
await git.clone(config.url, clonePath, ["--no-checkout"]);
const repoGit = simpleGit({ baseDir: clonePath, progress: progressHandler });
log?.(`${config.name}: Fetching tag ${config.tag}`, "info");
await repoGit.fetch(["--depth=1", "origin", `refs/tags/${config.tag}:refs/tags/${config.tag}`]);
const resolvedTag = await fetchTag(repoGit, config.tag, log, config.name);
log?.(`${config.name}: Checking out tag`, "debug");
await repoGit.checkout(config.tag);
await repoGit.checkout(resolvedTag);
} else {
await git.clone(config.url, clonePath, [
"--depth=1",
Expand Down Expand Up @@ -277,10 +306,11 @@ export async function needsReclone(config: RepoConfig): Promise<boolean> {
return !currentCommit?.startsWith(config.commit.substring(0, 7));
}

// If a tag is requested, check if we're at that tag
// If a tag is requested, check if we're at that tag (v-prefix insensitive)
if (config.tag) {
const currentTag = await getRepoTag(config.name);
return currentTag !== config.tag;
if (currentTag === null) return true;
return currentTag !== config.tag && currentTag !== alternateTagName(config.tag);
}

// For branches, we don't force re-clone (just update)
Expand Down
81 changes: 81 additions & 0 deletions tests/utils/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,35 @@ describe("cloneRepo", () => {
expect(mockGitInstance.checkout).toHaveBeenCalledWith("v1.0.0");
});

it("sparse + tag: falls back to alternate v-prefix on fetch failure", async () => {
const noVConfig: RepoConfig = {
...sparseConfig,
tag: "1.0.0", // no v prefix
};
mockExistsSync.mockReturnValue(false);
mockGitInstance.clone.mockResolvedValue(undefined);
mockGitInstance.raw.mockResolvedValue(undefined);
// First fetch (without v) fails, second (with v) succeeds
mockGitInstance.fetch
.mockRejectedValueOnce(new Error("not found"))
.mockResolvedValueOnce(undefined);
mockGitInstance.checkout.mockResolvedValue(undefined);

const result = await cloneRepo(noVConfig);
expect(result).toContain("Cloned aztec-packages");

// First attempt: refs/tags/1.0.0
expect(mockGitInstance.fetch).toHaveBeenCalledWith([
"--depth=1", "origin", "refs/tags/1.0.0:refs/tags/1.0.0",
]);
// Fallback: refs/tags/v1.0.0
expect(mockGitInstance.fetch).toHaveBeenCalledWith([
"--depth=1", "origin", "refs/tags/v1.0.0:refs/tags/v1.0.0",
]);
// Checkout uses the resolved tag name
expect(mockGitInstance.checkout).toHaveBeenCalledWith("v1.0.0");
});

it("sparse + commit: clones with sparse flags, fetches commit", async () => {
const commitConfig: RepoConfig = {
...sparseConfig,
Expand Down Expand Up @@ -211,6 +240,30 @@ describe("cloneRepo", () => {
expect(sparseCheckoutCalls).toHaveLength(0);
});

it("non-sparse + tag: falls back to stripping v-prefix on fetch failure", async () => {
const vConfig: RepoConfig = {
...nonSparseConfig,
tag: "v2.0.0",
};
mockExistsSync.mockReturnValue(false);
mockGitInstance.clone.mockResolvedValue(undefined);
// First fetch (with v) fails, second (without v) succeeds
mockGitInstance.fetch
.mockRejectedValueOnce(new Error("not found"))
.mockResolvedValueOnce(undefined);
mockGitInstance.checkout.mockResolvedValue(undefined);

await cloneRepo(vConfig);

expect(mockGitInstance.fetch).toHaveBeenCalledWith([
"--depth=1", "origin", "refs/tags/v2.0.0:refs/tags/v2.0.0",
]);
expect(mockGitInstance.fetch).toHaveBeenCalledWith([
"--depth=1", "origin", "refs/tags/2.0.0:refs/tags/2.0.0",
]);
expect(mockGitInstance.checkout).toHaveBeenCalledWith("2.0.0");
});

it("force=true clones to temp dir then swaps", async () => {
// existsSync calls:
// 1) needsReclone -> isRepoCloned(.git) -> false (needs reclone)
Expand Down Expand Up @@ -476,6 +529,34 @@ describe("needsReclone", () => {
expect(result).toBe(true);
});

it("returns false when tag matches via v-prefix alternate", async () => {
mockExistsSync.mockReturnValue(true);
// Repo is checked out at "v1.0.0" but config requests "1.0.0" (no v)
mockGitInstance.raw.mockResolvedValue("v1.0.0\n");

const result = await needsReclone({
name: "test",
url: "test",
tag: "1.0.0",
description: "test",
});
expect(result).toBe(false);
});

it("returns false when tag matches via v-prefix stripped", async () => {
mockExistsSync.mockReturnValue(true);
// Repo is checked out at "1.0.0" but config requests "v1.0.0"
mockGitInstance.raw.mockResolvedValue("1.0.0\n");

const result = await needsReclone({
name: "test",
url: "test",
tag: "v1.0.0",
description: "test",
});
expect(result).toBe(false);
});

it("returns false for branch-only config when cloned", async () => {
mockExistsSync.mockReturnValue(true);

Expand Down
Loading