my-durable 0.0.24 → 0.0.26
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/apps/dpi-backend/src/git.ts +211 -18
- package/apps/dpi-backend/src/routes.ts +11 -1
- package/apps/dpi-backend/src/store.ts +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-C9OQe54o.js +79 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DwYK0DEy.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +2 -2
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/GitPanel.vue +769 -170
- package/apps/dpi-sdk/src/client.ts +49 -8
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +1 -0
- package/docs/git-ui-checklist.md +194 -0
- package/package.json +1 -1
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-BLV50tg7.js +0 -78
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CpVvdDil.css +0 -1
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* worktree-add, worktree-remove
|
|
10
10
|
*/
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
|
-
import {
|
|
12
|
+
import { appendFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
13
14
|
|
|
14
15
|
const GIT_TIMEOUT = 15_000;
|
|
15
16
|
/** `git status` on a big Windows checkout (AV scanning, node_modules) can
|
|
@@ -58,8 +59,16 @@ export interface GitStatus {
|
|
|
58
59
|
worktreeOf?: string;
|
|
59
60
|
worktrees?: { path: string; branch: string | null; head: string }[];
|
|
60
61
|
/** Changed files (porcelain XY + path), capped — the IntelliJ-style commit
|
|
61
|
-
* dialog checks exactly these. */
|
|
62
|
-
files?: { path: string; x: string; y: string }[];
|
|
62
|
+
* dialog checks exactly these. ±n line counts come from numstat (-1 = binary). */
|
|
63
|
+
files?: { path: string; x: string; y: string; a?: number; d?: number }[];
|
|
64
|
+
/** A merge (with conflicts) is in progress — the banner + abort action. */
|
|
65
|
+
merge?: boolean;
|
|
66
|
+
/** Unmerged paths (UU/AA/DD/AU/UA/DU/UD). */
|
|
67
|
+
conflicts?: number;
|
|
68
|
+
/** Commit identity from config — warn before committing without one. */
|
|
69
|
+
identity?: { name?: string; email?: string };
|
|
70
|
+
/** `origin` URL, parsed by the UI into an open-in-remote link. */
|
|
71
|
+
remoteUrl?: string;
|
|
63
72
|
/** Local branches for pickers — nobody should TYPE a name that exists. */
|
|
64
73
|
branches?: { name: string; current?: boolean; upstream?: string }[];
|
|
65
74
|
}
|
|
@@ -72,6 +81,11 @@ export async function gitStatusBatch(paths: string[]): Promise<Record<string, Gi
|
|
|
72
81
|
return Object.fromEntries(entries);
|
|
73
82
|
}
|
|
74
83
|
|
|
84
|
+
/** Test hook: the TTL cache would serve a pre-mutation answer. */
|
|
85
|
+
export function clearGitStatusCache() {
|
|
86
|
+
statusCache.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
/** Structured status for a folder: everything the UIs show, one call. */
|
|
76
90
|
export async function gitStatus(path: string): Promise<GitStatus> {
|
|
77
91
|
const cached = statusCache.get(path);
|
|
@@ -86,13 +100,20 @@ export async function gitStatus(path: string): Promise<GitStatus> {
|
|
|
86
100
|
out.root = root;
|
|
87
101
|
// Everything after toplevel runs in PARALLEL — status is the long pole
|
|
88
102
|
// (20s+ on big Windows checkouts) and must not wait for four fast calls.
|
|
89
|
-
const [branchP, headP, statusP, gitDirP, wlP, branchesP] = await Promise.allSettled([
|
|
103
|
+
const [branchP, headP, statusP, gitDirP, wlP, branchesP, numWorkP, numIdxP, mergeP, nameP, emailP, remoteP] = await Promise.allSettled([
|
|
90
104
|
git(path, ["branch", "--show-current"]),
|
|
91
105
|
git(path, ["log", "-1", "--pretty=%h%x09%s"]),
|
|
92
106
|
git(path, ["--no-optional-locks", "status", "--porcelain=v1", "-b"], GIT_SLOW_TIMEOUT),
|
|
93
107
|
git(path, ["rev-parse", "--git-dir"]),
|
|
94
108
|
git(path, ["worktree", "list", "--porcelain"], GIT_SLOW_TIMEOUT),
|
|
95
109
|
git(path, ["for-each-ref", "refs/heads", "--format=%(refname:short)%09%(upstream:short)%09%(if)%(HEAD)%(then)1%(else)0%(end)"]),
|
|
110
|
+
git(path, ["--no-optional-locks", "diff", "--numstat", "--no-renames"], GIT_SLOW_TIMEOUT),
|
|
111
|
+
git(path, ["--no-optional-locks", "diff", "--numstat", "--cached", "--no-renames"]),
|
|
112
|
+
// MERGE_HEAD exists ⇔ a merge is waiting to be resolved.
|
|
113
|
+
git(path, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]),
|
|
114
|
+
git(path, ["config", "user.name"]),
|
|
115
|
+
git(path, ["config", "user.email"]),
|
|
116
|
+
git(path, ["remote", "get-url", "origin"]),
|
|
96
117
|
]);
|
|
97
118
|
const branch = branchP.status === "fulfilled" ? branchP.value : "";
|
|
98
119
|
out.branch = branch.trim() || undefined;
|
|
@@ -121,11 +142,31 @@ export async function gitStatus(path: string): Promise<GitStatus> {
|
|
|
121
142
|
}
|
|
122
143
|
// Per line — a ^-anchored regex on the joined blob only ever sees line 1.
|
|
123
144
|
const body = lines.slice(1).filter(Boolean);
|
|
124
|
-
out.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
145
|
+
out.merge = mergeP.status === "fulfilled";
|
|
146
|
+
out.conflicts = count(body.join("\n"), /^(UU|AA|DD|AU|UA|DU|UD)/gm);
|
|
147
|
+
out.identity = {
|
|
148
|
+
name: nameP.status === "fulfilled" ? nameP.value.trim() || undefined : undefined,
|
|
149
|
+
email: emailP.status === "fulfilled" ? emailP.value.trim() || undefined : undefined,
|
|
150
|
+
};
|
|
151
|
+
if (remoteP.status === "fulfilled") out.remoteUrl = remoteP.value.trim() || undefined;
|
|
152
|
+
// ±n per file: worktree-vs-index numstat for unstaged paths, cached for staged.
|
|
153
|
+
const numstat = (raw: string) => {
|
|
154
|
+
const m = new Map<string, { a: number; d: number }>();
|
|
155
|
+
for (const l of raw.split("\n")) {
|
|
156
|
+
const mt = l.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
157
|
+
if (!mt) continue;
|
|
158
|
+
m.set(mt[3]!, { a: mt[1] === "-" ? -1 : Number(mt[1]), d: mt[2] === "-" ? -1 : Number(mt[2]) });
|
|
159
|
+
}
|
|
160
|
+
return m;
|
|
161
|
+
};
|
|
162
|
+
const workNum = numstat(numWorkP.status === "fulfilled" ? numWorkP.value : "");
|
|
163
|
+
const idxNum = numstat(numIdxP.status === "fulfilled" ? numIdxP.value : "");
|
|
164
|
+
out.files = body.slice(0, 500).map((l) => {
|
|
165
|
+
const p = l.slice(3).replace(/^"|"$/g, "");
|
|
166
|
+
const staged = l[0] !== " " && l[0] !== "?";
|
|
167
|
+
const n = staged ? idxNum.get(p) : workNum.get(p);
|
|
168
|
+
return { path: p, x: l[0] ?? " ", y: l[1] ?? " ", ...(n ? { a: n.a, d: n.d } : {}) };
|
|
169
|
+
});
|
|
129
170
|
if (branchesP.status === "fulfilled")
|
|
130
171
|
out.branches = branchesP.value
|
|
131
172
|
.split("\n")
|
|
@@ -175,7 +216,21 @@ export type GitOp =
|
|
|
175
216
|
| "commit"
|
|
176
217
|
| "worktree-list"
|
|
177
218
|
| "worktree-add"
|
|
178
|
-
| "worktree-remove"
|
|
219
|
+
| "worktree-remove"
|
|
220
|
+
| "push"
|
|
221
|
+
| "stage"
|
|
222
|
+
| "discard"
|
|
223
|
+
| "diff-file"
|
|
224
|
+
| "stash-push"
|
|
225
|
+
| "stash-pop"
|
|
226
|
+
| "unstage"
|
|
227
|
+
| "compare"
|
|
228
|
+
| "merge-abort"
|
|
229
|
+
| "show-files"
|
|
230
|
+
| "ignore"
|
|
231
|
+
| "checkpoint"
|
|
232
|
+
| "checkpoints"
|
|
233
|
+
| "rewind";
|
|
179
234
|
|
|
180
235
|
/** Plain string an arg may never contain — we build argv ourselves anyway. */
|
|
181
236
|
const safeRef = (s: string) => /^[\w./@+-]+$/.test(s) && !s.includes("..");
|
|
@@ -184,9 +239,32 @@ const safeRef = (s: string) => /^[\w./@+-]+$/.test(s) && !s.includes("..");
|
|
|
184
239
|
export async function gitOp(
|
|
185
240
|
path: string,
|
|
186
241
|
op: GitOp,
|
|
187
|
-
opts: {
|
|
188
|
-
|
|
189
|
-
|
|
242
|
+
opts: {
|
|
243
|
+
ref?: string;
|
|
244
|
+
message?: string;
|
|
245
|
+
target?: string;
|
|
246
|
+
branch?: string;
|
|
247
|
+
files?: string[];
|
|
248
|
+
create?: boolean;
|
|
249
|
+
staged?: boolean;
|
|
250
|
+
/** pull: opt in to --rebase when --ff-only would fail. */
|
|
251
|
+
rebase?: boolean;
|
|
252
|
+
/** push: set upstream (-u origin <ref>). */
|
|
253
|
+
setUpstream?: boolean;
|
|
254
|
+
/** checkpoint name. */
|
|
255
|
+
name?: string;
|
|
256
|
+
/** ignore: pattern lines to append to .gitignore. */
|
|
257
|
+
lines?: string[];
|
|
258
|
+
} = {},
|
|
259
|
+
): Promise<{
|
|
260
|
+
text: string;
|
|
261
|
+
commits?: { hash: string; refs: string; author: string; date: string; subject: string; parents: string[] }[];
|
|
262
|
+
ahead?: number;
|
|
263
|
+
behind?: number;
|
|
264
|
+
changed?: { status: string; path: string }[];
|
|
265
|
+
checkpoints?: { name: string; hash: string; date: string; subject: string }[];
|
|
266
|
+
}> {
|
|
267
|
+
const { ref, message, target, branch, files, create, staged, rebase, setUpstream, name, lines } = opts;
|
|
190
268
|
switch (op) {
|
|
191
269
|
case "diff":
|
|
192
270
|
return { text: await git(path, ["--no-pager", "diff", ...(ref && safeRef(ref) ? [ref] : [])]) };
|
|
@@ -194,9 +272,10 @@ export async function gitOp(
|
|
|
194
272
|
return { text: (await git(path, ["--no-pager", "log", "--oneline", "-n", "30", ...(ref && safeRef(ref) ? [ref] : [])])).trimEnd() };
|
|
195
273
|
case "log-list": {
|
|
196
274
|
// IntelliJ's log window: hash, refs, author, date, subject — structured.
|
|
275
|
+
// %p = parents — the swimlane graph is computed from these on the UI.
|
|
197
276
|
const raw = await git(
|
|
198
277
|
path,
|
|
199
|
-
["--no-pager", "log", "-n", "50", "--date=short", "--pretty=format:%h%x1f%D%x1f%an%x1f%ad%x1f%s"],
|
|
278
|
+
["--no-pager", "log", "-n", "50", "--date=short", "--pretty=format:%h%x1f%D%x1f%an%x1f%ad%x1f%s%x1f%p"],
|
|
200
279
|
);
|
|
201
280
|
return {
|
|
202
281
|
text: "",
|
|
@@ -204,20 +283,134 @@ export async function gitOp(
|
|
|
204
283
|
.split("\n")
|
|
205
284
|
.filter(Boolean)
|
|
206
285
|
.map((l) => {
|
|
207
|
-
const [hash, refs, author, date, subject] = l.split("\x1f");
|
|
208
|
-
return {
|
|
286
|
+
const [hash, refs, author, date, subject, parents] = l.split("\x1f");
|
|
287
|
+
return {
|
|
288
|
+
hash: hash ?? "",
|
|
289
|
+
refs: refs ?? "",
|
|
290
|
+
author: author ?? "",
|
|
291
|
+
date: date ?? "",
|
|
292
|
+
subject: subject ?? "",
|
|
293
|
+
parents: (parents ?? "").split(" ").filter(Boolean),
|
|
294
|
+
};
|
|
209
295
|
}),
|
|
210
296
|
};
|
|
211
297
|
}
|
|
212
298
|
case "fetch":
|
|
213
299
|
return { text: (await git(path, ["fetch", "--all", "--prune"])).trim() || "fetched" };
|
|
214
300
|
case "pull":
|
|
215
|
-
|
|
301
|
+
// --ff-only stays the default; --rebase is an explicit opt-in the UI
|
|
302
|
+
// offers only after a ff-only failure.
|
|
303
|
+
return { text: (await git(path, ["pull", ...(rebase ? ["--rebase"] : ["--ff-only"])])).trim() || "up to date" };
|
|
216
304
|
case "branch":
|
|
217
305
|
return { text: (await git(path, ["--no-pager", "branch", "-vv"])).trimEnd() };
|
|
218
306
|
case "switch": {
|
|
219
307
|
if (!ref || !safeRef(ref)) throw Error("switch needs a branch name (letters, digits, ./-_)");
|
|
220
|
-
|
|
308
|
+
// create:true = git switch -c — IntelliJ's "new branch" action.
|
|
309
|
+
return { text: (await git(path, ["switch", ...(create ? ["-c"] : []), ref])).trim() };
|
|
310
|
+
}
|
|
311
|
+
case "push": {
|
|
312
|
+
if (setUpstream) {
|
|
313
|
+
if (!ref || !safeRef(ref)) throw Error("push -u needs a branch name");
|
|
314
|
+
return { text: (await git(path, ["push", "-u", "origin", ref], GIT_SLOW_TIMEOUT)).trim() || `pushed, upstream set to origin/${ref}` };
|
|
315
|
+
}
|
|
316
|
+
return { text: (await git(path, ["push"], GIT_SLOW_TIMEOUT)).trim() || "pushed (or nothing to push)" };
|
|
317
|
+
}
|
|
318
|
+
case "stage": {
|
|
319
|
+
if (!files?.length) throw Error("stage needs files");
|
|
320
|
+
return { text: (await git(path, ["add", "--", ...files])).trim() || `staged ${files.length} file(s)` };
|
|
321
|
+
}
|
|
322
|
+
case "discard": {
|
|
323
|
+
// IntelliJ's Rollback: index AND worktree back to HEAD for these paths.
|
|
324
|
+
if (!files?.length) throw Error("discard needs files");
|
|
325
|
+
return { text: (await git(path, ["checkout", "HEAD", "--", ...files])).trim() || `discarded ${files.length} file(s)` };
|
|
326
|
+
}
|
|
327
|
+
case "diff-file": {
|
|
328
|
+
if (!target) throw Error("diff-file needs a file path");
|
|
329
|
+
return { text: (await git(path, ["--no-pager", "diff", ...(staged ? ["--cached"] : []), "HEAD", "--", target])).trim() || "no diff" };
|
|
330
|
+
}
|
|
331
|
+
case "stash-push":
|
|
332
|
+
return { text: (await git(path, ["stash", "push", ...(message ? ["-m", message] : [])])).trim() || "stashed" };
|
|
333
|
+
case "stash-pop":
|
|
334
|
+
return { text: (await git(path, ["stash", "pop"])).trim() || "stash popped" };
|
|
335
|
+
case "unstage": {
|
|
336
|
+
// IntelliJ's Unstage: index only, worktree keeps its changes.
|
|
337
|
+
if (!files?.length) throw Error("unstage needs files");
|
|
338
|
+
try {
|
|
339
|
+
return { text: (await git(path, ["reset", "-q", "HEAD", "--", ...files])).trim() || `unstaged ${files.length} file(s)` };
|
|
340
|
+
} catch {
|
|
341
|
+
// unborn branch: HEAD does not resolve yet — reset against the empty tree.
|
|
342
|
+
return { text: (await git(path, ["reset", "-q", "--", ...files])).trim() || `unstaged ${files.length} file(s)` };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
case "compare": {
|
|
346
|
+
// ahead/behind vs ANY ref, not just the upstream (orca git-bridge).
|
|
347
|
+
if (!ref || !safeRef(ref)) throw Error("compare needs a branch name");
|
|
348
|
+
const raw = await git(path, ["rev-list", "--left-right", "--count", `${ref}...HEAD`]);
|
|
349
|
+
const [behind = "0", ahead = "0"] = raw.trim().split(/\s+/);
|
|
350
|
+
return { text: `${Number(ahead)} ahead, ${Number(behind)} behind of ${ref}`, ahead: Number(ahead), behind: Number(behind) };
|
|
351
|
+
}
|
|
352
|
+
case "merge-abort":
|
|
353
|
+
try {
|
|
354
|
+
return { text: (await git(path, ["merge", "--abort"])).trim() || "merge aborted" };
|
|
355
|
+
} catch {
|
|
356
|
+
return { text: (await git(path, ["rebase", "--abort"])).trim() || "rebase aborted" };
|
|
357
|
+
}
|
|
358
|
+
case "show-files": {
|
|
359
|
+
// What one commit touched — the log row expander.
|
|
360
|
+
if (!target || !/^[0-9a-f]{4,40}$/.test(target)) throw Error("show-files needs a commit hash");
|
|
361
|
+
const raw = await git(path, ["--no-pager", "show", "--name-status", "--pretty=format:", "--no-renames", "-m", "--first-parent", target]);
|
|
362
|
+
return {
|
|
363
|
+
text: "",
|
|
364
|
+
changed: raw
|
|
365
|
+
.split("\n")
|
|
366
|
+
.map((l) => l.trim())
|
|
367
|
+
.filter(Boolean)
|
|
368
|
+
.slice(0, 200)
|
|
369
|
+
.map((l) => {
|
|
370
|
+
const [status, p] = l.split(/\t/);
|
|
371
|
+
return { status: status ?? "", path: p ?? l };
|
|
372
|
+
}),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
case "ignore": {
|
|
376
|
+
// Appends to the repo's OWN .gitignore — root comes from git, not the caller.
|
|
377
|
+
if (!lines?.length) throw Error("ignore needs pattern lines");
|
|
378
|
+
if (lines.length > 50) throw Error("ignore takes at most 50 lines");
|
|
379
|
+
for (const l of lines) if (!l.trim() || l.length > 200 || /[\n\r\0]/.test(l)) throw Error("bad ignore pattern");
|
|
380
|
+
const root = (await git(path, ["rev-parse", "--show-toplevel"])).trim();
|
|
381
|
+
await appendFile(join(root, ".gitignore"), `\n${lines.join("\n")}\n`, "utf8");
|
|
382
|
+
return { text: `added to .gitignore: ${lines.join(", ")}` };
|
|
383
|
+
}
|
|
384
|
+
case "checkpoint": {
|
|
385
|
+
// A named ref of HEAD — the rewind target. Never touches the worktree.
|
|
386
|
+
if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) throw Error("checkpoint needs a name (letters, digits, ._-)");
|
|
387
|
+
const refname = `refs/dpi/checkpoints/${name}`;
|
|
388
|
+
await git(path, ["update-ref", refname, "HEAD"]);
|
|
389
|
+
return { text: `checkpoint ${name} saved` };
|
|
390
|
+
}
|
|
391
|
+
case "checkpoints": {
|
|
392
|
+
const raw = await git(path, [
|
|
393
|
+
"--no-pager", "for-each-ref", "refs/dpi/checkpoints",
|
|
394
|
+
"--format=%(refname:strip=3)%09%(objectname:short)%09%(committerdate:short)%09%(contents:subject)",
|
|
395
|
+
]);
|
|
396
|
+
return {
|
|
397
|
+
text: "",
|
|
398
|
+
checkpoints: raw
|
|
399
|
+
.split("\n")
|
|
400
|
+
.filter(Boolean)
|
|
401
|
+
.slice(0, 50)
|
|
402
|
+
.map((l) => {
|
|
403
|
+
const [full, hash, date, subject] = l.split("\t");
|
|
404
|
+
return { name: full ?? "", hash: hash ?? "", date: date ?? "", subject: (subject ?? "").slice(0, 80) };
|
|
405
|
+
}),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
case "rewind": {
|
|
409
|
+
// reset --hard to one of OUR checkpoint refs only — never an arbitrary ref.
|
|
410
|
+
if (!name || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) throw Error("rewind needs a checkpoint name");
|
|
411
|
+
const refname = `refs/dpi/checkpoints/${name}`;
|
|
412
|
+
const sha = (await git(path, ["rev-parse", "--verify", refname])).trim();
|
|
413
|
+
return { text: (await git(path, ["reset", "--hard", sha])).trim().split("\n")[0] ?? `rewound to ${name}` };
|
|
221
414
|
}
|
|
222
415
|
case "commit": {
|
|
223
416
|
const msg = (message ?? "").trim();
|
|
@@ -11,7 +11,7 @@ import type { DevicePairing } from "./pairing.js";
|
|
|
11
11
|
type Async = (req: Request, res: Response) => Promise<unknown>;
|
|
12
12
|
const route = (fn: Async) => (req: Request, res: Response, next: NextFunction) => fn(req, res).catch(next);
|
|
13
13
|
import { gitOp, gitStatus, gitStatusBatch, type GitOp } from "./git.ts";
|
|
14
|
-
const GIT_OPS: GitOp[] = ["diff", "log", "log-list", "fetch", "pull", "switch", "branch", "commit", "worktree-list", "worktree-add", "worktree-remove"];
|
|
14
|
+
const GIT_OPS: GitOp[] = ["diff", "log", "log-list", "fetch", "pull", "push", "switch", "branch", "commit", "stage", "unstage", "discard", "diff-file", "stash-push", "stash-pop", "compare", "merge-abort", "show-files", "ignore", "checkpoint", "checkpoints", "rewind", "worktree-list", "worktree-add", "worktree-remove"];
|
|
15
15
|
|
|
16
16
|
interface Locals {
|
|
17
17
|
row: SessionRow;
|
|
@@ -138,6 +138,13 @@ export function v1(
|
|
|
138
138
|
message: body.message,
|
|
139
139
|
target: body.target,
|
|
140
140
|
files: Array.isArray(body.files) ? body.files.map(String).slice(0, 500) : undefined,
|
|
141
|
+
// booleans stay booleans: gitOp validates names/refs itself.
|
|
142
|
+
create: body.create === true,
|
|
143
|
+
staged: body.staged === true,
|
|
144
|
+
rebase: body.rebase === true,
|
|
145
|
+
setUpstream: body.setUpstream === true,
|
|
146
|
+
name: typeof body.name === "string" ? body.name : undefined,
|
|
147
|
+
lines: Array.isArray(body.lines) ? body.lines.map(String).slice(0, 50) : undefined,
|
|
141
148
|
});
|
|
142
149
|
res.json(out);
|
|
143
150
|
}));
|
|
@@ -159,6 +166,9 @@ export function v1(
|
|
|
159
166
|
r.patch("/sessions/:s/compaction", route(async (req, res) => res.json(store.setCompaction(L(res).row, req.body ?? {}))));
|
|
160
167
|
r.delete("/sessions/:s", route(async (_req, res) => res.json(await store.archive(L(res).row))));
|
|
161
168
|
r.get("/sessions/:s/events", route(async (_req, res) => sessionStream(store, L(res).row, res)));
|
|
169
|
+
// The zcode flagship, adapted: which files has THIS session's agent touched?
|
|
170
|
+
// Powers "commit session changes" in the git panel.
|
|
171
|
+
r.get("/sessions/:s/git-files", route(async (_req, res) => res.json({ files: await store.sessionTouchedFiles(L(res).row) })));
|
|
162
172
|
r.get(
|
|
163
173
|
"/sessions/:s/tasks",
|
|
164
174
|
route(async (req, res) => {
|
|
@@ -717,6 +717,45 @@ export class Store {
|
|
|
717
717
|
}, ctx);
|
|
718
718
|
}
|
|
719
719
|
|
|
720
|
+
/**
|
|
721
|
+
* zcode's flagship, adapted: the paths this session's agents have written or
|
|
722
|
+
* edited — the "commit session changes" changelist. Read-only scan of every
|
|
723
|
+
* conversation's stored entries for write/edit tool calls; caps keep big
|
|
724
|
+
* sessions cheap. No worktree is touched.
|
|
725
|
+
*/
|
|
726
|
+
async sessionTouchedFiles(row: SessionRow): Promise<string[]> {
|
|
727
|
+
const h = await this.openAgent(row);
|
|
728
|
+
const paths = new Set<string>();
|
|
729
|
+
await h.commit(async (tx) => {
|
|
730
|
+
let convs = await tx.scanConversations({}, 1000);
|
|
731
|
+
const records = [...convs.items];
|
|
732
|
+
while (convs.next && records.length < 100) {
|
|
733
|
+
convs = await tx.scanConversations({}, 1000, convs.next);
|
|
734
|
+
records.push(...convs.items);
|
|
735
|
+
}
|
|
736
|
+
for (const c of records) {
|
|
737
|
+
let page = await tx.scanEntries({ conversationId: c.id }, 500);
|
|
738
|
+
let seen = 0;
|
|
739
|
+
for (;;) {
|
|
740
|
+
for (const e of page.items as unknown as { model?: readonly { content?: unknown }[] }[]) {
|
|
741
|
+
for (const m of e.model ?? []) {
|
|
742
|
+
const blocks = Array.isArray(m.content) ? (m.content as unknown[]) : [];
|
|
743
|
+
for (const b of blocks as { type?: string; name?: string; arguments?: Record<string, unknown> }[]) {
|
|
744
|
+
if (b?.type !== "toolCall" || (b.name !== "write" && b.name !== "edit")) continue;
|
|
745
|
+
const f = b.arguments?.file_path;
|
|
746
|
+
if (typeof f === "string" && f.length > 0 && f.length < 4096) paths.add(f);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
seen += page.items.length;
|
|
751
|
+
if (!page.next || seen >= 4000 || paths.size >= 800) break;
|
|
752
|
+
page = await tx.scanEntries({ conversationId: c.id }, 500, page.next);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}, ctx);
|
|
756
|
+
return [...paths];
|
|
757
|
+
}
|
|
758
|
+
|
|
720
759
|
// ------------------------------------------------------------ agents
|
|
721
760
|
|
|
722
761
|
private names(sessionId: string) {
|