pi-odd-worktree 0.0.0-stage → 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.
Files changed (60) hide show
  1. package/README.md +223 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,505 @@
1
+ 'use strict';
2
+ // Odd document discovery (odd-viewer S2,S6; ordering S10/S12; clone-aware
3
+ // recency S13).
4
+ //
5
+ // Scope is the CHECKOUT the session runs in: `<root>/odd/tasks/*.md`, not
6
+ // recursive, never the principal checkout when the session sits in a
7
+ // worktree. The caller resolves the root (the command uses the session cwd
8
+ // resolved to its Git root); this module only reads what it is given, so it
9
+ // stays testable on plain fixture directories.
10
+ //
11
+ // Recency, and why mtime alone is not enough (S13): a fresh clone stamps every
12
+ // file with the checkout time, so mtime says nothing about which document was
13
+ // modified last. The signal used for each document follows this priority:
14
+ //
15
+ // 1. LOCAL EDIT — the file's working-tree content is not what HEAD has
16
+ // (`git status` reports it: modified, staged, added or untracked).
17
+ // Recency = max(its own mtime, its own last commit time). A local edit is
18
+ // therefore at least as new as the commit it edits, and a normal edit
19
+ // (mtime = now) outranks every commit in the repository; a backdated
20
+ // mtime can never demote it below the commit whose content it replaced.
21
+ // An untracked document has no commit, so it is ranked by mtime alone.
22
+ // 2. COMMIT — tracked and clean: recency = the last commit time touching the
23
+ // file. This is the rule that makes a fresh clone meaningful, and it
24
+ // deliberately ignores mtime, which in a clone is checkout noise.
25
+ // 3. MTIME — no usable git signal (not a repository, git missing, git
26
+ // failing or timing out, unparseable output, a shallow clone without
27
+ // reachable history for that document, or an untracked document).
28
+ //
29
+ // Documents are then ordered by that timestamp DESCENDING, then display name
30
+ // ascending, then raw file name ascending — all code-unit comparisons, never
31
+ // `localeCompare`, so the order is identical on every host and every run. A
32
+ // document with no timestamp at all (stat failed and no commit time) is kept
33
+ // and placed after every ranked one, by name; the list never hides a document
34
+ // over a stat failure.
35
+ //
36
+ // Cost: at most THREE bounded git processes per listing — one `rev-parse HEAD`
37
+ // (the memo key), one `git log` for EVERY document's last commit time (never
38
+ // one process per document) and one `git status` for the local-edit signal —
39
+ // and NONE when `odd/tasks` yields no document. The log walks the commits
40
+ // reachable from HEAD that touch the `odd` tasks directory, so the work grows
41
+ // with that slice of history and not with the number of documents or the size
42
+ // of the tree; it is capped at MAX_LOG_COMMITS commits and each call at
43
+ // GIT_TIMEOUT_MS. Measured on a 271-commit checkout (78 commits touching
44
+ // `odd/tasks`): ~11 ms on local-speed storage, ~1.6 s on the same history over
45
+ // a slow network mount.
46
+ //
47
+ // That walk is therefore memoized (S13 cost): the parsed commit-time map is a
48
+ // pure function of the commits reachable from HEAD, so it is cached per
49
+ // repository AND per HEAD commit id. A repeated listing with an unchanged HEAD
50
+ // skips the walk and runs only the two cheap calls (`rev-parse`, `status`); a
51
+ // new commit changes HEAD and the walk runs again. The local-edit read is NEVER
52
+ // memoized — it is the cheap call and the only way an uncommitted edit can
53
+ // promote its document — so a dirty file still reorders the list. Nothing about
54
+ // a document's CONTENT is cached, only `file name -> commit time in ms`, and
55
+ // the cache is bounded (COMMIT_CACHE_LIMIT repositories, oldest evicted) so a
56
+ // session visiting many checkouts cannot grow it without limit. A HEAD that
57
+ // cannot be resolved is not a cache key at all: the listing walks history
58
+ // uncached or, when git cannot answer, degrades to the mtime fallback — it
59
+ // never serves a memoized order it cannot justify. A walk that exceeds the cap
60
+ // or the timeout degrades to the mtime fallback instead of delaying the listing
61
+ // further.
62
+ //
63
+ // Two readers exist because the viewer is an INTERACTIVE path (S21 «se traba»):
64
+ // `listOddDocsAsync` resolves the identical listing with every git read awaited
65
+ // instead of spawned synchronously, so a slow or network-mounted checkout can
66
+ // never freeze the host event loop while /odd loads or re-lists; and
67
+ // `listOddDocsLocal` is the cheap scan alone (mtime order, no git process at
68
+ // all) that paints the viewer's first frame and answers "is there anything to
69
+ // read here?" without a history walk. The two share the scan, the ordering rule,
70
+ // the parsers and the memo, so they cannot disagree about a checkout.
71
+ //
72
+ // Every failure mode is explicit and read-only: a missing or non-directory
73
+ // root fails closed, an absent `odd/tasks` directory is an empty list (a
74
+ // checkout simply without documents), and an unreadable document reports an
75
+ // error rather than rendering empty content.
76
+
77
+ const fs = require('node:fs');
78
+ const path = require('node:path');
79
+ const { spawnSync } = require('node:child_process');
80
+
81
+ const TASKS_DIR = path.join('odd', 'tasks');
82
+ // Git wants forward slashes even on Windows.
83
+ const TASKS_PATHSPEC = TASKS_DIR.split(path.sep).join('/');
84
+
85
+ const GIT_TIMEOUT_MS = 5000;
86
+ const MAX_LOG_COMMITS = 5000;
87
+ // Mark each commit record so a timestamp is never confused with a file name.
88
+ const COMMIT_MARK = '\u001e';
89
+
90
+ // The commit-time memo (see the cost note above): `root + NUL + HEAD commit id`
91
+ // -> `Map<file name, commit time in ms>`. Bounded so a session that lists many
92
+ // checkouts (one per worktree) cannot grow it without limit.
93
+ const COMMIT_CACHE_LIMIT = 8;
94
+ const CACHE_KEY_SEP = '\u0000';
95
+ const commitCache = new Map();
96
+
97
+ // The HEAD probe (memo key only) and the history walk: newest commit first,
98
+ // with the time it touched each path.
99
+ const HEAD_ARGS = ['rev-parse', '--verify', '--quiet', 'HEAD'];
100
+ const LOG_ARGS = [
101
+ 'log',
102
+ '-z',
103
+ '--name-only',
104
+ '-n',
105
+ String(MAX_LOG_COMMITS),
106
+ `--format=${COMMIT_MARK}%ct`,
107
+ '--',
108
+ TASKS_PATHSPEC,
109
+ ];
110
+ const STATUS_ARGS = ['status', '--porcelain', '-z', '--', TASKS_PATHSPEC];
111
+
112
+ // Neutralize the configuration that would corrupt parsing or add work: quoted
113
+ // paths, signature verification lines in `git log`, and an fsmonitor daemon in
114
+ // `git status` — whatever the host's global config says.
115
+ const GIT_CONFIG_ARGS = [
116
+ '-c',
117
+ 'core.quotePath=false',
118
+ '-c',
119
+ 'log.showSignature=false',
120
+ '-c',
121
+ 'core.fsmonitor=false',
122
+ ];
123
+
124
+ // Ordered most-recently-modified first (S10/S12), with the display name as the
125
+ // deterministic tie-break: every consumer inherits the order from here instead
126
+ // of re-sorting its own view (`recent` is this sequence; the view sorts its own
127
+ // copy for `name`).
128
+ //
129
+ // `opts` is an optional seam for tests and hosts: `gitBin` overrides the `git`
130
+ // executable (a missing or failing binary is the "git unavailable" path).
131
+ // Nothing it carries reaches the row shape.
132
+ function listOddDocs(root, opts) {
133
+ const scanned = scanOddDocs(root);
134
+ // An empty listing costs no git process at all (a root that is not a checkout
135
+ // is already answered by the scan).
136
+ if (scanned.ok !== true || scanned.docs.length === 0) return scanned;
137
+ return { ok: true, root: scanned.root, docs: orderDocs(scanned.docs, readGitRecency(scanned.root, opts)) };
138
+ }
139
+
140
+ // The CHEAP listing (S21): the very same scan, ordered by the documented mtime
141
+ // fallback, with NO git process of any kind. An interactive caller paints its
142
+ // first frame from this and asks for the ranked order separately, so opening the
143
+ // viewer never waits on a history walk — and a checkout that turns out to hold
144
+ // no document at all answers before git is consulted.
145
+ function listOddDocsLocal(root) {
146
+ const scanned = scanOddDocs(root);
147
+ if (scanned.ok !== true || scanned.docs.length === 0) return scanned;
148
+ return { ok: true, root: scanned.root, docs: orderDocs(scanned.docs, null) };
149
+ }
150
+
151
+ // The async twin of `listOddDocs` for interactive callers (S21): the same
152
+ // documents in the same order, with every git read AWAITED instead of spawned
153
+ // synchronously, so a slow or network-mounted checkout can no longer freeze the
154
+ // host event loop while the viewer loads or re-lists. Same rows, same failures
155
+ // and the same degradations as the synchronous reader: the directory scan stays
156
+ // synchronous because it is the cheap part, and both readers share the ordering
157
+ // rule, the parsers and the commit-time memo.
158
+ //
159
+ // `opts` carries the same `gitBin` seam plus `runAsync`, the package convention
160
+ // `(bin, argv, cwd) => Promise<{status, stdout, stderr, error}>` a test or host
161
+ // injects to simulate a slow or failing git deterministically.
162
+ async function listOddDocsAsync(root, opts) {
163
+ const scanned = scanOddDocs(root);
164
+ if (scanned.ok !== true || scanned.docs.length === 0) return scanned;
165
+ return { ok: true, root: scanned.root, docs: orderDocs(scanned.docs, await readGitRecencyAsync(scanned.root, opts)) };
166
+ }
167
+
168
+ // The directory scan both readers share: `<root>/odd/tasks/*.md`, non-recursive,
169
+ // regular files only. Documents come back in directory order — the ordering key
170
+ // is applied afterwards, so a scan stays pure and repeatable.
171
+ function scanOddDocs(root) {
172
+ if (typeof root !== 'string' || root.length === 0 || !path.isAbsolute(root)) {
173
+ return { ok: false, reason: 'no-checkout', docs: [] };
174
+ }
175
+ let stats = null;
176
+ try {
177
+ stats = fs.statSync(root);
178
+ } catch {
179
+ return { ok: false, reason: 'no-checkout', docs: [] };
180
+ }
181
+ if (!stats.isDirectory()) return { ok: false, reason: 'no-checkout', docs: [] };
182
+ const dir = path.join(root, TASKS_DIR);
183
+ let names = [];
184
+ try {
185
+ names = fs.readdirSync(dir);
186
+ } catch {
187
+ // No odd/tasks directory (or an unreadable one): an empty list, not a
188
+ // failure — the view renders its own empty state.
189
+ return { ok: true, root, docs: [] };
190
+ }
191
+ const docs = [];
192
+ for (const name of names) {
193
+ if (!name.toLowerCase().endsWith('.md')) continue;
194
+ if (name.startsWith('.')) continue;
195
+ const full = path.join(dir, name);
196
+ try {
197
+ // A directory named `x.md` is not a document; neither is a symlink to
198
+ // one, so a broken or circular link can never enter the list.
199
+ const entry = fs.lstatSync(full);
200
+ if (!entry.isFile()) continue;
201
+ } catch {
202
+ continue;
203
+ }
204
+ docs.push({ name: name.replace(/\.md$/i, ''), file: name, path: full });
205
+ }
206
+ return { ok: true, root, docs };
207
+ }
208
+
209
+ // The ONE ordering rule, applied to a scan: the ordering key stays OUT of the
210
+ // row shape, only the sequence changes. `git` is the recency signal, or null —
211
+ // which is exactly the documented mtime fallback.
212
+ function orderDocs(docs, git) {
213
+ const keyed = docs.map((doc) => ({ doc, modified: modifiedMs(doc, git, readMtime(doc.path)) }));
214
+ keyed.sort(compareByRecency);
215
+ return keyed.map((entry) => entry.doc);
216
+ }
217
+
218
+ // The per-document recency timestamp, applying the documented priority.
219
+ function modifiedMs(doc, git, mtime) {
220
+ const commit = git ? git.commit.get(doc.file) : undefined;
221
+ if (commit === undefined) return mtime; // (3) mtime: no commit for this file
222
+ if (!git.local.has(doc.file)) return commit; // (2) clean: the commit decides
223
+ // (1) local edit: never older than the commit it edits.
224
+ if (mtime === null) return commit;
225
+ return Math.max(mtime, commit);
226
+ }
227
+
228
+ // Both git signals in at most three bounded calls, or null when git cannot
229
+ // answer at all. The commit-time read is memoized per repository and HEAD
230
+ // commit; the local-edit read is not, so it still runs on every listing. A
231
+ // `git log` result with no matching record (shallow clone, or a repository
232
+ // without history for these documents) is still a usable answer: the files then
233
+ // fall back to mtime individually.
234
+ function readGitRecency(root, opts) {
235
+ const head = readHeadCommit(root, opts);
236
+ let commit = head === null ? undefined : cachedCommitTimes(root, head);
237
+ if (commit === undefined) {
238
+ const log = runGit(root, LOG_ARGS, opts);
239
+ // A failed walk with no usable memo leaves no commit signal at all: the
240
+ // listing degrades to mtime rather than to a stale order.
241
+ if (log === null) return null;
242
+ commit = parseCommitTimes(log);
243
+ // Only a resolved HEAD may key the memo; an unresolvable one stays uncached.
244
+ if (head !== null) rememberCommitTimes(root, head, commit);
245
+ }
246
+ // A failed status read costs the local-edit signal only: the commit times
247
+ // are still trustworthy, so the listing degrades instead of failing.
248
+ const status = runGit(root, STATUS_ARGS, opts);
249
+ return {
250
+ commit,
251
+ local: status === null ? new Set() : parseLocalChanges(status),
252
+ };
253
+ }
254
+
255
+ // The async twin of `readGitRecency`: the same three bounded reads in the same
256
+ // order, over the awaited runner. The memo lookup and the `file name -> commit
257
+ // time` map are SHARED with the synchronous reader, so a checkout one reader
258
+ // walked is never walked again by the other, and neither can serve an order it
259
+ // did not derive from the same HEAD.
260
+ async function readGitRecencyAsync(root, opts) {
261
+ const head = headCommitOf(await runGitAsync(root, HEAD_ARGS, opts));
262
+ let commit = head === null ? undefined : cachedCommitTimes(root, head);
263
+ if (commit === undefined) {
264
+ const log = await runGitAsync(root, LOG_ARGS, opts);
265
+ // A failed walk with no usable memo leaves no commit signal at all: the
266
+ // listing degrades to mtime rather than to a stale order.
267
+ if (log === null) return null;
268
+ commit = parseCommitTimes(log);
269
+ // Only a resolved HEAD may key the memo; an unresolvable one stays uncached.
270
+ if (head !== null) rememberCommitTimes(root, head, commit);
271
+ }
272
+ // A failed status read costs the local-edit signal only, exactly as above.
273
+ const status = await runGitAsync(root, STATUS_ARGS, opts);
274
+ return {
275
+ commit,
276
+ local: status === null ? new Set() : parseLocalChanges(status),
277
+ };
278
+ }
279
+
280
+ // The HEAD commit id, or null when git cannot name one (missing binary, no
281
+ // repository, an unborn branch, a timeout). It is only ever a memo key, so an
282
+ // unexpected value costs a cache miss and never a wrong order.
283
+ function readHeadCommit(root, opts) {
284
+ return headCommitOf(runGit(root, HEAD_ARGS, opts));
285
+ }
286
+
287
+ // The one reading of a HEAD probe's stdout, shared by both readers so they can
288
+ // never disagree about which commit the memo is keyed by.
289
+ function headCommitOf(stdout) {
290
+ if (stdout === null) return null;
291
+ const head = stdout.trim();
292
+ return head.length > 0 ? head : null;
293
+ }
294
+
295
+ function cacheKey(root, head) {
296
+ return `${root}${CACHE_KEY_SEP}${head}`;
297
+ }
298
+
299
+ // A memo hit, refreshed to the young end of the LRU order so a checkout that
300
+ // keeps being listed is not the one another checkout's listing evicts.
301
+ function cachedCommitTimes(root, head) {
302
+ const key = cacheKey(root, head);
303
+ const times = commitCache.get(key);
304
+ if (times === undefined) return undefined;
305
+ commitCache.delete(key);
306
+ commitCache.set(key, times);
307
+ return times;
308
+ }
309
+
310
+ // Remember a fresh walk. One entry per repository: a new HEAD replaces the
311
+ // previous map (the walk was just redone for it) instead of accumulating a
312
+ // history of HEADs, and the size stays within COMMIT_CACHE_LIMIT by evicting the
313
+ // oldest entry.
314
+ function rememberCommitTimes(root, head, times) {
315
+ for (const key of commitCache.keys()) {
316
+ if (key.slice(0, key.lastIndexOf(CACHE_KEY_SEP)) === root) commitCache.delete(key);
317
+ }
318
+ commitCache.set(cacheKey(root, head), times);
319
+ while (commitCache.size > COMMIT_CACHE_LIMIT) {
320
+ const oldest = commitCache.keys().next();
321
+ if (oldest.done) break;
322
+ commitCache.delete(oldest.value);
323
+ }
324
+ }
325
+
326
+ // Read-only diagnostic snapshot of the memo: its bound, how many repositories
327
+ // are remembered, and for each the HEAD it is keyed by plus the remembered
328
+ // `file name -> commit time` pairs. A copy, so a caller can never mutate the
329
+ // memo — and the shape itself is the guarantee that no document CONTENT is ever
330
+ // stored, only those timestamps.
331
+ function commitCacheInfo() {
332
+ const entries = [];
333
+ for (const [key, times] of commitCache) {
334
+ const separator = key.lastIndexOf(CACHE_KEY_SEP);
335
+ entries.push({
336
+ root: key.slice(0, separator),
337
+ head: key.slice(separator + 1),
338
+ times: [...times].map(([file, ms]) => [file, ms]),
339
+ });
340
+ }
341
+ return { limit: COMMIT_CACHE_LIMIT, size: commitCache.size, entries };
342
+ }
343
+
344
+ // One fixed-argv read. Returns stdout (possibly empty) on success and null on
345
+ // any failure — a missing binary, a non-zero exit, a timeout, an unreadable
346
+ // repository. Never throws, never writes.
347
+ function runGit(root, args, opts) {
348
+ const bin = gitBinOf(opts);
349
+ let result = null;
350
+ try {
351
+ result = spawnSync(bin, [...GIT_CONFIG_ARGS, ...args], {
352
+ cwd: root,
353
+ encoding: 'utf8',
354
+ timeout: GIT_TIMEOUT_MS,
355
+ windowsHide: true,
356
+ maxBuffer: 16 * 1024 * 1024,
357
+ // `git status` must never refresh the index of a read-only listing.
358
+ env: gitEnv(),
359
+ });
360
+ } catch {
361
+ return null;
362
+ }
363
+ if (!result || result.error || result.status !== 0) return null;
364
+ return typeof result.stdout === 'string' ? result.stdout : '';
365
+ }
366
+
367
+ // The async twin of `runGit` (S21): the same fixed argv, environment and
368
+ // timeout, awaited instead of blocking the host event loop, with the identical
369
+ // stdout-or-null contract — every failure (a missing binary, a non-zero exit, a
370
+ // timeout, an unreadable repository) is `null` instead of a throw.
371
+ function runGitAsync(root, args, opts) {
372
+ const bin = gitBinOf(opts);
373
+ let pending = null;
374
+ try {
375
+ pending = asyncRunnerOf(opts)(bin, [...GIT_CONFIG_ARGS, ...args], root);
376
+ } catch {
377
+ return Promise.resolve(null);
378
+ }
379
+ return Promise.resolve(pending)
380
+ .then((result) => {
381
+ if (!result || result.error || result.status !== 0) return null;
382
+ return typeof result.stdout === 'string' ? result.stdout : '';
383
+ })
384
+ .catch(() => null);
385
+ }
386
+
387
+ function gitBinOf(opts) {
388
+ const options = opts && typeof opts === 'object' ? opts : {};
389
+ return typeof options.gitBin === 'string' && options.gitBin.length > 0 ? options.gitBin : 'git';
390
+ }
391
+
392
+ // The environment every listing read runs with: `git status` must never refresh
393
+ // the index of a read-only listing, and git must never stop to prompt.
394
+ function gitEnv() {
395
+ return { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' };
396
+ }
397
+
398
+ // The awaited runner the async reader uses: the injected one when a test or a
399
+ // host supplies it (the package convention `(bin, argv, cwd) => Promise`),
400
+ // otherwise the shared bounded async spawn wrapper every interactive path in
401
+ // this package already uses — loaded lazily, so a caller that only ever lists
402
+ // synchronously never pays for it.
403
+ function asyncRunnerOf(opts) {
404
+ const options = opts && typeof opts === 'object' ? opts : {};
405
+ if (typeof options.runAsync === 'function') return options.runAsync;
406
+ const worktreeGit = require('./worktree-git.js');
407
+ return (bin, args, cwd) => worktreeGit.runArgvAsync(bin, args, {
408
+ cwd,
409
+ timeoutMs: GIT_TIMEOUT_MS,
410
+ windowsHide: true,
411
+ env: gitEnv(),
412
+ });
413
+ }
414
+
415
+ // `git log -z --name-only --format=<mark>%ct` walks commits newest first and
416
+ // lists each commit's changed paths. The FIRST record seen for a file name is
417
+ // therefore its newest commit — which is exactly the timestamp wanted, with no
418
+ // per-file process. Records and paths are matched by FILE NAME inside the
419
+ // listed `odd/tasks` directory: the listing is non-recursive and the pathspec
420
+ // is that same directory, so a name is unambiguous.
421
+ function parseCommitTimes(stdout) {
422
+ const times = new Map();
423
+ let current = null;
424
+ for (const token of stdout.split('\0')) {
425
+ if (token.length === 0) continue;
426
+ if (token.charCodeAt(0) === COMMIT_MARK.charCodeAt(0)) {
427
+ const seconds = Number(token.slice(1));
428
+ current = Number.isFinite(seconds) ? seconds * 1000 : null;
429
+ continue;
430
+ }
431
+ if (current === null) continue;
432
+ const name = token.replace(/^\r?\n/, '');
433
+ if (name.length === 0) continue;
434
+ const base = path.basename(name);
435
+ if (base.length === 0 || base === '.' || base === '..') continue;
436
+ if (!times.has(base)) times.set(base, current);
437
+ }
438
+ return times;
439
+ }
440
+
441
+ // `git status --porcelain -z` reports every entry whose working-tree content is
442
+ // not what HEAD has (modified, staged, added, untracked). Any such entry means
443
+ // the commit no longer describes the file the user is looking at, so the local
444
+ // edit signal applies. A rename/copy entry carries its original path as the
445
+ // next token, which is consumed so it can never be read as a status line.
446
+ function parseLocalChanges(stdout) {
447
+ const local = new Set();
448
+ const tokens = stdout.split('\0');
449
+ for (let index = 0; index < tokens.length; index += 1) {
450
+ const entry = tokens[index];
451
+ if (entry.length < 3) continue;
452
+ const status = entry.slice(0, 2);
453
+ const name = entry.slice(3);
454
+ if (status[0] === 'R' || status[0] === 'C') index += 1;
455
+ const base = path.basename(name);
456
+ if (base.length > 0) local.add(base);
457
+ }
458
+ return local;
459
+ }
460
+
461
+ // Modification time in ms, or null when it cannot be read.
462
+ function readMtime(target) {
463
+ try {
464
+ const stats = fs.statSync(target);
465
+ return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : null;
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ // Total order: ranked by recency descending, then display name, then raw file
472
+ // name — code-unit comparisons only, so the sequence is stable on every host.
473
+ // An unrankable document (no timestamp at all) sorts after every ranked one.
474
+ function compareByRecency(left, right) {
475
+ const a = left.modified;
476
+ const b = right.modified;
477
+ if (a !== null && b !== null && a !== b) return b - a;
478
+ if (a === null && b !== null) return 1;
479
+ if (a !== null && b === null) return -1;
480
+ if (left.doc.name !== right.doc.name) return left.doc.name < right.doc.name ? -1 : 1;
481
+ if (left.doc.file !== right.doc.file) return left.doc.file < right.doc.file ? -1 : 1;
482
+ return 0;
483
+ }
484
+
485
+ function readOddDoc(doc) {
486
+ const target = doc && typeof doc.path === 'string' ? doc.path : null;
487
+ if (!target) return { ok: false, error: 'no document selected' };
488
+ try {
489
+ const text = fs.readFileSync(target, 'utf8');
490
+ return { ok: true, text };
491
+ } catch (err) {
492
+ const detail = err && err.message ? err.message : 'unreadable';
493
+ return { ok: false, error: detail };
494
+ }
495
+ }
496
+
497
+ module.exports = {
498
+ listOddDocs,
499
+ listOddDocsLocal,
500
+ listOddDocsAsync,
501
+ readOddDoc,
502
+ commitCacheInfo,
503
+ COMMIT_CACHE_LIMIT,
504
+ TASKS_DIR,
505
+ };