Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 1 addition & 12 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface GitCommandResult {
export interface GitEligibility {
ok: boolean;
gitRoot?: string;
reason?: "not_git" | "no_head";
reason?: "not_git";
message?: string;
}

Expand Down Expand Up @@ -42,17 +42,6 @@ export async function getGitEligibility(cwd: string): Promise<GitEligibility> {
}

const gitRoot = (await git(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
try {
await git(gitRoot, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
} catch {
return {
ok: false,
gitRoot,
reason: "no_head",
message: "repository has no HEAD commit",
};
}

return { ok: true, gitRoot };
}

Expand Down
96 changes: 83 additions & 13 deletions src/review-checkpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test, { type TestContext } from "node:test";
Expand Down Expand Up @@ -50,6 +50,82 @@ test("show_changes reports and advances the last-shown checkpoint", async (t) =>
assert.equal(afterReviewed.patch, "");
});

test("a nested workspace only reviews changes inside its workspace root", async (t) => {
const repositoryRoot = await committedRepository(t);
const workspaceRoot = join(repositoryRoot, "packages", "app");
const siblingRoot = join(repositoryRoot, "packages", "other");
await mkdir(workspaceRoot, { recursive: true });
await mkdir(siblingRoot, { recursive: true });
await writeFile(join(workspaceRoot, "app.txt"), "app\n");
await writeFile(join(siblingRoot, "other.txt"), "other\n");
await git(repositoryRoot, ["add", "."]);
await git(repositoryRoot, ["commit", "-m", "Add packages"]);

const manager = createReviewCheckpointManager();
await manager.initializeWorkspace({ workspaceId: "ws_nested", root: workspaceRoot });

await writeFile(join(workspaceRoot, "app.txt"), "app changed\n");
await writeFile(join(siblingRoot, "other.txt"), "other changed\n");

const review = await manager.reviewChanges({
workspaceId: "ws_nested",
root: workspaceRoot,
markReviewed: false,
});

assert.deepEqual(review.files.map((file) => file.path), ["app.txt"]);
assert.match(review.patch, /app changed/);
assert.doesNotMatch(review.patch, /other changed/);
});

test("binary changes use a renderable review patch instead of a Git binary patch", async (t) => {
const root = await committedRepository(t);
await writeFile(join(root, "asset.bin"), Buffer.from([0, 1, 2, 3]));
await git(root, ["add", "asset.bin"]);
await git(root, ["commit", "-m", "Add binary asset"]);

const manager = createReviewCheckpointManager();
await manager.initializeWorkspace({ workspaceId: "ws_binary", root });
await writeFile(join(root, "asset.bin"), Buffer.from([0, 1, 9, 3]));

const review = await manager.reviewChanges({
workspaceId: "ws_binary",
root,
markReviewed: false,
});

assert.deepEqual(review.files.map((file) => file.path), ["asset.bin"]);
assert.match(review.patch, /Binary files/);
assert.doesNotMatch(review.patch, /GIT binary patch/);
});

test("review metadata preserves pure renames from the rendered patch", async (t) => {
const root = await committedRepository(t);
await writeFile(join(root, "before.txt"), "same content\n");
await git(root, ["add", "before.txt"]);
await git(root, ["commit", "-m", "Add rename source"]);

const manager = createReviewCheckpointManager();
await manager.initializeWorkspace({ workspaceId: "ws_rename", root });
await rename(join(root, "before.txt"), join(root, "after.txt"));

const review = await manager.reviewChanges({
workspaceId: "ws_rename",
root,
markReviewed: false,
});

assert.deepEqual(review.files, [
{
path: "after.txt",
previousPath: "before.txt",
type: "rename-pure",
additions: 0,
removals: 0,
},
]);
});

test("review checkpoints survive a manager restart", async (t) => {
const root = await committedRepository(t);
const manager = createReviewCheckpointManager();
Expand Down Expand Up @@ -173,27 +249,21 @@ test("a concurrent review rejects a different root after initialization", async
}
});

test("an unborn repository becomes reviewable after its first commit", async (t) => {
test("an unborn repository is reviewable without creating a commit", async (t) => {
const root = await unbornRepository(t);
const manager = createReviewCheckpointManager();

await manager.initializeWorkspace({ workspaceId: "ws_unborn", root });
await assert.rejects(
() => manager.reviewChanges({ workspaceId: "ws_unborn", root }),
/repository has no HEAD commit/,
);
await writeFile(join(root, "README.md"), "first file\n");

await writeFile(join(root, "README.md"), "first commit\n");
await git(root, ["add", "README.md"]);
await git(root, ["commit", "-m", "Initial commit"]);

const afterFirstCommit = await manager.reviewChanges({
const review = await manager.reviewChanges({
workspaceId: "ws_unborn",
root,
markReviewed: false,
});
assert.equal(afterFirstCommit.summary.files, 0);
assert.equal(afterFirstCommit.patch, "");
assert.deepEqual(review.files.map((file) => file.path), ["README.md"]);
assert.equal(review.files[0]?.type, "new");
assert.match(review.patch, /first file/);
});

async function committedRepository(t: TestContext): Promise<string> {
Expand Down
127 changes: 64 additions & 63 deletions src/review-checkpoints.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parsePatchFiles } from "@pierre/diffs";
import { git, getGitEligibility, safeWorkspaceRefSegment } from "./git.js";

export type ReviewSince = "last_shown" | "workspace_open";
Expand Down Expand Up @@ -47,6 +48,7 @@ export interface ReviewCheckpointManager {
}

const REVIEW_REF_PREFIX = "refs/devspace/review";
const REVIEW_DIFF_MAX_BUFFER = 10_000_000;

export function createReviewCheckpointManager(): ReviewCheckpointManager {
const states = new Map<string, WorkspaceReviewState>();
Expand Down Expand Up @@ -107,14 +109,21 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager {

const baselineRef = effectiveSince === "workspace_open" ? state.openRef : state.baselineRef;
const baseline = (await git(state.gitRoot, ["rev-parse", "--verify", `${baselineRef}^{commit}`])).stdout.trim();
const current = await createWorkingTreeSnapshot(state.gitRoot);
const patch = (await git(state.gitRoot, ["diff", "--binary", "--no-color", baseline, current], {
maxBuffer: 50 * 1024 * 1024,
const current = await createWorkingTreeSnapshot(state.gitRoot, state.root);
const patch = (await git(state.root, [
"diff",
"--relative",
"--patch",
"--find-renames",
"--no-color",
"--no-ext-diff",
"--no-textconv",
baseline,
current,
], {
maxBuffer: REVIEW_DIFF_MAX_BUFFER,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Reduced diff buffer breaks reviews

When a rendered review patch exceeds 10,000,000 bytes, the changed git diff command rejects instead of returning the review, causing change sets that fit under the previous 50 MiB limit to become unreviewable.

Suggested change
maxBuffer: REVIEW_DIFF_MAX_BUFFER,
maxBuffer: 50 * 1024 * 1024,

})).stdout;
const numstat = (await git(state.gitRoot, ["diff", "--numstat", "-z", baseline, current], {
maxBuffer: 50 * 1024 * 1024,
})).stdout;
const files = parseNumstat(numstat);
const files = parseReviewFiles(patch);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve the patch when metadata parsing fails.

parseReviewFiles throws at Line 175. reviewChanges then fails at Line 126 before it returns patch to ReviewPayload.

src/ui/review-payload.tsx cannot display its parser-error status on this path because it receives no payload. Return the patch with empty metadata and an explicit parse-error result. Preserve the error for the UI.

Also applies to: 151-176

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/review-checkpoints.ts` at line 126, Update reviewChanges around
parseReviewFiles so parsing failures do not abort the result: catch the parser
error, return the original patch with empty metadata and an explicit parse-error
result, and preserve the error in the returned payload for ReviewPayload and
src/ui/review-payload.tsx to display.

const summary = summarizeFiles(files);

if (markReviewed) {
Expand All @@ -139,6 +148,47 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager {
};
}

function parseReviewFiles(patch: string): ReviewFile[] {
if (patch.length === 0) return [];

try {
return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) =>
parsedPatch.files.map((file) => {
const stats = file.hunks.reduce(
(total, hunk) => ({
additions: total.additions + hunk.additionLines,
removals: total.removals + hunk.deletionLines,
}),
{ additions: 0, removals: 0 },
);

return {
path: file.name,
previousPath: file.prevName,
type: reviewFileType(file.type),
...stats,
};
}),
);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Review diff could not be rendered: ${detail}`);
}
}

function reviewFileType(type: string): ReviewFile["type"] {
switch (type) {
case "rename-pure":
case "rename-changed":
case "new":
case "deleted":
case "change":
return type;
default:
return "change";
}
}

function assertWorkspaceRoot(
state: WorkspaceReviewState | undefined,
workspaceId: string,
Expand Down Expand Up @@ -175,7 +225,7 @@ async function initializeWorkspaceState(
]);

if (!openCommit && !baselineCommit) {
const initialCommit = await createWorkingTreeSnapshot(eligibility.gitRoot);
const initialCommit = await createWorkingTreeSnapshot(eligibility.gitRoot, root);
await git(eligibility.gitRoot, ["update-ref", state.openRef, initialCommit]);
await git(eligibility.gitRoot, ["update-ref", state.baselineRef, initialCommit]);
state.openRefAvailable = true;
Expand Down Expand Up @@ -215,17 +265,18 @@ function reviewRefs(
};
}

async function createWorkingTreeSnapshot(gitRoot: string): Promise<string> {
async function createWorkingTreeSnapshot(gitRoot: string, workspaceRoot: string): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), "devspace-review-index-"));
const indexPath = join(tempDir, "index");
const env = checkpointEnv(indexPath);

try {
await git(gitRoot, ["read-tree", "HEAD"], { env });
await git(gitRoot, ["add", "-A", "--", "."], { env });
if (await commitForRef(gitRoot, "HEAD")) {
await git(gitRoot, ["read-tree", "HEAD"], { env });
}
await git(workspaceRoot, ["add", "-A", "--", "."], { env });
const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim();
const parent = (await git(gitRoot, ["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim();
return (await git(gitRoot, ["commit-tree", tree, "-p", parent, "-m", "DevSpace review snapshot"], { env })).stdout.trim();
return (await git(gitRoot, ["commit-tree", tree, "-m", "DevSpace review snapshot"], { env })).stdout.trim();
Comment on lines +268 to +279

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Set an internal Git identity for snapshot commits.

git commit-tree at Line 279 requires author and committer identity. A fresh git init can have no configured identity.

src/review-checkpoints.test.ts configures user.email and user.name for its unborn repository. The test therefore does not cover this valid workspace state. initializeWorkspace records a diagnostic and reviewChanges fails instead of reviewing the workspace.

Pass fixed internal author and committer environment variables only to the snapshot commit-tree command. Do not modify the user's Git configuration.

Proposed fix
+    const snapshotEnv = {
+      ...env,
+      GIT_AUTHOR_NAME: "DevSpace",
+      GIT_AUTHOR_EMAIL: "devspace@localhost",
+      GIT_COMMITTER_NAME: "DevSpace",
+      GIT_COMMITTER_EMAIL: "devspace@localhost",
+    };
     const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim();
-    return (await git(gitRoot, ["commit-tree", tree, "-m", "DevSpace review snapshot"], { env })).stdout.trim();
+    return (
+      await git(gitRoot, ["commit-tree", tree, "-m", "DevSpace review snapshot"], {
+        env: snapshotEnv,
+      })
+    ).stdout.trim();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function createWorkingTreeSnapshot(gitRoot: string, workspaceRoot: string): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), "devspace-review-index-"));
const indexPath = join(tempDir, "index");
const env = checkpointEnv(indexPath);
try {
await git(gitRoot, ["read-tree", "HEAD"], { env });
await git(gitRoot, ["add", "-A", "--", "."], { env });
if (await commitForRef(gitRoot, "HEAD")) {
await git(gitRoot, ["read-tree", "HEAD"], { env });
}
await git(workspaceRoot, ["add", "-A", "--", "."], { env });
const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim();
const parent = (await git(gitRoot, ["rev-parse", "--verify", "HEAD^{commit}"])).stdout.trim();
return (await git(gitRoot, ["commit-tree", tree, "-p", parent, "-m", "DevSpace review snapshot"], { env })).stdout.trim();
return (await git(gitRoot, ["commit-tree", tree, "-m", "DevSpace review snapshot"], { env })).stdout.trim();
async function createWorkingTreeSnapshot(gitRoot: string, workspaceRoot: string): Promise<string> {
const tempDir = await mkdtemp(join(tmpdir(), "devspace-review-index-"));
const indexPath = join(tempDir, "index");
const env = checkpointEnv(indexPath);
try {
if (await commitForRef(gitRoot, "HEAD")) {
await git(gitRoot, ["read-tree", "HEAD"], { env });
}
await git(workspaceRoot, ["add", "-A", "--", "."], { env });
const snapshotEnv = {
...env,
GIT_AUTHOR_NAME: "DevSpace",
GIT_AUTHOR_EMAIL: "devspace@localhost",
GIT_COMMITTER_NAME: "DevSpace",
GIT_COMMITTER_EMAIL: "devspace@localhost",
};
const tree = (await git(gitRoot, ["write-tree"], { env })).stdout.trim();
return (
await git(gitRoot, ["commit-tree", tree, "-m", "DevSpace review snapshot"], {
env: snapshotEnv,
})
).stdout.trim();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/review-checkpoints.ts` around lines 268 - 279, Update
createWorkingTreeSnapshot so the git commit-tree invocation supplies fixed
internal author and committer identity environment variables, while preserving
the existing checkpoint environment and passing these overrides only to that
snapshot commit command. Do not modify repository or user Git configuration.

} finally {
await rm(tempDir, { recursive: true, force: true });
}
Expand All @@ -241,56 +292,6 @@ function checkpointEnv(indexPath: string): NodeJS.ProcessEnv {
};
}

function parseNumstat(output: string): ReviewFile[] {
const fields = output.split("\0").filter((field) => field.length > 0);
const files: ReviewFile[] = [];

for (let index = 0; index < fields.length;) {
const header = fields[index++] ?? "";
const parts = header.split("\t");
const additions = parseStatNumber(parts[0]);
const removals = parseStatNumber(parts[1]);

if (parts.length >= 3) {
const path = parts[2] ?? "";
if (path) files.push({ path, type: fileType(path, undefined, additions, removals), additions, removals });
continue;
}

const previousPath = fields[index++];
const path = fields[index++];
if (!path) continue;

files.push({
path,
previousPath,
type: fileType(path, previousPath, additions, removals),
additions,
removals,
});
}

return files;
}

function parseStatNumber(value: string | undefined): number {
if (!value || value === "-") return 0;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}

function fileType(
path: string,
previousPath: string | undefined,
additions: number,
removals: number,
): ReviewFile["type"] {
if (previousPath) return additions === 0 && removals === 0 ? "rename-pure" : "rename-changed";
if (additions > 0 && removals === 0) return "new";
if (additions === 0 && removals > 0) return "deleted";
return "change";
}

function summarizeFiles(files: ReviewFile[]): ReviewSummary {
return files.reduce<ReviewSummary>(
(summary, file) => ({
Expand Down
25 changes: 21 additions & 4 deletions src/ui/review-payload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ interface MountedPayload {
unmount(): void;
}

interface ParsedReviewPatch {
files: FileDiffMetadata[];
error?: string;
}

export function mountReviewPayload(
container: HTMLElement,
options: PayloadRendererOptions,
Expand All @@ -50,13 +55,15 @@ function ReviewPayload({
}: PayloadRendererOptions) {
const patch = card.payload?.patch;
const themeType: ThemeType = hostContext?.theme === "light" ? "light" : "dark";
const files = useMemo(() => parseFiles(patch), [patch]);
const parsedPatch = useMemo(() => parseFiles(patch), [patch]);
const files = parsedPatch.files;
const visibleFiles = typeof visibleFileCount === "number"
? files.slice(0, visibleFileCount)
: files;
const [openFiles, setOpenFiles] = useState(() => new Set<string>());

if (errorMessage) return <StatusLine message={errorMessage} tone="error" />;
if (parsedPatch.error) return <StatusLine message={parsedPatch.error} tone="error" />;
if (!patch) return <StatusLine message="Diff payload is not available." />;
if (files.length === 0) return <StatusLine message="No diff hunks to review." />;

Expand Down Expand Up @@ -177,9 +184,19 @@ function fileChangeSymbol(kind: FileChangeKind): string {
}
}

function parseFiles(patch: string | undefined): FileDiffMetadata[] {
if (!patch) return [];
return parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files);
function parseFiles(patch: string | undefined): ParsedReviewPatch {
if (!patch) return { files: [] };

try {
return {
files: parsePatchFiles(patch, "review", true).flatMap((parsedPatch) => parsedPatch.files),
};
} catch {
return {
files: [],
error: "Review diff could not be rendered.",
};
}
}

function diffStats(fileDiff: FileDiffMetadata): { additions: number; removals: number } {
Expand Down
Loading