my-durable 0.0.25 → 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")
@@ -181,7 +222,15 @@ export type GitOp =
181
222
  | "discard"
182
223
  | "diff-file"
183
224
  | "stash-push"
184
- | "stash-pop";
225
+ | "stash-pop"
226
+ | "unstage"
227
+ | "compare"
228
+ | "merge-abort"
229
+ | "show-files"
230
+ | "ignore"
231
+ | "checkpoint"
232
+ | "checkpoints"
233
+ | "rewind";
185
234
 
186
235
  /** Plain string an arg may never contain — we build argv ourselves anyway. */
187
236
  const safeRef = (s: string) => /^[\w./@+-]+$/.test(s) && !s.includes("..");
@@ -198,9 +247,24 @@ export async function gitOp(
198
247
  files?: string[];
199
248
  create?: boolean;
200
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[];
201
258
  } = {},
202
- ): Promise<{ text: string; commits?: { hash: string; refs: string; author: string; date: string; subject: string }[] }> {
203
- const { ref, message, target, branch, files, create, staged } = opts;
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;
204
268
  switch (op) {
205
269
  case "diff":
206
270
  return { text: await git(path, ["--no-pager", "diff", ...(ref && safeRef(ref) ? [ref] : [])]) };
@@ -208,9 +272,10 @@ export async function gitOp(
208
272
  return { text: (await git(path, ["--no-pager", "log", "--oneline", "-n", "30", ...(ref && safeRef(ref) ? [ref] : [])])).trimEnd() };
209
273
  case "log-list": {
210
274
  // IntelliJ's log window: hash, refs, author, date, subject — structured.
275
+ // %p = parents — the swimlane graph is computed from these on the UI.
211
276
  const raw = await git(
212
277
  path,
213
- ["--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"],
214
279
  );
215
280
  return {
216
281
  text: "",
@@ -218,15 +283,24 @@ export async function gitOp(
218
283
  .split("\n")
219
284
  .filter(Boolean)
220
285
  .map((l) => {
221
- const [hash, refs, author, date, subject] = l.split("\x1f");
222
- 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
+ };
223
295
  }),
224
296
  };
225
297
  }
226
298
  case "fetch":
227
299
  return { text: (await git(path, ["fetch", "--all", "--prune"])).trim() || "fetched" };
228
300
  case "pull":
229
- 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" };
230
304
  case "branch":
231
305
  return { text: (await git(path, ["--no-pager", "branch", "-vv"])).trimEnd() };
232
306
  case "switch": {
@@ -234,8 +308,13 @@ export async function gitOp(
234
308
  // create:true = git switch -c — IntelliJ's "new branch" action.
235
309
  return { text: (await git(path, ["switch", ...(create ? ["-c"] : []), ref])).trim() };
236
310
  }
237
- case "push":
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
+ }
238
316
  return { text: (await git(path, ["push"], GIT_SLOW_TIMEOUT)).trim() || "pushed (or nothing to push)" };
317
+ }
239
318
  case "stage": {
240
319
  if (!files?.length) throw Error("stage needs files");
241
320
  return { text: (await git(path, ["add", "--", ...files])).trim() || `staged ${files.length} file(s)` };
@@ -253,6 +332,86 @@ export async function gitOp(
253
332
  return { text: (await git(path, ["stash", "push", ...(message ? ["-m", message] : [])])).trim() || "stashed" };
254
333
  case "stash-pop":
255
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}` };
414
+ }
256
415
  case "commit": {
257
416
  const msg = (message ?? "").trim();
258
417
  if (!msg) throw Error("commit needs a message");
@@ -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", "push", "switch", "branch", "commit", "stage", "discard", "diff-file", "stash-push", "stash-pop", "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) {