claude-garage 0.1.0
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/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/garage.js +143 -0
- package/daemon/package.json +13 -0
- package/daemon/src/diff.js +465 -0
- package/daemon/src/editor.js +63 -0
- package/daemon/src/events.js +54 -0
- package/daemon/src/hooks.js +241 -0
- package/daemon/src/index.js +76 -0
- package/daemon/src/notify.js +80 -0
- package/daemon/src/picker.js +48 -0
- package/daemon/src/poller.js +134 -0
- package/daemon/src/registry.js +189 -0
- package/daemon/src/security.js +39 -0
- package/daemon/src/sessions.js +255 -0
- package/daemon/src/status.js +59 -0
- package/daemon/src/term.js +94 -0
- package/daemon/src/tmux.js +124 -0
- package/daemon/src/workspaces.js +139 -0
- package/daemon/src/worktrees.js +180 -0
- package/package.json +57 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/google-sans-code-latin-400-normal-C-TpydLV.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-400-normal-CqaqKeEp.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-600-normal-BUCrGHE5.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-600-normal-CKee-OZj.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-700-normal-0vT4k3S3.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-700-normal-BvnkA064.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-400-normal-CALCk2lB.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-400-normal-xZllot2U.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-600-normal-CNNK3O9-.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-600-normal-DDkmmTp6.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-700-normal-CxgnixdG.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-700-normal-bxgt_uDG.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-400-normal-C2EgcUvD.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-math-400-normal-CgFzjhah.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-600-normal-B5PK-qTM.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-600-normal-DT_RT86i.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-math-700-normal-C65M3EbO.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-700-normal-CgTZgjvl.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-400-normal-BtBR4e7S.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-400-normal-NSSmFXdP.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-600-normal-DdWLfSlP.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-600-normal-QtnQvwms.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-700-normal-Ceb9rWtn.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-700-normal-DDa4ndCu.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-400-normal-CicwJrzf.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-600-normal-BtzXZASM.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-700-normal-Cci1DrKv.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-400-normal-CWRUr5CO.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-400-normal-DhSdRbI2.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-600-normal-DBBK0KXU.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-600-normal-zsb-D4cZ.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-700-normal-DDJUOtHZ.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-700-normal-jZ3jhCNl.woff2 +0 -0
- package/ui/dist/assets/index-B9ScE6Rx.css +32 -0
- package/ui/dist/assets/index-BjoMvOWt.js +70 -0
- package/ui/dist/favicon-32.png +0 -0
- package/ui/dist/index.html +23 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getWorkspace, getSessionMeta } from "./registry.js";
|
|
6
|
+
import { listPanePaths, resolveBranch } from "./tmux.js";
|
|
7
|
+
|
|
8
|
+
// D-diff-cmd (openspec/changes/p2-diff-review/design.md): every git
|
|
9
|
+
// invocation here is read-only — status/diff/rev-parse only, never `add -N`
|
|
10
|
+
// or anything else that would touch the index. Verified by the gate-1.3
|
|
11
|
+
// "status identical before/after repeated calls" check.
|
|
12
|
+
const run = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
// Diffs can legitimately be larger than Node's 1MB default execFile
|
|
15
|
+
// maxBuffer; we apply our own byte budgets below, so let git hand back
|
|
16
|
+
// whatever it has and truncate ourselves rather than erroring on big output.
|
|
17
|
+
const MAX_BUFFER = 64 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
const FILE_BUDGET = 30 * 1024; // ~30KB per file, cut at a hunk boundary when possible
|
|
20
|
+
const TOTAL_BUDGET = 300 * 1024; // ~300KB total diff bytes attached to one response
|
|
21
|
+
|
|
22
|
+
const BINARY_RE = /^Binary files .* differ$/m;
|
|
23
|
+
|
|
24
|
+
async function git(dir, args) {
|
|
25
|
+
return run("git", ["-C", dir, ...args], { maxBuffer: MAX_BUFFER });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Parses `git status --porcelain=v2 -z` output. -z NUL-terminates each
|
|
29
|
+
// record and, for type "2" (rename/copy) records, also uses a NUL (instead
|
|
30
|
+
// of the usual tab) to separate the new path from the original path — so a
|
|
31
|
+
// rename record consumes an extra token from the split.
|
|
32
|
+
function parseStatus(raw) {
|
|
33
|
+
const tokens = raw.split("\0").filter((t) => t.length > 0);
|
|
34
|
+
const entries = [];
|
|
35
|
+
let i = 0;
|
|
36
|
+
while (i < tokens.length) {
|
|
37
|
+
const token = tokens[i];
|
|
38
|
+
const type = token[0];
|
|
39
|
+
if (type === "1") {
|
|
40
|
+
const parts = token.split(" ");
|
|
41
|
+
entries.push({ kind: "ordinary", xy: parts[1], path: parts.slice(8).join(" ") });
|
|
42
|
+
i += 1;
|
|
43
|
+
} else if (type === "2") {
|
|
44
|
+
const parts = token.split(" ");
|
|
45
|
+
const path = parts.slice(9).join(" ");
|
|
46
|
+
const origPath = tokens[i + 1];
|
|
47
|
+
entries.push({ kind: "rename", xy: parts[1], path, origPath });
|
|
48
|
+
i += 2;
|
|
49
|
+
} else if (type === "u") {
|
|
50
|
+
const parts = token.split(" ");
|
|
51
|
+
entries.push({ kind: "ordinary", xy: "MM", path: parts.slice(10).join(" ") });
|
|
52
|
+
i += 1;
|
|
53
|
+
} else if (type === "?") {
|
|
54
|
+
entries.push({ kind: "untracked", path: token.slice(2) });
|
|
55
|
+
i += 1;
|
|
56
|
+
} else {
|
|
57
|
+
// "!" (ignored) or anything unrecognized — nothing to report.
|
|
58
|
+
i += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return entries;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function changeTypeFor(entry) {
|
|
65
|
+
if (entry.kind === "untracked") return "untracked";
|
|
66
|
+
if (entry.kind === "rename") return "renamed";
|
|
67
|
+
const [x, y] = entry.xy;
|
|
68
|
+
if (x === "A" || y === "A") return "added";
|
|
69
|
+
if (x === "D" || y === "D") return "deleted";
|
|
70
|
+
return "modified";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Splits one `git diff` blob into per-file chunks keyed by the file's
|
|
74
|
+
// current ("b/") path. Note: git's own -M similarity detection inside this
|
|
75
|
+
// single bulk diff can occasionally disagree with `git status`'s rename
|
|
76
|
+
// pairing (e.g. a rename with a big enough content change can show up here
|
|
77
|
+
// as a plain delete+add instead of one "rename from/rename to" chunk) — in
|
|
78
|
+
// that case this map still has a chunk keyed by the new path (the "add"
|
|
79
|
+
// half), just without the old-path pairing in its text. Known, accepted gap
|
|
80
|
+
// per design's Risks section on rename-detection nuance.
|
|
81
|
+
function splitDiffByFile(diffText) {
|
|
82
|
+
const map = new Map();
|
|
83
|
+
if (!diffText) return map;
|
|
84
|
+
const pieces = diffText.split(/(?=^diff --git )/m).filter(Boolean);
|
|
85
|
+
for (const chunk of pieces) {
|
|
86
|
+
const nl = chunk.indexOf("\n");
|
|
87
|
+
const headerLine = nl === -1 ? chunk : chunk.slice(0, nl);
|
|
88
|
+
const m = headerLine.match(/^diff --git a\/(.*) b\/(.*)$/);
|
|
89
|
+
if (m) map.set(m[2], chunk.replace(/\n$/, ""));
|
|
90
|
+
}
|
|
91
|
+
return map;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function countStats(diffText) {
|
|
95
|
+
let additions = 0;
|
|
96
|
+
let deletions = 0;
|
|
97
|
+
for (const line of diffText.split("\n")) {
|
|
98
|
+
if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
|
|
99
|
+
if (line.startsWith("+")) additions++;
|
|
100
|
+
else if (line.startsWith("-")) deletions++;
|
|
101
|
+
}
|
|
102
|
+
return { additions, deletions };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function splitHeaderAndHunks(diffText) {
|
|
106
|
+
const lines = diffText.split("\n");
|
|
107
|
+
const firstHunk = lines.findIndex((l) => l.startsWith("@@ "));
|
|
108
|
+
if (firstHunk === -1) return { header: diffText, hunks: [] };
|
|
109
|
+
const header = lines.slice(0, firstHunk).join("\n") + "\n";
|
|
110
|
+
const hunks = [];
|
|
111
|
+
let start = firstHunk;
|
|
112
|
+
for (let idx = firstHunk + 1; idx <= lines.length; idx++) {
|
|
113
|
+
if (idx === lines.length || lines[idx].startsWith("@@ ")) {
|
|
114
|
+
hunks.push(lines.slice(start, idx).join("\n") + "\n");
|
|
115
|
+
start = idx;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { header, hunks };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hardCut(text, budget) {
|
|
122
|
+
return Buffer.from(text, "utf8").subarray(0, budget).toString("utf8");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Caps a single file's diff text at FILE_BUDGET, cutting at a hunk boundary
|
|
126
|
+
// when one exists so we never emit a half-hunk; falls back to a hard byte
|
|
127
|
+
// cut only when no hunk boundary helps (e.g. one gigantic hunk).
|
|
128
|
+
function truncateToBudget(diffText, budget) {
|
|
129
|
+
if (Buffer.byteLength(diffText, "utf8") <= budget) {
|
|
130
|
+
return { diff: diffText, truncated: false };
|
|
131
|
+
}
|
|
132
|
+
const { header, hunks } = splitHeaderAndHunks(diffText);
|
|
133
|
+
if (hunks.length === 0) {
|
|
134
|
+
return { diff: hardCut(diffText, budget), truncated: true };
|
|
135
|
+
}
|
|
136
|
+
let acc = header;
|
|
137
|
+
for (const hunk of hunks) {
|
|
138
|
+
const next = acc + hunk;
|
|
139
|
+
if (Buffer.byteLength(next, "utf8") > budget) {
|
|
140
|
+
if (acc === header) return { diff: hardCut(next, budget), truncated: true };
|
|
141
|
+
return { diff: acc, truncated: true };
|
|
142
|
+
}
|
|
143
|
+
acc = next;
|
|
144
|
+
}
|
|
145
|
+
return { diff: acc, truncated: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function countLines(absPath) {
|
|
149
|
+
try {
|
|
150
|
+
const content = await readFile(absPath, "utf8");
|
|
151
|
+
if (content.length === 0) return 0;
|
|
152
|
+
return content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
|
|
153
|
+
} catch {
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Untracked (or, in a headless repo, "everything") content comes from a
|
|
159
|
+
// read-only `git diff --no-index -- /dev/null <path>` — deliberately not
|
|
160
|
+
// `git add -N`, which would write an index entry (a mutation, however
|
|
161
|
+
// reversible). --no-index exits 1 when there IS a difference (the normal
|
|
162
|
+
// case here); that's not an error, it's the expected signal.
|
|
163
|
+
async function noIndexDiff(dir, relPath) {
|
|
164
|
+
try {
|
|
165
|
+
const { stdout } = await git(dir, ["diff", "--no-color", "--no-index", "--", "/dev/null", relPath]);
|
|
166
|
+
return stdout;
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (typeof err.stdout === "string") return err.stdout;
|
|
169
|
+
throw err;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// D-wt-diff: `?sessionId=` overrides the diff root when the named session's
|
|
174
|
+
// live pane cwd (or, failing that, its worktree meta) sits outside the
|
|
175
|
+
// registry dir. Live pane cwd is preferred — it reflects wherever the user's
|
|
176
|
+
// shell/claude actually is right now, same source as the sessions list's
|
|
177
|
+
// branch chip — falling back to the recorded worktree path only if the pane
|
|
178
|
+
// is gone (session died between kill and finish, or was already dead when
|
|
179
|
+
// this request landed). Returns registeredDir unchanged if no override
|
|
180
|
+
// applies (no sessionId, unknown session, override path vanished, or the
|
|
181
|
+
// resolved path IS the registry dir).
|
|
182
|
+
// Returns { dir, isWorktree }: isWorktree marks that the override target
|
|
183
|
+
// is a worktree session (has worktree metadata), which switches the diff
|
|
184
|
+
// base to the fork point (see the route) so committed-but-unmerged branch
|
|
185
|
+
// work still shows.
|
|
186
|
+
async function resolveDiffRoot(registeredDir, sessionId) {
|
|
187
|
+
if (!sessionId) return { dir: registeredDir, isWorktree: false };
|
|
188
|
+
|
|
189
|
+
const meta = await getSessionMeta(sessionId).catch(() => null);
|
|
190
|
+
const wtPath = meta?.worktree?.path;
|
|
191
|
+
|
|
192
|
+
const panePaths = await listPanePaths();
|
|
193
|
+
const paneCwd = panePaths.get(sessionId);
|
|
194
|
+
if (paneCwd && paneCwd !== registeredDir) {
|
|
195
|
+
const s = await stat(paneCwd).catch(() => null);
|
|
196
|
+
if (s?.isDirectory()) return { dir: paneCwd, isWorktree: !!wtPath };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (wtPath && wtPath !== registeredDir) {
|
|
200
|
+
const s = await stat(wtPath).catch(() => null);
|
|
201
|
+
if (s?.isDirectory()) return { dir: wtPath, isWorktree: true };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { dir: registeredDir, isWorktree: false };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// `git diff --name-status -z` records: STATUS\0PATH\0, renames/copies:
|
|
208
|
+
// Rnnn\0OLD\0NEW\0. Emitted in parseStatus's entry shape so the same
|
|
209
|
+
// changeTypeFor/rendering path applies.
|
|
210
|
+
function parseNameStatusZ(raw) {
|
|
211
|
+
const tokens = raw.split("\0").filter((t) => t.length > 0);
|
|
212
|
+
const entries = [];
|
|
213
|
+
let i = 0;
|
|
214
|
+
while (i < tokens.length) {
|
|
215
|
+
const status = tokens[i];
|
|
216
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
217
|
+
entries.push({ kind: "rename", xy: "R.", path: tokens[i + 2], origPath: tokens[i + 1] });
|
|
218
|
+
i += 3;
|
|
219
|
+
} else {
|
|
220
|
+
entries.push({ kind: "ordinary", xy: status[0], path: tokens[i + 1] });
|
|
221
|
+
i += 2;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return entries;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default async function diffRoutes(app) {
|
|
228
|
+
app.get("/api/diff/:workspace", async (req, reply) => {
|
|
229
|
+
const { workspace } = req.params;
|
|
230
|
+
const { sessionId } = req.query ?? {};
|
|
231
|
+
const registered = await getWorkspace(workspace);
|
|
232
|
+
if (!registered) {
|
|
233
|
+
return reply.code(404).send({ error: `unknown workspace: ${workspace}` });
|
|
234
|
+
}
|
|
235
|
+
const { dir, isWorktree } = await resolveDiffRoot(registered.dir, sessionId);
|
|
236
|
+
const root = dir;
|
|
237
|
+
|
|
238
|
+
// One gate covers both "not a git repo" and "registered dir no longer
|
|
239
|
+
// exists" — both mean the same observable thing: nothing to diff.
|
|
240
|
+
try {
|
|
241
|
+
await git(dir, ["rev-parse", "--is-inside-work-tree"]);
|
|
242
|
+
} catch {
|
|
243
|
+
return { files: [], truncated: false, root, branch: null };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const branch = await resolveBranch(dir);
|
|
247
|
+
|
|
248
|
+
let statusRaw;
|
|
249
|
+
try {
|
|
250
|
+
({ stdout: statusRaw } = await git(dir, ["status", "--porcelain=v2", "-z"]));
|
|
251
|
+
} catch {
|
|
252
|
+
return { files: [], truncated: false, root, branch };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const statusEntries = parseStatus(statusRaw);
|
|
256
|
+
|
|
257
|
+
// Repos with zero commits have no HEAD to diff against — every
|
|
258
|
+
// status-reported path is treated the same as an untracked file.
|
|
259
|
+
let hasHead = true;
|
|
260
|
+
try {
|
|
261
|
+
await git(dir, ["rev-parse", "HEAD"]);
|
|
262
|
+
} catch {
|
|
263
|
+
hasHead = false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Fork-point diff for worktree sessions: "what changed" means
|
|
267
|
+
// everything the branch would bring relative to where it forked from
|
|
268
|
+
// the source repo — committed AND uncommitted — because that is the
|
|
269
|
+
// delta the finish (merge/discard) decision acts on. Base = merge-base
|
|
270
|
+
// of the worktree HEAD and the source repo's current HEAD. Tracked
|
|
271
|
+
// entries are re-derived against that base (git status alone goes
|
|
272
|
+
// blank the moment the agent commits); untracked files still come from
|
|
273
|
+
// status (diff never lists them). Any failure falls back to the plain
|
|
274
|
+
// uncommitted-only behavior.
|
|
275
|
+
let baseRef = "HEAD";
|
|
276
|
+
let entries = statusEntries;
|
|
277
|
+
if (isWorktree && hasHead) {
|
|
278
|
+
try {
|
|
279
|
+
const { stdout: repoHead } = await git(registered.dir, ["rev-parse", "HEAD"]);
|
|
280
|
+
const { stdout: mergeBase } = await git(dir, ["merge-base", "HEAD", repoHead.trim()]);
|
|
281
|
+
if (mergeBase.trim()) baseRef = mergeBase.trim();
|
|
282
|
+
} catch {
|
|
283
|
+
// fork point unknown (repo dir gone, unrelated histories) — keep HEAD
|
|
284
|
+
}
|
|
285
|
+
if (baseRef !== "HEAD") {
|
|
286
|
+
try {
|
|
287
|
+
const { stdout } = await git(dir, [
|
|
288
|
+
"diff",
|
|
289
|
+
"-M",
|
|
290
|
+
"--name-status",
|
|
291
|
+
"-z",
|
|
292
|
+
baseRef,
|
|
293
|
+
"--",
|
|
294
|
+
".",
|
|
295
|
+
]);
|
|
296
|
+
entries = [
|
|
297
|
+
...parseNameStatusZ(stdout),
|
|
298
|
+
...statusEntries.filter((e) => e.kind === "untracked"),
|
|
299
|
+
];
|
|
300
|
+
} catch {
|
|
301
|
+
entries = statusEntries;
|
|
302
|
+
baseRef = "HEAD";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (entries.length === 0) return { files: [], truncated: false, root, branch };
|
|
308
|
+
|
|
309
|
+
let diffMap = new Map();
|
|
310
|
+
if (hasHead) {
|
|
311
|
+
const hasTracked = entries.some((e) => e.kind !== "untracked");
|
|
312
|
+
if (hasTracked) {
|
|
313
|
+
try {
|
|
314
|
+
const { stdout } = await git(dir, ["diff", "-M", "--no-color", baseRef, "--", "."]);
|
|
315
|
+
diffMap = splitDiffByFile(stdout);
|
|
316
|
+
} catch {
|
|
317
|
+
diffMap = new Map();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const files = [];
|
|
323
|
+
let totalBytes = 0;
|
|
324
|
+
let truncated = false;
|
|
325
|
+
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
const relPath = entry.path;
|
|
328
|
+
const oldPath = entry.kind === "rename" ? entry.origPath : undefined;
|
|
329
|
+
const changeType = hasHead
|
|
330
|
+
? changeTypeFor(entry)
|
|
331
|
+
: entry.kind === "untracked"
|
|
332
|
+
? "untracked"
|
|
333
|
+
: "added";
|
|
334
|
+
|
|
335
|
+
if (entry.kind === "untracked" || !hasHead) {
|
|
336
|
+
if (totalBytes >= TOTAL_BUDGET) {
|
|
337
|
+
const additions = await countLines(join(dir, relPath));
|
|
338
|
+
files.push({
|
|
339
|
+
path: relPath,
|
|
340
|
+
oldPath,
|
|
341
|
+
changeType,
|
|
342
|
+
additions,
|
|
343
|
+
deletions: 0,
|
|
344
|
+
binary: false,
|
|
345
|
+
diff: "",
|
|
346
|
+
truncated: true,
|
|
347
|
+
});
|
|
348
|
+
truncated = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let raw;
|
|
353
|
+
try {
|
|
354
|
+
raw = await noIndexDiff(dir, relPath);
|
|
355
|
+
} catch {
|
|
356
|
+
// Real failure (permission denied, etc.) — degrade to stat-only
|
|
357
|
+
// rather than failing the whole request.
|
|
358
|
+
files.push({
|
|
359
|
+
path: relPath,
|
|
360
|
+
oldPath,
|
|
361
|
+
changeType,
|
|
362
|
+
additions: 0,
|
|
363
|
+
deletions: 0,
|
|
364
|
+
binary: false,
|
|
365
|
+
diff: "",
|
|
366
|
+
truncated: false,
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (BINARY_RE.test(raw)) {
|
|
372
|
+
files.push({
|
|
373
|
+
path: relPath,
|
|
374
|
+
oldPath,
|
|
375
|
+
changeType,
|
|
376
|
+
additions: 0,
|
|
377
|
+
deletions: 0,
|
|
378
|
+
binary: true,
|
|
379
|
+
diff: "",
|
|
380
|
+
truncated: false,
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const { additions, deletions } = countStats(raw);
|
|
386
|
+
const { diff, truncated: fileTruncated } = truncateToBudget(raw, FILE_BUDGET);
|
|
387
|
+
totalBytes += Buffer.byteLength(diff, "utf8");
|
|
388
|
+
if (fileTruncated) truncated = true;
|
|
389
|
+
files.push({
|
|
390
|
+
path: relPath,
|
|
391
|
+
oldPath,
|
|
392
|
+
changeType,
|
|
393
|
+
additions,
|
|
394
|
+
deletions,
|
|
395
|
+
binary: false,
|
|
396
|
+
diff,
|
|
397
|
+
truncated: fileTruncated,
|
|
398
|
+
});
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Tracked (ordinary or rename) with a HEAD to diff against.
|
|
403
|
+
const raw = diffMap.get(relPath);
|
|
404
|
+
if (!raw) {
|
|
405
|
+
files.push({
|
|
406
|
+
path: relPath,
|
|
407
|
+
oldPath,
|
|
408
|
+
changeType,
|
|
409
|
+
additions: 0,
|
|
410
|
+
deletions: 0,
|
|
411
|
+
binary: false,
|
|
412
|
+
diff: "",
|
|
413
|
+
truncated: false,
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (BINARY_RE.test(raw)) {
|
|
419
|
+
files.push({
|
|
420
|
+
path: relPath,
|
|
421
|
+
oldPath,
|
|
422
|
+
changeType,
|
|
423
|
+
additions: 0,
|
|
424
|
+
deletions: 0,
|
|
425
|
+
binary: true,
|
|
426
|
+
diff: "",
|
|
427
|
+
truncated: false,
|
|
428
|
+
});
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const { additions, deletions } = countStats(raw);
|
|
433
|
+
if (totalBytes >= TOTAL_BUDGET) {
|
|
434
|
+
files.push({
|
|
435
|
+
path: relPath,
|
|
436
|
+
oldPath,
|
|
437
|
+
changeType,
|
|
438
|
+
additions,
|
|
439
|
+
deletions,
|
|
440
|
+
binary: false,
|
|
441
|
+
diff: "",
|
|
442
|
+
truncated: true,
|
|
443
|
+
});
|
|
444
|
+
truncated = true;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const { diff, truncated: fileTruncated } = truncateToBudget(raw, FILE_BUDGET);
|
|
449
|
+
totalBytes += Buffer.byteLength(diff, "utf8");
|
|
450
|
+
if (fileTruncated) truncated = true;
|
|
451
|
+
files.push({
|
|
452
|
+
path: relPath,
|
|
453
|
+
oldPath,
|
|
454
|
+
changeType,
|
|
455
|
+
additions,
|
|
456
|
+
deletions,
|
|
457
|
+
binary: false,
|
|
458
|
+
diff,
|
|
459
|
+
truncated: fileTruncated,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return { files, truncated, root, branch };
|
|
464
|
+
});
|
|
465
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getWorkspace } from "./registry.js";
|
|
4
|
+
|
|
5
|
+
// D-editor (openspec/changes/p2-diff-review/design.md): mirrors the
|
|
6
|
+
// GARAGE_CLAUDE_CMD override pattern already used by sessions.js.
|
|
7
|
+
const EDITOR_CMD = process.env.GARAGE_EDITOR_CMD ?? "code";
|
|
8
|
+
|
|
9
|
+
// Resolves once the child has actually spawned (so a caller can trust a
|
|
10
|
+
// resolved promise means the CLI was found), rejects with the spawn error
|
|
11
|
+
// (notably ENOENT when the editor CLI isn't on PATH) otherwise. The child is
|
|
12
|
+
// detached and its stdio ignored — it's a GUI app we fire-and-forget, not a
|
|
13
|
+
// process whose output or lifetime the daemon should own.
|
|
14
|
+
function spawnEditor(args) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(EDITOR_CMD, args, { stdio: "ignore", detached: true });
|
|
17
|
+
child.once("error", reject);
|
|
18
|
+
child.once("spawn", () => {
|
|
19
|
+
child.unref();
|
|
20
|
+
resolve();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default async function editorRoutes(app) {
|
|
26
|
+
app.post("/api/open-editor", async (req, reply) => {
|
|
27
|
+
const { workspace, file, line } = req.body ?? {};
|
|
28
|
+
|
|
29
|
+
const registered = await getWorkspace(workspace);
|
|
30
|
+
if (!registered) {
|
|
31
|
+
return reply.code(404).send({ error: `unknown workspace: ${workspace}` });
|
|
32
|
+
}
|
|
33
|
+
const dir = registered.dir;
|
|
34
|
+
|
|
35
|
+
let args;
|
|
36
|
+
if (file !== undefined && file !== null && file !== "") {
|
|
37
|
+
if (typeof file !== "string") {
|
|
38
|
+
return reply.code(400).send({ error: "file must be a string" });
|
|
39
|
+
}
|
|
40
|
+
// Prefix-check the RESOLVED path, not the raw string, so both
|
|
41
|
+
// "../"-style traversal and absolute-path overrides of `file` are
|
|
42
|
+
// caught regardless of how they're spelled.
|
|
43
|
+
const abs = path.resolve(dir, file);
|
|
44
|
+
if (abs !== dir && !abs.startsWith(dir + path.sep)) {
|
|
45
|
+
return reply.code(400).send({ error: "file escapes workspace directory" });
|
|
46
|
+
}
|
|
47
|
+
args = ["--goto", `${abs}:${line ?? 1}`];
|
|
48
|
+
} else {
|
|
49
|
+
args = [dir];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await spawnEditor(args);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code === "ENOENT") {
|
|
56
|
+
return reply.code(501).send({ error: `editor CLI not found (${EDITOR_CMD})` });
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return reply.code(200).send({ ok: true });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { statusEvents } from "./status.js";
|
|
2
|
+
import { pollerEvents } from "./poller.js";
|
|
3
|
+
|
|
4
|
+
const KEEPALIVE_MS = 15_000;
|
|
5
|
+
|
|
6
|
+
// D-push: GET /api/events as SSE. One-directional daemon -> UI, so SSE over
|
|
7
|
+
// a plain reply.raw loop — no new dependency, EventSource auto-reconnects.
|
|
8
|
+
const activeStreams = new Set();
|
|
9
|
+
|
|
10
|
+
export default async function eventRoutes(app) {
|
|
11
|
+
app.get("/api/events", async (req, reply) => {
|
|
12
|
+
reply.hijack();
|
|
13
|
+
const res = reply.raw;
|
|
14
|
+
res.writeHead(200, {
|
|
15
|
+
"Content-Type": "text/event-stream",
|
|
16
|
+
"Cache-Control": "no-cache",
|
|
17
|
+
Connection: "keep-alive",
|
|
18
|
+
});
|
|
19
|
+
res.write(": connected\n\n");
|
|
20
|
+
activeStreams.add(res);
|
|
21
|
+
|
|
22
|
+
const send = (event, data) => {
|
|
23
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const onTransition = ({ id, to }) => send("status", { id, status: to });
|
|
27
|
+
const onSessionsChanged = () => send("sessions", {});
|
|
28
|
+
|
|
29
|
+
statusEvents.on("transition", onTransition);
|
|
30
|
+
pollerEvents.on("sessions-changed", onSessionsChanged);
|
|
31
|
+
|
|
32
|
+
const keepalive = setInterval(() => {
|
|
33
|
+
res.write(": keepalive\n\n");
|
|
34
|
+
}, KEEPALIVE_MS);
|
|
35
|
+
keepalive.unref?.();
|
|
36
|
+
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
clearInterval(keepalive);
|
|
39
|
+
statusEvents.off("transition", onTransition);
|
|
40
|
+
pollerEvents.off("sessions-changed", onSessionsChanged);
|
|
41
|
+
activeStreams.delete(res);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
req.raw.on("close", cleanup);
|
|
45
|
+
res.on("error", cleanup);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
app.addHook("onClose", async () => {
|
|
49
|
+
for (const res of activeStreams) {
|
|
50
|
+
res.end();
|
|
51
|
+
}
|
|
52
|
+
activeStreams.clear();
|
|
53
|
+
});
|
|
54
|
+
}
|