Unify review core across Bun and Pi (#310)

* Unify review core across Bun and Pi

* Surface review diff failures

* fix: review core fixes from code review

- Handle unborn HEAD in runGitDiff("uncommitted") via rev-parse check
  instead of assertGitSuccess, so fresh repos fall through to untracked diffs
- Bind Pi server to 0.0.0.0 for remote sessions (regression from e47eda0)
- Update CLAUDE.md: packages/shared/ description, PLANNOTATOR_SHARE env var,
  /api/diff response shape

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update favicon background to dark navy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-03-16 07:55:15 -07:00
committed by GitHub
parent 01d807787b
commit ea689d9745
12 changed files with 1998 additions and 473 deletions
+3 -2
View File
@@ -54,7 +54,7 @@ plannotator/
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts
│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts
│ │ └── types.ts
│ ├── shared/ # Cross-package types (EditorAnnotation)
│ ├── shared/ # Shared types, utilities, and cross-package logic
│ ├── editor/ # Plan review App.tsx
│ └── review-editor/ # Code review UI
│ ├── App.tsx # Main review app
@@ -86,6 +86,7 @@ claude --plugin-dir ./apps/hook
| `PLANNOTATOR_REMOTE` | Set to `1` or `true` for remote mode (devcontainer, SSH). Uses fixed port and skips browser open. |
| `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. |
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
| `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. |
| `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. |
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. |
@@ -176,7 +177,7 @@ Send Annotations → feedback sent to agent session
| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin }` |
| `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, diffType, gitContext }` |
| `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context |
| `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) |
| `/api/feedback` | POST | Submit review (body: feedback, annotations, agentSwitch) |
+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#6B3FA0"/>
<rect width="64" height="64" rx="14" fill="#070b14"/>
<rect x="12" y="28" width="40" height="14" rx="3" fill="#E0BA55" opacity="0.35"/>
<text x="32" y="46" text-anchor="middle" font-family="Inter,system-ui,sans-serif" font-weight="800" font-size="42" fill="white">P</text>
</svg>

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 347 B

+10 -3
View File
@@ -272,18 +272,25 @@ export default function plannotator(pi: ExtensionAPI): void {
ctx.ui.notify("Opening code review UI...", "info");
const gitCtx = getGitContext();
const { patch: rawPatch, label: gitRef } = runGitDiff("uncommitted", gitCtx.defaultBranch);
let server: ReviewServerResult;
try {
const gitCtx = await getGitContext();
const {
patch: rawPatch,
label: gitRef,
error,
} = await runGitDiff("uncommitted", gitCtx.defaultBranch);
server = await startReviewServer({
rawPatch,
gitRef,
error,
origin: "pi",
diffType: "uncommitted",
gitContext: gitCtx,
htmlContent: reviewHtmlContent,
sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
});
} catch (err) {
ctx.ui.notify(`Failed to start code review UI: ${getStartupErrorMessage(err)}`, "error");
+2 -1
View File
@@ -21,6 +21,7 @@
"files": [
"index.ts",
"server.ts",
"review-core.ts",
"utils.ts",
"README.md",
"plannotator.html",
@@ -28,7 +29,7 @@
"feedback-templates.ts"
],
"scripts": {
"build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && cp ../../packages/shared/feedback-templates.ts feedback-templates.ts",
"build": "cp ../hook/dist/index.html plannotator.html && cp ../hook/dist/review.html review-editor.html && cp ../../packages/shared/feedback-templates.ts feedback-templates.ts && cp ../../packages/shared/review-core.ts review-core.ts",
"prepublishOnly": "cd ../.. && bun run build:pi"
},
"peerDependencies": {
+480
View File
@@ -0,0 +1,480 @@
/**
* Runtime-agnostic code-review core shared by Bun runtimes and Pi.
*
* Pi consumes a build-time copy of this file so its published package stays
* self-contained while review diff logic remains sourced from one module.
*/
import { resolve as resolvePath } from "node:path";
export type DiffType =
| "uncommitted"
| "staged"
| "unstaged"
| "last-commit"
| "branch"
| `worktree:${string}`;
export interface DiffOption {
id: string;
label: string;
}
export interface WorktreeInfo {
path: string;
branch: string | null;
head: string;
}
export interface GitContext {
currentBranch: string;
defaultBranch: string;
diffOptions: DiffOption[];
worktrees: WorktreeInfo[];
}
export interface DiffResult {
patch: string;
label: string;
error?: string;
}
export interface GitCommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
export interface ReviewGitRuntime {
runGit: (
args: string[],
options?: { cwd?: string },
) => Promise<GitCommandResult>;
readTextFile: (path: string) => Promise<string | null>;
}
export async function getCurrentBranch(
runtime: ReviewGitRuntime,
cwd?: string,
): Promise<string> {
const result = await runtime.runGit(
["rev-parse", "--abbrev-ref", "HEAD"],
{ cwd },
);
return result.exitCode === 0 ? result.stdout.trim() || "HEAD" : "HEAD";
}
export async function getDefaultBranch(
runtime: ReviewGitRuntime,
cwd?: string,
): Promise<string> {
const remoteHead = await runtime.runGit(
["symbolic-ref", "refs/remotes/origin/HEAD"],
{ cwd },
);
if (remoteHead.exitCode === 0) {
const ref = remoteHead.stdout.trim();
if (ref) return ref.replace("refs/remotes/origin/", "");
}
const mainBranch = await runtime.runGit(
["show-ref", "--verify", "refs/heads/main"],
{ cwd },
);
if (mainBranch.exitCode === 0) return "main";
return "master";
}
export async function getWorktrees(
runtime: ReviewGitRuntime,
): Promise<WorktreeInfo[]> {
const result = await runtime.runGit(["worktree", "list", "--porcelain"]);
if (result.exitCode !== 0) return [];
const entries: WorktreeInfo[] = [];
let current: Partial<WorktreeInfo> = {};
for (const line of result.stdout.split("\n")) {
if (line.startsWith("worktree ")) {
if (current.path) {
entries.push({
path: current.path,
head: current.head || "",
branch: current.branch ?? null,
});
}
current = { path: line.slice("worktree ".length) };
} else if (line.startsWith("HEAD ")) {
current.head = line.slice("HEAD ".length);
} else if (line.startsWith("branch ")) {
current.branch = line
.slice("branch ".length)
.replace("refs/heads/", "");
} else if (line === "detached") {
current.branch = null;
}
}
if (current.path) {
entries.push({
path: current.path,
head: current.head || "",
branch: current.branch ?? null,
});
}
return entries;
}
export async function getGitContext(
runtime: ReviewGitRuntime,
): Promise<GitContext> {
const [currentBranch, defaultBranch] = await Promise.all([
getCurrentBranch(runtime),
getDefaultBranch(runtime),
]);
const diffOptions: DiffOption[] = [
{ id: "uncommitted", label: "Uncommitted changes" },
{ id: "staged", label: "Staged changes" },
{ id: "unstaged", label: "Unstaged changes" },
{ id: "last-commit", label: "Last commit" },
];
if (currentBranch !== defaultBranch) {
diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` });
}
const [worktrees, currentTreePathResult] = await Promise.all([
getWorktrees(runtime),
runtime.runGit(["rev-parse", "--show-toplevel"]),
]);
const currentTreePath =
currentTreePathResult.exitCode === 0
? currentTreePathResult.stdout.trim()
: null;
return {
currentBranch,
defaultBranch,
diffOptions,
worktrees: worktrees.filter((wt) => wt.path !== currentTreePath),
};
}
async function getUntrackedFileDiffs(
runtime: ReviewGitRuntime,
srcPrefix = "a/",
dstPrefix = "b/",
cwd?: string,
): Promise<string> {
const lsResult = await runtime.runGit(
["ls-files", "--others", "--exclude-standard"],
{ cwd },
);
if (lsResult.exitCode !== 0) return "";
const files = lsResult.stdout
.trim()
.split("\n")
.filter((file) => file.length > 0);
if (files.length === 0) return "";
const diffs = await Promise.all(
files.map(async (file) => {
const diffResult = await runtime.runGit(
[
"diff",
"--no-index",
`--src-prefix=${srcPrefix}`,
`--dst-prefix=${dstPrefix}`,
"/dev/null",
file,
],
{ cwd },
);
return diffResult.stdout;
}),
);
return diffs.join("");
}
function assertGitSuccess(
result: GitCommandResult,
args: string[],
): GitCommandResult {
if (result.exitCode === 0) return result;
const command = `git ${args.join(" ")}`;
const stderr = result.stderr.trim();
throw new Error(
stderr
? `${command} failed: ${stderr}`
: `${command} failed with exit code ${result.exitCode}`,
);
}
const WORKTREE_SUB_TYPES = new Set([
"uncommitted",
"staged",
"unstaged",
"last-commit",
"branch",
]);
export function parseWorktreeDiffType(
diffType: string,
): { path: string; subType: string } | null {
if (!diffType.startsWith("worktree:")) return null;
const rest = diffType.slice("worktree:".length);
const lastColon = rest.lastIndexOf(":");
if (lastColon !== -1) {
const maybeSub = rest.slice(lastColon + 1);
if (WORKTREE_SUB_TYPES.has(maybeSub)) {
return { path: rest.slice(0, lastColon), subType: maybeSub };
}
}
return { path: rest, subType: "uncommitted" };
}
export async function runGitDiff(
runtime: ReviewGitRuntime,
diffType: DiffType,
defaultBranch: string = "main",
): Promise<DiffResult> {
let patch = "";
let label = "";
let cwd: string | undefined;
let effectiveDiffType = diffType as string;
if (diffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(diffType);
if (!parsed) {
return {
patch: "",
label: "Worktree error",
error: "Could not parse worktree diff type",
};
}
cwd = parsed.path;
effectiveDiffType = parsed.subType;
}
try {
switch (effectiveDiffType) {
case "uncommitted": {
const trackedDiffArgs = [
"diff",
"HEAD",
"--src-prefix=a/",
"--dst-prefix=b/",
];
const hasHead =
(await runtime.runGit(["rev-parse", "--verify", "HEAD"], { cwd }))
.exitCode === 0;
const trackedPatch = hasHead
? assertGitSuccess(
await runtime.runGit(trackedDiffArgs, { cwd }),
trackedDiffArgs,
).stdout
: "";
const untrackedDiff = await getUntrackedFileDiffs(
runtime,
"a/",
"b/",
cwd,
);
patch = trackedPatch + untrackedDiff;
label = "Uncommitted changes";
break;
}
case "staged": {
const stagedDiffArgs = [
"diff",
"--staged",
"--src-prefix=a/",
"--dst-prefix=b/",
];
const stagedDiff = assertGitSuccess(
await runtime.runGit(stagedDiffArgs, { cwd }),
stagedDiffArgs,
);
patch = stagedDiff.stdout;
label = "Staged changes";
break;
}
case "unstaged": {
const trackedDiffArgs = ["diff", "--src-prefix=a/", "--dst-prefix=b/"];
const trackedDiff = assertGitSuccess(
await runtime.runGit(trackedDiffArgs, { cwd }),
trackedDiffArgs,
);
const untrackedDiff = await getUntrackedFileDiffs(
runtime,
"a/",
"b/",
cwd,
);
patch = trackedDiff.stdout + untrackedDiff;
label = "Unstaged changes";
break;
}
case "last-commit": {
const hasParent = await runtime.runGit(
["rev-parse", "--verify", "HEAD~1"],
{ cwd },
);
const args =
hasParent.exitCode === 0
? ["diff", "HEAD~1..HEAD", "--src-prefix=a/", "--dst-prefix=b/"]
: ["diff", "--root", "HEAD", "--src-prefix=a/", "--dst-prefix=b/"];
const lastCommitDiff = assertGitSuccess(
await runtime.runGit(args, { cwd }),
args,
);
patch = lastCommitDiff.stdout;
label = "Last commit";
break;
}
case "branch": {
const branchDiffArgs = [
"diff",
`${defaultBranch}..HEAD`,
"--src-prefix=a/",
"--dst-prefix=b/",
];
const branchDiff = assertGitSuccess(
await runtime.runGit(branchDiffArgs, { cwd }),
branchDiffArgs,
);
patch = branchDiff.stdout;
label = `Changes vs ${defaultBranch}`;
break;
}
default:
return { patch: "", label: "Unknown diff type" };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
patch: "",
label: cwd ? "Worktree error" : `Error: ${diffType}`,
error: message,
};
}
if (cwd) {
const branch = await getCurrentBranch(runtime, cwd);
label =
branch && branch !== "HEAD"
? `${branch}: ${label}`
: `${cwd.split("/").pop()}: ${label}`;
}
return { patch, label };
}
export async function getFileContentsForDiff(
runtime: ReviewGitRuntime,
diffType: DiffType,
defaultBranch: string,
filePath: string,
oldPath?: string,
cwd?: string,
): Promise<{ oldContent: string | null; newContent: string | null }> {
const oldFilePath = oldPath || filePath;
let effectiveDiffType = diffType as string;
if (diffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(diffType);
if (!parsed) return { oldContent: null, newContent: null };
cwd = parsed.path;
effectiveDiffType = parsed.subType;
}
async function gitShow(ref: string, path: string): Promise<string | null> {
const result = await runtime.runGit(["show", `${ref}:${path}`], { cwd });
return result.exitCode === 0 ? result.stdout : null;
}
async function readWorkingTree(path: string): Promise<string | null> {
const fullPath = cwd ? resolvePath(cwd, path) : path;
return runtime.readTextFile(fullPath);
}
switch (effectiveDiffType) {
case "uncommitted":
return {
oldContent: await gitShow("HEAD", oldFilePath),
newContent: await readWorkingTree(filePath),
};
case "staged":
return {
oldContent: await gitShow("HEAD", oldFilePath),
newContent: await gitShow(":0", filePath),
};
case "unstaged":
return {
oldContent: await gitShow(":0", oldFilePath),
newContent: await readWorkingTree(filePath),
};
case "last-commit":
return {
oldContent: await gitShow("HEAD~1", oldFilePath),
newContent: await gitShow("HEAD", filePath),
};
case "branch":
return {
oldContent: await gitShow(defaultBranch, oldFilePath),
newContent: await gitShow("HEAD", filePath),
};
default:
return { oldContent: null, newContent: null };
}
}
export function validateFilePath(filePath: string): void {
if (filePath.includes("..") || filePath.startsWith("/")) {
throw new Error("Invalid file path");
}
}
async function ensureGitSuccess(
runtime: ReviewGitRuntime,
args: string[],
cwd?: string,
): Promise<void> {
const result = await runtime.runGit(args, { cwd });
if (result.exitCode !== 0) {
throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
}
}
export async function gitAddFile(
runtime: ReviewGitRuntime,
filePath: string,
cwd?: string,
): Promise<void> {
validateFilePath(filePath);
await ensureGitSuccess(runtime, ["add", "--", filePath], cwd);
}
export async function gitResetFile(
runtime: ReviewGitRuntime,
filePath: string,
cwd?: string,
): Promise<void> {
validateFilePath(filePath);
await ensureGitSuccess(runtime, ["reset", "HEAD", "--", filePath], cwd);
}
+271
View File
@@ -0,0 +1,271 @@
import { afterEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGitContext, runGitDiff, startReviewServer } from "./server";
const tempDirs: string[] = [];
const originalCwd = process.cwd();
const originalHome = process.env.HOME;
const originalPort = process.env.PLANNOTATOR_PORT;
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function git(cwd: string, args: string[]): string {
const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
if (result.status !== 0) {
throw new Error(result.stderr || `git ${args.join(" ")} failed`);
}
return result.stdout.trim();
}
function initRepo(): string {
const repoDir = makeTempDir("plannotator-pi-review-");
git(repoDir, ["init"]);
git(repoDir, ["branch", "-M", "main"]);
git(repoDir, ["config", "user.email", "pi-review@example.com"]);
git(repoDir, ["config", "user.name", "Pi Review"]);
writeFileSync(join(repoDir, "tracked.txt"), "before\n", "utf-8");
git(repoDir, ["add", "tracked.txt"]);
git(repoDir, ["commit", "-m", "initial"]);
return repoDir;
}
function reservePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createNetServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to reserve test port"));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
afterEach(() => {
process.chdir(originalCwd);
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalPort === undefined) {
delete process.env.PLANNOTATOR_PORT;
} else {
process.env.PLANNOTATOR_PORT = originalPort;
}
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("pi review server", () => {
test("serves review diff parity endpoints including drafts, uploads, and editor annotations", async () => {
const homeDir = makeTempDir("plannotator-pi-home-");
const repoDir = initRepo();
process.env.HOME = homeDir;
process.chdir(repoDir);
process.env.PLANNOTATOR_PORT = String(await reservePort());
writeFileSync(join(repoDir, "tracked.txt"), "after\n", "utf-8");
writeFileSync(join(repoDir, "untracked.txt"), "brand new\n", "utf-8");
const gitContext = await getGitContext();
const diff = await runGitDiff("uncommitted", gitContext.defaultBranch);
const server = await startReviewServer({
rawPatch: diff.patch,
gitRef: diff.label,
error: diff.error,
diffType: "uncommitted",
gitContext,
origin: "pi",
htmlContent: "<!doctype html><html><body>review</body></html>",
});
try {
const diffResponse = await fetch(`${server.url}/api/diff`);
expect(diffResponse.status).toBe(200);
const diffPayload = await diffResponse.json() as {
rawPatch: string;
gitContext?: { diffOptions: Array<{ id: string }> };
origin?: string;
repoInfo?: { display: string };
};
expect(diffPayload.origin).toBe("pi");
expect(diffPayload.rawPatch).toContain("diff --git a/untracked.txt b/untracked.txt");
expect(diffPayload.gitContext?.diffOptions.map((option) => option.id)).toEqual(
expect.arrayContaining(["uncommitted", "staged", "unstaged", "last-commit"]),
);
expect(diffPayload.repoInfo?.display).toBeTruthy();
const fileContentResponse = await fetch(`${server.url}/api/file-content?path=tracked.txt`);
const fileContent = await fileContentResponse.json() as {
oldContent: string | null;
newContent: string | null;
};
expect(fileContent.oldContent).toBe("before\n");
expect(fileContent.newContent).toBe("after\n");
const draftBody = { annotations: [{ id: "draft-1" }] };
const draftSave = await fetch(`${server.url}/api/draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draftBody),
});
expect(draftSave.status).toBe(200);
const draftLoad = await fetch(`${server.url}/api/draft`);
expect(draftLoad.status).toBe(200);
expect(await draftLoad.json()).toEqual(draftBody);
const annotationCreate = await fetch(`${server.url}/api/editor-annotation`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filePath: "tracked.txt",
selectedText: "after",
lineStart: 1,
lineEnd: 1,
comment: "Check wording",
}),
});
expect(annotationCreate.status).toBe(200);
const createdAnnotation = await annotationCreate.json() as { id: string };
expect(createdAnnotation.id).toBeTruthy();
const annotationsList = await fetch(`${server.url}/api/editor-annotations`);
const annotationsPayload = await annotationsList.json() as { annotations: Array<{ id: string }> };
expect(annotationsPayload.annotations).toHaveLength(1);
expect(annotationsPayload.annotations[0].id).toBe(createdAnnotation.id);
const annotationDelete = await fetch(
`${server.url}/api/editor-annotation?id=${encodeURIComponent(createdAnnotation.id)}`,
{ method: "DELETE" },
);
expect(annotationDelete.status).toBe(200);
const agentsResponse = await fetch(`${server.url}/api/agents`);
expect(await agentsResponse.json()).toEqual({ agents: [] });
const formData = new FormData();
formData.append("file", new File(["png-bytes"], "diagram.png", { type: "image/png" }));
const uploadResponse = await fetch(`${server.url}/api/upload`, {
method: "POST",
body: formData,
});
expect(uploadResponse.status).toBe(200);
const uploadPayload = await uploadResponse.json() as { path: string; originalName: string };
expect(uploadPayload.originalName).toBe("diagram.png");
const imageResponse = await fetch(
`${server.url}/api/image?path=${encodeURIComponent(uploadPayload.path)}`,
);
expect(imageResponse.status).toBe(200);
expect(await imageResponse.text()).toBe("png-bytes");
const draftDelete = await fetch(`${server.url}/api/draft`, { method: "DELETE" });
expect(draftDelete.status).toBe(200);
const draftMissing = await fetch(`${server.url}/api/draft`);
expect(draftMissing.status).toBe(404);
const feedbackResponse = await fetch(`${server.url}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
approved: false,
feedback: "Please update the diff",
annotations: [{ id: "note-1" }],
}),
});
expect(feedbackResponse.status).toBe(200);
await expect(server.waitForDecision()).resolves.toEqual({
approved: false,
feedback: "Please update the diff",
annotations: [{ id: "note-1" }],
agentSwitch: undefined,
});
} finally {
server.stop();
}
});
test("git-add endpoint stages and unstages files in review mode", async () => {
const homeDir = makeTempDir("plannotator-pi-home-");
const repoDir = initRepo();
process.env.HOME = homeDir;
process.chdir(repoDir);
process.env.PLANNOTATOR_PORT = String(await reservePort());
writeFileSync(join(repoDir, "stage-me.txt"), "new file\n", "utf-8");
const gitContext = await getGitContext();
const diff = await runGitDiff("uncommitted", gitContext.defaultBranch);
const server = await startReviewServer({
rawPatch: diff.patch,
gitRef: diff.label,
error: diff.error,
diffType: "uncommitted",
gitContext,
origin: "pi",
htmlContent: "<!doctype html><html><body>review</body></html>",
});
try {
const stageResponse = await fetch(`${server.url}/api/git-add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filePath: "stage-me.txt" }),
});
expect(stageResponse.status).toBe(200);
expect(git(repoDir, ["diff", "--staged", "--name-only"])).toContain("stage-me.txt");
const unstageResponse = await fetch(`${server.url}/api/git-add`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filePath: "stage-me.txt", undo: true }),
});
expect(unstageResponse.status).toBe(200);
expect(git(repoDir, ["diff", "--staged", "--name-only"])).not.toContain("stage-me.txt");
expect(git(repoDir, ["status", "--short"])).toContain("?? stage-me.txt");
await fetch(`${server.url}/api/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
approved: true,
feedback: "LGTM - no changes requested.",
annotations: [],
}),
});
await server.waitForDecision();
} finally {
server.stop();
}
});
});
+476 -56
View File
@@ -7,10 +7,26 @@
*/
import { createServer, type IncomingMessage, type Server } from "node:http";
import { execSync, spawn } from "node:child_process";
import { execSync, spawn, spawnSync } from "node:child_process";
import os from "node:os";
import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync } from "node:fs";
import { join, basename, resolve as resolvePath } from "node:path";
import { Readable } from "node:stream";
import {
type DiffOption,
type DiffType,
type GitCommandResult,
type GitContext,
type ReviewGitRuntime,
getFileContentsForDiff as getFileContentsForDiffCore,
getGitContext as getGitContextCore,
gitAddFile as gitAddFileCore,
gitResetFile as gitResetFileCore,
parseWorktreeDiffType,
runGitDiff as runGitDiffCore,
validateFilePath,
} from "./review-core.js";
// ── Helpers ──────────────────────────────────────────────────────────────
@@ -38,6 +54,177 @@ function html(res: import("node:http").ServerResponse, content: string): void {
res.end(content);
}
function send(
res: import("node:http").ServerResponse,
body: string | Buffer,
status = 200,
headers: Record<string, string> = {},
): void {
res.writeHead(status, headers);
res.end(body);
}
interface EditorAnnotation {
id: string;
filePath: string;
selectedText: string;
lineStart: number;
lineEnd: number;
comment?: string;
createdAt: number;
}
function createEditorAnnotationHandler() {
const annotations: EditorAnnotation[] = [];
return {
async handle(req: IncomingMessage, res: import("node:http").ServerResponse, url: URL): Promise<boolean> {
if (url.pathname === "/api/editor-annotations" && req.method === "GET") {
json(res, { annotations });
return true;
}
if (url.pathname === "/api/editor-annotation" && req.method === "POST") {
const body = await parseBody(req);
if (!body.filePath || !body.selectedText || !body.lineStart || !body.lineEnd) {
json(res, { error: "Missing required fields" }, 400);
return true;
}
const annotation: EditorAnnotation = {
id: randomUUID(),
filePath: String(body.filePath),
selectedText: String(body.selectedText),
lineStart: Number(body.lineStart),
lineEnd: Number(body.lineEnd),
comment: typeof body.comment === "string" ? body.comment : undefined,
createdAt: Date.now(),
};
annotations.push(annotation);
json(res, { id: annotation.id });
return true;
}
if (url.pathname === "/api/editor-annotation" && req.method === "DELETE") {
const id = url.searchParams.get("id");
if (!id) {
json(res, { error: "Missing id parameter" }, 400);
return true;
}
const idx = annotations.findIndex((annotation) => annotation.id === id);
if (idx !== -1) {
annotations.splice(idx, 1);
}
json(res, { ok: true });
return true;
}
return false;
},
};
}
const ALLOWED_IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"bmp",
"ico",
"tiff",
"tif",
"avif",
]);
const IMAGE_CONTENT_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
avif: "image/avif",
};
const UPLOAD_DIR = join(os.tmpdir(), "plannotator");
function getExtension(filePath: string): string {
const lastDot = filePath.lastIndexOf(".");
if (lastDot === -1) return "";
return filePath.slice(lastDot + 1).toLowerCase();
}
function validateImagePath(rawPath: string): {
valid: boolean;
resolved: string;
error?: string;
} {
const resolved = resolvePath(rawPath);
const ext = getExtension(resolved);
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
return {
valid: false,
resolved,
error: "Path does not point to a supported image file",
};
}
return { valid: true, resolved };
}
function validateUploadExtension(fileName: string): {
valid: boolean;
ext: string;
error?: string;
} {
const ext = getExtension(fileName) || "png";
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
return {
valid: false,
ext,
error: `File extension ".${ext}" is not a supported image type`,
};
}
return { valid: true, ext };
}
function getImageContentType(filePath: string): string {
return IMAGE_CONTENT_TYPES[getExtension(filePath)] || "application/octet-stream";
}
function toWebRequest(req: IncomingMessage): Request {
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const item of value) headers.append(key, item);
} else {
headers.set(key, value);
}
}
const init: RequestInit & { duplex?: "half" } = {
method: req.method,
headers,
};
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = Readable.toWeb(req) as unknown as BodyInit;
init.duplex = "half";
}
return new Request(`http://localhost${req.url ?? "/"}`, init);
}
const DEFAULT_REMOTE_PORT = 19432;
/**
@@ -88,7 +275,7 @@ async function listenOnPort(server: Server): Promise<{ port: number; portSource:
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(result.port, () => {
server.listen(result.port, isRemoteSession() ? "0.0.0.0" : "127.0.0.1", () => {
server.removeListener("error", reject);
resolve();
});
@@ -209,6 +396,49 @@ function detectProjectName(): string {
}
}
function parseRemoteUrl(url: string): string | null {
if (!url) return null;
const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/);
if (sshMatch) return sshMatch[1];
const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/);
if (httpsMatch) return httpsMatch[1];
return null;
}
function getDirName(path: string): string | null {
if (!path) return null;
const trimmed = path.trim().replace(/\/+$/, "");
const parts = trimmed.split("/");
return parts[parts.length - 1] || null;
}
function getRepoInfo(): { display: string; branch?: string } | null {
const branch = git("rev-parse --abbrev-ref HEAD");
const safeBranch = branch && branch !== "HEAD" ? branch : undefined;
const originUrl = git("remote get-url origin");
const orgRepo = parseRemoteUrl(originUrl);
if (orgRepo) {
return { display: orgRepo, branch: safeBranch };
}
const topLevel = git("rev-parse --show-toplevel");
const repoName = getDirName(topLevel);
if (repoName) {
return { display: repoName, branch: safeBranch };
}
const cwdName = getDirName(process.cwd());
if (cwdName) {
return { display: cwdName };
}
return null;
}
function getHistoryDir(project: string, slug: string): string {
const historyDir = join(os.homedir(), ".plannotator", "history", project, slug);
mkdirSync(historyDir, { recursive: true });
@@ -337,6 +567,39 @@ function listProjectPlans(
}
}
function getDraftDir(): string {
const dir = join(os.homedir(), ".plannotator", "drafts");
mkdirSync(dir, { recursive: true });
return dir;
}
function contentHash(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16);
}
function saveDraft(key: string, data: object): void {
writeFileSync(join(getDraftDir(), `${key}.json`), JSON.stringify(data), "utf-8");
}
function loadDraft(key: string): object | null {
const filePath = join(getDraftDir(), `${key}.json`);
try {
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, "utf-8"));
} catch {
return null;
}
}
function deleteDraft(key: string): void {
const filePath = join(getDraftDir(), `${key}.json`);
try {
if (existsSync(filePath)) unlinkSync(filePath);
} catch {
// Ignore delete failures
}
}
// ── Plan Review Server ──────────────────────────────────────────────────
export interface PlanServerResult {
@@ -421,24 +684,18 @@ export async function startPlanReviewServer(options: {
};
}
export type DiffType = "uncommitted" | "staged" | "unstaged" | "last-commit" | "branch";
export interface DiffOption {
id: DiffType | "separator";
label: string;
}
export interface GitContext {
currentBranch: string;
defaultBranch: string;
diffOptions: DiffOption[];
}
export type { DiffType, DiffOption, GitContext } from "./review-core.js";
export interface ReviewServerResult {
port: number;
portSource: "env" | "remote-default" | "random";
url: string;
waitForDecision: () => Promise<{ approved: boolean; feedback: string }>;
waitForDecision: () => Promise<{
approved: boolean;
feedback: string;
annotations: unknown[];
agentSwitch?: string;
}>;
stop: () => void;
}
@@ -451,45 +708,37 @@ function git(cmd: string): string {
}
}
export function getGitContext(): GitContext {
const currentBranch = git("rev-parse --abbrev-ref HEAD") || "HEAD";
const reviewRuntime: ReviewGitRuntime = {
async runGit(args: string[], options?: { cwd?: string }): Promise<GitCommandResult> {
const result = spawnSync("git", args, {
cwd: options?.cwd,
encoding: "utf-8",
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
exitCode: result.status ?? (result.error ? 1 : 0),
};
},
let defaultBranch = "";
const symRef = git("symbolic-ref refs/remotes/origin/HEAD");
if (symRef) {
defaultBranch = symRef.replace("refs/remotes/origin/", "");
}
if (!defaultBranch) {
const hasMain = git("show-ref --verify refs/heads/main");
defaultBranch = hasMain ? "main" : "master";
}
async readTextFile(path: string): Promise<string | null> {
try {
return readFileSync(path, "utf-8");
} catch {
return null;
}
},
};
const diffOptions: DiffOption[] = [
{ id: "uncommitted", label: "Uncommitted changes" },
{ id: "last-commit", label: "Last commit" },
];
if (currentBranch !== defaultBranch) {
diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` });
}
return { currentBranch, defaultBranch, diffOptions };
export function getGitContext(): Promise<GitContext> {
return getGitContextCore(reviewRuntime);
}
export function runGitDiff(diffType: DiffType, defaultBranch = "main"): { patch: string; label: string } {
switch (diffType) {
case "uncommitted":
return { patch: git("diff HEAD --src-prefix=a/ --dst-prefix=b/"), label: "Uncommitted changes" };
case "staged":
return { patch: git("diff --staged --src-prefix=a/ --dst-prefix=b/"), label: "Staged changes" };
case "unstaged":
return { patch: git("diff --src-prefix=a/ --dst-prefix=b/"), label: "Unstaged changes" };
case "last-commit":
return { patch: git("diff HEAD~1..HEAD --src-prefix=a/ --dst-prefix=b/"), label: "Last commit" };
case "branch":
return { patch: git(`diff ${defaultBranch}..HEAD --src-prefix=a/ --dst-prefix=b/`), label: `Changes vs ${defaultBranch}` };
default:
return { patch: "", label: "Unknown diff type" };
}
export function runGitDiff(
diffType: DiffType,
defaultBranch = "main",
): Promise<{ patch: string; label: string; error?: string }> {
return runGitDiffCore(reviewRuntime, diffType, defaultBranch);
}
export async function startReviewServer(options: {
@@ -499,13 +748,34 @@ export async function startReviewServer(options: {
origin?: string;
diffType?: DiffType;
gitContext?: GitContext;
error?: string;
sharingEnabled?: boolean;
shareBaseUrl?: string;
}): Promise<ReviewServerResult> {
const draftKey = contentHash(options.rawPatch);
const repoInfo = getRepoInfo();
const editorAnnotations = createEditorAnnotationHandler();
let currentPatch = options.rawPatch;
let currentGitRef = options.gitRef;
let currentDiffType: DiffType = options.diffType || "uncommitted";
let currentError = options.error;
const sharingEnabled =
options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled";
const shareBaseUrl =
(options.shareBaseUrl ?? process.env.PLANNOTATOR_SHARE_URL) || undefined;
let resolveDecision!: (result: { approved: boolean; feedback: string }) => void;
const decisionPromise = new Promise<{ approved: boolean; feedback: string }>((r) => {
let resolveDecision!: (result: {
approved: boolean;
feedback: string;
annotations: unknown[];
agentSwitch?: string;
}) => void;
const decisionPromise = new Promise<{
approved: boolean;
feedback: string;
annotations: unknown[];
agentSwitch?: string;
}>((r) => {
resolveDecision = r;
});
@@ -519,6 +789,10 @@ export async function startReviewServer(options: {
origin: options.origin ?? "pi",
diffType: currentDiffType,
gitContext: options.gitContext,
sharingEnabled,
shareBaseUrl,
repoInfo,
...(currentError ? { error: currentError } : {}),
});
} else if (url.pathname === "/api/diff/switch" && req.method === "POST") {
const body = await parseBody(req);
@@ -528,16 +802,162 @@ export async function startReviewServer(options: {
return;
}
const defaultBranch = options.gitContext?.defaultBranch || "main";
const result = runGitDiff(newType, defaultBranch);
const result = await runGitDiff(newType, defaultBranch);
currentPatch = result.patch;
currentGitRef = result.label;
currentDiffType = newType;
json(res, { rawPatch: currentPatch, gitRef: currentGitRef, diffType: currentDiffType });
currentError = result.error;
json(res, {
rawPatch: currentPatch,
gitRef: currentGitRef,
diffType: currentDiffType,
...(currentError ? { error: currentError } : {}),
});
} else if (url.pathname === "/api/file-content" && req.method === "GET") {
const filePath = url.searchParams.get("path");
if (!filePath) {
json(res, { error: "Missing path" }, 400);
return;
}
try {
validateFilePath(filePath);
} catch {
json(res, { error: "Invalid path" }, 400);
return;
}
const oldPath = url.searchParams.get("oldPath") || undefined;
if (oldPath) {
try {
validateFilePath(oldPath);
} catch {
json(res, { error: "Invalid path" }, 400);
return;
}
}
const defaultBranch = options.gitContext?.defaultBranch || "main";
const result = await getFileContentsForDiffCore(
reviewRuntime,
currentDiffType,
defaultBranch,
filePath,
oldPath,
);
json(res, result);
} else if (url.pathname === "/api/image") {
const imagePath = url.searchParams.get("path");
if (!imagePath) {
send(res, "Missing path parameter", 400, { "Content-Type": "text/plain" });
return;
}
const tryServePath = (candidate: string): boolean => {
const validation = validateImagePath(candidate);
if (!validation.valid) {
return false;
}
try {
if (!existsSync(validation.resolved)) {
return false;
}
const data = readFileSync(validation.resolved);
send(res, data, 200, { "Content-Type": getImageContentType(validation.resolved) });
return true;
} catch {
return false;
}
};
if (tryServePath(imagePath)) return;
const base = url.searchParams.get("base");
if (base && !imagePath.startsWith("/") && tryServePath(resolvePath(base, imagePath))) {
return;
}
const validation = validateImagePath(imagePath);
if (!validation.valid) {
send(res, validation.error || "Invalid image path", 403, { "Content-Type": "text/plain" });
return;
}
send(res, "File not found", 404, { "Content-Type": "text/plain" });
} else if (url.pathname === "/api/upload" && req.method === "POST") {
try {
const request = toWebRequest(req);
const formData = await request.formData();
const file = formData.get("file");
if (!file || typeof file !== "object" || !("arrayBuffer" in file) || !("name" in file)) {
json(res, { error: "No file provided" }, 400);
return;
}
const upload = file as File;
const extResult = validateUploadExtension(upload.name);
if (!extResult.valid) {
json(res, { error: extResult.error }, 400);
return;
}
mkdirSync(UPLOAD_DIR, { recursive: true });
const tempPath = join(UPLOAD_DIR, `${randomUUID()}.${extResult.ext}`);
const bytes = Buffer.from(await upload.arrayBuffer());
writeFileSync(tempPath, bytes);
json(res, { path: tempPath, originalName: upload.name });
} catch (err) {
const message = err instanceof Error ? err.message : "Upload failed";
json(res, { error: message }, 500);
}
} else if (url.pathname === "/api/agents" && req.method === "GET") {
json(res, { agents: [] });
} else if (url.pathname === "/api/git-add" && req.method === "POST") {
const body = await parseBody(req);
const filePath = body.filePath as string | undefined;
if (!filePath) {
json(res, { error: "Missing filePath" }, 400);
return;
}
try {
let cwd: string | undefined;
if (currentDiffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(currentDiffType);
if (parsed) cwd = parsed.path;
}
if (body.undo) {
await gitResetFileCore(reviewRuntime, filePath, cwd);
} else {
await gitAddFileCore(reviewRuntime, filePath, cwd);
}
json(res, { ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to git add";
json(res, { error: message }, 500);
}
} else if (url.pathname === "/api/draft") {
if (req.method === "POST") {
const body = await parseBody(req);
saveDraft(draftKey, body);
json(res, { ok: true });
} else if (req.method === "DELETE") {
deleteDraft(draftKey);
json(res, { ok: true });
} else {
const draft = loadDraft(draftKey);
if (!draft) {
json(res, { found: false }, 404);
return;
}
json(res, draft);
}
} else if (await editorAnnotations.handle(req, res, url)) {
return;
} else if (url.pathname === "/api/feedback" && req.method === "POST") {
const body = await parseBody(req);
deleteDraft(draftKey);
resolveDecision({
approved: (body.approved as boolean) ?? false,
feedback: (body.feedback as string) || "",
annotations: (body.annotations as unknown[]) || [],
agentSwitch: body.agentSwitch as string | undefined,
});
json(res, { ok: true });
} else {
+89 -391
View File
@@ -5,418 +5,116 @@
* Used by both Claude Code hook and OpenCode plugin.
*/
import { $ } from "bun";
import {
type DiffOption,
type DiffResult,
type DiffType,
type GitCommandResult,
type GitContext,
type ReviewGitRuntime,
type WorktreeInfo,
getCurrentBranch as getCurrentBranchCore,
getDefaultBranch as getDefaultBranchCore,
getWorktrees as getWorktreesCore,
getGitContext as getGitContextCore,
getFileContentsForDiff as getFileContentsForDiffCore,
gitAddFile as gitAddFileCore,
gitResetFile as gitResetFileCore,
parseWorktreeDiffType,
runGitDiff as runGitDiffCore,
validateFilePath,
} from "@plannotator/shared/review-core";
export type { DiffOption, WorktreeInfo, GitContext } from "@plannotator/shared/types";
export type {
DiffOption,
DiffType,
DiffResult,
GitContext,
WorktreeInfo,
} from "@plannotator/shared/review-core";
export type DiffType =
| "uncommitted"
| "staged"
| "unstaged"
| "last-commit"
| "branch"
| `worktree:${string}`;
async function runGit(
args: string[],
options?: { cwd?: string },
): Promise<GitCommandResult> {
const proc = Bun.spawn(["git", ...args], {
cwd: options?.cwd,
stdout: "pipe",
stderr: "pipe",
});
export interface DiffResult {
patch: string;
label: string;
error?: string;
}
/**
* Get the current branch name
*/
export async function getCurrentBranch(): Promise<string> {
try {
const result = await $`git rev-parse --abbrev-ref HEAD`.quiet();
return result.text().trim();
} catch {
return "HEAD"; // Detached HEAD state
}
}
/**
* Detect the default branch (main, master, etc.)
*
* Strategy:
* 1. Check origin's HEAD reference
* 2. Fallback to checking if 'main' exists
* 3. Final fallback to 'master'
*/
export async function getDefaultBranch(): Promise<string> {
// Try origin's HEAD first (most reliable for repos with remotes)
try {
const result =
await $`git symbolic-ref refs/remotes/origin/HEAD`.quiet();
const ref = result.text().trim();
return ref.replace("refs/remotes/origin/", "");
} catch {
// No remote or no HEAD set - check local branches
}
// Fallback: check if main exists locally
try {
await $`git show-ref --verify refs/heads/main`.quiet();
return "main";
} catch {
// main doesn't exist
}
// Final fallback
return "master";
}
/**
* List all git worktrees by parsing `git worktree list --porcelain`
*/
export async function getWorktrees(): Promise<WorktreeInfo[]> {
try {
const result = await $`git worktree list --porcelain`.quiet().nothrow();
if (result.exitCode !== 0) return [];
const text = result.text();
const entries: WorktreeInfo[] = [];
let current: Partial<WorktreeInfo> = {};
for (const line of text.split("\n")) {
if (line.startsWith("worktree ")) {
if (current.path) {
entries.push({ path: current.path, head: current.head || "", branch: current.branch ?? null });
}
current = { path: line.slice("worktree ".length) };
} else if (line.startsWith("HEAD ")) {
current.head = line.slice("HEAD ".length);
} else if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).replace("refs/heads/", "");
} else if (line === "detached") {
current.branch = null;
}
}
// Flush last entry
if (current.path) {
entries.push({ path: current.path, head: current.head || "", branch: current.branch ?? null });
}
return entries;
} catch {
return [];
}
}
/**
* Get git context including branch info and available diff options
*/
export async function getGitContext(): Promise<GitContext> {
const [currentBranch, defaultBranch] = await Promise.all([
getCurrentBranch(),
getDefaultBranch(),
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
const diffOptions: DiffOption[] = [
{ id: "uncommitted", label: "Uncommitted changes" },
{ id: "staged", label: "Staged changes" },
{ id: "unstaged", label: "Unstaged changes" },
{ id: "last-commit", label: "Last commit" },
];
// Only show branch diff if not on default branch
if (currentBranch !== defaultBranch) {
diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` });
}
// Discover worktrees (exposed separately from diff options)
const [worktrees, currentTreePath] = await Promise.all([
getWorktrees(),
$`git rev-parse --show-toplevel`.quiet().then(r => r.text().trim()).catch(() => null),
]);
const otherWorktrees = worktrees.filter(wt => wt.path !== currentTreePath);
return { currentBranch, defaultBranch, diffOptions, worktrees: otherWorktrees };
return { stdout, stderr, exitCode };
}
/**
* Get diffs for untracked (new) files not yet added to git.
*
* `git diff HEAD` and `git diff` only show tracked files. Newly created files
* that haven't been staged with `git add` are invisible to those commands.
* This helper discovers them via `git ls-files --others --exclude-standard` and
* generates a proper unified diff for each using `git diff --no-index`.
*
* Note: `git diff --no-index` exits with code 1 when files differ (standard git
* behaviour), so we use `.nothrow()` to avoid treating that as an error.
*/
async function getUntrackedFileDiffs(srcPrefix = 'a/', dstPrefix = 'b/', cwd?: string): Promise<string> {
try {
const lsCmd = $`git ls-files --others --exclude-standard`.quiet();
const output = (cwd ? await lsCmd.cwd(cwd) : await lsCmd).text();
const files = output.trim().split('\n').filter((f) => f.length > 0);
if (files.length === 0) return '';
const diffs = await Promise.all(
files.map(async (file) => {
try {
const diffCmd = $`git diff --no-index --src-prefix=${srcPrefix} --dst-prefix=${dstPrefix} /dev/null ${file}`
.quiet()
.nothrow();
const result = cwd ? await diffCmd.cwd(cwd) : await diffCmd;
return result.text();
} catch {
return '';
}
}),
);
return diffs.join('');
} catch {
return '';
}
}
/**
* Parse a worktree diff type like `worktree:/path:last-commit` into path + sub-type.
* Falls back to `uncommitted` if no sub-type suffix (backwards compatible).
*/
const WORKTREE_SUB_TYPES = new Set(["uncommitted", "staged", "unstaged", "last-commit", "branch"]);
export function parseWorktreeDiffType(diffType: string): { path: string; subType: string } | null {
if (!diffType.startsWith("worktree:")) return null;
const rest = diffType.slice("worktree:".length);
const lastColon = rest.lastIndexOf(":");
if (lastColon !== -1) {
const maybeSub = rest.slice(lastColon + 1);
if (WORKTREE_SUB_TYPES.has(maybeSub)) {
return { path: rest.slice(0, lastColon), subType: maybeSub };
}
}
return { path: rest, subType: "uncommitted" };
}
/**
* Run git diff with the specified type
*/
export async function runGitDiff(
diffType: DiffType,
defaultBranch: string = "main"
): Promise<DiffResult> {
let patch: string;
let label: string;
// Handle worktree diffs — run git commands in the worktree's directory
if (diffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(diffType);
if (!parsed) {
return { patch: "", label: "Worktree error", error: "Could not parse worktree diff type" };
}
const { path: wtPath, subType } = parsed;
const runtime: ReviewGitRuntime = {
runGit,
async readTextFile(path: string): Promise<string | null> {
try {
switch (subType) {
case "uncommitted": {
const trackedDiff = (await $`git diff HEAD --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
const untrackedDiff = await getUntrackedFileDiffs('a/', 'b/', wtPath);
patch = trackedDiff + untrackedDiff;
label = "Uncommitted changes";
break;
}
case "last-commit": {
const hasParent = (await $`git rev-parse --verify HEAD~1`.quiet().nothrow().cwd(wtPath)).exitCode === 0;
if (hasParent) {
patch = (await $`git diff HEAD~1..HEAD --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
} else {
// Initial commit — show full tree as diff
patch = (await $`git diff --root HEAD --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
}
label = "Last commit";
break;
}
case "staged":
patch = (await $`git diff --staged --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
label = "Staged changes";
break;
case "unstaged": {
const trackedDiff = (await $`git diff --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
const untrackedDiff = await getUntrackedFileDiffs('a/', 'b/', wtPath);
patch = trackedDiff + untrackedDiff;
label = "Unstaged changes";
break;
}
case "branch":
patch = (await $`git diff ${defaultBranch}..HEAD --src-prefix=a/ --dst-prefix=b/`.quiet().cwd(wtPath)).text();
label = `Changes vs ${defaultBranch}`;
break;
default:
patch = "";
label = "Unknown worktree diff type";
}
// Prefix label with worktree branch name for context
try {
const branch = (await $`git rev-parse --abbrev-ref HEAD`.quiet().cwd(wtPath)).text().trim();
label = `${branch}: ${label}`;
} catch {
label = `${wtPath.split("/").pop()}: ${label}`;
}
return { patch, label };
} catch (error) {
console.error(`Git diff error for ${diffType}:`, error);
const errorMessage = error instanceof Error ? error.message : String(error);
return { patch: "", label: "Worktree error", error: errorMessage };
return await Bun.file(path).text();
} catch {
return null;
}
}
},
};
try {
switch (diffType) {
case "uncommitted": {
// Include tracked changes (staged + unstaged vs HEAD) and untracked new files
const trackedDiff = (await $`git diff HEAD --src-prefix=a/ --dst-prefix=b/`.quiet()).text();
const untrackedDiff = await getUntrackedFileDiffs();
patch = trackedDiff + untrackedDiff;
label = "Uncommitted changes";
break;
}
case "staged":
patch = (await $`git diff --staged --src-prefix=a/ --dst-prefix=b/`.quiet()).text();
label = "Staged changes";
break;
case "unstaged": {
// Include unstaged changes to tracked files and untracked new files
const trackedDiff = (await $`git diff --src-prefix=a/ --dst-prefix=b/`.quiet()).text();
const untrackedDiff = await getUntrackedFileDiffs();
patch = trackedDiff + untrackedDiff;
label = "Unstaged changes";
break;
}
case "last-commit": {
const hasParent = (await $`git rev-parse --verify HEAD~1`.quiet().nothrow()).exitCode === 0;
if (hasParent) {
patch = (await $`git diff HEAD~1..HEAD --src-prefix=a/ --dst-prefix=b/`.quiet()).text();
} else {
patch = (await $`git diff --root HEAD --src-prefix=a/ --dst-prefix=b/`.quiet()).text();
}
label = "Last commit";
break;
}
case "branch":
patch = (await $`git diff ${defaultBranch}..HEAD --src-prefix=a/ --dst-prefix=b/`.quiet()).text();
label = `Changes vs ${defaultBranch}`;
break;
default:
patch = "";
label = "Unknown diff type";
}
} catch (error) {
// Handle errors gracefully (e.g., no commits yet, invalid ref)
console.error(`Git diff error for ${diffType}:`, error);
const errorMessage = error instanceof Error ? error.message : String(error);
patch = "";
label = `Error: ${diffType}`;
return { patch, label, error: errorMessage };
}
return { patch, label };
export function getCurrentBranch(): Promise<string> {
return getCurrentBranchCore(runtime);
}
/**
* Get old and new file contents for a given diff type.
* Used by the expandable context feature to feed full file content to @pierre/diffs.
*/
export async function getFileContentsForDiff(
export function getDefaultBranch(): Promise<string> {
return getDefaultBranchCore(runtime);
}
export function getWorktrees(): Promise<WorktreeInfo[]> {
return getWorktreesCore(runtime);
}
export function getGitContext(): Promise<GitContext> {
return getGitContextCore(runtime);
}
export function runGitDiff(
diffType: DiffType,
defaultBranch: string = "main",
): Promise<DiffResult> {
return runGitDiffCore(runtime, diffType, defaultBranch);
}
export function getFileContentsForDiff(
diffType: DiffType,
defaultBranch: string,
filePath: string,
oldPath?: string,
cwd?: string,
): Promise<{ oldContent: string | null; newContent: string | null }> {
const oldFilePath = oldPath || filePath;
async function gitShow(ref: string, path: string): Promise<string | null> {
try {
const cmd = $`git show ${ref}:${path}`.quiet();
return (cwd ? await cmd.cwd(cwd) : await cmd).text();
} catch {
return null;
}
}
async function readWorkingTree(path: string): Promise<string | null> {
try {
const fullPath = cwd ? `${cwd}/${path}` : path;
return await Bun.file(fullPath).text();
} catch {
return null;
}
}
// Determine the effective diff type (handle worktree prefix)
let effectiveDiffType = diffType as string;
if (diffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(diffType);
if (!parsed) return { oldContent: null, newContent: null };
cwd = parsed.path;
effectiveDiffType = parsed.subType;
}
let oldContent: string | null = null;
let newContent: string | null = null;
switch (effectiveDiffType) {
case "uncommitted":
oldContent = await gitShow("HEAD", oldFilePath);
newContent = await readWorkingTree(filePath);
break;
case "staged":
oldContent = await gitShow("HEAD", oldFilePath);
newContent = await gitShow(":0", filePath);
break;
case "unstaged":
oldContent = await gitShow(":0", oldFilePath);
newContent = await readWorkingTree(filePath);
break;
case "last-commit":
oldContent = await gitShow("HEAD~1", oldFilePath);
newContent = await gitShow("HEAD", filePath);
break;
case "branch":
oldContent = await gitShow(defaultBranch, oldFilePath);
newContent = await gitShow("HEAD", filePath);
break;
}
return { oldContent, newContent };
return getFileContentsForDiffCore(
runtime,
diffType,
defaultBranch,
filePath,
oldPath,
cwd,
);
}
/**
* Validate a file path for git operations.
* Rejects path traversal and absolute paths.
*/
export function validateFilePath(filePath: string): void {
if (filePath.includes("..") || filePath.startsWith("/")) {
throw new Error("Invalid file path");
}
export function gitAddFile(
filePath: string,
cwd?: string,
): Promise<void> {
return gitAddFileCore(runtime, filePath, cwd);
}
/**
* Stage a file via `git add`.
*/
export async function gitAddFile(filePath: string, cwd?: string): Promise<void> {
validateFilePath(filePath);
const cmd = $`git add -- ${filePath}`.quiet();
await (cwd ? cmd.cwd(cwd) : cmd);
export function gitResetFile(
filePath: string,
cwd?: string,
): Promise<void> {
return gitResetFileCore(runtime, filePath, cwd);
}
/**
* Unstage a file via `git reset HEAD`.
*/
export async function gitResetFile(filePath: string, cwd?: string): Promise<void> {
validateFilePath(filePath);
const cmd = $`git reset HEAD -- ${filePath}`.quiet();
await (cwd ? cmd.cwd(cwd) : cmd);
}
export { parseWorktreeDiffType, validateFilePath };
+3 -1
View File
@@ -5,6 +5,8 @@
"exports": {
"./compress": "./compress.ts",
"./crypto": "./crypto.ts",
"./feedback-templates": "./feedback-templates.ts"
"./feedback-templates": "./feedback-templates.ts",
"./review-core": "./review-core.ts",
"./types": "./types.ts"
}
}
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import {
getFileContentsForDiff,
getGitContext,
runGitDiff,
type ReviewGitRuntime,
} from "./review-core";
const tempDirs: string[] = [];
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function git(cwd: string, args: string[]): string {
const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
if (result.status !== 0) {
throw new Error(result.stderr || `git ${args.join(" ")} failed`);
}
return result.stdout.trim();
}
function makeRuntime(baseCwd: string): ReviewGitRuntime {
return {
async runGit(args: string[], options?: { cwd?: string }) {
const result = spawnSync("git", args, {
cwd: options?.cwd ?? baseCwd,
encoding: "utf-8",
});
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
exitCode: result.status ?? (result.error ? 1 : 0),
};
},
async readTextFile(path: string) {
try {
const fullPath = path.startsWith("/") ? path : resolvePath(baseCwd, path);
return readFileSync(fullPath, "utf-8");
} catch {
return null;
}
},
};
}
function initRepo(initialBranch = "main"): string {
const repoDir = makeTempDir("plannotator-review-core-");
git(repoDir, ["init"]);
git(repoDir, ["branch", "-M", initialBranch]);
git(repoDir, ["config", "user.email", "review-core@example.com"]);
git(repoDir, ["config", "user.name", "Review Core"]);
writeFileSync(join(repoDir, "tracked.txt"), "before\n", "utf-8");
git(repoDir, ["add", "tracked.txt"]);
git(repoDir, ["commit", "-m", "initial"]);
return repoDir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("review-core", () => {
test("uncommitted diff includes tracked and untracked files", async () => {
const repoDir = initRepo();
const runtime = makeRuntime(repoDir);
writeFileSync(join(repoDir, "tracked.txt"), "after\n", "utf-8");
writeFileSync(join(repoDir, "untracked.txt"), "brand new\n", "utf-8");
const result = await runGitDiff(runtime, "uncommitted", "main");
expect(result.label).toBe("Uncommitted changes");
expect(result.patch).toContain("diff --git a/tracked.txt b/tracked.txt");
expect(result.patch).toContain("diff --git a/untracked.txt b/untracked.txt");
expect(result.patch).toContain("+++ b/untracked.txt");
});
test("unstaged diff includes untracked files", async () => {
const repoDir = initRepo();
const runtime = makeRuntime(repoDir);
writeFileSync(join(repoDir, "tracked.txt"), "after\n", "utf-8");
git(repoDir, ["add", "tracked.txt"]);
writeFileSync(join(repoDir, "tracked.txt"), "after again\n", "utf-8");
writeFileSync(join(repoDir, "scratch.txt"), "tmp\n", "utf-8");
const result = await runGitDiff(runtime, "unstaged", "main");
expect(result.label).toBe("Unstaged changes");
expect(result.patch).toContain("diff --git a/tracked.txt b/tracked.txt");
expect(result.patch).toContain("diff --git a/scratch.txt b/scratch.txt");
});
test("staged diff excludes untracked files until they are staged", async () => {
const repoDir = initRepo();
const runtime = makeRuntime(repoDir);
writeFileSync(join(repoDir, "tracked.txt"), "staged change\n", "utf-8");
git(repoDir, ["add", "tracked.txt"]);
writeFileSync(join(repoDir, "draft.txt"), "not staged yet\n", "utf-8");
const stagedOnly = await runGitDiff(runtime, "staged", "main");
expect(stagedOnly.patch).toContain("diff --git a/tracked.txt b/tracked.txt");
expect(stagedOnly.patch).not.toContain("draft.txt");
git(repoDir, ["add", "draft.txt"]);
const stagedWithNewFile = await runGitDiff(runtime, "staged", "main");
expect(stagedWithNewFile.patch).toContain("diff --git a/draft.txt b/draft.txt");
});
test("branch diff returns an error when the default branch ref is invalid", async () => {
const repoDir = initRepo("trunk");
const runtime = makeRuntime(repoDir);
writeFileSync(join(repoDir, "tracked.txt"), "after\n", "utf-8");
const context = await getGitContext(runtime);
expect(context.defaultBranch).toBe("master");
const result = await runGitDiff(runtime, "branch", context.defaultBranch);
expect(result.patch).toBe("");
expect(result.label).toBe("Error: branch");
expect(result.error).toContain("git diff master..HEAD");
});
test("git context lists worktrees and file content lookup returns old/new content", async () => {
const repoDir = initRepo();
const runtime = makeRuntime(repoDir);
const worktreeParent = makeTempDir("plannotator-review-core-worktree-");
const worktreeDir = join(worktreeParent, "feature-worktree");
git(repoDir, ["worktree", "add", "-b", "feature/review-core", worktreeDir]);
writeFileSync(join(repoDir, "tracked.txt"), "after\n", "utf-8");
writeFileSync(join(repoDir, "new-file.txt"), "brand new\n", "utf-8");
const context = await getGitContext(runtime);
expect(context.diffOptions.map((option) => option.id)).toEqual(
expect.arrayContaining(["uncommitted", "staged", "unstaged", "last-commit"]),
);
expect(
context.worktrees.some((worktree) => worktree.path.endsWith("/feature-worktree")),
).toBe(true);
const trackedContents = await getFileContentsForDiff(
runtime,
"uncommitted",
context.defaultBranch,
"tracked.txt",
);
expect(trackedContents.oldContent).toBe("before\n");
expect(trackedContents.newContent).toBe("after\n");
const newFileContents = await getFileContentsForDiff(
runtime,
"uncommitted",
context.defaultBranch,
"new-file.txt",
);
expect(newFileContents.oldContent).toBeNull();
expect(newFileContents.newContent).toBe("brand new\n");
});
});
+480
View File
@@ -0,0 +1,480 @@
/**
* Runtime-agnostic code-review core shared by Bun runtimes and Pi.
*
* Pi consumes a build-time copy of this file so its published package stays
* self-contained while review diff logic remains sourced from one module.
*/
import { resolve as resolvePath } from "node:path";
export type DiffType =
| "uncommitted"
| "staged"
| "unstaged"
| "last-commit"
| "branch"
| `worktree:${string}`;
export interface DiffOption {
id: string;
label: string;
}
export interface WorktreeInfo {
path: string;
branch: string | null;
head: string;
}
export interface GitContext {
currentBranch: string;
defaultBranch: string;
diffOptions: DiffOption[];
worktrees: WorktreeInfo[];
}
export interface DiffResult {
patch: string;
label: string;
error?: string;
}
export interface GitCommandResult {
stdout: string;
stderr: string;
exitCode: number;
}
export interface ReviewGitRuntime {
runGit: (
args: string[],
options?: { cwd?: string },
) => Promise<GitCommandResult>;
readTextFile: (path: string) => Promise<string | null>;
}
export async function getCurrentBranch(
runtime: ReviewGitRuntime,
cwd?: string,
): Promise<string> {
const result = await runtime.runGit(
["rev-parse", "--abbrev-ref", "HEAD"],
{ cwd },
);
return result.exitCode === 0 ? result.stdout.trim() || "HEAD" : "HEAD";
}
export async function getDefaultBranch(
runtime: ReviewGitRuntime,
cwd?: string,
): Promise<string> {
const remoteHead = await runtime.runGit(
["symbolic-ref", "refs/remotes/origin/HEAD"],
{ cwd },
);
if (remoteHead.exitCode === 0) {
const ref = remoteHead.stdout.trim();
if (ref) return ref.replace("refs/remotes/origin/", "");
}
const mainBranch = await runtime.runGit(
["show-ref", "--verify", "refs/heads/main"],
{ cwd },
);
if (mainBranch.exitCode === 0) return "main";
return "master";
}
export async function getWorktrees(
runtime: ReviewGitRuntime,
): Promise<WorktreeInfo[]> {
const result = await runtime.runGit(["worktree", "list", "--porcelain"]);
if (result.exitCode !== 0) return [];
const entries: WorktreeInfo[] = [];
let current: Partial<WorktreeInfo> = {};
for (const line of result.stdout.split("\n")) {
if (line.startsWith("worktree ")) {
if (current.path) {
entries.push({
path: current.path,
head: current.head || "",
branch: current.branch ?? null,
});
}
current = { path: line.slice("worktree ".length) };
} else if (line.startsWith("HEAD ")) {
current.head = line.slice("HEAD ".length);
} else if (line.startsWith("branch ")) {
current.branch = line
.slice("branch ".length)
.replace("refs/heads/", "");
} else if (line === "detached") {
current.branch = null;
}
}
if (current.path) {
entries.push({
path: current.path,
head: current.head || "",
branch: current.branch ?? null,
});
}
return entries;
}
export async function getGitContext(
runtime: ReviewGitRuntime,
): Promise<GitContext> {
const [currentBranch, defaultBranch] = await Promise.all([
getCurrentBranch(runtime),
getDefaultBranch(runtime),
]);
const diffOptions: DiffOption[] = [
{ id: "uncommitted", label: "Uncommitted changes" },
{ id: "staged", label: "Staged changes" },
{ id: "unstaged", label: "Unstaged changes" },
{ id: "last-commit", label: "Last commit" },
];
if (currentBranch !== defaultBranch) {
diffOptions.push({ id: "branch", label: `vs ${defaultBranch}` });
}
const [worktrees, currentTreePathResult] = await Promise.all([
getWorktrees(runtime),
runtime.runGit(["rev-parse", "--show-toplevel"]),
]);
const currentTreePath =
currentTreePathResult.exitCode === 0
? currentTreePathResult.stdout.trim()
: null;
return {
currentBranch,
defaultBranch,
diffOptions,
worktrees: worktrees.filter((wt) => wt.path !== currentTreePath),
};
}
async function getUntrackedFileDiffs(
runtime: ReviewGitRuntime,
srcPrefix = "a/",
dstPrefix = "b/",
cwd?: string,
): Promise<string> {
const lsResult = await runtime.runGit(
["ls-files", "--others", "--exclude-standard"],
{ cwd },
);
if (lsResult.exitCode !== 0) return "";
const files = lsResult.stdout
.trim()
.split("\n")
.filter((file) => file.length > 0);
if (files.length === 0) return "";
const diffs = await Promise.all(
files.map(async (file) => {
const diffResult = await runtime.runGit(
[
"diff",
"--no-index",
`--src-prefix=${srcPrefix}`,
`--dst-prefix=${dstPrefix}`,
"/dev/null",
file,
],
{ cwd },
);
return diffResult.stdout;
}),
);
return diffs.join("");
}
function assertGitSuccess(
result: GitCommandResult,
args: string[],
): GitCommandResult {
if (result.exitCode === 0) return result;
const command = `git ${args.join(" ")}`;
const stderr = result.stderr.trim();
throw new Error(
stderr
? `${command} failed: ${stderr}`
: `${command} failed with exit code ${result.exitCode}`,
);
}
const WORKTREE_SUB_TYPES = new Set([
"uncommitted",
"staged",
"unstaged",
"last-commit",
"branch",
]);
export function parseWorktreeDiffType(
diffType: string,
): { path: string; subType: string } | null {
if (!diffType.startsWith("worktree:")) return null;
const rest = diffType.slice("worktree:".length);
const lastColon = rest.lastIndexOf(":");
if (lastColon !== -1) {
const maybeSub = rest.slice(lastColon + 1);
if (WORKTREE_SUB_TYPES.has(maybeSub)) {
return { path: rest.slice(0, lastColon), subType: maybeSub };
}
}
return { path: rest, subType: "uncommitted" };
}
export async function runGitDiff(
runtime: ReviewGitRuntime,
diffType: DiffType,
defaultBranch: string = "main",
): Promise<DiffResult> {
let patch = "";
let label = "";
let cwd: string | undefined;
let effectiveDiffType = diffType as string;
if (diffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(diffType);
if (!parsed) {
return {
patch: "",
label: "Worktree error",
error: "Could not parse worktree diff type",
};
}
cwd = parsed.path;
effectiveDiffType = parsed.subType;
}
try {
switch (effectiveDiffType) {
case "uncommitted": {
const trackedDiffArgs = [
"diff",
"HEAD",
"--src-prefix=a/",
"--dst-prefix=b/",
];
const hasHead =
(await runtime.runGit(["rev-parse", "--verify", "HEAD"], { cwd }))
.exitCode === 0;
const trackedPatch = hasHead
? assertGitSuccess(
await runtime.runGit(trackedDiffArgs, { cwd }),
trackedDiffArgs,
).stdout
: "";
const untrackedDiff = await getUntrackedFileDiffs(
runtime,
"a/",
"b/",
cwd,
);
patch = trackedPatch + untrackedDiff;
label = "Uncommitted changes";
break;
}
case "staged": {
const stagedDiffArgs = [
"diff",
"--staged",
"--src-prefix=a/",
"--dst-prefix=b/",
];
const stagedDiff = assertGitSuccess(
await runtime.runGit(stagedDiffArgs, { cwd }),
stagedDiffArgs,
);
patch = stagedDiff.stdout;
label = "Staged changes";
break;
}
case "unstaged": {
const trackedDiffArgs = ["diff", "--src-prefix=a/", "--dst-prefix=b/"];
const trackedDiff = assertGitSuccess(
await runtime.runGit(trackedDiffArgs, { cwd }),
trackedDiffArgs,
);
const untrackedDiff = await getUntrackedFileDiffs(
runtime,
"a/",
"b/",
cwd,
);
patch = trackedDiff.stdout + untrackedDiff;
label = "Unstaged changes";
break;
}
case "last-commit": {
const hasParent = await runtime.runGit(
["rev-parse", "--verify", "HEAD~1"],
{ cwd },
);
const args =
hasParent.exitCode === 0
? ["diff", "HEAD~1..HEAD", "--src-prefix=a/", "--dst-prefix=b/"]
: ["diff", "--root", "HEAD", "--src-prefix=a/", "--dst-prefix=b/"];
const lastCommitDiff = assertGitSuccess(
await runtime.runGit(args, { cwd }),
args,
);
patch = lastCommitDiff.stdout;
label = "Last commit";
break;
}
case "branch": {
const branchDiffArgs = [
"diff",
`${defaultBranch}..HEAD`,
"--src-prefix=a/",
"--dst-prefix=b/",
];
const branchDiff = assertGitSuccess(
await runtime.runGit(branchDiffArgs, { cwd }),
branchDiffArgs,
);
patch = branchDiff.stdout;
label = `Changes vs ${defaultBranch}`;
break;
}
default:
return { patch: "", label: "Unknown diff type" };
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
patch: "",
label: cwd ? "Worktree error" : `Error: ${diffType}`,
error: message,
};
}
if (cwd) {
const branch = await getCurrentBranch(runtime, cwd);
label =
branch && branch !== "HEAD"
? `${branch}: ${label}`
: `${cwd.split("/").pop()}: ${label}`;
}
return { patch, label };
}
export async function getFileContentsForDiff(
runtime: ReviewGitRuntime,
diffType: DiffType,
defaultBranch: string,
filePath: string,
oldPath?: string,
cwd?: string,
): Promise<{ oldContent: string | null; newContent: string | null }> {
const oldFilePath = oldPath || filePath;
let effectiveDiffType = diffType as string;
if (diffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(diffType);
if (!parsed) return { oldContent: null, newContent: null };
cwd = parsed.path;
effectiveDiffType = parsed.subType;
}
async function gitShow(ref: string, path: string): Promise<string | null> {
const result = await runtime.runGit(["show", `${ref}:${path}`], { cwd });
return result.exitCode === 0 ? result.stdout : null;
}
async function readWorkingTree(path: string): Promise<string | null> {
const fullPath = cwd ? resolvePath(cwd, path) : path;
return runtime.readTextFile(fullPath);
}
switch (effectiveDiffType) {
case "uncommitted":
return {
oldContent: await gitShow("HEAD", oldFilePath),
newContent: await readWorkingTree(filePath),
};
case "staged":
return {
oldContent: await gitShow("HEAD", oldFilePath),
newContent: await gitShow(":0", filePath),
};
case "unstaged":
return {
oldContent: await gitShow(":0", oldFilePath),
newContent: await readWorkingTree(filePath),
};
case "last-commit":
return {
oldContent: await gitShow("HEAD~1", oldFilePath),
newContent: await gitShow("HEAD", filePath),
};
case "branch":
return {
oldContent: await gitShow(defaultBranch, oldFilePath),
newContent: await gitShow("HEAD", filePath),
};
default:
return { oldContent: null, newContent: null };
}
}
export function validateFilePath(filePath: string): void {
if (filePath.includes("..") || filePath.startsWith("/")) {
throw new Error("Invalid file path");
}
}
async function ensureGitSuccess(
runtime: ReviewGitRuntime,
args: string[],
cwd?: string,
): Promise<void> {
const result = await runtime.runGit(args, { cwd });
if (result.exitCode !== 0) {
throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
}
}
export async function gitAddFile(
runtime: ReviewGitRuntime,
filePath: string,
cwd?: string,
): Promise<void> {
validateFilePath(filePath);
await ensureGitSuccess(runtime, ["add", "--", filePath], cwd);
}
export async function gitResetFile(
runtime: ReviewGitRuntime,
filePath: string,
cwd?: string,
): Promise<void> {
validateFilePath(filePath);
await ensureGitSuccess(runtime, ["reset", "HEAD", "--", filePath], cwd);
}
+6 -18
View File
@@ -9,21 +9,9 @@ export interface EditorAnnotation {
createdAt: number;
}
// Git diff types shared between server and client
export interface DiffOption {
id: string;
label: string;
}
export interface WorktreeInfo {
path: string;
branch: string | null;
head: string;
}
export interface GitContext {
currentBranch: string;
defaultBranch: string;
diffOptions: DiffOption[];
worktrees: WorktreeInfo[];
}
// Git review types shared between server and client
export type {
DiffOption,
WorktreeInfo,
GitContext,
} from "./review-core";