pi-odd-worktree 0.0.0-stage → 1.0.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/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
package/lib/odd-docs.js
ADDED
|
@@ -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
|
+
};
|