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.
@@ -9,7 +9,8 @@
9
9
  * worktree-add, worktree-remove
10
10
  */
11
11
  import { execFile } from "node:child_process";
12
- import { dirname, isAbsolute } from "node:path";
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.files = body.slice(0, 500).map((l) => ({
125
- path: l.slice(3).replace(/^"|"$/g, ""),
126
- x: l[0] ?? " ",
127
- y: l[1] ?? " ",
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: { ref?: string; message?: string; target?: string; branch?: string; files?: string[] } = {},
188
- ): Promise<{ text: string; commits?: { hash: string; refs: string; author: string; date: string; subject: string }[] }> {
189
- const { ref, message, target, branch, files } = opts;
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 { hash: hash ?? "", refs: refs ?? "", author: author ?? "", date: date ?? "", subject: subject ?? "" };
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
- return { text: (await git(path, ["pull", "--ff-only"])).trim() || "up to date" };
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
- return { text: (await git(path, ["switch", ref])).trim() };
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) {