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
22 changes: 21 additions & 1 deletion apps/server/src/shared/git/git-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export async function resolveRepoRoot(
* Root of the current checkout (`--show-toplevel`) — for a linked worktree
* this is the worktree path, not the main repository root. No timeout is
* applied unless one is passed. Throws when `cwd` is not inside a git
* working tree; callers that need a domain-specific error should rewrap.
* working tree; callers that need a domain-specific error should use
* resolveCheckoutRootOrThrow.
*/
export async function resolveCheckoutRoot(
cwd: string,
Expand All @@ -85,6 +86,25 @@ export async function resolveCheckoutRoot(
);
}

/**
* resolveCheckoutRoot, rewrapping any failure as a 404 of the caller's
* domain error class so HTTP/MCP error mapping keys off that class.
*/
export async function resolveCheckoutRootOrThrow(
cwd: string,
commandRunner: CommandRunner,
ErrorClass: new (message: string, statusCode: number) => Error
): Promise<string> {
try {
return await resolveCheckoutRoot(cwd, commandRunner);
} catch {
throw new ErrorClass(
"No git repository found for the provided working directory.",
404
);
}
}

export async function resolveWorktreeRoot(
cwd: string,
opts: ProbeOptions = {}
Expand Down
35 changes: 11 additions & 24 deletions apps/server/src/shared/git/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { access } from "node:fs/promises";
import { runCommand, type CommandRunner } from "../lib/run-command.js";
import {
normalizePath,
resolveCheckoutRoot,
resolveCheckoutRootOrThrow,
resolveCurrentBranch,
} from "./git-context.js";

Expand Down Expand Up @@ -115,7 +115,11 @@ export async function createGitWorktree(
throw new GitWorktreeError("name is required.", 400);
}

const repoRoot = await resolveRepoRoot(cwd, commandRunner);
const repoRoot = await resolveCheckoutRootOrThrow(
cwd,
commandRunner,
GitWorktreeError
);
const baseBranch = normalizeRefName(input.baseBranch, "main", "baseBranch");
const createNewBranch = input.createNewBranch ?? false;
const branchName = createNewBranch
Expand Down Expand Up @@ -230,7 +234,11 @@ export async function cleanupGitWorktree(
throw new GitWorktreeError("cwd is required.", 400);
}

const worktreePath = await resolveCurrentCheckoutRoot(cwd, commandRunner);
const worktreePath = await resolveCheckoutRootOrThrow(
cwd,
commandRunner,
GitWorktreeError
);
const repoRoot = await resolveCommonRepoRoot(worktreePath, commandRunner);
const normalizedWorktreePath = normalizePath(worktreePath);
const normalizedRepoRoot = normalizePath(repoRoot);
Expand Down Expand Up @@ -344,27 +352,6 @@ export async function cleanupGitWorktree(
};
}

async function resolveRepoRoot(
cwd: string,
commandRunner: CommandRunner
): Promise<string> {
try {
return await resolveCheckoutRoot(cwd, commandRunner);
} catch {
throw new GitWorktreeError(
"No git repository found for the provided working directory.",
404
);
}
}

async function resolveCurrentCheckoutRoot(
cwd: string,
commandRunner: CommandRunner
): Promise<string> {
return await resolveRepoRoot(cwd, commandRunner);
}

async function resolveCommonRepoRoot(
cwd: string,
commandRunner: CommandRunner
Expand Down
28 changes: 11 additions & 17 deletions apps/server/src/shared/github/pr.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
resolveCheckoutRoot,
resolveCheckoutRootOrThrow,
resolveCurrentBranch,
} from "../git/git-context.js";
import { runCommand, type CommandRunner } from "../lib/run-command.js";
Expand Down Expand Up @@ -62,7 +62,11 @@ export async function createPr(
commandRunner: CommandRunner = runCommand
): Promise<CreatePrResult> {
const cwd = requireString(input.cwd, "cwd");
const repoRoot = await resolveRepoRoot(cwd, commandRunner);
const repoRoot = await resolveCheckoutRootOrThrow(
cwd,
commandRunner,
GitHubPrError
);
const baseBranch = input.baseBranch?.trim() || "main";
const branchName = await resolveCurrentBranch(repoRoot, commandRunner);

Expand Down Expand Up @@ -132,7 +136,11 @@ export async function getPrStatus(
commandRunner: CommandRunner = runCommand
): Promise<GetPrStatusResult> {
const cwd = requireString(input.cwd, "cwd");
const repoRoot = await resolveRepoRoot(cwd, commandRunner);
const repoRoot = await resolveCheckoutRootOrThrow(
cwd,
commandRunner,
GitHubPrError
);

const args = [
"pr",
Expand Down Expand Up @@ -164,20 +172,6 @@ export async function getPrStatus(
};
}

async function resolveRepoRoot(
cwd: string,
commandRunner: CommandRunner
): Promise<string> {
try {
return await resolveCheckoutRoot(cwd, commandRunner);
} catch {
throw new GitHubPrError(
"No git repository found for the provided working directory.",
404
);
}
}

async function ensureBaseBranchHasDiff(
repoRoot: string,
baseBranch: string,
Expand Down
22 changes: 22 additions & 0 deletions apps/server/test/git-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,28 @@ describe("git worktree services", () => {
});
});

it("rejects with a 404 GitWorktreeError outside a git repository", async () => {
const cwd = path.join(tempRoot, "not-a-repo");

vi.mocked(runCommand).mockImplementation(async (_command, args) => {
throw new Error(`fatal: not a git repository: ${args.join(" ")}`);
});

const createPromise = createGitWorktree({ cwd, name: "feature" });
await expect(createPromise).rejects.toBeInstanceOf(GitWorktreeError);
await expect(createPromise).rejects.toMatchObject({
message: "No git repository found for the provided working directory.",
statusCode: 404,
});

const cleanupPromise = cleanupGitWorktree({ cwd });
await expect(cleanupPromise).rejects.toBeInstanceOf(GitWorktreeError);
await expect(cleanupPromise).rejects.toMatchObject({
message: "No git repository found for the provided working directory.",
statusCode: 404,
});
});

it("rejects cleanup when called from the primary checkout", async () => {
const repoRoot = path.join(tempRoot, "repo");

Expand Down
20 changes: 20 additions & 0 deletions apps/server/test/github-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,26 @@ describe("github pr services", () => {
);
});

it("rejects with a 404 GitHubPrError outside a git repository", async () => {
const runner = vi.fn(async (_command: string, args: string[]) => {
throw new Error(`fatal: not a git repository: ${args.join(" ")}`);
});

const createPromise = createPr({ cwd: "/tmp/not-a-repo" }, runner);
await expect(createPromise).rejects.toBeInstanceOf(GitHubPrError);
await expect(createPromise).rejects.toMatchObject({
message: "No git repository found for the provided working directory.",
statusCode: 404,
});

const statusPromise = getPrStatus({ cwd: "/tmp/not-a-repo" }, runner);
await expect(statusPromise).rejects.toBeInstanceOf(GitHubPrError);
await expect(statusPromise).rejects.toMatchObject({
message: "No git repository found for the provided working directory.",
statusCode: 404,
});
});

it("reports PR status details", async () => {
const repoRoot = "/tmp/repo";
const runner = vi.fn(async (_command: string, args: string[]) => {
Expand Down
Loading