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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/review-change-journal.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
16 changes: 16 additions & 0 deletions src/apply-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ assert.equal(await readFile(join(root, "windows.txt"), "utf8"), "first\r\nupdate
await assert.rejects(readFile(join(root, "remove.txt"), "utf8"), /ENOENT/);

if (process.platform !== "win32") await chmod(join(root, "alpha.txt"), 0o755);
let movePreparedBeforeMutation = false;
const moveResult = await applyPatch(
root,
`*** Begin Patch
Expand All @@ -73,7 +74,22 @@ const moveResult = await applyPatch(
+ONE
changed
*** End Patch`,
{
beforeApply: async ({ paths, files }) => {
assert.deepEqual(new Set(paths), new Set([
join(root, "alpha.txt"),
join(root, "moved", "alpha.txt"),
]));
assert.deepEqual(files, [
{ path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" },
]);
assert.equal(await readFile(join(root, "alpha.txt"), "utf8"), "one\nchanged\nthree\n");
await assert.rejects(readFile(join(root, "moved", "alpha.txt"), "utf8"), /ENOENT/);
movePreparedBeforeMutation = true;
},
},
);
assert.equal(movePreparedBeforeMutation, true);
assert.deepEqual(moveResult.files, [
{ path: "moved/alpha.txt", previousPath: "alpha.txt", operation: "move" },
]);
Expand Down
18 changes: 17 additions & 1 deletion src/apply-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export interface ApplyPatchResult {
removals: number;
}

export interface ApplyPatchOptions {
beforeApply?: (input: {
paths: readonly string[];
files: readonly AppliedPatchFile[];
}) => Promise<void> | void;
}

interface HunkLine {
kind: "context" | "add" | "remove";
text: string;
Expand Down Expand Up @@ -340,7 +347,11 @@ export async function isSamePatchFile(
}
}

export async function applyPatch(root: string, patch: string): Promise<ApplyPatchResult> {
export async function applyPatch(
root: string,
patch: string,
options: ApplyPatchOptions = {},
): Promise<ApplyPatchResult> {
const actions = parsePatch(patch);
const results: AppliedPatchFile[] = [];
const patches: string[] = [];
Expand Down Expand Up @@ -396,6 +407,11 @@ export async function applyPatch(root: string, patch: string): Promise<ApplyPatc
}
}

await options.beforeApply?.({
paths: [...staged.keys()],
files: results,
});

for (const [absolute, file] of staged) {
if (file) await writeTextFile(absolute, file.content, file.mode);
}
Expand Down
115 changes: 115 additions & 0 deletions src/review-change-journal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { 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";
import { createReviewChangeJournal } from "./review-change-journal.js";

test("journal reports the net result of repeated successful edits", async (t) => {
const root = await workspace(t);
const path = join(root, "file.txt");
await writeFile(path, "A\n");
const journal = createReviewChangeJournal();

const first = await journal.prepareMutation({ workspaceId: "ws_net", root, paths: [path] });
await writeFile(path, "B\n");
journal.commitMutation(first);

const second = await journal.prepareMutation({ workspaceId: "ws_net", root, paths: [path] });
await writeFile(path, "C\n");
journal.commitMutation(second);

const review = await journal.reviewChanges({ workspaceId: "ws_net", root });
assert.deepEqual(review.files.map((file) => file.path), ["file.txt"]);
assert.match(review.patch, /-A/);
assert.match(review.patch, /\+C/);
assert.doesNotMatch(review.patch, /[+-]B/);
});

test("journal drops net-zero mutations and unrelated filesystem changes", async (t) => {
const root = await workspace(t);
const tracked = join(root, "tracked.txt");
const unrelated = join(root, "unrelated.txt");
await writeFile(tracked, "A\n");
await writeFile(unrelated, "before\n");
const journal = createReviewChangeJournal();

const mutation = await journal.prepareMutation({
workspaceId: "ws_zero",
root,
paths: [tracked],
});
await writeFile(tracked, "B\n");
await writeFile(tracked, "A\n");
await writeFile(unrelated, "after\n");
journal.commitMutation(mutation);

const review = await journal.reviewChanges({ workspaceId: "ws_zero", root });
assert.equal(review.summary.files, 0);
assert.equal(review.patch, "");
});

test("journal preserves a move across later edits", async (t) => {
const root = await workspace(t);
const before = join(root, "before.txt");
const after = join(root, "after.txt");
await writeFile(before, "before\n");
const journal = createReviewChangeJournal();

const move = await journal.prepareMutation({
workspaceId: "ws_move",
root,
paths: [before, after],
});
await rename(before, after);
journal.commitMutation(move, [{ fromPath: "before.txt", toPath: "after.txt" }]);

const edit = await journal.prepareMutation({ workspaceId: "ws_move", root, paths: [after] });
await writeFile(after, "after\n");
journal.commitMutation(edit);

const review = await journal.reviewChanges({ workspaceId: "ws_move", root });
assert.deepEqual(review.files, [
{
path: "after.txt",
previousPath: "before.txt",
type: "rename-changed",
additions: 1,
removals: 1,
},
]);
});

test("markReviewed advances the journal without requiring Git", async (t) => {
const root = await workspace(t);
const path = join(root, "file.txt");
await writeFile(path, "A\n");
const journal = createReviewChangeJournal();
const mutation = await journal.prepareMutation({ workspaceId: "ws_advance", root, paths: [path] });
await writeFile(path, "B\n");
journal.commitMutation(mutation);

journal.markReviewed({ workspaceId: "ws_advance", root });
assert.equal(journal.hasTrackedMutations("ws_advance"), false);
const review = await journal.reviewChanges({ workspaceId: "ws_advance", root });
assert.equal(review.summary.files, 0);
});

test("journal preserves empty-file additions as additions", async (t) => {
const root = await workspace(t);
const path = join(root, "empty.txt");
const journal = createReviewChangeJournal();
const mutation = await journal.prepareMutation({ workspaceId: "ws_empty", root, paths: [path] });
await writeFile(path, "");
journal.commitMutation(mutation);

const review = await journal.reviewChanges({ workspaceId: "ws_empty", root });
assert.equal(review.files[0]?.type, "new");
assert.match(review.patch, /new file mode/);
});

async function workspace(t: TestContext): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "devspace-review-journal-test-"));
t.after(() => rm(root, { recursive: true, force: true }));
return root;
}
Loading
Loading