@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.
- package/dist/capability/schema.d.ts +2 -2
- package/dist/estate-scan.d.ts +215 -0
- package/dist/estate-scan.d.ts.map +1 -0
- package/dist/estate-scan.js +792 -0
- package/dist/estate-scan.js.map +1 -0
- package/dist/estate-scan.test.d.ts +20 -0
- package/dist/estate-scan.test.d.ts.map +1 -0
- package/dist/estate-scan.test.js +1309 -0
- package/dist/estate-scan.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/spine/ledgers.d.ts +18 -18
- package/package.json +1 -1
|
@@ -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
|