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
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Focused-checkout facts for the warp selector (warp-popup W1 — S2, S4, S9).
|
|
3
|
+
//
|
|
4
|
+
// One call yields everything the selector's footer shows for ONE checkout:
|
|
5
|
+
// the branch, when the checkout was created, its last activity, its
|
|
6
|
+
// uncommitted-change summary (the file count plus the added/deleted LINE
|
|
7
|
+
// counts of the tracked diff and of every untracked file, S16), and the
|
|
8
|
+
// commits it is ahead of / behind a base branch. S1: a worktree no longer
|
|
9
|
+
// receives issues or ODD documents, so the payload carries NO association
|
|
10
|
+
// field at all. Legacy registry rows still store `issues` and `odd`; that data
|
|
11
|
+
// is preserved untouched (never migrated, never erased) and is simply never
|
|
12
|
+
// read here. The row is still read for the ONE fact it legitimately owns — a
|
|
13
|
+
// linked worktree's `createdAt` (S12).
|
|
14
|
+
//
|
|
15
|
+
// Each fact is its own envelope — `{available:true, value, reason:null}` or
|
|
16
|
+
// `{available:false, value:null, reason}` — because S9 forbids rendering an
|
|
17
|
+
// unreadable value as a number. A failing git read, a missing registry row
|
|
18
|
+
// or a malformed registry is therefore "unavailable", never zero and never
|
|
19
|
+
// an empty list. Facts fail independently: a broken registry leaves the git
|
|
20
|
+
// facts intact and vice versa. Two facts are about TIME, and what they answer
|
|
21
|
+
// depends on the KIND of checkout (S12) rather than on one rule for all: the
|
|
22
|
+
// PRINCIPAL is the checkout git lists first, so its `createdAt` is when its
|
|
23
|
+
// git history began (the oldest root commit, `source:'root-commit'`), while a
|
|
24
|
+
// linked worktree reports when THAT checkout was created — its registry row's
|
|
25
|
+
// `createdAt` (`source:'registry'`) or, when that is missing or unreadable,
|
|
26
|
+
// the directory's birth time (`source:'birthtime'`). `lastActivity` is the
|
|
27
|
+
// winning side of `last commit` vs `newest uncommitted file` for either kind
|
|
28
|
+
// (S12), and S8 adds its provenance: an inherited commit older than the
|
|
29
|
+
// checkout's own creation is not local modification and is dropped, while a
|
|
30
|
+
// later commit or a dirty mtime stays honest. A checkout whose kind cannot be
|
|
31
|
+
// established is never treated as the principal, so git's beginning is never
|
|
32
|
+
// claimed without evidence.
|
|
33
|
+
//
|
|
34
|
+
// Cost control (S9 again): the expensive reads — `summarizeChanges` spawns
|
|
35
|
+
// two git processes, ahead/behind spawns up to two more, the last-commit
|
|
36
|
+
// read one, and the oldest-root-commit read one more but ONLY for the
|
|
37
|
+
// principal — only ever run for the checkout the caller names, and the whole
|
|
38
|
+
// per-path result is cached for `ttlMs`, so a selector that repaints per
|
|
39
|
+
// keystroke does not re-spawn git. The dirty summary is computed once and
|
|
40
|
+
// feeds both `changes` and the dirty side of `lastActivity`; the birth-time
|
|
41
|
+
// fallback is only stat'ed for a linked worktree with no usable registry row.
|
|
42
|
+
// Concurrent callers for the same path share one in-flight read. `facts()`
|
|
43
|
+
// is async (the git work is awaited, never spawned inline) and `peek()`
|
|
44
|
+
// answers from the cache synchronously at paint time.
|
|
45
|
+
//
|
|
46
|
+
// Reuse, not duplication: the dirty summary comes from
|
|
47
|
+
// lib/worktree-changes.js, the registry row from lib/worktree-registry.js,
|
|
48
|
+
// the branch from `listWorktrees`, and the comparison from
|
|
49
|
+
// lib/worktree-git.js's `aheadBehind`. The registry file read is local and
|
|
50
|
+
// network-free; every net-capable or heavier read, the repository listing
|
|
51
|
+
// included, is async so it never blocks the host event loop.
|
|
52
|
+
|
|
53
|
+
const path = require('node:path');
|
|
54
|
+
const fs = require('node:fs');
|
|
55
|
+
|
|
56
|
+
const gitLib = require('./worktree-git.js');
|
|
57
|
+
const changesLib = require('./worktree-changes.js');
|
|
58
|
+
const registryLib = require('./worktree-registry.js');
|
|
59
|
+
|
|
60
|
+
const DEFAULT_TTL_MS = 5000;
|
|
61
|
+
|
|
62
|
+
function available(value) {
|
|
63
|
+
return { available: true, value, reason: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function unavailable(reason) {
|
|
67
|
+
const text = typeof reason === 'string' && reason.length > 0 ? reason : 'unavailable';
|
|
68
|
+
return { available: false, value: null, reason: text };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function errorText(err) {
|
|
72
|
+
return (err && err.message) || String(err);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Awaits a seam that may be sync or async and never lets a rejection escape.
|
|
76
|
+
async function attempt(fn, label) {
|
|
77
|
+
try {
|
|
78
|
+
return { ok: true, value: await fn() };
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { ok: false, error: `${label}: ${errorText(err)}` };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Build a facts provider for one repository.
|
|
85
|
+
//
|
|
86
|
+
// Options (every seam is injectable so tests and hosts can substitute it):
|
|
87
|
+
// repoRoot repository whose registry and worktree listing are read
|
|
88
|
+
// readRegistry (repoRoot) -> {ok, worktrees, error}
|
|
89
|
+
// listWorktrees (repoRoot) -> [{path, branch, detached}] or a promise of
|
|
90
|
+
// the same; the default is the async repository listing
|
|
91
|
+
// summarize (checkoutPath) -> Promise<summary> (worktree-changes shape)
|
|
92
|
+
// aheadBehind (checkoutPath) -> Promise<{ok, ahead, behind, base}>
|
|
93
|
+
// lastCommit (checkoutPath) -> Promise<{ok, timestampMs}> (git log -1)
|
|
94
|
+
// firstCommit (checkoutPath) -> Promise<{ok, timestampMs}> (oldest root commit)
|
|
95
|
+
// repoIdentity (checkoutPath) -> identity or a promise of one; the
|
|
96
|
+
// default is the async `repoIdentityAsync`
|
|
97
|
+
// fs fs seam for the createdAt birth-time fallback (statSync)
|
|
98
|
+
// runAsync git runner forwarded to the default git seams
|
|
99
|
+
// ttlMs cache lifetime in ms (default 5000)
|
|
100
|
+
// now clock in ms (default Date.now)
|
|
101
|
+
function createCheckoutFacts(opts = {}) {
|
|
102
|
+
const repoRoot = typeof opts.repoRoot === 'string' && opts.repoRoot.length > 0 ? opts.repoRoot : null;
|
|
103
|
+
const ttlMs = Number.isFinite(opts.ttlMs) && opts.ttlMs >= 0 ? opts.ttlMs : DEFAULT_TTL_MS;
|
|
104
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
105
|
+
const runAsync = typeof opts.runAsync === 'function' ? opts.runAsync : null;
|
|
106
|
+
const fsImpl = opts.fs && typeof opts.fs.statSync === 'function' ? opts.fs : fs;
|
|
107
|
+
|
|
108
|
+
const readRegistry = typeof opts.readRegistry === 'function'
|
|
109
|
+
? opts.readRegistry
|
|
110
|
+
: (root) => registryLib.readRegistry(root);
|
|
111
|
+
const listWorktrees = typeof opts.listWorktrees === 'function'
|
|
112
|
+
? opts.listWorktrees
|
|
113
|
+
: (root) => gitLib.listWorktreesAsync(root, runAsync ? { runAsync } : {});
|
|
114
|
+
const summarize = typeof opts.summarize === 'function'
|
|
115
|
+
? opts.summarize
|
|
116
|
+
: (checkout) => summarizeWithIdentity(checkout);
|
|
117
|
+
const aheadBehind = typeof opts.aheadBehind === 'function' ? opts.aheadBehind : null;
|
|
118
|
+
const lastCommit = typeof opts.lastCommit === 'function'
|
|
119
|
+
? opts.lastCommit
|
|
120
|
+
: (checkout) => gitLib.lastCommitTime(checkout, runAsync ? { runAsync } : {});
|
|
121
|
+
const firstCommit = typeof opts.firstCommit === 'function'
|
|
122
|
+
? opts.firstCommit
|
|
123
|
+
: (checkout) => gitLib.firstCommitTime(checkout, runAsync ? { runAsync } : {});
|
|
124
|
+
const repoIdentity = typeof opts.repoIdentity === 'function' ? opts.repoIdentity : null;
|
|
125
|
+
|
|
126
|
+
// Repository identity of ONE checkout, resolved through the ASYNC twin
|
|
127
|
+
// (worktree-issues S11). `summarizeChanges` resolves the repository root
|
|
128
|
+
// itself, and it can only do that synchronously — `git rev-parse
|
|
129
|
+
// --show-toplevel` plus `--git-common-dir` through `spawnSync`, i.e. two
|
|
130
|
+
// children that freeze the host for their whole lifetime, per checkout. The
|
|
131
|
+
// selector loads every row's facts at once, so N checkouts froze the host N
|
|
132
|
+
// times over while the panel was merely reading. The answer here is the same
|
|
133
|
+
// one, and it is read before `summarizeChanges` runs, so the identity is
|
|
134
|
+
// handed to it instead of re-spawned.
|
|
135
|
+
function identityFor(checkout) {
|
|
136
|
+
const read = repoIdentity !== null
|
|
137
|
+
? () => repoIdentity(checkout)
|
|
138
|
+
: () => gitLib.repoIdentityAsync(checkout, runAsync ? { runAsync } : {});
|
|
139
|
+
// A read that fails is NOT reported here: it falls through to the shared
|
|
140
|
+
// reader inside `summarizeChanges`, which produces its own verdict and its
|
|
141
|
+
// own message, exactly as before this read existed.
|
|
142
|
+
return Promise.resolve().then(read).catch(() => null);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The `git` seam `summarizeChanges` takes: the identity already read, for the
|
|
146
|
+
// path it was read for, and the shared synchronous reader otherwise (a
|
|
147
|
+
// spelling this read did not answer for), so correctness never depends on
|
|
148
|
+
// the pass-through.
|
|
149
|
+
function identitySeam(identity) {
|
|
150
|
+
let resolved = null;
|
|
151
|
+
try {
|
|
152
|
+
resolved = identity && typeof identity.root === 'string' ? path.resolve(identity.root) : null;
|
|
153
|
+
} catch {
|
|
154
|
+
resolved = null;
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
...gitLib,
|
|
158
|
+
repoIdentity: (target) => {
|
|
159
|
+
try {
|
|
160
|
+
if (resolved !== null && typeof target === 'string' && path.resolve(target) === resolved) return identity;
|
|
161
|
+
} catch {
|
|
162
|
+
// A path that cannot be resolved falls through to the shared reader.
|
|
163
|
+
}
|
|
164
|
+
return gitLib.repoIdentity(target);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function summarizeWithIdentity(checkout) {
|
|
170
|
+
const identity = await identityFor(checkout);
|
|
171
|
+
const options = { git: identitySeam(identity) };
|
|
172
|
+
if (runAsync) options.runAsync = runAsync;
|
|
173
|
+
return changesLib.summarizeChanges(checkout, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const cache = new Map(); // absolute checkout path -> {at, facts}
|
|
177
|
+
const inFlight = new Map(); // absolute checkout path -> Promise<facts>
|
|
178
|
+
const listingInFlight = new Map(); // repository root -> Promise<listing entries>
|
|
179
|
+
|
|
180
|
+
// Absolute spelling used as the cache key and reported back to the
|
|
181
|
+
// caller; an unusable input stays as the caller wrote it.
|
|
182
|
+
function keyOf(checkoutPath) {
|
|
183
|
+
if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) return null;
|
|
184
|
+
return path.resolve(checkoutPath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Two spellings of one checkout are the same checkout: the resolved paths
|
|
188
|
+
// first, then the realpaths (a listing may name a symlinked checkout).
|
|
189
|
+
function sameCheckout(a, b) {
|
|
190
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a.length === 0 || b.length === 0) return false;
|
|
191
|
+
if (path.resolve(a) === path.resolve(b)) return true;
|
|
192
|
+
if (typeof fsImpl.realpathSync !== 'function') return false;
|
|
193
|
+
try {
|
|
194
|
+
return fsImpl.realpathSync(a) === fsImpl.realpathSync(b);
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Which KIND of checkout this is (S12). The PRINCIPAL is the one git lists
|
|
201
|
+
// FIRST — `git worktree list` documents the main worktree first — never the
|
|
202
|
+
// calling checkout and never a branch name, so the answer does not depend on
|
|
203
|
+
// where the session runs or on `main`/`master` being called that. A listing
|
|
204
|
+
// that cannot be read leaves the kind unknown, and an unknown kind never
|
|
205
|
+
// claims principal semantics.
|
|
206
|
+
function isPrincipalCheckout(checkout, listing) {
|
|
207
|
+
if (!listing || listing.ok !== true) return false;
|
|
208
|
+
const entries = Array.isArray(listing.value) ? listing.value : [];
|
|
209
|
+
const first = entries[0];
|
|
210
|
+
return Boolean(first) && sameCheckout(first.path, checkout);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The listing entry for exactly this checkout (resolve-equality, the
|
|
214
|
+
// same rule the registry's findByPath uses). Unavailable when the
|
|
215
|
+
// listing fails or the path is not a checkout git knows about.
|
|
216
|
+
function branchFact(checkout, listed) {
|
|
217
|
+
if (!listed.ok) return unavailable(listed.error);
|
|
218
|
+
const entries = Array.isArray(listed.value) ? listed.value : [];
|
|
219
|
+
const entry = entries.find((candidate) => candidate && typeof candidate.path === 'string'
|
|
220
|
+
&& path.isAbsolute(candidate.path)
|
|
221
|
+
&& path.resolve(candidate.path) === checkout);
|
|
222
|
+
if (!entry) return unavailable(`not a known checkout: ${checkout}`);
|
|
223
|
+
// A detached HEAD has no branch name; saying "detached" is honest,
|
|
224
|
+
// inventing a name is not.
|
|
225
|
+
return { ...available(entry.branch), detached: entry.detached === true };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Registry row for this checkout — read ONLY for the one fact it owns, a
|
|
229
|
+
// linked worktree's `createdAt`. `ok:false` (malformed/unreadable registry)
|
|
230
|
+
// and "no row" are different reasons but the same verdict: that creation
|
|
231
|
+
// has no registry answer, so the birth-time fallback decides.
|
|
232
|
+
function registryRow(checkout) {
|
|
233
|
+
let read;
|
|
234
|
+
try {
|
|
235
|
+
read = readRegistry(repoRoot || checkout);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return { row: null, reason: `registry: ${errorText(err)}` };
|
|
238
|
+
}
|
|
239
|
+
if (!read || read.ok !== true) {
|
|
240
|
+
const reason = (read && read.error) || 'cannot read registry';
|
|
241
|
+
return { row: null, reason };
|
|
242
|
+
}
|
|
243
|
+
const rows = Array.isArray(read.worktrees) ? read.worktrees : [];
|
|
244
|
+
const row = rows.find((candidate) => candidate && typeof candidate.path === 'string'
|
|
245
|
+
&& path.isAbsolute(candidate.path)
|
|
246
|
+
&& path.resolve(candidate.path) === checkout);
|
|
247
|
+
if (!row) return { row: null, reason: 'no registry entry for this checkout' };
|
|
248
|
+
return { row, reason: null };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// When the checkout was created (S12). The answer depends on the KIND:
|
|
252
|
+
// principal — when its git HISTORY began: the oldest root commit. Git
|
|
253
|
+
// records nothing older, so an unreadable history stays
|
|
254
|
+
// unavailable instead of borrowing the directory's birth
|
|
255
|
+
// time (which is when the folder was made, not when the
|
|
256
|
+
// project started).
|
|
257
|
+
// worktree — when THAT checkout was created: its registry row's
|
|
258
|
+
// `createdAt` (a registration time is never a creation, so
|
|
259
|
+
// `registeredAt` is ignored) or, when the row has no usable
|
|
260
|
+
// timestamp, the directory's birth time.
|
|
261
|
+
// `source` names which answer came back — 'root-commit', 'registry' or
|
|
262
|
+
// 'birthtime' — and with none readable the fact stays unavailable.
|
|
263
|
+
function birthTimeMs(checkout) {
|
|
264
|
+
try {
|
|
265
|
+
const ms = Number(fsImpl.statSync(checkout).birthtimeMs);
|
|
266
|
+
if (Number.isFinite(ms) && ms > 0) return ms;
|
|
267
|
+
} catch {
|
|
268
|
+
// A checkout that cannot be stat'ed has no birth time to report.
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function createdAtFact(checkout, row, registryReason, principal, root) {
|
|
274
|
+
if (principal) {
|
|
275
|
+
if (root.ok) return { ...available(new Date(root.ms).toISOString()), source: 'root-commit' };
|
|
276
|
+
return { ...unavailable(root.reason), source: null };
|
|
277
|
+
}
|
|
278
|
+
if (row && typeof row.createdAt === 'string' && row.createdAt.length > 0
|
|
279
|
+
&& Number.isFinite(Date.parse(row.createdAt))) {
|
|
280
|
+
return { ...available(row.createdAt), source: 'registry' };
|
|
281
|
+
}
|
|
282
|
+
const birth = birthTimeMs(checkout);
|
|
283
|
+
if (birth === null) {
|
|
284
|
+
return { ...unavailable(registryReason || 'no registry entry and no readable birth time'), source: null };
|
|
285
|
+
}
|
|
286
|
+
return { ...available(new Date(birth).toISOString()), source: 'birthtime' };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The mtime of the most recently modified uncommitted file in the summary,
|
|
290
|
+
// or null when nothing still exists. Reads the SAME summary the `changes`
|
|
291
|
+
// fact already computed, so last activity never spawns git a second time
|
|
292
|
+
// for the dirty side.
|
|
293
|
+
function latestDirtyMtime(entries) {
|
|
294
|
+
let latest = null;
|
|
295
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
296
|
+
if (!entry || typeof entry.path !== 'string' || entry.path.length === 0) continue;
|
|
297
|
+
let ms;
|
|
298
|
+
try {
|
|
299
|
+
ms = Number(fsImpl.statSync(entry.path).mtimeMs);
|
|
300
|
+
} catch {
|
|
301
|
+
continue; // Deleted since the summary; it contributes nothing.
|
|
302
|
+
}
|
|
303
|
+
if (Number.isFinite(ms) && ms > 0 && (latest === null || ms > latest)) latest = ms;
|
|
304
|
+
}
|
|
305
|
+
return latest;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The shared summary measures each untracked file from disk and reports an
|
|
309
|
+
// UNREADABLE one as zero added lines — indistinguishable from an empty
|
|
310
|
+
// file. S16 forbids counting that as a zero, so the untracked entries the
|
|
311
|
+
// summary measured as zero lines are re-opened here: a file that still
|
|
312
|
+
// exists but cannot be read makes the whole fact unavailable, naming the
|
|
313
|
+
// path, instead of a fake zero. Binary files are labelled, not counted, and
|
|
314
|
+
// a file gone since the summary contributes nothing.
|
|
315
|
+
function unreadableUntracked(entries, fsImpl) {
|
|
316
|
+
if (typeof fsImpl.readFileSync !== 'function') return null;
|
|
317
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
318
|
+
if (!entry || entry.untracked !== true || typeof entry.path !== 'string') continue;
|
|
319
|
+
if (Number(entry.added) !== 0 || entry.binary === true) continue;
|
|
320
|
+
try {
|
|
321
|
+
fsImpl.readFileSync(entry.path);
|
|
322
|
+
} catch {
|
|
323
|
+
try {
|
|
324
|
+
fsImpl.statSync(entry.path);
|
|
325
|
+
} catch {
|
|
326
|
+
continue; // Gone since the summary; it contributes nothing.
|
|
327
|
+
}
|
|
328
|
+
return entry.rel || entry.path;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function changesRead(checkout) {
|
|
335
|
+
const summary = await attempt(() => summarize(checkout), 'uncommitted changes');
|
|
336
|
+
if (!summary.ok) return { fact: unavailable(summary.error), latestMtimeMs: null };
|
|
337
|
+
const value = summary.value;
|
|
338
|
+
if (!value || value.ok !== true) {
|
|
339
|
+
return {
|
|
340
|
+
fact: unavailable(`uncommitted changes: ${(value && value.error) || 'unreadable summary'}`),
|
|
341
|
+
latestMtimeMs: null,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const unreadable = unreadableUntracked(value.entries, fsImpl);
|
|
345
|
+
if (unreadable !== null) {
|
|
346
|
+
return {
|
|
347
|
+
fact: unavailable(`uncommitted changes: cannot read untracked file ${JSON.stringify(unreadable)}`),
|
|
348
|
+
latestMtimeMs: null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
fact: available({
|
|
353
|
+
files: value.files,
|
|
354
|
+
added: value.added,
|
|
355
|
+
deleted: value.deleted,
|
|
356
|
+
binaryCount: value.binaryCount || 0,
|
|
357
|
+
text: changesLib.formatChangeSummary(value),
|
|
358
|
+
}),
|
|
359
|
+
latestMtimeMs: latestDirtyMtime(value.entries),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// The last commit timestamp as an internal `{ok, ms, reason}` so the
|
|
364
|
+
// activity fact can merge it with the dirty mtimes behind one read.
|
|
365
|
+
async function commitRead(checkout) {
|
|
366
|
+
const read = await attempt(() => lastCommit(checkout), 'last commit');
|
|
367
|
+
if (!read.ok) return { ok: false, reason: read.error };
|
|
368
|
+
const value = read.value;
|
|
369
|
+
if (!value || value.ok !== true) {
|
|
370
|
+
return { ok: false, reason: `last commit: ${(value && value.error) || 'unreadable commit'}` };
|
|
371
|
+
}
|
|
372
|
+
const ms = Number(value.timestampMs);
|
|
373
|
+
if (!Number.isFinite(ms) || ms < 0) return { ok: false, reason: 'last commit: unreadable timestamp' };
|
|
374
|
+
return { ok: true, ms };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// The root commit timestamp as an internal `{ok, ms, reason}`, mirroring
|
|
378
|
+
// `commitRead` so the principal's creation answers in the same shape.
|
|
379
|
+
async function rootCommitRead(checkout) {
|
|
380
|
+
const read = await attempt(() => firstCommit(checkout), 'first commit');
|
|
381
|
+
if (!read.ok) return { ok: false, reason: read.error };
|
|
382
|
+
const value = read.value;
|
|
383
|
+
if (!value || value.ok !== true) {
|
|
384
|
+
return { ok: false, reason: `first commit: ${(value && value.error) || 'unreadable commit'}` };
|
|
385
|
+
}
|
|
386
|
+
const ms = Number(value.timestampMs);
|
|
387
|
+
if (!Number.isFinite(ms) || ms < 0) return { ok: false, reason: 'first commit: unreadable timestamp' };
|
|
388
|
+
return { ok: true, ms };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Last activity (S12/S8): the greater of the last commit timestamp and the
|
|
392
|
+
// mtime of the most recently modified uncommitted file. `source` records
|
|
393
|
+
// which side won; with neither readable the fact is unavailable, never a
|
|
394
|
+
// zero time. An inherited pre-checkout commit — one older than the
|
|
395
|
+
// checkout's own creation — is not local modification: when the creation
|
|
396
|
+
// is known it is dropped, and with no local evidence left the fact is
|
|
397
|
+
// unavailable rather than a modification dated before the checkout.
|
|
398
|
+
function activityFact(commit, dirtyMs, createdFact) {
|
|
399
|
+
let createdMs = null;
|
|
400
|
+
if (createdFact && createdFact.available === true) {
|
|
401
|
+
const parsed = Date.parse(createdFact.value);
|
|
402
|
+
if (Number.isFinite(parsed)) createdMs = parsed;
|
|
403
|
+
}
|
|
404
|
+
const inherited = commit.ok && createdMs !== null && commit.ms < createdMs;
|
|
405
|
+
const candidates = [];
|
|
406
|
+
if (commit.ok && !inherited) candidates.push({ ms: commit.ms, source: 'commit' });
|
|
407
|
+
if (Number.isFinite(dirtyMs)) candidates.push({ ms: dirtyMs, source: 'dirty' });
|
|
408
|
+
if (candidates.length === 0) {
|
|
409
|
+
if (inherited) return { ...unavailable('no activity since the checkout was created'), source: null };
|
|
410
|
+
return unavailable(commit.reason || 'no commit and no uncommitted file to read');
|
|
411
|
+
}
|
|
412
|
+
let winner = candidates[0];
|
|
413
|
+
for (const candidate of candidates) {
|
|
414
|
+
if (candidate.ms > winner.ms) winner = candidate;
|
|
415
|
+
}
|
|
416
|
+
return { ...available(new Date(winner.ms).toISOString()), source: winner.source };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function aheadBehindFact(checkout, listingRead) {
|
|
420
|
+
// The base is resolved against the repository listing, which this read
|
|
421
|
+
// already awaited for the branch fact, so the shared helper is handed the
|
|
422
|
+
// principal's branch instead of spawning its own `git worktree list` once
|
|
423
|
+
// per checkout (worktree-issues S11). An injected `aheadBehind` seam is
|
|
424
|
+
// called exactly as before.
|
|
425
|
+
const listed = await listingRead;
|
|
426
|
+
const principalBranch = principalBranchFor(checkout, listed);
|
|
427
|
+
const compare = aheadBehind !== null
|
|
428
|
+
? () => aheadBehind(checkout)
|
|
429
|
+
: () => {
|
|
430
|
+
const options = { principalBranch };
|
|
431
|
+
if (runAsync) options.runAsync = runAsync;
|
|
432
|
+
return gitLib.aheadBehind(checkout, options);
|
|
433
|
+
};
|
|
434
|
+
const compared = await attempt(compare, 'commits ahead/behind');
|
|
435
|
+
if (!compared.ok) return unavailable(compared.error);
|
|
436
|
+
const value = compared.value;
|
|
437
|
+
if (!value || value.ok !== true) {
|
|
438
|
+
return unavailable(`commits ahead/behind: ${(value && value.error) || 'unreadable comparison'}`);
|
|
439
|
+
}
|
|
440
|
+
return available({
|
|
441
|
+
ahead: value.ahead, behind: value.behind, base: value.base, baseSource: value.baseSource,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The principal checkout's branch, from the listing this read already has.
|
|
446
|
+
// `undefined` — the answer that keeps the previous behavior — means "this
|
|
447
|
+
// listing does not describe this checkout" (a checkout of ANOTHER
|
|
448
|
+
// repository, which the caller may ask about), so the shared helper reads
|
|
449
|
+
// the repository itself. `null` states that the principal has no branch to
|
|
450
|
+
// offer (detached or bare), which is what such a listing always meant.
|
|
451
|
+
function principalBranchFor(checkout, listed) {
|
|
452
|
+
if (!listed || listed.ok !== true) return undefined;
|
|
453
|
+
const entries = Array.isArray(listed.value) ? listed.value : [];
|
|
454
|
+
const known = entries.some((entry) => entry && typeof entry.path === 'string'
|
|
455
|
+
&& path.isAbsolute(entry.path) && path.resolve(entry.path) === checkout);
|
|
456
|
+
if (!known) return undefined;
|
|
457
|
+
const first = entries[0];
|
|
458
|
+
if (!first) return undefined;
|
|
459
|
+
return typeof first.branch === 'string' && first.branch.length > 0 ? first.branch : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// One repository listing per root while it is PENDING (S23): the branch
|
|
463
|
+
// fact and the checkout KIND need the same `git worktree list`, and a
|
|
464
|
+
// selector entry lists every checkout at once, so N concurrent checkouts
|
|
465
|
+
// must not spawn N identical listings. Ownership is published before the
|
|
466
|
+
// seam runs, so a seam that throws synchronously cannot leave an entry
|
|
467
|
+
// without an owner, and it is released the moment it settles: a settled
|
|
468
|
+
// listing is never retained, so a later read asks git again instead of
|
|
469
|
+
// showing a repository state that has since changed.
|
|
470
|
+
function listingFor(root) {
|
|
471
|
+
const key = path.resolve(root);
|
|
472
|
+
const pending = listingInFlight.get(key);
|
|
473
|
+
if (pending) return pending;
|
|
474
|
+
const promise = Promise.resolve().then(() => listWorktrees(root));
|
|
475
|
+
listingInFlight.set(key, promise);
|
|
476
|
+
// Release only OUR entry: an `invalidate()` may already have dropped it
|
|
477
|
+
// and a newer listing may own the key, so a late completion never clears
|
|
478
|
+
// newer work.
|
|
479
|
+
const release = () => {
|
|
480
|
+
if (listingInFlight.get(key) === promise) listingInFlight.delete(key);
|
|
481
|
+
};
|
|
482
|
+
promise.then(release, release);
|
|
483
|
+
return promise;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function read(checkout) {
|
|
487
|
+
const startedAt = now();
|
|
488
|
+
// The expensive git reads run together but only for THIS checkout; the
|
|
489
|
+
// cheap synchronous registry read runs first (it answers nothing but the
|
|
490
|
+
// linked worktree's `createdAt`), and the listing is read once because
|
|
491
|
+
// both the branch fact and the checkout KIND need it. The dirty summary is
|
|
492
|
+
// computed once and feeds both `changes` and the dirty side of
|
|
493
|
+
// `lastActivity`.
|
|
494
|
+
const registry = registryRow(checkout);
|
|
495
|
+
// The listing is started FIRST and awaited by both the branch fact and the
|
|
496
|
+
// ahead/behind base, so one `git worktree list` serves the whole read.
|
|
497
|
+
const listingRead = attempt(() => listingFor(repoRoot || checkout), 'git worktree list');
|
|
498
|
+
const [listing, changes, compared, commit] = await Promise.all([
|
|
499
|
+
listingRead,
|
|
500
|
+
changesRead(checkout),
|
|
501
|
+
aheadBehindFact(checkout, listingRead),
|
|
502
|
+
commitRead(checkout),
|
|
503
|
+
]);
|
|
504
|
+
const principal = isPrincipalCheckout(checkout, listing);
|
|
505
|
+
// The root-commit read only ever runs for the principal: a linked
|
|
506
|
+
// worktree's creation is never git's history beginning.
|
|
507
|
+
const root = principal
|
|
508
|
+
? await rootCommitRead(checkout)
|
|
509
|
+
: { ok: false, reason: 'not the principal checkout' };
|
|
510
|
+
const createdAt = createdAtFact(checkout, registry.row, registry.reason, principal, root);
|
|
511
|
+
return {
|
|
512
|
+
checkout,
|
|
513
|
+
fetchedAt: startedAt,
|
|
514
|
+
cached: false,
|
|
515
|
+
branch: branchFact(checkout, listing),
|
|
516
|
+
createdAt,
|
|
517
|
+
lastActivity: activityFact(commit, changes.latestMtimeMs, createdAt),
|
|
518
|
+
changes: changes.fact,
|
|
519
|
+
aheadBehind: compared,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function fresh(entry) {
|
|
524
|
+
return entry !== null && now() - entry.at < ttlMs;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function clone(entry, extra) {
|
|
528
|
+
return { ...entry.facts, cached: true, ...extra };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Resolve the facts for one checkout: cached within the TTL, the shared
|
|
532
|
+
// in-flight read while one is running, otherwise a fresh read.
|
|
533
|
+
async function facts(checkoutPath) {
|
|
534
|
+
const checkout = keyOf(checkoutPath);
|
|
535
|
+
if (checkout === null) {
|
|
536
|
+
return {
|
|
537
|
+
checkout: checkoutPath === undefined ? null : checkoutPath,
|
|
538
|
+
fetchedAt: now(),
|
|
539
|
+
cached: false,
|
|
540
|
+
branch: unavailable('checkout path is empty'),
|
|
541
|
+
createdAt: unavailable('checkout path is empty'),
|
|
542
|
+
lastActivity: unavailable('checkout path is empty'),
|
|
543
|
+
changes: unavailable('checkout path is empty'),
|
|
544
|
+
aheadBehind: unavailable('checkout path is empty'),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const entry = cache.get(checkout);
|
|
548
|
+
if (entry && fresh(entry)) return clone(entry);
|
|
549
|
+
const pending = inFlight.get(checkout);
|
|
550
|
+
if (pending) return clone({ facts: await pending });
|
|
551
|
+
const promise = read(checkout);
|
|
552
|
+
inFlight.set(checkout, promise);
|
|
553
|
+
try {
|
|
554
|
+
const values = await promise;
|
|
555
|
+
// A read that an `invalidate()` superseded must not repopulate the
|
|
556
|
+
// cache: the in-flight entry is cleared on invalidate, so this guard
|
|
557
|
+
// keeps a slow pre-save read from resurrecting stale facts.
|
|
558
|
+
if (inFlight.get(checkout) === promise) cache.set(checkout, { at: now(), facts: values });
|
|
559
|
+
return values;
|
|
560
|
+
} finally {
|
|
561
|
+
inFlight.delete(checkout);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Synchronous paint-time read of the cache: null when nothing was read
|
|
566
|
+
// yet, `stale:true` when the entry is past its TTL (the caller may show
|
|
567
|
+
// it and refresh in the background, but is told it is old).
|
|
568
|
+
function peek(checkoutPath) {
|
|
569
|
+
const checkout = keyOf(checkoutPath);
|
|
570
|
+
if (checkout === null) return null;
|
|
571
|
+
const entry = cache.get(checkout);
|
|
572
|
+
if (!entry) return null;
|
|
573
|
+
return clone(entry, { stale: !fresh(entry) });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Drop one checkout's cached facts, or all of them (after a create, a
|
|
577
|
+
// registry edit or a commit the selector itself performed).
|
|
578
|
+
function invalidate(checkoutPath) {
|
|
579
|
+
const checkout = keyOf(checkoutPath);
|
|
580
|
+
if (checkout === null) {
|
|
581
|
+
cache.clear();
|
|
582
|
+
inFlight.clear();
|
|
583
|
+
listingInFlight.clear();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
cache.delete(checkout);
|
|
587
|
+
// Drop any in-flight read too: its result predates the invalidation.
|
|
588
|
+
inFlight.delete(checkout);
|
|
589
|
+
// The pending listing predates the invalidation as well, so it must not
|
|
590
|
+
// be served to a read that follows it; dropping the ownership makes the
|
|
591
|
+
// next read list again.
|
|
592
|
+
listingInFlight.delete(path.resolve(repoRoot || checkout));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return { facts, peek, invalidate, ttlMs };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
module.exports = { createCheckoutFacts, DEFAULT_TTL_MS };
|