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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user