mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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,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 |
@@ -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");
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user