@cueloop/daemon 0.1.0-alpha.43 → 0.1.0-alpha.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.43",
3
+ "version": "0.1.0-alpha.44",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -21,6 +21,7 @@
21
21
  ".": "./src/index.ts",
22
22
  "./client": "./src/client.ts",
23
23
  "./review": "./src/review.ts",
24
+ "./working-tree": "./src/working-tree.ts",
24
25
  "./herdr-pane": "./src/herdr-pane.ts",
25
26
  "./herdr-split": "./src/herdr-split.ts",
26
27
  "./share-blob": "./src/share-blob.ts"
@@ -29,7 +30,7 @@
29
30
  "access": "public"
30
31
  },
31
32
  "dependencies": {
32
- "@cueloop/schema": "0.1.0-alpha.43",
33
+ "@cueloop/schema": "0.1.0-alpha.44",
33
34
  "valibot": "^1.4.2"
34
35
  },
35
36
  "engines": {
package/src/api.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  } from "@cueloop/schema";
25
25
  import { SessionStore } from "./store";
26
26
  import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
27
+ import { DiffWatcher } from "./diff-watcher";
28
+ import { workingTreeDiff } from "./working-tree";
27
29
  import { DaemonError } from "./errors";
28
30
 
29
31
  export type EventName =
@@ -46,11 +48,27 @@ export class DaemonCore {
46
48
  private waiters = new Map<string, ((session: ReviewSession) => void)[]>();
47
49
  private listeners = new Set<EventListener>();
48
50
  private seq = 0;
51
+ /** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
52
+ private readonly diffWatcher: DiffWatcher;
53
+ /**
54
+ * Per-diff-session capture generation. Bumped when a refresh begins; a
55
+ * capture whose generation is stale by the time it finishes discards its
56
+ * result, so overlapping captures never write an older patch over a newer one.
57
+ */
58
+ private readonly diffRefreshGenerations = new Map<string, number>();
49
59
 
50
60
  constructor(home: string) {
51
61
  this.store = new SessionStore(home);
52
62
  this.store.recover();
53
63
  this.herdrTabs = new HerdrTabStore(home);
64
+ this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
65
+ // resume hot-reload for diff sessions that survived a daemon restart
66
+ for (const session of this.store.list()) this.watchIfDiffSession(session);
67
+ }
68
+
69
+ /** Release the fs watchers behind diff hot-reload; call on daemon shutdown. */
70
+ dispose(): void {
71
+ this.diffWatcher.close();
54
72
  }
55
73
 
56
74
  /** The herdr tab opened for a review, if any (adapter scratch, not on the session). */
@@ -90,6 +108,7 @@ export class DaemonCore {
90
108
  createdAt: now,
91
109
  };
92
110
  this.store.upsert(session);
111
+ this.watchIfDiffSession(session);
93
112
  this.emit("session.created", session.id);
94
113
  this.emit("inbox.changed", session.id);
95
114
  return session;
@@ -216,7 +235,10 @@ export class DaemonCore {
216
235
 
217
236
  /** Remove a session for good (inbox delete); resolved or pending, both go. */
218
237
  sessionDelete(id: string): void {
238
+ const session = this.store.get(id);
219
239
  if (!this.store.delete(id)) throw new DaemonError("not_found", `no session ${id}`);
240
+ if (session) this.unwatchIfDiffSession(session);
241
+ this.diffRefreshGenerations.delete(id);
220
242
  this.herdrTabs.delete(id);
221
243
  this.emit("inbox.changed", id);
222
244
  }
@@ -259,6 +281,8 @@ export class DaemonCore {
259
281
  session.verdict = verdict;
260
282
  session.status = "resolved";
261
283
  this.store.upsert(session);
284
+ // a resolved diff review is frozen; stop hot-reloading its working tree
285
+ this.unwatchIfDiffSession(session);
262
286
  const parked = this.waiters.get(id) ?? [];
263
287
  this.waiters.delete(id);
264
288
  for (const parkedWaiter of parked) parkedWaiter(session);
@@ -313,6 +337,53 @@ export class DaemonCore {
313
337
  return session;
314
338
  }
315
339
 
340
+ /** Re-capture a diff session's working tree; broadcasts session.updated only when the patch moved. */
341
+ async sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
342
+ const session = this.mutable(id);
343
+ if (session.artifact.type !== "diff") return { changed: false };
344
+ const generation = (this.diffRefreshGenerations.get(id) ?? 0) + 1;
345
+ this.diffRefreshGenerations.set(id, generation);
346
+ const diff = await workingTreeDiff(session.workspace.repoRoot);
347
+ // The capture yields the event loop: a newer refresh may have started, or a
348
+ // concurrent resolve/delete may have closed the session. Discard a stale
349
+ // capture, and re-read so a resolved review is never mutated and a deleted
350
+ // one is never revived by this upsert.
351
+ if (this.diffRefreshGenerations.get(id) !== generation) return { changed: false };
352
+ const current = this.store.get(id);
353
+ if (!current || current.status !== "pending" || current.artifact.type !== "diff")
354
+ return { changed: false };
355
+ if (diff.patch === current.artifact.content) return { changed: false };
356
+ current.artifact = { ...current.artifact, content: diff.patch, files: diff.files };
357
+ this.store.upsert(current);
358
+ this.emit("session.updated", id);
359
+ return { changed: true };
360
+ }
361
+
362
+ /** Re-capture every live diff session sharing a repo root (one debounced fs change). */
363
+ private async refreshDiffsForRepo(repoRoot: string): Promise<void> {
364
+ const live = this.store
365
+ .list()
366
+ .filter((session) => isLiveDiffSession(session) && session.workspace.repoRoot === repoRoot);
367
+ for (const session of live) {
368
+ // a session resolved or deleted between the change and this tick just skips
369
+ try {
370
+ await this.sessionRefreshDiff(session.id);
371
+ } catch {
372
+ // no-op: the session is gone or already resolved
373
+ }
374
+ }
375
+ }
376
+
377
+ private watchIfDiffSession(session: ReviewSession): void {
378
+ if (isLiveDiffSession(session))
379
+ this.diffWatcher.trackDiffRepo(session.workspace.repoRoot, session.id);
380
+ }
381
+
382
+ private unwatchIfDiffSession(session: ReviewSession): void {
383
+ if (session.artifact.type === "diff")
384
+ this.diffWatcher.untrackDiffRepo(session.workspace.repoRoot, session.id);
385
+ }
386
+
316
387
  private mutable(id: string): ReviewSession {
317
388
  const session = this.sessionGet(id);
318
389
  if (session.status === "resolved")
@@ -323,6 +394,11 @@ export class DaemonCore {
323
394
 
324
395
  export { DaemonError };
325
396
 
397
+ /** A pending diff session: the state that warrants hot-reload watching of its working tree. */
398
+ function isLiveDiffSession(session: ReviewSession): boolean {
399
+ return session.status === "pending" && session.artifact.type === "diff";
400
+ }
401
+
326
402
  /** Convenience for adapters: map a resolved session to the agent contract. */
327
403
  export function verdictResponse(session: ReviewSession): { allow: boolean; feedback: string } {
328
404
  if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
@@ -19,6 +19,7 @@ describe("roleAllowsMethod", () => {
19
19
  "session.setWorkingCopy",
20
20
  "session.delete",
21
21
  "session.setShareId",
22
+ "session.refreshDiff",
22
23
  ])
23
24
  expect(roleAllowsMethod("agent", method)).toBe(false);
24
25
  expect(roleAllowsMethod("collaborator", "session.resolve")).toBe(false);
package/src/client.ts CHANGED
@@ -215,6 +215,10 @@ export class DaemonClient implements SessionClient {
215
215
  sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
216
216
  return this.request("session.setViewed", { id, viewedPaths });
217
217
  }
218
+ /** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
219
+ sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
220
+ return this.request("session.refreshDiff", { id });
221
+ }
218
222
  sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
219
223
  return this.request("session.setShareId", { id, shareId });
220
224
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Diff hot-reload: session.refreshDiff re-captures a diff session's working
3
+ * tree, and the fs watcher drives it on a real change. Exercised against a
4
+ * real temp git repo so the capture path is the production one.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { DaemonCore } from "./api";
12
+ import { workingTreeDiff } from "./working-tree";
13
+ import { resolveWorkspace } from "./review";
14
+ import type { Artifact } from "@cueloop/schema";
15
+
16
+ function git(args: string[], cwd: string): void {
17
+ const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
18
+ if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
19
+ }
20
+
21
+ let home: string;
22
+ let repo: string;
23
+ let core: DaemonCore;
24
+
25
+ beforeEach(() => {
26
+ home = mkdtempSync(join(tmpdir(), "cueloop-hotreload-home-"));
27
+ repo = mkdtempSync(join(tmpdir(), "cueloop-hotreload-repo-"));
28
+ git(["init", "-q", "-b", "main"], repo);
29
+ git(["config", "user.email", "t@t"], repo);
30
+ git(["config", "user.name", "t"], repo);
31
+ writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
32
+ git(["add", "."], repo);
33
+ git(["commit", "-qm", "init"], repo);
34
+ core = new DaemonCore(home);
35
+ });
36
+
37
+ afterEach(() => {
38
+ core.dispose();
39
+ rmSync(home, { recursive: true, force: true });
40
+ rmSync(repo, { recursive: true, force: true });
41
+ });
42
+
43
+ /** Open a diff session over the temp repo's current working tree. */
44
+ async function openDiffSession() {
45
+ const workspace = await resolveWorkspace(repo);
46
+ const diff = await workingTreeDiff(repo);
47
+ const artifact: Artifact = { type: "diff", content: diff.patch, files: diff.files, meta: {} };
48
+ return core.sessionCreate({ workspace, artifact });
49
+ }
50
+
51
+ describe("session.refreshDiff", () => {
52
+ test("re-captures the working tree and updates the artifact when the patch changed", async () => {
53
+ // Given an open diff session whose working tree then gains a new change
54
+ const session = await openDiffSession();
55
+ writeFileSync(join(repo, "a.ts"), "export const a = 2;\n");
56
+
57
+ // When the diff is refreshed
58
+ const result = await core.sessionRefreshDiff(session.id);
59
+
60
+ // Then the change is reported and the stored artifact carries the fresh patch and files
61
+ expect(result.changed).toBe(true);
62
+ const refreshed = core.sessionGet(session.id);
63
+ expect(refreshed.artifact.content).toContain("+export const a = 2;");
64
+ const modified = refreshed.artifact.files!.find((file) => file.path === "a.ts")!;
65
+ expect(modified.newContents).toBe("export const a = 2;\n");
66
+ });
67
+
68
+ test("reports no change and emits nothing when the working tree is unchanged", async () => {
69
+ // Given an open diff session and a listener for session events
70
+ const session = await openDiffSession();
71
+ const events: string[] = [];
72
+ core.onEvent((event) => events.push(event.event));
73
+
74
+ // When the diff is refreshed without any working-tree change
75
+ const result = await core.sessionRefreshDiff(session.id);
76
+
77
+ // Then nothing changed and no session.updated event fired
78
+ expect(result.changed).toBe(false);
79
+ expect(events).not.toContain("session.updated");
80
+ });
81
+
82
+ test("a changed refresh emits session.updated so attached clients re-fetch", async () => {
83
+ // Given an open diff session, a listener, and a fresh working-tree change
84
+ const session = await openDiffSession();
85
+ const events: string[] = [];
86
+ core.onEvent((event) => events.push(event.event));
87
+ writeFileSync(join(repo, "a.ts"), "export const a = 3;\n");
88
+
89
+ // When the diff is refreshed
90
+ await core.sessionRefreshDiff(session.id);
91
+
92
+ // Then a session.updated event fired for this session
93
+ expect(events).toContain("session.updated");
94
+ });
95
+
96
+ test("is a no-op for a non-diff (plan) session", async () => {
97
+ // Given a plan session in the same workspace
98
+ const workspace = await resolveWorkspace(repo);
99
+ const plan: Artifact = { type: "plan", content: "# Plan\n\nBody.\n", meta: {} };
100
+ const session = core.sessionCreate({ workspace, artifact: plan });
101
+
102
+ // When a refresh is attempted
103
+ const result = await core.sessionRefreshDiff(session.id);
104
+
105
+ // Then it reports no change and leaves the plan content untouched
106
+ expect(result.changed).toBe(false);
107
+ expect(core.sessionGet(session.id).artifact.content).toBe("# Plan\n\nBody.\n");
108
+ });
109
+
110
+ test("refusing a resolved diff session never mutates it (no revive of a closed review)", async () => {
111
+ // Given a diff session that has been resolved
112
+ const session = await openDiffSession();
113
+ core.sessionResolve(session.id, "approve", "");
114
+ const resolvedContent = core.sessionGet(session.id).artifact.content;
115
+ writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
116
+
117
+ // When a refresh is attempted on the resolved session
118
+ // Then it is rejected and the frozen artifact is untouched
119
+ expect(core.sessionRefreshDiff(session.id)).rejects.toThrow(/resolved/);
120
+ expect(core.sessionGet(session.id).artifact.content).toBe(resolvedContent);
121
+ });
122
+
123
+ test("overlapping refreshes settle to one coherent artifact without error", async () => {
124
+ // Given a live diff session with a fresh working-tree change
125
+ const session = await openDiffSession();
126
+ writeFileSync(join(repo, "a.ts"), "export const a = 7;\n");
127
+
128
+ // When two refreshes run concurrently over the same session
129
+ const [first, second] = await Promise.all([
130
+ core.sessionRefreshDiff(session.id),
131
+ core.sessionRefreshDiff(session.id),
132
+ ]);
133
+
134
+ // Then both settle and the stored artifact reflects the current tree exactly
135
+ // once - the generation guard keeps the older capture from regressing it
136
+ expect(first.changed || second.changed).toBe(true);
137
+ expect(core.sessionGet(session.id).artifact.content).toContain("+export const a = 7;");
138
+ });
139
+ });
140
+
141
+ describe("the fs watcher drives hot-reload", () => {
142
+ test("a working-tree change under a live diff session refreshes it in place", async () => {
143
+ // Given a live diff session whose repo the daemon is watching
144
+ const session = await openDiffSession();
145
+
146
+ // When a tracked file changes on disk
147
+ writeFileSync(join(repo, "a.ts"), "export const a = 42;\n");
148
+
149
+ // Then the watcher debounces and re-captures the diff without any manual call.
150
+ // Watchers can be slow under CI contention, so poll generously.
151
+ const deadline = Date.now() + 8_000;
152
+ let content = core.sessionGet(session.id).artifact.content;
153
+ while (!content.includes("+export const a = 42;") && Date.now() < deadline) {
154
+ await Bun.sleep(100);
155
+ content = core.sessionGet(session.id).artifact.content;
156
+ }
157
+ expect(content).toContain("+export const a = 42;");
158
+ }, 12_000);
159
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Watches a diff session's repository for working-tree changes and drives the
3
+ * hot-reload: on any change under a repo root that a live diff session tracks,
4
+ * the daemon re-captures that session's diff so the open review updates in place.
5
+ * One recursive fs watcher per repo root - many diff sessions can share a repo -
6
+ * with a debounce so a burst of file writes triggers a single re-capture.
7
+ */
8
+
9
+ import { watch, type FSWatcher } from "node:fs";
10
+ import { sep } from "node:path";
11
+
12
+ /** Debounce window: a save or a checkout writes many files in a burst; collapse them into one re-capture. */
13
+ const DIFF_REFRESH_DEBOUNCE_MS = 300;
14
+
15
+ /**
16
+ * Paths whose churn must never trigger a diff refresh. git rewrites `.git`
17
+ * metadata (the index, lock files) on every read `cueloop diff` itself runs, so
18
+ * watching it would loop; `node_modules` churns on installs and is never review
19
+ * content.
20
+ */
21
+ function isIgnoredWatchPath(relativePath: string): boolean {
22
+ const segments = relativePath.split(sep);
23
+ return segments.includes(".git") || segments.includes("node_modules");
24
+ }
25
+
26
+ interface RepoWatch {
27
+ handle: FSWatcher;
28
+ /** Live diff session ids sharing this repo root; the watch closes when the last one leaves. */
29
+ sessionIds: Set<string>;
30
+ debounce: ReturnType<typeof setTimeout> | null;
31
+ }
32
+
33
+ /**
34
+ * Owns the fs watchers behind diff hot-reload. `onRepoChange` fires once per
35
+ * debounced burst with the repo root that changed; the caller re-captures every
36
+ * live diff session on that root. A watcher error or an unwatchable root is
37
+ * swallowed - hot-reload is best-effort and must never crash the daemon.
38
+ */
39
+ export class DiffWatcher {
40
+ private readonly repoWatches = new Map<string, RepoWatch>();
41
+
42
+ constructor(private readonly onRepoChange: (repoRoot: string) => void) {}
43
+
44
+ /** Start (or join) watching a repo root for one diff session. Idempotent per (root, session). */
45
+ trackDiffRepo(repoRoot: string, sessionId: string): void {
46
+ const existing = this.repoWatches.get(repoRoot);
47
+ if (existing) {
48
+ existing.sessionIds.add(sessionId);
49
+ return;
50
+ }
51
+ let handle: FSWatcher;
52
+ try {
53
+ handle = watch(repoRoot, { recursive: true }, (_event, filename) => {
54
+ if (filename !== null && isIgnoredWatchPath(filename.toString())) return;
55
+ this.scheduleRepoRefresh(repoRoot);
56
+ });
57
+ } catch {
58
+ // repo root gone or not watchable on this platform: skip, no hot-reload here
59
+ return;
60
+ }
61
+ // a watcher error (the root is deleted mid-review) must not take down the
62
+ // daemon; the inherited EventEmitter `on` is absent from Bun's FSWatcher type
63
+ (handle as FSWatcher & { on(event: "error", listener: (error: Error) => void): void }).on(
64
+ "error",
65
+ () => {},
66
+ );
67
+ this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
68
+ }
69
+
70
+ /** Drop one diff session; close the repo watch once no diff session tracks it. */
71
+ untrackDiffRepo(repoRoot: string, sessionId: string): void {
72
+ const repoWatch = this.repoWatches.get(repoRoot);
73
+ if (!repoWatch) return;
74
+ repoWatch.sessionIds.delete(sessionId);
75
+ if (repoWatch.sessionIds.size > 0) return;
76
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
77
+ repoWatch.handle.close();
78
+ this.repoWatches.delete(repoRoot);
79
+ }
80
+
81
+ private scheduleRepoRefresh(repoRoot: string): void {
82
+ const repoWatch = this.repoWatches.get(repoRoot);
83
+ if (!repoWatch) return;
84
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
85
+ repoWatch.debounce = setTimeout(() => {
86
+ repoWatch.debounce = null;
87
+ this.onRepoChange(repoRoot);
88
+ }, DIFF_REFRESH_DEBOUNCE_MS);
89
+ }
90
+
91
+ /** Close every watcher and cancel pending debounces (daemon shutdown). */
92
+ close(): void {
93
+ for (const repoWatch of this.repoWatches.values()) {
94
+ if (repoWatch.debounce !== null) clearTimeout(repoWatch.debounce);
95
+ repoWatch.handle.close();
96
+ }
97
+ this.repoWatches.clear();
98
+ }
99
+ }
package/src/server.ts CHANGED
@@ -172,6 +172,7 @@ export class DaemonServer {
172
172
  }
173
173
 
174
174
  stop(): void {
175
+ this.core.dispose();
175
176
  this.server?.stop(true);
176
177
  this.server = null;
177
178
  rmSync(socketPath(this.home), { force: true });
@@ -275,6 +276,10 @@ export class DaemonServer {
275
276
  const params = parseParams("session.setViewed", request.params);
276
277
  return core.sessionSetViewed(params.id, params.viewedPaths);
277
278
  }
279
+ case "session.refreshDiff": {
280
+ const params = parseParams("session.refreshDiff", request.params);
281
+ return core.sessionRefreshDiff(params.id);
282
+ }
278
283
  case "session.setShareId": {
279
284
  const params = parseParams("session.setShareId", request.params);
280
285
  return core.sessionSetShareId(params.id, params.shareId);
package/src/validate.ts CHANGED
@@ -133,6 +133,7 @@ export const Params = {
133
133
  "session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
134
134
  "session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
135
135
  "session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
136
+ "session.refreshDiff": v.object({ id: SessionId }),
136
137
  "session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
137
138
  "session.delete": v.object({ id: SessionId }),
138
139
  "session.mergeShared": v.object({
@@ -0,0 +1,100 @@
1
+ /** Working-tree diff capture for `cueloop diff`, untracked files included. */
2
+
3
+ import { join } from "node:path";
4
+ import type { DiffFileContents } from "@cueloop/schema";
5
+
6
+ async function git(args: string[], cwd: string): Promise<string | null> {
7
+ const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
8
+ const stdout = await new Response(gitProcess.stdout).text();
9
+ if ((await gitProcess.exited) !== 0) return null;
10
+ return stdout.trim();
11
+ }
12
+
13
+ /** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
14
+ async function headContents(path: string, cwd: string): Promise<string> {
15
+ const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {
16
+ cwd,
17
+ stdout: "pipe",
18
+ stderr: "ignore",
19
+ });
20
+ const stdout = await new Response(gitProcess.stdout).text();
21
+ return (await gitProcess.exited) === 0 ? stdout : "";
22
+ }
23
+
24
+ /** The working-tree file, or "" when it is gone (deleted). */
25
+ async function workingContents(path: string, cwd: string): Promise<string> {
26
+ try {
27
+ return await Bun.file(join(cwd, path)).text();
28
+ } catch {
29
+ return "";
30
+ }
31
+ }
32
+
33
+ /** A NUL byte marks content git would treat as binary - not text-curatable. */
34
+ function looksBinary(text: string): boolean {
35
+ for (let index = 0; index < text.length; index++) {
36
+ if (text.charCodeAt(index) === 0) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Full old/new contents for every tracked change against HEAD, classified by
43
+ * git's own status letter. Renames/copies and binary files are omitted: a
44
+ * path+text pair cannot express a rename header or a binary blob, so curation
45
+ * would emit a patch that drops the old path or corrupts the file - leaving them
46
+ * out keeps those changes in the patch but simply not curatable. The
47
+ * NUL-delimited stream survives paths with spaces or unicode.
48
+ */
49
+ async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
50
+ const nameStatus = (await git(["diff", "HEAD", "--name-status", "-z"], cwd)) ?? "";
51
+ const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
52
+ const files: DiffFileContents[] = [];
53
+ let cursor = 0;
54
+ while (cursor < tokens.length) {
55
+ const code = tokens[cursor++]![0];
56
+ if (code === "R" || code === "C") {
57
+ cursor += 2;
58
+ continue;
59
+ }
60
+ const path = tokens[cursor++]!;
61
+ const oldContents = code === "A" ? "" : await headContents(path, cwd);
62
+ const newContents = code === "D" ? "" : await workingContents(path, cwd);
63
+ if (looksBinary(oldContents) || looksBinary(newContents)) continue;
64
+ const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
65
+ files.push({ path, oldContents, newContents, status });
66
+ }
67
+ return files;
68
+ }
69
+
70
+ export interface WorkingTreeDiff {
71
+ patch: string;
72
+ /** Full file contents per curatable changed file, keeping curation applyable. */
73
+ files: DiffFileContents[];
74
+ }
75
+
76
+ export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeDiff> {
77
+ const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
78
+ const files = await trackedFileContents(cwd);
79
+ // untracked files included by default
80
+ const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
81
+ let untracked = "";
82
+ for (const file of untrackedList.split("\n").filter(Boolean)) {
83
+ // `git diff --no-index` exits 1 when the files differ - that is success here
84
+ const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
85
+ cwd,
86
+ stdout: "pipe",
87
+ stderr: "ignore",
88
+ });
89
+ const stdout = await new Response(gitProcess.stdout).text();
90
+ const code = await gitProcess.exited;
91
+ if ((code === 0 || code === 1) && stdout.trim()) {
92
+ untracked += stdout.trim() + "\n";
93
+ const newContents = await workingContents(file, cwd);
94
+ if (!looksBinary(newContents)) {
95
+ files.push({ path: file, oldContents: "", newContents, status: "added" });
96
+ }
97
+ }
98
+ }
99
+ return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
100
+ }