@mmnto/totem 1.111.0 → 1.112.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.
@@ -0,0 +1,792 @@
1
+ /**
2
+ * Worktree-estate sensor (mmnto-ai/totem#2580 slice-1) — the read-only scan
3
+ * behind `totem doctor --estate`.
4
+ *
5
+ * Two arms, both local-git + local-fs only:
6
+ * - **registered**: for every repo in the user-level registry, enumerate the
7
+ * LINKED worktrees (`git worktree list --porcelain`) and classify each one
8
+ * from evidence — dirty tree → active; clean + ancestry-merged into the
9
+ * default branch → stale; clean + NOT ancestry-merged → `indeterminate`,
10
+ * never stale. That last state is the squash-merge gap stated honestly: a
11
+ * squash-merged branch leaves no ancestry edge, and this sensor has no
12
+ * merged-facts source (the status-lane snapshot extension is a later
13
+ * slice), so it declines to claim either way.
14
+ * - **husk sweep**: candidate roots are swept one level for worktree-shaped
15
+ * residue. Roots come in two kinds, and the kind decides what counts as
16
+ * evidence. A CONTAINER root exists solely to hold worktrees
17
+ * (`<repo>/.claude/worktrees`, or any `--root` the operator names), so an
18
+ * untracked directory there is residue BY LOCATION. A STANDARD root (the
19
+ * parent of a registry path or of a listed worktree) is an ordinary
20
+ * working directory, so residue there needs the older positive evidence —
21
+ * a dangling `.git` pointer, or a repo-name prefix plus a leftover
22
+ * `node_modules`. Either way an unclassifiable directory is not reported
23
+ * at all rather than guessed at.
24
+ *
25
+ * Registry membership is NOT protection from candidacy. Git's own worktree
26
+ * list is what protects a path (cohort-overlay §2), and a genuine repo
27
+ * checkout is already protected by the `.git`-DIRECTORY rule; a registry entry
28
+ * only records that something was synced from a path once. Registry accounting
29
+ * and disk residue are different axes — the same path can carry both a repo
30
+ * row and a husk row.
31
+ *
32
+ * Sensor, never actuator: the only git verbs invoked are `worktree list`,
33
+ * `status`, `merge-base --is-ancestor`, `log -1`, and `rev-parse`, and every
34
+ * invocation carries `--no-optional-locks`. That flag is what makes the
35
+ * read-only claim true rather than aspirational: `git status` otherwise
36
+ * refreshes and WRITES the index, taking `index.lock` (git-status(1) §
37
+ * BACKGROUND REFRESH), which in a cohort's shared worktrees would collide with
38
+ * a seat's live `git add`. No registry entry is mutated and nothing is cached
39
+ * across calls. Every probe failure lands as an `unscannable` row (or a
40
+ * class-`unscannable` worktree row naming the failed step) so a degraded scan
41
+ * can never read as a clean one.
42
+ *
43
+ * `safeExec` is injected rather than imported so the scan stays testable
44
+ * without a real git tree (the author-sandbox.ts:21 idiom).
45
+ */
46
+ import * as fs from 'node:fs';
47
+ import * as os from 'node:os';
48
+ import * as path from 'node:path';
49
+ // ─── Constants ──────────────────────────────────────────
50
+ /** The compatibility contract with the status-lane consumer of `--estate --json`. */
51
+ export const ESTATE_SCHEMA_VERSION = 1;
52
+ const GIT_COMMAND_TIMEOUT_MS = 15_000;
53
+ const MS_PER_DAY = 86_400_000;
54
+ /**
55
+ * `git merge-base --is-ancestor` answers via exit code: 0 = ancestor, 1 = not
56
+ * an ancestor. Any OTHER status is a real failure (bad ref, corrupt object
57
+ * store) and must NOT be read as "not merged".
58
+ */
59
+ const NOT_AN_ANCESTOR_EXIT = 1;
60
+ // ─── Porcelain parser ───────────────────────────────────
61
+ /**
62
+ * Parse `git worktree list --porcelain`: one `worktree <path>` header per
63
+ * entry, attribute lines until a blank line. Unknown attributes are ignored so
64
+ * a newer git cannot break the parse. Git lists the MAIN worktree first,
65
+ * followed by the linked ones — callers depend on that order.
66
+ */
67
+ export function parseWorktreeListPorcelain(raw) {
68
+ const entries = [];
69
+ let current;
70
+ const flush = () => {
71
+ if (current !== undefined)
72
+ entries.push(current);
73
+ current = undefined;
74
+ };
75
+ for (const rawLine of raw.split('\n')) {
76
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
77
+ if (line.length === 0) {
78
+ flush();
79
+ continue;
80
+ }
81
+ const sep = line.indexOf(' ');
82
+ const key = sep === -1 ? line : line.slice(0, sep);
83
+ const value = sep === -1 ? '' : line.slice(sep + 1).trim();
84
+ if (key === 'worktree') {
85
+ flush();
86
+ current = { path: value, bare: false, detached: false, locked: false, prunable: false };
87
+ continue;
88
+ }
89
+ // An attribute before any header is malformed output; drop it rather than
90
+ // inventing an entry with no path.
91
+ if (current === undefined)
92
+ continue;
93
+ switch (key) {
94
+ case 'HEAD':
95
+ current.head = value;
96
+ break;
97
+ case 'branch':
98
+ current.branch = value;
99
+ break;
100
+ case 'bare':
101
+ current.bare = true;
102
+ break;
103
+ case 'detached':
104
+ current.detached = true;
105
+ break;
106
+ case 'locked':
107
+ current.locked = true;
108
+ if (value.length > 0)
109
+ current.lockedReason = value;
110
+ break;
111
+ case 'prunable':
112
+ current.prunable = true;
113
+ if (value.length > 0)
114
+ current.prunableReason = value;
115
+ break;
116
+ default:
117
+ break;
118
+ }
119
+ }
120
+ flush();
121
+ return entries;
122
+ }
123
+ // ─── Local helpers ──────────────────────────────────────
124
+ /**
125
+ * Windows git and Node can disagree on drive-letter case (GCA #2293), which
126
+ * would let a live worktree double-report as a husk. Fold on win32 ONLY —
127
+ * POSIX filesystems are case-sensitive and folding there conflates real paths.
128
+ */
129
+ function foldCase(p) {
130
+ return process.platform === 'win32' ? p.toLowerCase() : p;
131
+ }
132
+ function pathKey(p) {
133
+ return foldCase(path.resolve(p));
134
+ }
135
+ function describe(err) {
136
+ return err instanceof Error ? err.message : String(err);
137
+ }
138
+ /** No-follow directory probe: a symlinked/junctioned path is not a directory here. */
139
+ function isRealDirectory(p) {
140
+ // totem-context: intentional cleanup — an ENOENT/EACCES lstat degrades to "not a directory", matching the sweep's skip-don't-abort posture (mail.ts:406 idiom).
141
+ try {
142
+ return fs.lstatSync(p).isDirectory();
143
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ function lstatSafe(p) {
150
+ // totem-context: intentional cleanup — a missing or unreadable `.git` entry is an ordinary sweep outcome; the caller decides which evidence arm (if any) applies.
151
+ try {
152
+ return fs.lstatSync(p);
153
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ }
159
+ /**
160
+ * The home repo a linked worktree's `gitdir:` points into:
161
+ * `<repo>/.git/worktrees/<name>` → `<repo>`. Returns undefined when the target
162
+ * is not worktree-shaped (no typed evidence, so no husk row).
163
+ */
164
+ function homeRepoFromGitdir(target) {
165
+ const parts = target.split(/[\\/]/);
166
+ const idx = parts.lastIndexOf('worktrees');
167
+ if (idx < 1)
168
+ return undefined;
169
+ const parent = parts[idx - 1];
170
+ // Standard layout: `<repo>/.git/worktrees/<n>` — the repo is above `.git`.
171
+ // Bare layout: `<repo>.git/worktrees/<n>` — the `.git`-suffixed directory IS
172
+ // the repo, so it stays in the path.
173
+ const home = parent === '.git'
174
+ ? parts.slice(0, idx - 1).join(path.sep)
175
+ : parent.endsWith('.git')
176
+ ? parts.slice(0, idx).join(path.sep)
177
+ : undefined;
178
+ return home !== undefined && home.length > 0 ? home : undefined;
179
+ }
180
+ function shortBranchName(ref) {
181
+ return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref;
182
+ }
183
+ function byPath(a, b) {
184
+ return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
185
+ }
186
+ // ─── Scan ───────────────────────────────────────────────
187
+ /**
188
+ * Scan the worktree estate. Pure per invocation: nothing persists, nothing is
189
+ * written, and the result is the complete accounting — every enumerated
190
+ * candidate is either classified, a husk candidate, or `unscannable`.
191
+ */
192
+ export function scanEstate(inputs) {
193
+ const { registry, safeExec, now } = inputs;
194
+ const repos = [];
195
+ const worktrees = [];
196
+ const huskCandidates = [];
197
+ const unscannable = [];
198
+ /** Every path git has named as a worktree — invariant 1's never-a-husk set. */
199
+ const gitKnownPaths = new Set();
200
+ /** Folded key → root, so a root is swept once regardless of casing. */
201
+ const sweepRoots = new Map();
202
+ /** Suppressed roots, filtered at the end against what actually got swept. */
203
+ const classifiedKeys = new Set();
204
+ const defaultRefCache = new Map();
205
+ const homeListCache = new Map();
206
+ /** Registry entries that probed clean AND enumerated — the attribution targets. */
207
+ const verifiedRepos = [];
208
+ let reposUnscannable = 0;
209
+ const addUnscannable = (p, reason, source) => {
210
+ unscannable.push({ path: p, reason, source });
211
+ };
212
+ /**
213
+ * Register a sweep root. `container` marks a root that exists SOLELY to hold
214
+ * worktrees, which is what licenses the by-location `container-residue`
215
+ * class. A root reached by both kinds is a container: the more specific
216
+ * declaration wins.
217
+ */
218
+ const addSweepRoot = (dir, container) => {
219
+ const resolved = path.resolve(dir);
220
+ const key = foldCase(resolved);
221
+ const existing = sweepRoots.get(key);
222
+ if (existing === undefined)
223
+ sweepRoots.set(key, { path: resolved, container });
224
+ else if (container)
225
+ existing.container = true;
226
+ };
227
+ /**
228
+ * True for the OS temp dir. Compared against both the reported path and its
229
+ * realpath, because macOS reports `/var/folders/...` while every real path
230
+ * under it resolves through `/private/var` (either form must match).
231
+ */
232
+ const isOsTmpdir = (resolved) => {
233
+ const folded = foldCase(resolved);
234
+ if (folded === foldCase(path.resolve(os.tmpdir())))
235
+ return true;
236
+ // totem-context: intentional cleanup — a temp dir that cannot be realpath'd (unusual TMPDIR, permissions) simply falls back to the lexical comparison above; the exclusion is a heuristic narrowing, not a correctness gate.
237
+ try {
238
+ return folded === foldCase(fs.realpathSync(os.tmpdir()));
239
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
240
+ }
241
+ catch {
242
+ return false;
243
+ }
244
+ };
245
+ const TMPDIR_SUPPRESSION = 'os tmpdir — derived only from a registry entry path; pass --root to sweep it';
246
+ /** Every reason that suppressed a given root, folded key → reason set. */
247
+ const suppressionReasons = new Map();
248
+ /**
249
+ * Record a root the derivation declined to produce, so the narrowing is
250
+ * disclosed rather than silent. Reasons AGGREGATE: a root can be reached (and
251
+ * declined) by several derivations, and reporting only the first would
252
+ * under-state why it is not swept. The tmpdir reason still stands alone
253
+ * because it names the `--root` escape hatch, which the others do not. The
254
+ * end-filter drops any root that some OTHER derivation did produce — a swept
255
+ * root is never reported here.
256
+ */
257
+ const noteSuppressedRoot = (dir, reason) => {
258
+ const resolved = path.resolve(dir);
259
+ const key = foldCase(resolved);
260
+ if (isOsTmpdir(resolved)) {
261
+ suppressionReasons.set(key, { path: resolved, reasons: new Set([TMPDIR_SUPPRESSION]) });
262
+ return;
263
+ }
264
+ const existing = suppressionReasons.get(key);
265
+ // A tmpdir suppression already recorded for this root outranks the rest.
266
+ if (existing?.reasons.has(TMPDIR_SUPPRESSION) === true)
267
+ return;
268
+ if (existing === undefined) {
269
+ suppressionReasons.set(key, { path: resolved, reasons: new Set([reason]) });
270
+ }
271
+ else {
272
+ existing.reasons.add(reason);
273
+ }
274
+ };
275
+ /**
276
+ * Sweep roots derived from a REGISTRY entry's parent — the weakest of the
277
+ * derivations: a registry entry only proves a repo was synced from that path,
278
+ * not that its parent is a place worktrees live. The OS temp dir is excluded
279
+ * on that basis; entries there are near-always fixture pollution, and
280
+ * sweeping it mints `residue-shape` rows for any tool's `<repo>-*` scratch
281
+ * dir that happens to carry a `node_modules`.
282
+ *
283
+ * The exclusion is scoped to THIS derivation. A listed worktree's parent is
284
+ * positive evidence that worktrees live there, and `--root` is an explicit
285
+ * instruction; either one sweeps the temp dir, and the suppression is then
286
+ * dropped from the disclosure.
287
+ */
288
+ const addRegistryDerivedRoot = (dir) => {
289
+ const resolved = path.resolve(dir);
290
+ if (isOsTmpdir(resolved)) {
291
+ noteSuppressedRoot(resolved, TMPDIR_SUPPRESSION);
292
+ return;
293
+ }
294
+ addSweepRoot(resolved, false);
295
+ };
296
+ // `--no-optional-locks` on EVERY invocation: `git status` otherwise refreshes
297
+ // and writes the index, taking `index.lock` (git-status(1) § BACKGROUND
298
+ // REFRESH). A sensor that runs from the ambient `totem doctor` row must not
299
+ // race a seat's live `git add` in a shared worktree.
300
+ const git = (cwd, args) => safeExec('git', ['--no-optional-locks', '-C', cwd, ...args], {
301
+ timeout: GIT_COMMAND_TIMEOUT_MS,
302
+ });
303
+ const listWorktrees = (repoPath) => {
304
+ const entries = parseWorktreeListPorcelain(git(repoPath, ['worktree', 'list', '--porcelain']));
305
+ for (const entry of entries)
306
+ gitKnownPaths.add(pathKey(entry.path));
307
+ return entries;
308
+ };
309
+ /**
310
+ * The default-branch ref used as the ancestry target. The remote-tracking
311
+ * form (`origin/main`) is used verbatim because a linked worktree need not
312
+ * have the local branch checked out anywhere. Underivable → undefined; the
313
+ * branch name is NEVER guessed (a wrong guess would mint false `stale` rows).
314
+ */
315
+ const defaultRef = (repoPath) => {
316
+ const key = pathKey(repoPath);
317
+ const cached = defaultRefCache.get(key);
318
+ if (cached !== undefined || defaultRefCache.has(key))
319
+ return cached;
320
+ let ref;
321
+ // totem-context: intentional cleanup — a repo with no `origin/HEAD` (no remote, never `set-head`) leaves the default branch underivable; the caller degrades to `ancestryMerged: 'unknown'` rather than guessing a branch name.
322
+ try {
323
+ const out = git(repoPath, ['rev-parse', '--abbrev-ref', 'origin/HEAD']).trim();
324
+ ref = out.length > 0 && !out.includes('\n') ? out : undefined;
325
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
326
+ }
327
+ catch {
328
+ ref = undefined;
329
+ }
330
+ defaultRefCache.set(key, ref);
331
+ return ref;
332
+ };
333
+ /** Days since the worktree's last commit; undefined when `log -1` did not answer. */
334
+ const lastCommitAgeDays = (wtPath) => {
335
+ // totem-context: intentional cleanup — an unborn branch or unreadable object store yields no commit date; the row still classifies and its evidence names the missing age (no silent age of 0).
336
+ try {
337
+ const seconds = Number.parseInt(git(wtPath, ['log', '-1', '--format=%ct']).trim(), 10);
338
+ if (!Number.isFinite(seconds))
339
+ return undefined;
340
+ return Math.max(0, Math.floor((now - seconds * 1000) / MS_PER_DAY));
341
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
342
+ }
343
+ catch {
344
+ return undefined;
345
+ }
346
+ };
347
+ const isAncestor = (repoPath, ref, target) => {
348
+ // totem-context: intentional cleanup — `merge-base --is-ancestor` ANSWERS by exit code, so a non-zero exit is the result, not a failure; exit 1 is returned as `false` and every other status is surfaced to the caller as a named unscannable reason.
349
+ try {
350
+ git(repoPath, ['merge-base', '--is-ancestor', ref, target]);
351
+ return true;
352
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
353
+ }
354
+ catch (err) {
355
+ const status = err.status;
356
+ if (status === NOT_AN_ANCESTOR_EXIT)
357
+ return false;
358
+ return { probeFailure: describe(err) };
359
+ }
360
+ };
361
+ const classify = (repoPath, entry, target) => {
362
+ const wtPath = path.resolve(entry.path);
363
+ const base = {
364
+ path: wtPath,
365
+ repoPath,
366
+ ...(entry.branch !== undefined ? { branch: shortBranchName(entry.branch) } : {}),
367
+ ...(entry.head !== undefined ? { head: entry.head } : {}),
368
+ ...(entry.locked ? { locked: true } : {}),
369
+ ...(entry.prunable ? { prunable: true } : {}),
370
+ };
371
+ let dirty;
372
+ // totem-context: intentional cleanup — a failed status probe becomes a class-`unscannable` row plus a named failure entry, so the degraded worktree is reported rather than dropped; throwing would abort the scan of every OTHER worktree.
373
+ try {
374
+ dirty = git(wtPath, ['status', '--porcelain']).trim().length > 0;
375
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
376
+ }
377
+ catch (err) {
378
+ const reason = `status --porcelain failed: ${describe(err)}`;
379
+ addUnscannable(wtPath, reason, 'worktree');
380
+ return { ...base, class: 'unscannable', ancestryMerged: 'unknown', evidence: reason };
381
+ }
382
+ const ageDays = lastCommitAgeDays(wtPath);
383
+ const age = ageDays === undefined ? 'last-commit age unavailable' : `last commit ${ageDays}d ago`;
384
+ const ageField = ageDays === undefined ? {} : { ageDays };
385
+ if (entry.detached) {
386
+ return {
387
+ ...base,
388
+ ...ageField,
389
+ class: 'registered-detached',
390
+ dirty,
391
+ ancestryMerged: 'unknown',
392
+ evidence: `detached HEAD, ${dirty ? 'dirty' : 'clean'} tree, ${age} — no branch to test ancestry against`,
393
+ };
394
+ }
395
+ if (dirty) {
396
+ return {
397
+ ...base,
398
+ ...ageField,
399
+ class: 'registered-active',
400
+ dirty: true,
401
+ ancestryMerged: 'unknown',
402
+ evidence: `dirty working tree, ${age} — in use, merge state not probed`,
403
+ };
404
+ }
405
+ if (entry.branch === undefined || target === undefined) {
406
+ const why = entry.branch === undefined
407
+ ? 'no branch reported by git'
408
+ : 'default branch underivable (no origin/HEAD)';
409
+ return {
410
+ ...base,
411
+ ...ageField,
412
+ class: 'registered-indeterminate',
413
+ dirty: false,
414
+ ancestryMerged: 'unknown',
415
+ evidence: `clean tree, ${age}, ancestry not testable: ${why}`,
416
+ };
417
+ }
418
+ const ancestry = isAncestor(repoPath, entry.branch, target);
419
+ if (typeof ancestry !== 'boolean') {
420
+ const reason = `merge-base --is-ancestor failed: ${ancestry.probeFailure}`;
421
+ addUnscannable(wtPath, reason, 'worktree');
422
+ return {
423
+ ...base,
424
+ ...ageField,
425
+ class: 'unscannable',
426
+ ancestryMerged: 'unknown',
427
+ evidence: reason,
428
+ };
429
+ }
430
+ if (ancestry) {
431
+ return {
432
+ ...base,
433
+ ...ageField,
434
+ class: 'registered-stale',
435
+ dirty: false,
436
+ ancestryMerged: true,
437
+ evidence: `clean tree, ${age}, branch is an ancestor of ${target}`,
438
+ };
439
+ }
440
+ return {
441
+ ...base,
442
+ ...ageField,
443
+ class: 'registered-indeterminate',
444
+ dirty: false,
445
+ ancestryMerged: false,
446
+ evidence: `clean tree, ${age}, branch is NOT an ancestor of ${target} — squash-merged branches look identical to unmerged ones from ancestry alone`,
447
+ };
448
+ };
449
+ // ─── Registered arm ───────────────────────────────────
450
+ for (const entry of registry) {
451
+ const repoPath = path.resolve(entry.path);
452
+ const lastSyncField = entry.lastSync === undefined ? {} : { lastSync: entry.lastSync };
453
+ // A missing entry contributes its `missing: true` row and nothing else: a
454
+ // path that no longer exists is no evidence at all about its parent, and
455
+ // deriving a sweep root from it would let stale registry entries drag
456
+ // unrelated directories into the sweep.
457
+ if (!isRealDirectory(repoPath)) {
458
+ repos.push({ path: repoPath, ...lastSyncField, missing: true, worktrees: 0 });
459
+ noteSuppressedRoot(path.dirname(repoPath), 'derived from missing registry entry path(s)');
460
+ continue;
461
+ }
462
+ // Toplevel verification before anything is derived FROM the entry. `git -C
463
+ // <dir>` silently discovers an ANCESTOR repo, so a registry path that is
464
+ // merely INSIDE a repo would otherwise report the ancestor's worktree list
465
+ // as its own and drag the ancestor's neighbourhood into the sweep.
466
+ let toplevel;
467
+ // totem-context: intentional cleanup — a failed toplevel probe is recorded as a named unscannable row and the remaining registry entries still scan, matching the worktree-list failure path below.
468
+ try {
469
+ toplevel = git(repoPath, ['rev-parse', '--show-toplevel']).trim();
470
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
471
+ }
472
+ catch (err) {
473
+ // An entry that cannot be VERIFIED derives nothing, exactly as an entry
474
+ // verified to be a non-root derives nothing: without the toplevel answer
475
+ // there is no evidence this path is a repo at all, so its parent is a
476
+ // guess. Suppressed and disclosed rather than swept.
477
+ repos.push({ path: repoPath, ...lastSyncField, worktrees: 0 });
478
+ addUnscannable(repoPath, `rev-parse --show-toplevel failed: ${describe(err)}`, 'registry');
479
+ reposUnscannable += 1;
480
+ noteSuppressedRoot(path.dirname(repoPath), 'derived from unverifiable registry entry path(s)');
481
+ continue;
482
+ }
483
+ if (pathKey(toplevel) !== pathKey(repoPath)) {
484
+ // Nothing is derived from this entry — not its dirname, and above all not
485
+ // git's answer, which describes the ancestor rather than the entry.
486
+ repos.push({
487
+ path: repoPath,
488
+ ...lastSyncField,
489
+ notGitRoot: true,
490
+ enclosingRepo: path.resolve(toplevel),
491
+ worktrees: 0,
492
+ });
493
+ noteSuppressedRoot(path.dirname(repoPath), 'derived from non-git-root registry entry path(s)');
494
+ continue;
495
+ }
496
+ addRegistryDerivedRoot(path.dirname(repoPath));
497
+ const container = path.join(repoPath, '.claude', 'worktrees');
498
+ let listed;
499
+ // totem-context: intentional cleanup — one unreadable repo (moved, corrupt, not a git tree) is recorded as a named unscannable row and the remaining registry entries still scan; a throw here would make one bad entry hide the whole estate.
500
+ try {
501
+ listed = listWorktrees(repoPath);
502
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
503
+ }
504
+ catch (err) {
505
+ repos.push({ path: repoPath, ...lastSyncField, worktrees: 0 });
506
+ addUnscannable(repoPath, `worktree list --porcelain failed: ${describe(err)}`, 'registry');
507
+ reposUnscannable += 1;
508
+ // The container root is WITHDRAWN when the repo's worktree list failed.
509
+ // Container-residue is a by-location claim that only holds against a
510
+ // known set of live worktrees; without that list every live worktree in
511
+ // the container would read as residue. A degraded scan must never report
512
+ // DIRTIER than a healthy one.
513
+ if (isRealDirectory(container)) {
514
+ noteSuppressedRoot(container, 'container of a repo whose worktree list failed');
515
+ }
516
+ continue;
517
+ }
518
+ // A CONTAINER root, added only once the repo's live worktrees are known:
519
+ // this directory exists solely to hold worktrees, so an untracked directory
520
+ // inside it is residue by location alone.
521
+ if (isRealDirectory(container))
522
+ addSweepRoot(container, true);
523
+ verifiedRepos.push(repoPath);
524
+ for (const wt of listed) {
525
+ // The listed entry that IS the registry path carries no evidence beyond
526
+ // the registry entry itself, so its parent stays on the registry-derived
527
+ // path (otherwise the weakest derivation would launder itself through
528
+ // git's own output). Every OTHER listed worktree is positive evidence
529
+ // that worktrees live in that parent.
530
+ const parent = path.dirname(path.resolve(wt.path));
531
+ if (pathKey(wt.path) === pathKey(repoPath))
532
+ addRegistryDerivedRoot(parent);
533
+ else
534
+ addSweepRoot(parent, false);
535
+ }
536
+ // Git lists the main worktree first; it IS the repo, not estate residue, so
537
+ // only the linked worktrees are classified. Its path still joined the
538
+ // never-a-husk set above.
539
+ const linked = listed.slice(1);
540
+ const target = defaultRef(repoPath);
541
+ repos.push({
542
+ path: repoPath,
543
+ ...lastSyncField,
544
+ ...(target === undefined ? {} : { defaultBranch: shortBranchName(target) }),
545
+ worktrees: linked.length,
546
+ });
547
+ for (const wt of linked) {
548
+ // Two registry entries can resolve to the same repo (a repo and one of
549
+ // its own worktrees both registered) — classify each path once so the
550
+ // summary counts stay equal to the row counts.
551
+ const key = pathKey(wt.path);
552
+ if (classifiedKeys.has(key))
553
+ continue;
554
+ classifiedKeys.add(key);
555
+ worktrees.push(classify(repoPath, wt, target));
556
+ }
557
+ }
558
+ // ─── Husk sweep ───────────────────────────────────────
559
+ // An operator naming a root with `--root` is DECLARING a worktree location,
560
+ // which is the same claim `<repo>/.claude/worktrees` makes structurally.
561
+ for (const extra of inputs.extraRoots ?? [])
562
+ addSweepRoot(extra, true);
563
+ /**
564
+ * Attribution targets for the residue-shape prefix match: VERIFIED repos
565
+ * only. A missing, not-git-root, or unprobeable entry is not a repo this scan
566
+ * can vouch for, and letting one attribute would let a husk name ITSELF as
567
+ * the repo it is residue of. Sorted longest-name first so attribution takes
568
+ * the most specific repo: with both `totem` and `totem-strategy` verified,
569
+ * `totem-strategy-claude-x` must name `totem-strategy`.
570
+ */
571
+ const repoBasenames = verifiedRepos
572
+ .map((repoPath) => ({ repoPath, name: foldCase(path.basename(repoPath)) }))
573
+ .sort((a, b) => b.name.length - a.name.length);
574
+ const huskAgeDays = (dir) => {
575
+ // totem-context: intentional cleanup — a husk has no commit history, so mtime is the only available age; an unreadable stat drops the field rather than reporting a fabricated age.
576
+ try {
577
+ return Math.max(0, Math.floor((now - fs.statSync(dir).mtimeMs) / MS_PER_DAY));
578
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
579
+ }
580
+ catch {
581
+ return undefined;
582
+ }
583
+ };
584
+ const homeWorktreeList = (home) => {
585
+ const key = pathKey(home);
586
+ if (homeListCache.has(key))
587
+ return homeListCache.get(key);
588
+ let listed;
589
+ // totem-context: intentional cleanup — the home repo of a `.git` pointer may itself be gone or unreadable; the caller records the directory as unscannable rather than asserting husk-ness it cannot prove.
590
+ try {
591
+ listed = listWorktrees(home);
592
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
593
+ }
594
+ catch {
595
+ listed = undefined;
596
+ }
597
+ homeListCache.set(key, listed);
598
+ return listed;
599
+ };
600
+ const classifyHusk = (dir, root, container) => {
601
+ const gitEntry = lstatSafe(path.join(dir, '.git'));
602
+ const ageDays = huskAgeDays(dir);
603
+ const ageField = ageDays === undefined ? {} : { ageDays };
604
+ /**
605
+ * Under a CONTAINER root the by-location claim still stands when the
606
+ * `.git`-FILE arm produces no TYPED outcome (a pointer with no `gitdir:`
607
+ * line, or a target that is not worktree-shaped): git tracks no worktree
608
+ * here and the directory sits in a place that exists only to hold
609
+ * worktrees. Under a STANDARD root the same shapelessness means no
610
+ * evidence at all.
611
+ */
612
+ const containerFallback = () => container
613
+ ? { path: dir, sweptRoot: root, evidence: 'container-residue', ...ageField }
614
+ : undefined;
615
+ // A `.git` DIRECTORY is an ordinary repo checkout — never a husk, under any
616
+ // root kind.
617
+ if (gitEntry?.isDirectory() === true)
618
+ return undefined;
619
+ if (gitEntry?.isFile() === true) {
620
+ let pointer;
621
+ // totem-context: intentional cleanup — an unreadable `.git` pointer is recorded as a named unscannable row: the directory cannot be proven a husk, and guessing either way is exactly what the evidence-typed classification exists to prevent.
622
+ try {
623
+ const raw = fs.readFileSync(path.join(dir, '.git'), 'utf-8');
624
+ const match = /^gitdir:\s*(.+)$/m.exec(raw);
625
+ pointer = match === null ? undefined : path.resolve(dir, match[1].trim());
626
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
627
+ }
628
+ catch (err) {
629
+ addUnscannable(dir, `.git pointer unreadable: ${describe(err)}`, 'sweep');
630
+ return undefined;
631
+ }
632
+ // A `.git` file with no `gitdir:` line is shapeless — no typed evidence
633
+ // under a STANDARD root; under a CONTAINER root the location still is.
634
+ if (pointer === undefined)
635
+ return containerFallback();
636
+ if (!fs.existsSync(pointer)) {
637
+ return { path: dir, sweptRoot: root, evidence: 'dangling-gitdir-pointer', ...ageField };
638
+ }
639
+ const home = homeRepoFromGitdir(pointer);
640
+ if (home === undefined)
641
+ return containerFallback();
642
+ const listed = homeWorktreeList(home);
643
+ if (listed === undefined) {
644
+ addUnscannable(dir, `home repo worktree list failed (${home})`, 'sweep');
645
+ return undefined;
646
+ }
647
+ // The home repo still tracks it: a live worktree of an unregistered repo,
648
+ // not residue.
649
+ if (listed.some((e) => pathKey(e.path) === pathKey(dir)))
650
+ return undefined;
651
+ return {
652
+ path: dir,
653
+ sweptRoot: root,
654
+ evidence: 'deregistered-intact',
655
+ matchedRepo: home,
656
+ ...ageField,
657
+ };
658
+ }
659
+ // No `.git` at all, or a `.git` that is neither file nor directory.
660
+ if (gitEntry === undefined && !isRealDirectory(dir)) {
661
+ // The directory readdir named is gone or unreadable by the time we probe
662
+ // it — a raced scan must report the hole, not silently drop the entry.
663
+ addUnscannable(dir, 'vanished or turned unreadable mid-sweep', 'sweep');
664
+ return undefined;
665
+ }
666
+ // Under a CONTAINER root the location IS the evidence: these roots exist
667
+ // solely to hold worktrees, so a directory git does not track is residue
668
+ // without needing a name or a `node_modules`. The stronger `.git`-FILE
669
+ // classes above still win when they apply.
670
+ if (container) {
671
+ return { path: dir, sweptRoot: root, evidence: 'container-residue', ...ageField };
672
+ }
673
+ // Under a STANDARD root — an ordinary working directory — residue-shape
674
+ // needs BOTH a registered-repo name prefix and a leftover `node_modules`.
675
+ // The prefix is hyphen-BOUNDED (`<repo>-`), the worktree naming convention's
676
+ // own shape: a bare `startsWith` would husk any ordinary `.git`-less project
677
+ // whose name merely begins with a repo's (`totemville` vs `totem`).
678
+ // A symlinked `.git` yields no typed evidence here.
679
+ if (gitEntry !== undefined)
680
+ return undefined;
681
+ const name = foldCase(path.basename(dir));
682
+ const matched = repoBasenames.find((r) => r.name.length > 0 && name.startsWith(r.name + '-'));
683
+ if (matched === undefined)
684
+ return undefined;
685
+ if (!isRealDirectory(path.join(dir, 'node_modules')))
686
+ return undefined;
687
+ return {
688
+ path: dir,
689
+ sweptRoot: root,
690
+ evidence: 'residue-shape',
691
+ matchedRepo: matched.repoPath,
692
+ ...ageField,
693
+ };
694
+ };
695
+ const roots = [...sweepRoots.values()].sort(byPath);
696
+ const sweptRoots = roots.map((r) => ({
697
+ path: r.path,
698
+ kind: r.container ? 'container' : 'standard',
699
+ }));
700
+ for (const { path: root, container } of roots) {
701
+ let dirents;
702
+ // totem-context: intentional cleanup — an unreadable sweep root (EACCES, raced deletion) is recorded as a named unscannable row so the omission is visible, and the sibling roots still sweep.
703
+ try {
704
+ dirents = fs.readdirSync(root, { withFileTypes: true });
705
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
706
+ }
707
+ catch (err) {
708
+ // Keyed by the ROOT, not by a candidate. Root-level failures live outside
709
+ // the candidate partition: the same path can legitimately be a failed
710
+ // root here AND a candidate row under its own parent when that parent is
711
+ // also swept. Partition assertions therefore exclude ledger rows whose
712
+ // path equals a swept root.
713
+ addUnscannable(root, `sweep root unreadable: ${describe(err)}`, 'sweep');
714
+ continue;
715
+ }
716
+ for (const dirent of dirents) {
717
+ // `isDirectory()` on a readdir Dirent is lstat-shaped: a symlink or
718
+ // junction answers false, so the sweep never follows one out of the root.
719
+ if (!dirent.isDirectory())
720
+ continue;
721
+ if (dirent.name.startsWith('.') || dirent.name === 'node_modules')
722
+ continue;
723
+ const dir = path.join(root, dirent.name);
724
+ // Invariant 1: what protects a path from candidacy is GIT's worktree
725
+ // list (cohort-overlay §2) — plus the `.git`-DIRECTORY rule inside
726
+ // classifyHusk, which covers every genuine repo checkout. Registry
727
+ // membership is deliberately NOT protection: registry accounting and
728
+ // disk residue are different axes, so one path may carry both a repo row
729
+ // and a husk row.
730
+ if (gitKnownPaths.has(pathKey(dir)))
731
+ continue;
732
+ // totem-context: intentional cleanup — a directory that vanishes or turns unreadable mid-sweep (TOCTOU) is recorded as a named unscannable row; one raced entry must not abort the sweep of its siblings.
733
+ try {
734
+ const husk = classifyHusk(dir, root, container);
735
+ if (husk !== undefined)
736
+ huskCandidates.push(husk);
737
+ // totem-context: intentional cleanup — see directive above the try; dual placement so the rule fires on either the catch-keyword line or the catch-body line.
738
+ }
739
+ catch (err) {
740
+ addUnscannable(dir, `husk probe failed: ${describe(err)}`, 'sweep');
741
+ }
742
+ }
743
+ }
744
+ // ─── Accounting ───────────────────────────────────────
745
+ worktrees.sort(byPath);
746
+ huskCandidates.sort(byPath);
747
+ unscannable.sort(byPath);
748
+ // A suppression only stands if NO other derivation reached the same root:
749
+ // worktree-parent and `--root` both outrank the registry-dirname exclusion,
750
+ // and a swept root must never also be reported as excluded. Excluded roots
751
+ // are a derivation disclosure, not part of the candidate partition. Every
752
+ // reason that declined the root is carried, sorted so the disclosure is
753
+ // stable across runs.
754
+ const excludedRoots = [...suppressionReasons]
755
+ .filter(([key]) => !sweepRoots.has(key))
756
+ .map(([, row]) => {
757
+ const reasons = [...row.reasons].sort();
758
+ return {
759
+ path: row.path,
760
+ reason: reasons.length === 1 && reasons[0] === TMPDIR_SUPPRESSION
761
+ ? TMPDIR_SUPPRESSION
762
+ : `not derived: ${reasons.join('; ')}`,
763
+ };
764
+ })
765
+ .sort(byPath);
766
+ const countClass = (cls) => worktrees.filter((w) => w.class === cls).length;
767
+ return {
768
+ schemaVersion: ESTATE_SCHEMA_VERSION,
769
+ derivedAt: new Date(now).toISOString(),
770
+ sweptRoots,
771
+ excludedRoots,
772
+ repos,
773
+ worktrees,
774
+ huskCandidates,
775
+ unscannable,
776
+ summary: {
777
+ repos: repos.length,
778
+ reposMissing: repos.filter((r) => r.missing === true).length,
779
+ reposNotGitRoot: repos.filter((r) => r.notGitRoot === true).length,
780
+ reposUnscannable,
781
+ worktrees: worktrees.length,
782
+ active: countClass('registered-active'),
783
+ stale: countClass('registered-stale'),
784
+ indeterminate: countClass('registered-indeterminate'),
785
+ detached: countClass('registered-detached'),
786
+ unscannableWorktrees: countClass('unscannable'),
787
+ huskCandidates: huskCandidates.length,
788
+ unscannable: unscannable.length,
789
+ },
790
+ };
791
+ }
792
+ //# sourceMappingURL=estate-scan.js.map