diff --git a/src/git.ts b/src/git.ts index 8cb08752..a40a36de 100644 --- a/src/git.ts +++ b/src/git.ts @@ -12,7 +12,8 @@ export interface GitCommandResult { export interface GitEligibility { ok: boolean; gitRoot?: string; - reason?: "not_git" | "no_head"; + hasHead?: boolean; + reason?: "not_git"; message?: string; } @@ -44,16 +45,30 @@ export async function getGitEligibility(cwd: string): Promise { const gitRoot = (await git(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim(); try { await git(gitRoot, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]); - } catch { + } catch (error) { + let headRef: string; + try { + headRef = (await git(gitRoot, ["symbolic-ref", "--quiet", "HEAD"])).stdout.trim(); + } catch { + throw error; + } + + const existingHeadRef = (await git(gitRoot, [ + "for-each-ref", + "--format=%(refname)", + "--count=1", + headRef, + ])).stdout.trim(); + if (existingHeadRef) throw error; + return { - ok: false, + ok: true, gitRoot, - reason: "no_head", - message: "repository has no HEAD commit", + hasHead: false, }; } - return { ok: true, gitRoot }; + return { ok: true, gitRoot, hasHead: true }; } export function safeWorkspaceRefSegment(workspaceId: string): string { diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 37ee2c55..0a6dfce8 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -238,27 +238,59 @@ test("a concurrent review rejects a different root after initialization", async } }); -test("an unborn repository becomes reviewable after its first commit", async (t) => { +test("an unborn repository is reviewable without creating a HEAD commit", async (t) => { const root = await unbornRepository(t); + await writeFile(join(root, "existing.txt"), "present at open\n"); const manager = createReviewCheckpointManager(); - await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); - await assert.rejects( - () => manager.reviewChanges({ workspaceId: "ws_unborn", root }), - /repository has no HEAD commit/, - ); + const availability = await manager.initializeWorkspace({ workspaceId: "ws_unborn", root }); + assert.deepEqual(availability, { available: true }); + await assert.rejects(() => git(root, ["rev-parse", "--verify", "HEAD^{commit}"])); - await writeFile(join(root, "README.md"), "first commit\n"); - await git(root, ["add", "README.md"]); - await git(root, ["commit", "-m", "Initial commit"]); + await writeFile(join(root, "created-after-open.txt"), "new file\n"); - const afterFirstCommit = await manager.reviewChanges({ + const review = await manager.reviewChanges({ workspaceId: "ws_unborn", root, markReviewed: false, }); - assert.equal(afterFirstCommit.summary.files, 0); - assert.equal(afterFirstCommit.patch, ""); + assert.deepEqual(review.files.map((file) => file.path), ["created-after-open.txt"]); + assert.equal(review.files[0]?.type, "new"); + assert.match(review.patch, /new file/); +}); + +test("a broken HEAD is not treated as an unborn repository", async (t) => { + const root = await committedRepository(t); + const head = await gitOutput(root, ["rev-parse", "HEAD"]); + await rm(join(root, ".git", "objects", head.slice(0, 2), head.slice(2))); + const manager = createReviewCheckpointManager(); + + const availability = await manager.initializeWorkspace({ workspaceId: "ws_broken_head", root }); + + assert.equal(availability.available, false); +}); + +test("an unborn review baseline survives the first user commit", async (t) => { + const root = await unbornRepository(t); + await writeFile(join(root, "existing.txt"), "present at open\n"); + const manager = createReviewCheckpointManager(); + + await manager.initializeWorkspace({ workspaceId: "ws_first_commit", root }); + await writeFile(join(root, "before-first-commit.txt"), "reviewed before commit\n"); + await manager.reviewChanges({ workspaceId: "ws_first_commit", root }); + + await git(root, ["add", "-A"]); + await git(root, ["commit", "-m", "Initial commit"]); + await writeFile(join(root, "after-first-commit.txt"), "created after commit\n"); + + const review = await manager.reviewChanges({ + workspaceId: "ws_first_commit", + root, + markReviewed: false, + }); + assert.deepEqual(review.files.map((file) => file.path), ["after-first-commit.txt"]); + assert.match(review.patch, /created after commit/); + assert.doesNotMatch(review.patch, /reviewed before commit/); }); async function committedRepository(t: TestContext): Promise { diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 6f687275..1c43778c 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -230,7 +230,9 @@ async function initializeWorkspaceState( ]); if (!openCommit && !baselineCommit) { - const head = (await git(eligibility.gitRoot, ["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim(); + const head = eligibility.hasHead + ? (await git(eligibility.gitRoot, ["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim() + : undefined; const initialCommit = await createWorkingTreeSnapshot(eligibility.gitRoot, head); await git(eligibility.gitRoot, ["update-ref", state.openRef, initialCommit]); await git(eligibility.gitRoot, ["update-ref", state.baselineRef, initialCommit]); @@ -280,16 +282,19 @@ function reviewRefs( }; } -async function createWorkingTreeSnapshot(gitRoot: string, parent: string): Promise { +async function createWorkingTreeSnapshot(gitRoot: string, parent?: string): Promise { const tempDir = await mkdtemp(join(tmpdir(), "devspace-review-index-")); const indexPath = join(tempDir, "index"); const env = checkpointEnv(indexPath); try { - await git(gitRoot, ["read-tree", "HEAD"], { env }); + await git(gitRoot, parent ? ["read-tree", parent] : ["read-tree", "--empty"], { env }); await git(gitRoot, ["add", "-A", "--", "."], { env }); const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim(); - return (await git(gitRoot, ["commit-tree", tree, "-p", parent, "-m", "DevSpace review snapshot"], { env })).stdout.trim(); + const commitArgs = ["commit-tree", tree]; + if (parent) commitArgs.push("-p", parent); + commitArgs.push("-m", "DevSpace review snapshot"); + return (await git(gitRoot, commitArgs, { env })).stdout.trim(); } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/src/server.test.ts b/src/server.test.ts index 22f26323..59bcc5d3 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -186,6 +186,39 @@ test("open_workspace reports aggregate review availability", async (t) => { assert.deepEqual(gitReview, { available: true }); }); +test("show_changes reviews an unborn repository through the MCP tool surface", async (t) => { + const context = await fixture(t, { uiEnabled: false }); + await git(context.project, ["init"]); + + const opened = structuredContent(await callOpen(context.client, context.project, "unborn-review")); + const workspaceId = opened.workspace_id; + assert.equal(typeof workspaceId, "string"); + assert.deepEqual(opened.review, { available: true }); + + await writeFile(join(context.project, "created-after-open.txt"), "new file\n"); + const review = await context.client.callTool({ + name: "show_changes", + arguments: { workspace_id: workspaceId }, + }); + const card = responseCard(review); + + assert.deepEqual(card.files, [ + { + path: "created-after-open.txt", + type: "new", + additions: 1, + removals: 0, + }, + ]); + assert.match( + ((card.payload as { patch?: string } | undefined)?.patch) ?? "", + /new file/, + ); + await assert.rejects(() => execFileAsync("git", ["rev-parse", "--verify", "HEAD^{commit}"], { + cwd: context.project, + })); +}); + test("show_changes keeps model output compact and preserves the rich review card", async (t) => { const context = await fixture(t, { git: true, uiEnabled: false }); const opened = structuredContent(