@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,1309 @@
1
+ /**
2
+ * Estate-sensor tests (mmnto-ai/totem#2580 slice-1). One `describe` per
3
+ * invariant in the design's "Invariants to lock in via tests" list — the
4
+ * numbering is load-bearing: these are the claims the sensor is allowed to
5
+ * make, and each one is pinned by the test that names it.
6
+ *
7
+ * Git is never invoked: every fixture injects an exec spy that answers canned
8
+ * porcelain, so the tests assert the CLASSIFIER, not the local git install.
9
+ * The filesystem side (husk sweep) uses real temp trees, because the evidence
10
+ * classes are literally fs shapes.
11
+ *
12
+ * Known untested arm: the mid-sweep TOCTOU path in `classifyHusk` (a directory
13
+ * that readdir named and that vanishes before the `.git` probe, recorded as
14
+ * `vanished or turned unreadable mid-sweep`). Reaching it needs a race between
15
+ * two fs syscalls, which is not portably schedulable without an fs seam the
16
+ * scan does not have. Recorded as a gap rather than covered by a test that
17
+ * would assert nothing.
18
+ */
19
+ import * as fs from 'node:fs';
20
+ import * as os from 'node:os';
21
+ import * as path from 'node:path';
22
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
23
+ import { ESTATE_SCHEMA_VERSION, parseWorktreeListPorcelain, scanEstate, } from './estate-scan.js';
24
+ const NOW = Date.parse('2026-08-05T12:00:00.000Z');
25
+ const COMMIT_10_DAYS_AGO = Math.floor((NOW - 10 * 86_400_000) / 1000);
26
+ let root;
27
+ /** Fixture dirs outside `root` (e.g. directly under os.tmpdir()) to clean up. */
28
+ let extraDirs;
29
+ beforeEach(() => {
30
+ root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-estate-')));
31
+ extraDirs = [];
32
+ });
33
+ afterEach(() => {
34
+ fs.rmSync(root, { recursive: true, force: true });
35
+ for (const dir of extraDirs)
36
+ fs.rmSync(dir, { recursive: true, force: true });
37
+ });
38
+ /**
39
+ * A directory whose parent is EXACTLY `os.tmpdir()` — not realpath'd, because
40
+ * the exclusion compares against `os.tmpdir()` as Node reports it (on macOS
41
+ * `realpathSync` resolves /var → /private/var and the parent would no longer
42
+ * match).
43
+ */
44
+ function mkTmpdirChild(prefix) {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
46
+ extraDirs.push(dir);
47
+ return dir;
48
+ }
49
+ // ─── Fixture helpers ────────────────────────────────────
50
+ function fold(p) {
51
+ return process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p);
52
+ }
53
+ /** A path as git prints it in porcelain output (forward slashes, even on win32). */
54
+ function gitPath(p) {
55
+ return p.split(path.sep).join('/');
56
+ }
57
+ function mkdir(...segments) {
58
+ const dir = path.join(root, ...segments);
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ return dir;
61
+ }
62
+ function porcelain(entries) {
63
+ return (entries
64
+ .map((e) => {
65
+ const lines = [`worktree ${gitPath(e.path)}`, `HEAD ${e.head ?? 'a'.repeat(40)}`];
66
+ if (e.detached === true)
67
+ lines.push('detached');
68
+ else if (e.branch !== undefined)
69
+ lines.push(`branch ${e.branch}`);
70
+ if (e.locked !== undefined)
71
+ lines.push(e.locked === true ? 'locked' : `locked ${e.locked}`);
72
+ if (e.prunable !== undefined) {
73
+ lines.push(e.prunable === true ? 'prunable' : `prunable ${e.prunable}`);
74
+ }
75
+ return lines.join('\n');
76
+ })
77
+ .join('\n\n') + '\n');
78
+ }
79
+ function execFailure(message, status) {
80
+ return Object.assign(new Error(message), { status });
81
+ }
82
+ /** Canned git, recording every invocation for the allowlist assertion. */
83
+ function makeExec(fixture, calls) {
84
+ const lists = new Map(Object.entries(fixture.lists).map(([k, v]) => [fold(k), v]));
85
+ const refs = new Map(Object.entries(fixture.defaultRefs ?? {}).map(([k, v]) => [fold(k), v]));
86
+ const dirty = new Set((fixture.dirty ?? []).map(fold));
87
+ const merged = new Set(fixture.merged ?? []);
88
+ const failStatus = new Set((fixture.failStatus ?? []).map(fold));
89
+ const failAncestry = new Set(fixture.failAncestry ?? []);
90
+ const toplevels = new Map(Object.entries(fixture.toplevels ?? {}).map(([k, v]) => [fold(k), v]));
91
+ const failToplevel = new Set((fixture.failToplevel ?? []).map(fold));
92
+ return (command, args = []) => {
93
+ calls.push([command, ...args]);
94
+ // Production argv is `git --no-optional-locks -C <cwd> <verb…>`.
95
+ const cwd = args[2] ?? '';
96
+ const verb = args.slice(3);
97
+ const key = fold(cwd);
98
+ if (verb[0] === 'worktree') {
99
+ const listed = lists.get(key);
100
+ if (listed === undefined)
101
+ throw execFailure(`not a git repository: ${cwd}`, 128);
102
+ return listed;
103
+ }
104
+ if (verb[0] === 'rev-parse' && verb[1] === '--show-toplevel') {
105
+ if (failToplevel.has(key))
106
+ throw execFailure(`not a git repository: ${cwd}`, 128);
107
+ // Default: the probed path IS the git root.
108
+ return gitPath(toplevels.get(key) ?? path.resolve(cwd));
109
+ }
110
+ if (verb[0] === 'rev-parse') {
111
+ const ref = refs.get(key);
112
+ if (ref === undefined)
113
+ throw execFailure('ambiguous argument origin/HEAD', 128);
114
+ return ref;
115
+ }
116
+ if (verb[0] === 'status') {
117
+ if (failStatus.has(key))
118
+ throw execFailure('fatal: unable to read index', 128);
119
+ return dirty.has(key) ? ' M packages/core/src/index.ts' : '';
120
+ }
121
+ if (verb[0] === 'merge-base') {
122
+ const ref = verb[2] ?? '';
123
+ if (failAncestry.has(ref))
124
+ throw execFailure(`bad revision ${ref}`, 128);
125
+ if (merged.has(ref))
126
+ return '';
127
+ throw execFailure('', NOT_AN_ANCESTOR);
128
+ }
129
+ if (verb[0] === 'log')
130
+ return String(COMMIT_10_DAYS_AGO);
131
+ throw execFailure(`unexpected git verb: ${verb.join(' ')}`, 1);
132
+ };
133
+ }
134
+ const NOT_AN_ANCESTOR = 1;
135
+ /** A `.git` file pointing at `target` — the linked-worktree pointer shape. */
136
+ function writeGitdirPointer(dir, target) {
137
+ fs.writeFileSync(path.join(dir, '.git'), `gitdir: ${gitPath(target)}\n`, 'utf-8');
138
+ }
139
+ function classesOf(result) {
140
+ return Object.fromEntries(result.worktrees.map((w) => [path.basename(w.path), w.class]));
141
+ }
142
+ /** Swept-root paths only — the kind is asserted separately where it matters. */
143
+ function rootPaths(result) {
144
+ return result.sweptRoots.map((r) => r.path);
145
+ }
146
+ /** The sweep-axis failure ledger — the only axis the candidate partition covers. */
147
+ function sweepFailures(result) {
148
+ const rootPaths = new Set(result.sweptRoots.map((r) => r.path));
149
+ // Root-level failures are keyed by the ROOT, not by a candidate, so they sit
150
+ // OUTSIDE the candidate partition: the same path can be a failed root here
151
+ // and a candidate row under its own parent. Excluded from every partition
152
+ // assertion for that reason.
153
+ return result.unscannable
154
+ .filter((u) => u.source === 'sweep' && !rootPaths.has(u.path))
155
+ .map((u) => u.path);
156
+ }
157
+ /** Sweep-source rows keyed by a swept ROOT — the namespace the partition excludes. */
158
+ function rootFailures(result) {
159
+ const rootPathSet = new Set(result.sweptRoots.map((r) => r.path));
160
+ return result.unscannable.filter((u) => u.source === 'sweep' && rootPathSet.has(u.path));
161
+ }
162
+ /** Every entry under `dir` with its kind, size, and mtime — a write-detector. */
163
+ function treeSnapshot(dir, prefix = '') {
164
+ const out = [];
165
+ for (const entry of fs
166
+ .readdirSync(dir, { withFileTypes: true })
167
+ .sort((a, b) => (a.name < b.name ? -1 : 1))) {
168
+ const abs = path.join(dir, entry.name);
169
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
170
+ const stat = fs.lstatSync(abs);
171
+ out.push(`${rel} ${entry.isDirectory() ? 'dir' : 'file'} ${stat.size} ${stat.mtimeMs}`);
172
+ if (entry.isDirectory())
173
+ out.push(...treeSnapshot(abs, rel));
174
+ }
175
+ return out;
176
+ }
177
+ // ─── Parser ─────────────────────────────────────────────
178
+ describe('parseWorktreeListPorcelain', () => {
179
+ it('parses every documented field across blank-line-separated entries', () => {
180
+ const raw = [
181
+ 'worktree /dev/totem',
182
+ 'HEAD 1111111111111111111111111111111111111111',
183
+ 'branch refs/heads/main',
184
+ '',
185
+ 'worktree /dev/totem-2580',
186
+ 'HEAD 2222222222222222222222222222222222222222',
187
+ 'branch refs/heads/2580-estate-sensor',
188
+ 'locked under review',
189
+ 'prunable gitdir file points to non-existent location',
190
+ '',
191
+ 'worktree /dev/totem-detached',
192
+ 'HEAD 3333333333333333333333333333333333333333',
193
+ 'detached',
194
+ '',
195
+ 'worktree /dev/bare-mirror',
196
+ 'bare',
197
+ '',
198
+ ].join('\n');
199
+ const entries = parseWorktreeListPorcelain(raw);
200
+ expect(entries.map((e) => e.path)).toEqual([
201
+ '/dev/totem',
202
+ '/dev/totem-2580',
203
+ '/dev/totem-detached',
204
+ '/dev/bare-mirror',
205
+ ]);
206
+ expect(entries[0].branch).toBe('refs/heads/main');
207
+ expect(entries[1].locked).toBe(true);
208
+ expect(entries[1].lockedReason).toBe('under review');
209
+ expect(entries[1].prunable).toBe(true);
210
+ expect(entries[1].prunableReason).toBe('gitdir file points to non-existent location');
211
+ expect(entries[2].detached).toBe(true);
212
+ expect(entries[2].branch).toBeUndefined();
213
+ expect(entries[3].bare).toBe(true);
214
+ });
215
+ it('tolerates CRLF, a bare `locked`, unknown attributes, and a missing trailing blank line', () => {
216
+ const raw = [
217
+ 'worktree C:/dev/totem',
218
+ 'HEAD 1111111111111111111111111111111111111111',
219
+ 'branch refs/heads/main',
220
+ 'some-future-attribute whatever',
221
+ '',
222
+ 'worktree C:/dev/totem-wt',
223
+ 'HEAD 2222222222222222222222222222222222222222',
224
+ 'detached',
225
+ 'locked',
226
+ ].join('\r\n');
227
+ const entries = parseWorktreeListPorcelain(raw);
228
+ expect(entries).toHaveLength(2);
229
+ expect(entries[0].path).toBe('C:/dev/totem');
230
+ expect(entries[1].locked).toBe(true);
231
+ expect(entries[1].lockedReason).toBeUndefined();
232
+ });
233
+ it('returns nothing for empty output', () => {
234
+ expect(parseWorktreeListPorcelain('')).toEqual([]);
235
+ });
236
+ });
237
+ // ─── Invariant 1 ────────────────────────────────────────
238
+ describe('invariant 1 — a path in any repo worktree list is NEVER a husk candidate', () => {
239
+ /**
240
+ * The fold is platform-conditional, so BOTH arms are exercised by stubbing
241
+ * `process.platform` — otherwise the POSIX assertion is a tautology on a
242
+ * Windows host and vice versa. The fixture is identical in both runs: a
243
+ * container root holding a directory whose on-disk name differs from the
244
+ * listed worktree path only in case.
245
+ */
246
+ function foldFixture(platform) {
247
+ const repo = mkdir('repo');
248
+ mkdir('repo', '.claude', 'worktrees');
249
+ const onDisk = mkdir('repo', '.claude', 'worktrees', 'agent-x');
250
+ const asGitPrintsIt = path.join(root, 'repo', '.claude', 'worktrees', 'AGENT-X');
251
+ const original = Object.getOwnPropertyDescriptor(process, 'platform');
252
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true });
253
+ try {
254
+ const calls = [];
255
+ const result = scanEstate({
256
+ registry: [{ path: repo }],
257
+ now: NOW,
258
+ safeExec: makeExec({
259
+ lists: {
260
+ [repo]: porcelain([
261
+ { path: repo, branch: 'refs/heads/main' },
262
+ { path: asGitPrintsIt, branch: 'refs/heads/2580' },
263
+ ]),
264
+ },
265
+ defaultRefs: { [repo]: 'origin/main' },
266
+ merged: ['refs/heads/2580'],
267
+ }, calls),
268
+ });
269
+ expect(path.basename(onDisk)).toBe('agent-x');
270
+ return result;
271
+ }
272
+ finally {
273
+ Object.defineProperty(process, 'platform', original);
274
+ }
275
+ }
276
+ it('win32: a drive/case disagreement between git and Node cannot double-report a live worktree', () => {
277
+ const result = foldFixture('win32');
278
+ // Folded join: git's `AGENT-X` and the on-disk `agent-x` are the SAME path,
279
+ // so the live worktree is never also a husk candidate (GCA #2293).
280
+ expect(result.huskCandidates).toEqual([]);
281
+ expect(result.worktrees).toHaveLength(1);
282
+ expect(result.worktrees[0].class).toBe('registered-stale');
283
+ });
284
+ it('POSIX: two case-divergent paths stay DISTINCT — a listed /A/wt does not protect /a/wt', () => {
285
+ const result = foldFixture('linux');
286
+ // Unfolded join: `AGENT-X` protects only itself. The on-disk `agent-x` is a
287
+ // genuinely different path on a case-sensitive filesystem and stays a
288
+ // candidate — folding here would conflate real paths.
289
+ expect(result.huskCandidates.map((h) => path.basename(h.path))).toEqual(['agent-x']);
290
+ expect(result.huskCandidates[0].evidence).toBe('container-residue');
291
+ });
292
+ it('never husks the main worktree (the repo itself) even though it is swept', () => {
293
+ const repo = mkdir('repo');
294
+ const calls = [];
295
+ const result = scanEstate({
296
+ registry: [{ path: repo }],
297
+ now: NOW,
298
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
299
+ });
300
+ expect(result.huskCandidates).toEqual([]);
301
+ expect(result.worktrees).toEqual([]);
302
+ expect(result.repos[0].worktrees).toBe(0);
303
+ });
304
+ });
305
+ // ─── Invariant 2 ────────────────────────────────────────
306
+ describe('invariant 2 — clean+unmerged is indeterminate, never stale (squash honesty)', () => {
307
+ function threeWorktrees() {
308
+ const repo = mkdir('repo');
309
+ const stale = mkdir('repo-stale');
310
+ const open = mkdir('repo-open');
311
+ const dirty = mkdir('repo-dirty');
312
+ const calls = [];
313
+ return scanEstate({
314
+ registry: [{ path: repo }],
315
+ now: NOW,
316
+ safeExec: makeExec({
317
+ lists: {
318
+ [repo]: porcelain([
319
+ { path: repo, branch: 'refs/heads/main' },
320
+ { path: stale, branch: 'refs/heads/stale' },
321
+ { path: open, branch: 'refs/heads/open' },
322
+ { path: dirty, branch: 'refs/heads/dirty' },
323
+ ]),
324
+ },
325
+ defaultRefs: { [repo]: 'origin/main' },
326
+ // `dirty` is ALSO merged — invariant 2's "dirty is active regardless
327
+ // of merge state" is only tested if the merge state would say stale.
328
+ merged: ['refs/heads/stale', 'refs/heads/dirty'],
329
+ dirty: [dirty],
330
+ }, calls),
331
+ });
332
+ }
333
+ it('classifies clean+merged stale, clean+unmerged indeterminate, dirty active', () => {
334
+ const classes = classesOf(threeWorktrees());
335
+ expect(classes['repo-stale']).toBe('registered-stale');
336
+ expect(classes['repo-open']).toBe('registered-indeterminate');
337
+ expect(classes['repo-dirty']).toBe('registered-active');
338
+ });
339
+ it('records ancestryMerged honestly and names the squash gap in the evidence', () => {
340
+ const result = threeWorktrees();
341
+ const open = result.worktrees.find((w) => w.branch === 'open');
342
+ const dirty = result.worktrees.find((w) => w.branch === 'dirty');
343
+ expect(open.ancestryMerged).toBe(false);
344
+ expect(open.evidence).toContain('squash-merged');
345
+ // Never probed for a dirty tree — so the field says unknown, not false.
346
+ expect(dirty.ancestryMerged).toBe('unknown');
347
+ expect(result.worktrees.find((w) => w.branch === 'stale').ancestryMerged).toBe(true);
348
+ });
349
+ it('falls back to indeterminate (not stale) when the default branch is underivable', () => {
350
+ const repo = mkdir('repo');
351
+ const wt = mkdir('repo-wt');
352
+ const calls = [];
353
+ const result = scanEstate({
354
+ registry: [{ path: repo }],
355
+ now: NOW,
356
+ safeExec: makeExec({
357
+ lists: {
358
+ [repo]: porcelain([
359
+ { path: repo, branch: 'refs/heads/main' },
360
+ { path: wt, branch: 'refs/heads/wt' },
361
+ ]),
362
+ },
363
+ // No defaultRefs entry: `rev-parse --abbrev-ref origin/HEAD` throws.
364
+ }, calls),
365
+ });
366
+ expect(result.worktrees[0].class).toBe('registered-indeterminate');
367
+ expect(result.worktrees[0].ancestryMerged).toBe('unknown');
368
+ expect(result.repos[0].defaultBranch).toBeUndefined();
369
+ expect(calls.some((c) => c.includes('merge-base'))).toBe(false);
370
+ });
371
+ it('classifies a detached worktree as detached and never probes its ancestry', () => {
372
+ const repo = mkdir('repo');
373
+ const wt = mkdir('repo-detached');
374
+ const calls = [];
375
+ const result = scanEstate({
376
+ registry: [{ path: repo }],
377
+ now: NOW,
378
+ safeExec: makeExec({
379
+ lists: {
380
+ [repo]: porcelain([
381
+ { path: repo, branch: 'refs/heads/main' },
382
+ { path: wt, detached: true },
383
+ ]),
384
+ },
385
+ defaultRefs: { [repo]: 'origin/main' },
386
+ }, calls),
387
+ });
388
+ expect(result.worktrees[0].class).toBe('registered-detached');
389
+ expect(result.worktrees[0].ageDays).toBe(10);
390
+ expect(calls.some((c) => c.includes('merge-base'))).toBe(false);
391
+ });
392
+ });
393
+ // ─── Invariant 3 ────────────────────────────────────────
394
+ describe('invariant 3 — husks require typed evidence; a `.git` DIRECTORY is never one', () => {
395
+ it('gives a shapeless `.git` pointer NO evidence under a STANDARD root', () => {
396
+ const repo = mkdir('repo');
397
+ const shapeless = mkdir('repo-shapeless');
398
+ fs.writeFileSync(path.join(shapeless, '.git'), 'no gitdir line here\n', 'utf-8');
399
+ const calls = [];
400
+ const result = scanEstate({
401
+ registry: [{ path: repo }],
402
+ now: NOW,
403
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
404
+ });
405
+ expect(result.huskCandidates).toEqual([]);
406
+ });
407
+ it('gives an unresolvable pointer target NO evidence under a STANDARD root', () => {
408
+ const repo = mkdir('repo');
409
+ const target = mkdir('somewhere-else');
410
+ const odd = mkdir('repo-odd');
411
+ writeGitdirPointer(odd, target);
412
+ const calls = [];
413
+ const result = scanEstate({
414
+ registry: [{ path: repo }],
415
+ now: NOW,
416
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
417
+ });
418
+ expect(result.huskCandidates).toEqual([]);
419
+ });
420
+ it('reports residue-shape and skips both the real checkout and the shapeless dir', () => {
421
+ const repo = mkdir('repo');
422
+ mkdir('repo-residue', 'node_modules');
423
+ mkdir('sibling-checkout', '.git');
424
+ mkdir('unrelated-empty-dir');
425
+ const calls = [];
426
+ const result = scanEstate({
427
+ registry: [{ path: repo }],
428
+ now: NOW,
429
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
430
+ });
431
+ const husks = Object.fromEntries(result.huskCandidates.map((h) => [path.basename(h.path), h.evidence]));
432
+ expect(husks).toEqual({ 'repo-residue': 'residue-shape' });
433
+ expect(result.huskCandidates[0].matchedRepo).toBe(repo);
434
+ for (const husk of result.huskCandidates) {
435
+ expect(['dangling-gitdir-pointer', 'residue-shape', 'deregistered-intact']).toContain(husk.evidence);
436
+ }
437
+ });
438
+ // The prefix is hyphen-BOUNDED: `<repo>-…` is the worktree naming
439
+ // convention's own shape, while a name that merely BEGINS with a repo's
440
+ // (`repoville` vs `repo`) is an ordinary project, not residue.
441
+ it('does not husk a `.git`-less project whose name merely begins with a repo name', () => {
442
+ const repo = mkdir('repo');
443
+ mkdir('repoville', 'node_modules');
444
+ mkdir('repo-ville', 'node_modules');
445
+ const result = scanEstate({
446
+ registry: [{ path: repo }],
447
+ now: NOW,
448
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, []),
449
+ });
450
+ expect(result.huskCandidates.map((h) => path.basename(h.path))).toEqual(['repo-ville']);
451
+ });
452
+ it('does not husk a live worktree of an UNREGISTERED repo (its home repo still lists it)', () => {
453
+ const repo = mkdir('repo');
454
+ const otherRepo = mkdir('other');
455
+ const otherWt = mkdir('other-wt');
456
+ const gitdir = mkdir('other', '.git', 'worktrees', 'other-wt');
457
+ writeGitdirPointer(otherWt, gitdir);
458
+ const calls = [];
459
+ const result = scanEstate({
460
+ registry: [{ path: repo }],
461
+ now: NOW,
462
+ safeExec: makeExec({
463
+ lists: {
464
+ [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]),
465
+ [otherRepo]: porcelain([
466
+ { path: otherRepo, branch: 'refs/heads/main' },
467
+ { path: otherWt, branch: 'refs/heads/side' },
468
+ ]),
469
+ },
470
+ }, calls),
471
+ });
472
+ expect(result.huskCandidates).toEqual([]);
473
+ });
474
+ it('reports deregistered-intact when the home repo no longer lists an intact pointer', () => {
475
+ const repo = mkdir('repo');
476
+ const otherRepo = mkdir('other');
477
+ const orphan = mkdir('other-orphan');
478
+ const gitdir = mkdir('other', '.git', 'worktrees', 'other-orphan');
479
+ writeGitdirPointer(orphan, gitdir);
480
+ const calls = [];
481
+ const result = scanEstate({
482
+ registry: [{ path: repo }],
483
+ now: NOW,
484
+ safeExec: makeExec({
485
+ lists: {
486
+ [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]),
487
+ [otherRepo]: porcelain([{ path: otherRepo, branch: 'refs/heads/main' }]),
488
+ },
489
+ }, calls),
490
+ });
491
+ expect(result.huskCandidates).toHaveLength(1);
492
+ expect(result.huskCandidates[0].evidence).toBe('deregistered-intact');
493
+ expect(result.huskCandidates[0].matchedRepo).toBe(otherRepo);
494
+ expect(result.huskCandidates[0].ageDays).toBe(0);
495
+ });
496
+ });
497
+ // ─── Invariant 4 ────────────────────────────────────────
498
+ describe('invariant 4 — a dangling `.git` pointer is a husk regardless of the directory name', () => {
499
+ it('reports a dangling pointer under a name matching no convention', () => {
500
+ const repo = mkdir('repo');
501
+ const odd = mkdir('zzz-scratch-42');
502
+ writeGitdirPointer(odd, path.join(root, 'gone', '.git', 'worktrees', 'zzz'));
503
+ const calls = [];
504
+ const result = scanEstate({
505
+ registry: [{ path: repo }],
506
+ now: NOW,
507
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
508
+ });
509
+ expect(result.huskCandidates).toHaveLength(1);
510
+ expect(result.huskCandidates[0].path).toBe(odd);
511
+ expect(result.huskCandidates[0].evidence).toBe('dangling-gitdir-pointer');
512
+ expect(result.huskCandidates[0].sweptRoot).toBe(root);
513
+ });
514
+ });
515
+ // ─── Invariant 5 ────────────────────────────────────────
516
+ describe('invariant 5 — total accounting: nothing is dropped, counts equal rows', () => {
517
+ function mixedEstate() {
518
+ const repo = mkdir('repo');
519
+ const stale = mkdir('repo-stale');
520
+ const open = mkdir('repo-open');
521
+ const broken = mkdir('repo-broken');
522
+ const husk = mkdir('zzz-husk');
523
+ writeGitdirPointer(husk, path.join(root, 'gone', '.git', 'worktrees', 'zzz-husk'));
524
+ const calls = [];
525
+ return scanEstate({
526
+ registry: [
527
+ { path: repo, lastSync: '2026-08-01T00:00:00.000Z' },
528
+ { path: path.join(root, 'vanished') },
529
+ ],
530
+ now: NOW,
531
+ safeExec: makeExec({
532
+ lists: {
533
+ [repo]: porcelain([
534
+ { path: repo, branch: 'refs/heads/main' },
535
+ { path: stale, branch: 'refs/heads/stale' },
536
+ { path: open, branch: 'refs/heads/open' },
537
+ { path: broken, branch: 'refs/heads/broken' },
538
+ ]),
539
+ },
540
+ defaultRefs: { [repo]: 'origin/main' },
541
+ merged: ['refs/heads/stale'],
542
+ failStatus: [broken],
543
+ }, calls),
544
+ });
545
+ }
546
+ it('lands every enumerated candidate in exactly one bucket ON THE SWEEP AXIS', () => {
547
+ const result = mixedEstate();
548
+ // The partition is defined over the SWEEP axis: worktree rows, husk rows,
549
+ // and sweep-source failures. Registry- and worktree-source ledger rows are
550
+ // a different axis (registry accounting) and MAY share a path with a husk
551
+ // row — the two-axes rule.
552
+ const classified = result.worktrees.filter((w) => w.class !== 'unscannable').map((w) => w.path);
553
+ const husks = result.huskCandidates.map((h) => h.path);
554
+ const sweepFailed = sweepFailures(result);
555
+ const all = [...classified, ...husks, ...sweepFailed];
556
+ expect(new Set(all).size).toBe(all.length);
557
+ // A worktree whose probe failed is class-unscannable AND carries a named
558
+ // worktree-source failure row — the degraded state is never silent.
559
+ const unscannableWorktrees = result.worktrees.filter((w) => w.class === 'unscannable');
560
+ expect(unscannableWorktrees).toHaveLength(1);
561
+ const worktreeFailed = result.unscannable
562
+ .filter((u) => u.source === 'worktree')
563
+ .map((u) => u.path);
564
+ for (const w of unscannableWorktrees) {
565
+ expect(worktreeFailed).toContain(w.path);
566
+ expect(w.evidence).toContain('status --porcelain failed');
567
+ }
568
+ });
569
+ it('tags every ledger row with its axis', () => {
570
+ const result = mixedEstate();
571
+ for (const row of result.unscannable) {
572
+ expect(['registry', 'worktree', 'sweep']).toContain(row.source);
573
+ }
574
+ });
575
+ it('keeps summary counts equal to the row counts', () => {
576
+ const result = mixedEstate();
577
+ const s = result.summary;
578
+ expect(s.worktrees).toBe(result.worktrees.length);
579
+ expect(s.active + s.stale + s.indeterminate + s.detached + s.unscannableWorktrees).toBe(result.worktrees.length);
580
+ expect(s.huskCandidates).toBe(result.huskCandidates.length);
581
+ expect(s.unscannable).toBe(result.unscannable.length);
582
+ expect(s.repos).toBe(result.repos.length);
583
+ expect(s.reposMissing).toBe(1);
584
+ expect(result.repos.find((r) => r.missing === true).path).toBe(path.join(root, 'vanished'));
585
+ });
586
+ it('discloses every swept root and never scans a missing registry path', () => {
587
+ const result = mixedEstate();
588
+ expect(rootPaths(result)).toContain(root);
589
+ expect(result.repos.find((r) => r.missing === true).worktrees).toBe(0);
590
+ });
591
+ it('records an unscannable row (and no husk rows) for an unreadable sweep root', () => {
592
+ const repo = mkdir('repo');
593
+ const missingRoot = path.join(root, 'no-such-root');
594
+ const calls = [];
595
+ const result = scanEstate({
596
+ registry: [{ path: repo }],
597
+ now: NOW,
598
+ extraRoots: [missingRoot],
599
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
600
+ });
601
+ expect(rootPaths(result)).toContain(missingRoot);
602
+ expect(result.unscannable.map((u) => u.path)).toContain(missingRoot);
603
+ expect(result.unscannable[0].reason).toContain('sweep root unreadable');
604
+ });
605
+ });
606
+ // ─── Sweep-root derivation ──────────────────────────────
607
+ describe('sweep-root derivation — evidence-ranked, disclosed when narrowed', () => {
608
+ it('does not derive a root from a MISSING registry entry (T1)', () => {
609
+ const repo = mkdir('repo');
610
+ const nested = path.join(root, 'nested');
611
+ const calls = [];
612
+ const result = scanEstate({
613
+ registry: [{ path: repo }, { path: path.join(nested, 'gone') }],
614
+ now: NOW,
615
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
616
+ });
617
+ expect(rootPaths(result)).toContain(root);
618
+ expect(rootPaths(result)).not.toContain(nested);
619
+ // Suppressed, not silently dropped (the disclosure covers every narrowing).
620
+ expect(result.excludedRoots).toEqual([
621
+ { path: nested, reason: 'not derived: derived from missing registry entry path(s)' },
622
+ ]);
623
+ expect(result.repos.find((r) => r.missing === true).path).toBe(path.join(nested, 'gone'));
624
+ });
625
+ it('excludes os.tmpdir() when only a registry entry derives it, and says so (T2)', () => {
626
+ const repo = mkTmpdirChild('totem-estate-tmproot-');
627
+ const calls = [];
628
+ const result = scanEstate({
629
+ registry: [{ path: repo }],
630
+ now: NOW,
631
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
632
+ });
633
+ expect(rootPaths(result)).not.toContain(path.resolve(os.tmpdir()));
634
+ expect(result.excludedRoots).toHaveLength(1);
635
+ expect(result.excludedRoots[0].path).toBe(path.resolve(os.tmpdir()));
636
+ expect(result.excludedRoots[0].reason).toBe('os tmpdir — derived only from a registry entry path; pass --root to sweep it');
637
+ });
638
+ it('sweeps os.tmpdir() when a LISTED WORKTREE parent derives it (T3)', () => {
639
+ const repo = mkTmpdirChild('totem-estate-tmproot-');
640
+ const wt = mkTmpdirChild('totem-estate-tmpwt-');
641
+ const calls = [];
642
+ const result = scanEstate({
643
+ registry: [{ path: repo }],
644
+ now: NOW,
645
+ safeExec: makeExec({
646
+ lists: {
647
+ [repo]: porcelain([
648
+ { path: repo, branch: 'refs/heads/main' },
649
+ { path: wt, branch: 'refs/heads/side' },
650
+ ]),
651
+ },
652
+ defaultRefs: { [repo]: 'origin/main' },
653
+ }, calls),
654
+ });
655
+ // A live worktree in the temp dir IS evidence that worktrees live there.
656
+ expect(rootPaths(result)).toContain(path.resolve(os.tmpdir()));
657
+ expect(result.excludedRoots).toEqual([]);
658
+ });
659
+ it('sweeps os.tmpdir() when --root names it explicitly (T4)', () => {
660
+ const repo = mkTmpdirChild('totem-estate-tmproot-');
661
+ const calls = [];
662
+ const result = scanEstate({
663
+ registry: [{ path: repo }],
664
+ now: NOW,
665
+ extraRoots: [os.tmpdir()],
666
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
667
+ });
668
+ expect(rootPaths(result)).toContain(path.resolve(os.tmpdir()));
669
+ expect(result.excludedRoots).toEqual([]);
670
+ });
671
+ });
672
+ // ─── Toplevel verification ──────────────────────────────
673
+ describe('registered arm — a registry entry must be a git TOPLEVEL to be enumerated', () => {
674
+ it('reports a not-git-root entry with its enclosing repo and derives nothing from it', () => {
675
+ const repo = mkdir('repo');
676
+ const inside = mkdir('repo', 'packages', 'core');
677
+ const calls = [];
678
+ const result = scanEstate({
679
+ registry: [{ path: repo }, { path: inside }],
680
+ now: NOW,
681
+ safeExec: makeExec({
682
+ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) },
683
+ // git -C <repo>/packages/core discovers the ANCESTOR repo.
684
+ toplevels: { [inside]: repo },
685
+ }, calls),
686
+ });
687
+ const row = result.repos.find((r) => r.path === inside);
688
+ expect(row.notGitRoot).toBe(true);
689
+ expect(row.enclosingRepo).toBe(repo);
690
+ expect(row.worktrees).toBe(0);
691
+ // Neither its dirname nor git's ancestor answer may feed the sweep.
692
+ expect(rootPaths(result)).not.toContain(path.join(repo, 'packages'));
693
+ expect(result.excludedRoots.map((r) => r.path)).toContain(path.join(repo, 'packages'));
694
+ expect(result.excludedRoots.find((r) => r.path === path.join(repo, 'packages')).reason).toBe('not derived: derived from non-git-root registry entry path(s)');
695
+ // The ancestor's worktree list is never requested on its behalf.
696
+ expect(calls.filter((c) => c.includes('worktree'))).toHaveLength(1);
697
+ });
698
+ it('keeps the unscannable-repo path when the toplevel probe itself fails', () => {
699
+ const repo = mkdir('repo');
700
+ const calls = [];
701
+ const result = scanEstate({
702
+ registry: [{ path: repo }],
703
+ now: NOW,
704
+ safeExec: makeExec({ lists: {}, failToplevel: [repo] }, calls),
705
+ });
706
+ expect(result.repos).toHaveLength(1);
707
+ expect(result.repos[0].notGitRoot).toBeUndefined();
708
+ expect(result.unscannable).toHaveLength(1);
709
+ expect(result.unscannable[0].reason).toContain('rev-parse --show-toplevel failed');
710
+ expect(result.unscannable[0].source).toBe('registry');
711
+ expect(result.summary.reposUnscannable).toBe(1);
712
+ expect(calls.some((c) => c.includes('worktree'))).toBe(false);
713
+ });
714
+ it('derives NOTHING from an entry whose toplevel could not be verified', () => {
715
+ // The unverifiable entry sits next to a node_modules-bearing sibling that
716
+ // would qualify as residue-shape if its parent were swept on its account.
717
+ const unverifiable = mkdir('nested', 'maybe-repo');
718
+ const sibling = mkdir('nested', 'maybe-repo-residue');
719
+ fs.mkdirSync(path.join(sibling, 'node_modules'));
720
+ const calls = [];
721
+ const result = scanEstate({
722
+ registry: [{ path: unverifiable }],
723
+ now: NOW,
724
+ safeExec: makeExec({ lists: {}, failToplevel: [unverifiable] }, calls),
725
+ });
726
+ expect(rootPaths(result)).not.toContain(path.join(root, 'nested'));
727
+ expect(result.huskCandidates).toEqual([]);
728
+ expect(result.excludedRoots).toEqual([
729
+ {
730
+ path: path.join(root, 'nested'),
731
+ reason: 'not derived: derived from unverifiable registry entry path(s)',
732
+ },
733
+ ]);
734
+ });
735
+ it('lets a VERIFIED sibling derive the same root — both axes then coexist', () => {
736
+ // Same tree, but a verified repo shares the parent, so the root IS swept.
737
+ const unverifiable = mkdir('nested', 'maybe-repo');
738
+ fs.mkdirSync(path.join(unverifiable, 'node_modules'));
739
+ const verified = mkdir('nested', 'maybe');
740
+ const calls = [];
741
+ const result = scanEstate({
742
+ registry: [{ path: unverifiable }, { path: verified }],
743
+ now: NOW,
744
+ safeExec: makeExec({
745
+ lists: { [verified]: porcelain([{ path: verified, branch: 'refs/heads/main' }]) },
746
+ failToplevel: [unverifiable],
747
+ }, calls),
748
+ });
749
+ expect(rootPaths(result)).toContain(path.join(root, 'nested'));
750
+ // The unverifiable entry's path carries BOTH a registry-source ledger row
751
+ // and a husk row — different axes, so this is not a partition violation.
752
+ expect(result.unscannable.filter((u) => u.path === unverifiable)[0].source).toBe('registry');
753
+ expect(result.huskCandidates.map((h) => h.path)).toContain(unverifiable);
754
+ // The sweep-axis partition still holds.
755
+ const classified = result.worktrees.map((w) => w.path);
756
+ const all = [
757
+ ...classified,
758
+ ...result.huskCandidates.map((h) => h.path),
759
+ ...sweepFailures(result),
760
+ ];
761
+ expect(new Set(all).size).toBe(all.length);
762
+ });
763
+ it('never lets an unverified entry attribute a residue-shape row to itself', () => {
764
+ const unverifiable = mkdir('nested', 'maybe-repo');
765
+ fs.mkdirSync(path.join(unverifiable, 'node_modules'));
766
+ const verified = mkdir('nested', 'maybe');
767
+ const calls = [];
768
+ const result = scanEstate({
769
+ registry: [{ path: unverifiable }, { path: verified }],
770
+ now: NOW,
771
+ safeExec: makeExec({
772
+ lists: { [verified]: porcelain([{ path: verified, branch: 'refs/heads/main' }]) },
773
+ failToplevel: [unverifiable],
774
+ }, calls),
775
+ });
776
+ const husk = result.huskCandidates.find((h) => h.path === unverifiable);
777
+ expect(husk.evidence).toBe('residue-shape');
778
+ // Attribution names the VERIFIED repo, never the husk's own registry entry.
779
+ expect(husk.matchedRepo).toBe(verified);
780
+ expect(husk.matchedRepo).not.toBe(unverifiable);
781
+ });
782
+ });
783
+ // ─── Container roots ────────────────────────────────────
784
+ describe('container roots — location is the evidence', () => {
785
+ function containerEstate(extraRoots) {
786
+ const repo = mkdir('repo');
787
+ mkdir('repo', '.claude', 'worktrees', 'agent-abc');
788
+ mkdir('repo', '.claude', 'worktrees', 'kimi-2385');
789
+ const live = path.join(root, 'repo', '.claude', 'worktrees', 'agent-live');
790
+ fs.mkdirSync(live, { recursive: true });
791
+ const calls = [];
792
+ return scanEstate({
793
+ registry: [{ path: repo }],
794
+ now: NOW,
795
+ ...(extraRoots === undefined ? {} : { extraRoots }),
796
+ safeExec: makeExec({
797
+ lists: {
798
+ [repo]: porcelain([
799
+ { path: repo, branch: 'refs/heads/main' },
800
+ { path: live, branch: 'refs/heads/live' },
801
+ ]),
802
+ },
803
+ defaultRefs: { [repo]: 'origin/main' },
804
+ }, calls),
805
+ });
806
+ }
807
+ it('sweeps <repo>/.claude/worktrees and reports untracked dirs as container-residue', () => {
808
+ const result = containerEstate();
809
+ expect(rootPaths(result)).toContain(path.join(root, 'repo', '.claude', 'worktrees'));
810
+ const husks = Object.fromEntries(result.huskCandidates.map((h) => [path.basename(h.path), h.evidence]));
811
+ // No name convention, no node_modules, no `.git` at all — the location
812
+ // alone qualifies them.
813
+ expect(husks).toEqual({ 'agent-abc': 'container-residue', 'kimi-2385': 'container-residue' });
814
+ });
815
+ it('never husks a LIVE worktree inside a container root', () => {
816
+ const result = containerEstate();
817
+ expect(result.huskCandidates.map((h) => path.basename(h.path))).not.toContain('agent-live');
818
+ expect(result.worktrees.map((w) => path.basename(w.path))).toContain('agent-live');
819
+ });
820
+ it('treats a --root as a container declaration too', () => {
821
+ const scratch = mkdir('scratch');
822
+ fs.mkdirSync(path.join(scratch, 'leftover-wt'));
823
+ const result = containerEstate([scratch]);
824
+ const leftover = result.huskCandidates.find((h) => h.path === path.join(scratch, 'leftover-wt'));
825
+ expect(leftover.evidence).toBe('container-residue');
826
+ });
827
+ it('still refuses a `.git`-DIRECTORY dir under a container root', () => {
828
+ const repo = mkdir('repo');
829
+ mkdir('repo', '.claude', 'worktrees', 'real-clone', '.git');
830
+ const calls = [];
831
+ const result = scanEstate({
832
+ registry: [{ path: repo }],
833
+ now: NOW,
834
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
835
+ });
836
+ expect(result.huskCandidates).toEqual([]);
837
+ });
838
+ it('prefers the stronger dangling-pointer class over container-residue', () => {
839
+ const repo = mkdir('repo');
840
+ const dangling = mkdir('repo', '.claude', 'worktrees', 'agent-dangling');
841
+ writeGitdirPointer(dangling, path.join(root, 'gone', '.git', 'worktrees', 'agent-dangling'));
842
+ const calls = [];
843
+ const result = scanEstate({
844
+ registry: [{ path: repo }],
845
+ now: NOW,
846
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
847
+ });
848
+ expect(result.huskCandidates).toHaveLength(1);
849
+ expect(result.huskCandidates[0].evidence).toBe('dangling-gitdir-pointer');
850
+ });
851
+ it('withdraws the container root when the repo worktree list failed', () => {
852
+ // Without the repo's live-worktree list, container-residue cannot tell a
853
+ // live worktree from residue — a degraded scan must not read DIRTIER.
854
+ const repo = mkdir('repo');
855
+ mkdir('repo', '.claude', 'worktrees', 'agent-a');
856
+ mkdir('repo', '.claude', 'worktrees', 'agent-b');
857
+ const container = path.join(root, 'repo', '.claude', 'worktrees');
858
+ const calls = [];
859
+ const result = scanEstate({
860
+ registry: [{ path: repo }],
861
+ now: NOW,
862
+ // `lists` is empty, so `worktree list` throws for this repo.
863
+ safeExec: makeExec({ lists: {} }, calls),
864
+ });
865
+ expect(rootPaths(result)).not.toContain(container);
866
+ expect(result.excludedRoots.map((r) => r.path)).toContain(container);
867
+ expect(result.excludedRoots.find((r) => r.path === container).reason).toBe('not derived: container of a repo whose worktree list failed');
868
+ expect(result.huskCandidates.filter((h) => h.evidence === 'container-residue')).toEqual([]);
869
+ expect(result.summary.reposUnscannable).toBe(1);
870
+ });
871
+ it('falls through to container-residue for a shapeless `.git` pointer', () => {
872
+ const repo = mkdir('repo');
873
+ const shapeless = mkdir('repo', '.claude', 'worktrees', 'agent-shapeless');
874
+ fs.writeFileSync(path.join(shapeless, '.git'), 'this file has no gitdir line\n', 'utf-8');
875
+ const calls = [];
876
+ const result = scanEstate({
877
+ registry: [{ path: repo }],
878
+ now: NOW,
879
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
880
+ });
881
+ expect(result.huskCandidates).toHaveLength(1);
882
+ expect(result.huskCandidates[0].evidence).toBe('container-residue');
883
+ });
884
+ it('falls through to container-residue when the pointer target is not worktree-shaped', () => {
885
+ const repo = mkdir('repo');
886
+ const oddTarget = mkdir('somewhere-else');
887
+ const odd = mkdir('repo', '.claude', 'worktrees', 'agent-odd');
888
+ writeGitdirPointer(odd, oddTarget);
889
+ const calls = [];
890
+ const result = scanEstate({
891
+ registry: [{ path: repo }],
892
+ now: NOW,
893
+ safeExec: makeExec({ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) } }, calls),
894
+ });
895
+ const husk = result.huskCandidates.find((h) => h.path === odd);
896
+ expect(husk.evidence).toBe('container-residue');
897
+ });
898
+ it('reports a registry-registered directory that is also disk residue on BOTH axes', () => {
899
+ // Registry membership is not protection: `totem sync` having touched a path
900
+ // once says nothing about whether the worktree still exists.
901
+ const repo = mkdir('repo');
902
+ const residue = mkdir('repo', '.claude', 'worktrees', 'agent-registered');
903
+ const calls = [];
904
+ const result = scanEstate({
905
+ registry: [{ path: repo }, { path: residue }],
906
+ now: NOW,
907
+ safeExec: makeExec({
908
+ lists: { [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]) },
909
+ toplevels: { [residue]: repo },
910
+ }, calls),
911
+ });
912
+ expect(result.repos.find((r) => r.path === residue).notGitRoot).toBe(true);
913
+ expect(result.huskCandidates.map((h) => h.path)).toContain(residue);
914
+ });
915
+ });
916
+ // ─── Residue attribution ────────────────────────────────
917
+ describe('residue-shape attribution takes the most specific repo name', () => {
918
+ it('attributes a husk to the LONGEST matching registered basename (T5)', () => {
919
+ const totem = mkdir('totem');
920
+ const strategy = mkdir('totem-strategy');
921
+ const husk = mkdir('totem-strategy-claude-x');
922
+ fs.mkdirSync(path.join(husk, 'node_modules'));
923
+ const calls = [];
924
+ const result = scanEstate({
925
+ registry: [{ path: totem }, { path: strategy }],
926
+ now: NOW,
927
+ safeExec: makeExec({
928
+ lists: {
929
+ [totem]: porcelain([{ path: totem, branch: 'refs/heads/main' }]),
930
+ [strategy]: porcelain([{ path: strategy, branch: 'refs/heads/main' }]),
931
+ },
932
+ }, calls),
933
+ });
934
+ expect(result.huskCandidates).toHaveLength(1);
935
+ expect(result.huskCandidates[0].path).toBe(husk);
936
+ expect(result.huskCandidates[0].evidence).toBe('residue-shape');
937
+ expect(result.huskCandidates[0].matchedRepo).toBe(strategy);
938
+ });
939
+ });
940
+ // ─── Multi-husk totality ────────────────────────────────
941
+ describe('accounting totality across a multi-husk estate', () => {
942
+ it('reports every husk and accounts for EVERY enumerated candidate dir', () => {
943
+ const repo = mkdir('repo');
944
+ const container = mkdir('repo', '.claude', 'worktrees');
945
+ const agentA = mkdir('repo', '.claude', 'worktrees', 'agent-a');
946
+ const agentB = mkdir('repo', '.claude', 'worktrees', 'agent-b');
947
+ const liveWt = mkdir('repo', '.claude', 'worktrees', 'live-wt');
948
+ const dangling = mkdir('zzz-dangling');
949
+ writeGitdirPointer(dangling, path.join(root, 'gone', '.git', 'worktrees', 'zzz'));
950
+ const residue = mkdir('repo-residue');
951
+ fs.mkdirSync(path.join(residue, 'node_modules'));
952
+ const plain = mkdir('plain-dir');
953
+ const otherCheckout = mkdir('other-checkout');
954
+ fs.mkdirSync(path.join(otherCheckout, '.git'));
955
+ const nodeModules = mkdir('node_modules');
956
+ const hidden = mkdir('.hidden');
957
+ const calls = [];
958
+ const result = scanEstate({
959
+ registry: [{ path: repo }],
960
+ now: NOW,
961
+ safeExec: makeExec({
962
+ lists: {
963
+ [repo]: porcelain([
964
+ { path: repo, branch: 'refs/heads/main' },
965
+ { path: liveWt, branch: 'refs/heads/live' },
966
+ ]),
967
+ },
968
+ defaultRefs: { [repo]: 'origin/main' },
969
+ }, calls),
970
+ });
971
+ // (i) every husk is reported — a one-husk cap would fail this outright.
972
+ expect(Object.fromEntries(result.huskCandidates.map((h) => [h.path, h.evidence]))).toEqual({
973
+ [agentA]: 'container-residue',
974
+ [agentB]: 'container-residue',
975
+ [dangling]: 'dangling-gitdir-pointer',
976
+ [residue]: 'residue-shape',
977
+ });
978
+ // (ii) totality: enumerate the swept roots' children independently and
979
+ // account for each one. The partition is a SWEEP-AXIS property, so the
980
+ // failure set is built from sweep-source, candidate-keyed rows only — a
981
+ // registry- or worktree-source row lives on a different axis and must never
982
+ // be what satisfies `unaccounted`.
983
+ const classified = new Set(result.worktrees.map((w) => w.path));
984
+ const husks = new Set(result.huskCandidates.map((h) => h.path));
985
+ const failed = new Set(sweepFailures(result));
986
+ for (const p of classified)
987
+ expect(husks.has(p) || failed.has(p)).toBe(false);
988
+ for (const p of husks)
989
+ expect(failed.has(p)).toBe(false);
990
+ // This fixture probes cleanly, so the other two axes are empty — asserted
991
+ // rather than assumed, so a future fixture change cannot quietly start
992
+ // leaning on them.
993
+ expect(result.unscannable.filter((u) => u.source !== 'sweep')).toEqual([]);
994
+ expect(rootFailures(result)).toEqual([]);
995
+ const enumerated = [];
996
+ for (const { path: sweptRoot } of result.sweptRoots) {
997
+ for (const dirent of fs.readdirSync(sweptRoot, { withFileTypes: true })) {
998
+ if (dirent.isDirectory())
999
+ enumerated.push(path.join(sweptRoot, dirent.name));
1000
+ }
1001
+ }
1002
+ const unaccounted = enumerated
1003
+ .filter((p) => !classified.has(p) && !husks.has(p) && !failed.has(p))
1004
+ .sort();
1005
+ // Every remaining dir is one of the NAMED exempt shapes, listed explicitly
1006
+ // so a newly-dropped candidate fails here instead of vanishing:
1007
+ // .hidden — dot-dir, never swept
1008
+ // node_modules — name-excluded
1009
+ // other-checkout— carries a `.git` DIRECTORY (a real checkout)
1010
+ // plain-dir — no evidence under a STANDARD root
1011
+ // repo — git-known (its own worktree list names it)
1012
+ // `live-wt` is NOT exempt: it is git-known AND lands in the worktrees
1013
+ // bucket as a classified row, which the assertion below pins.
1014
+ expect(classified.has(liveWt)).toBe(true);
1015
+ expect(unaccounted).toEqual([hidden, nodeModules, otherCheckout, plain, repo].sort());
1016
+ expect(rootPaths(result)).toEqual([container, root].sort());
1017
+ });
1018
+ });
1019
+ // ─── Root vs candidate namespace ────────────────────────
1020
+ /**
1021
+ * Can this platform make a directory unreadable via chmod? POSIX yes; Windows
1022
+ * maps chmod to the read-only attribute, which does not block `readdir`.
1023
+ */
1024
+ function canBlockReaddir() {
1025
+ const probe = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-estate-perm-'));
1026
+ try {
1027
+ fs.chmodSync(probe, 0o000);
1028
+ fs.readdirSync(probe);
1029
+ return false;
1030
+ // totem-context: intentional cleanup — a throw here is the POSITIVE result (the platform blocked the read); the probe dir is removed in the finally below either way.
1031
+ }
1032
+ catch {
1033
+ return true;
1034
+ }
1035
+ finally {
1036
+ // totem-context: intentional cleanup — restore permissions before removing the probe dir; a failure to chmod back would only leak one temp dir and must not fail the suite.
1037
+ try {
1038
+ fs.chmodSync(probe, 0o700);
1039
+ fs.rmSync(probe, { recursive: true, force: true });
1040
+ // 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.
1041
+ }
1042
+ catch {
1043
+ /* probe dir leak is harmless */
1044
+ }
1045
+ }
1046
+ }
1047
+ describe('root-level failures live outside the candidate partition', () => {
1048
+ it('keys an unreadable root by the ROOT while its parent still yields a candidate row', () => {
1049
+ const parent = mkdir('sweep-parent');
1050
+ const child = path.join(parent, 'child-wt');
1051
+ fs.mkdirSync(child);
1052
+ const blocked = canBlockReaddir();
1053
+ if (blocked)
1054
+ fs.chmodSync(child, 0o000);
1055
+ try {
1056
+ const calls = [];
1057
+ const result = scanEstate({
1058
+ registry: [],
1059
+ now: NOW,
1060
+ // Both the parent and the child are declared roots. The child is swept
1061
+ // as a ROOT (and fails when the platform can block it) while ALSO being
1062
+ // an enumerable candidate under the parent.
1063
+ extraRoots: [parent, child],
1064
+ safeExec: makeExec({ lists: {} }, calls),
1065
+ });
1066
+ expect(rootPaths(result)).toEqual([child, parent].sort());
1067
+ // The candidate row: the child is untracked under a container root.
1068
+ expect(result.huskCandidates.map((h) => h.path)).toEqual([child]);
1069
+ if (blocked) {
1070
+ // The root-failure row is keyed by the same path, on the root namespace.
1071
+ const rootRows = rootFailures(result);
1072
+ expect(rootRows.map((r) => r.path)).toEqual([child]);
1073
+ expect(rootRows[0].reason).toContain('sweep root unreadable');
1074
+ // Distinguishable from a candidate failure, and EXCLUDED from the
1075
+ // partition — otherwise this fixture would read as a double-report.
1076
+ expect(sweepFailures(result)).toEqual([]);
1077
+ }
1078
+ const classified = new Set(result.worktrees.map((w) => w.path));
1079
+ const husks = new Set(result.huskCandidates.map((h) => h.path));
1080
+ const failed = new Set(sweepFailures(result));
1081
+ const all = [...classified, ...husks, ...failed];
1082
+ expect(new Set(all).size).toBe(all.length);
1083
+ }
1084
+ finally {
1085
+ if (blocked)
1086
+ fs.chmodSync(child, 0o700);
1087
+ }
1088
+ });
1089
+ it('keys a nonexistent declared root by the root path, with no candidate row', () => {
1090
+ // The portable half of the same property: a root that cannot be read at all
1091
+ // produces a root-keyed ledger row and nothing on the candidate axis.
1092
+ const gone = path.join(root, 'no-such-root');
1093
+ const calls = [];
1094
+ const result = scanEstate({
1095
+ registry: [],
1096
+ now: NOW,
1097
+ extraRoots: [gone],
1098
+ safeExec: makeExec({ lists: {} }, calls),
1099
+ });
1100
+ expect(rootFailures(result).map((r) => r.path)).toEqual([gone]);
1101
+ expect(sweepFailures(result)).toEqual([]);
1102
+ expect(result.huskCandidates).toEqual([]);
1103
+ });
1104
+ });
1105
+ // ─── Suppression-reason aggregation ─────────────────────
1106
+ describe('suppressed roots carry EVERY reason that declined them', () => {
1107
+ it('aggregates missing and unverifiable reasons into one disclosure row', () => {
1108
+ const nested = path.join(root, 'nested');
1109
+ const missing = path.join(nested, 'gone');
1110
+ const unverifiable = mkdir('nested', 'maybe-repo');
1111
+ const calls = [];
1112
+ const result = scanEstate({
1113
+ registry: [{ path: missing }, { path: unverifiable }],
1114
+ now: NOW,
1115
+ safeExec: makeExec({ lists: {}, failToplevel: [unverifiable] }, calls),
1116
+ });
1117
+ expect(result.excludedRoots).toHaveLength(1);
1118
+ expect(result.excludedRoots[0].path).toBe(nested);
1119
+ // Both derivations declined this root; reporting only the first would
1120
+ // under-state why it is not swept.
1121
+ expect(result.excludedRoots[0].reason).toBe('not derived: derived from missing registry entry path(s); derived from unverifiable registry entry path(s)');
1122
+ });
1123
+ it('keeps the tmpdir reason standing alone — it names the escape hatch', () => {
1124
+ const underTmp = mkTmpdirChild('totem-estate-tmproot-');
1125
+ const missingUnderTmp = path.join(path.resolve(os.tmpdir()), 'totem-estate-not-here');
1126
+ const calls = [];
1127
+ const result = scanEstate({
1128
+ registry: [{ path: underTmp }, { path: missingUnderTmp }],
1129
+ now: NOW,
1130
+ safeExec: makeExec({ lists: { [underTmp]: porcelain([{ path: underTmp, branch: 'refs/heads/main' }]) } }, calls),
1131
+ });
1132
+ const row = result.excludedRoots.find((r) => r.path === path.resolve(os.tmpdir()));
1133
+ expect(row.reason).toBe('os tmpdir — derived only from a registry entry path; pass --root to sweep it');
1134
+ });
1135
+ });
1136
+ // ─── Bare-repo gitdir layout ────────────────────────────
1137
+ describe('homeRepoFromGitdir accepts the bare-repo worktree layout', () => {
1138
+ it('resolves `<repo>.git/worktrees/<n>` to the `.git`-suffixed repo dir', () => {
1139
+ const repo = mkdir('repo');
1140
+ const bare = mkdir('bare-mirror.git');
1141
+ const gitdir = mkdir('bare-mirror.git', 'worktrees', 'orphan');
1142
+ const orphan = mkdir('orphan-wt');
1143
+ writeGitdirPointer(orphan, gitdir);
1144
+ const calls = [];
1145
+ const result = scanEstate({
1146
+ registry: [{ path: repo }],
1147
+ now: NOW,
1148
+ safeExec: makeExec({
1149
+ lists: {
1150
+ [repo]: porcelain([{ path: repo, branch: 'refs/heads/main' }]),
1151
+ // The bare mirror still answers, and no longer lists the orphan.
1152
+ [bare]: porcelain([{ path: bare, branch: 'refs/heads/main' }]),
1153
+ },
1154
+ }, calls),
1155
+ });
1156
+ const row = result.huskCandidates.find((h) => h.path === orphan);
1157
+ expect(row.evidence).toBe('deregistered-intact');
1158
+ expect(row.matchedRepo).toBe(bare);
1159
+ });
1160
+ });
1161
+ // ─── Invariant 6 ────────────────────────────────────────
1162
+ describe('invariant 6 — an empty registry yields a valid, degenerate result', () => {
1163
+ it('returns a zeroed result and invokes git zero times', () => {
1164
+ const calls = [];
1165
+ const result = scanEstate({
1166
+ registry: [],
1167
+ now: NOW,
1168
+ safeExec: makeExec({ lists: {} }, calls),
1169
+ });
1170
+ expect(calls).toEqual([]);
1171
+ expect(result.schemaVersion).toBe(ESTATE_SCHEMA_VERSION);
1172
+ expect(result.derivedAt).toBe('2026-08-05T12:00:00.000Z');
1173
+ expect(result.repos).toEqual([]);
1174
+ expect(result.worktrees).toEqual([]);
1175
+ expect(result.huskCandidates).toEqual([]);
1176
+ expect(result.unscannable).toEqual([]);
1177
+ expect(result.sweptRoots).toEqual([]);
1178
+ expect(result.excludedRoots).toEqual([]);
1179
+ expect(result.summary).toEqual({
1180
+ repos: 0,
1181
+ reposMissing: 0,
1182
+ reposNotGitRoot: 0,
1183
+ reposUnscannable: 0,
1184
+ worktrees: 0,
1185
+ active: 0,
1186
+ stale: 0,
1187
+ indeterminate: 0,
1188
+ detached: 0,
1189
+ unscannableWorktrees: 0,
1190
+ huskCandidates: 0,
1191
+ unscannable: 0,
1192
+ });
1193
+ });
1194
+ });
1195
+ // ─── Invariant 7 ────────────────────────────────────────
1196
+ describe('invariant 7 — read verbs only, zero filesystem writes', () => {
1197
+ const READ_VERBS = [
1198
+ 'worktree list --porcelain',
1199
+ 'status --porcelain',
1200
+ 'merge-base --is-ancestor',
1201
+ 'log -1 --format=%ct',
1202
+ 'rev-parse --abbrev-ref',
1203
+ 'rev-parse --show-toplevel',
1204
+ ];
1205
+ it('invokes no git verb outside the read allowlist across a full mixed scan', () => {
1206
+ const repo = mkdir('repo');
1207
+ const stale = mkdir('repo-stale');
1208
+ const open = mkdir('repo-open');
1209
+ const detached = mkdir('repo-detached');
1210
+ const husk = mkdir('zzz-husk');
1211
+ writeGitdirPointer(husk, path.join(root, 'gone', '.git', 'worktrees', 'zzz-husk'));
1212
+ const calls = [];
1213
+ scanEstate({
1214
+ registry: [{ path: repo }],
1215
+ now: NOW,
1216
+ safeExec: makeExec({
1217
+ lists: {
1218
+ [repo]: porcelain([
1219
+ { path: repo, branch: 'refs/heads/main' },
1220
+ { path: stale, branch: 'refs/heads/stale' },
1221
+ { path: open, branch: 'refs/heads/open' },
1222
+ { path: detached, detached: true },
1223
+ ]),
1224
+ },
1225
+ defaultRefs: { [repo]: 'origin/main' },
1226
+ merged: ['refs/heads/stale'],
1227
+ dirty: [open],
1228
+ }, calls),
1229
+ });
1230
+ expect(calls.length).toBeGreaterThan(0);
1231
+ for (const call of calls) {
1232
+ expect(call[0]).toBe('git');
1233
+ // `--no-optional-locks` is what makes the read-only claim true rather
1234
+ // than aspirational: without it `git status` refreshes and WRITES the
1235
+ // index, taking index.lock (git-status(1) § BACKGROUND REFRESH). Asserted
1236
+ // on EVERY invocation, not just the status ones — a future verb must not
1237
+ // be able to opt out silently.
1238
+ expect(call[1]).toBe('--no-optional-locks');
1239
+ // Every invocation is `git --no-optional-locks -C <path> <verb…>`; the
1240
+ // verb is what the allowlist constrains.
1241
+ expect(call[2]).toBe('-C');
1242
+ const verb = call.slice(4).join(' ');
1243
+ expect(READ_VERBS.some((allowed) => verb.startsWith(allowed)), `non-read git verb invoked: ${verb}`).toBe(true);
1244
+ }
1245
+ });
1246
+ it('leaves the swept tree byte-identical (zero filesystem writes)', () => {
1247
+ // Asserted on the TREE rather than on fs spies: an artifact impossible to
1248
+ // produce without actually touching the referent, and indifferent to which
1249
+ // write primitive a regression would reach for.
1250
+ const repo = mkdir('repo');
1251
+ const wt = mkdir('repo-wt');
1252
+ const husk = mkdir('zzz-husk');
1253
+ writeGitdirPointer(husk, path.join(root, 'gone', '.git', 'worktrees', 'zzz-husk'));
1254
+ const before = treeSnapshot(root);
1255
+ const calls = [];
1256
+ scanEstate({
1257
+ registry: [{ path: repo }],
1258
+ now: NOW,
1259
+ safeExec: makeExec({
1260
+ lists: {
1261
+ [repo]: porcelain([
1262
+ { path: repo, branch: 'refs/heads/main' },
1263
+ { path: wt, branch: 'refs/heads/wt' },
1264
+ ]),
1265
+ },
1266
+ defaultRefs: { [repo]: 'origin/main' },
1267
+ }, calls),
1268
+ });
1269
+ expect(treeSnapshot(root)).toEqual(before);
1270
+ });
1271
+ });
1272
+ // ─── Invariant 8 ────────────────────────────────────────
1273
+ describe('invariant 8 — the scan owns no output stream', () => {
1274
+ it('emits nothing on stdout or stderr, so the CLI alone decides the surface', () => {
1275
+ const repo = mkdir('repo');
1276
+ const wt = mkdir('repo-wt');
1277
+ const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
1278
+ const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
1279
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => { });
1280
+ const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => { });
1281
+ try {
1282
+ const calls = [];
1283
+ scanEstate({
1284
+ registry: [{ path: repo }],
1285
+ now: NOW,
1286
+ safeExec: makeExec({
1287
+ lists: {
1288
+ [repo]: porcelain([
1289
+ { path: repo, branch: 'refs/heads/main' },
1290
+ { path: wt, branch: 'refs/heads/wt' },
1291
+ ]),
1292
+ },
1293
+ defaultRefs: { [repo]: 'origin/main' },
1294
+ }, calls),
1295
+ });
1296
+ expect(stdout).not.toHaveBeenCalled();
1297
+ expect(stderr).not.toHaveBeenCalled();
1298
+ expect(consoleError).not.toHaveBeenCalled();
1299
+ expect(consoleLog).not.toHaveBeenCalled();
1300
+ }
1301
+ finally {
1302
+ stdout.mockRestore();
1303
+ stderr.mockRestore();
1304
+ consoleError.mockRestore();
1305
+ consoleLog.mockRestore();
1306
+ }
1307
+ });
1308
+ });
1309
+ //# sourceMappingURL=estate-scan.test.js.map