@mmnto/cli 1.112.0 → 1.113.1

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,625 @@
1
+ /**
2
+ * `totem wt create|remove|list` — worktree lifecycle verbs with VERIFIED
3
+ * removal (mmnto-ai/totem#2580 slice-2).
4
+ *
5
+ * The reason this exists is one git behaviour: `git worktree remove` exits 0
6
+ * on a removal that leaves the directory standing, and a husk that nobody
7
+ * notices is the failure class the slice-1 estate sensor keeps finding. So the
8
+ * removal verb never trusts an exit code — it removes, verifies absence,
9
+ * finishes the residue if anything is left, re-verifies, and only then touches
10
+ * the registry. Exit 0 means the directory is gone, and nothing else does.
11
+ *
12
+ * Three disciplines run through every verb here:
13
+ * - **Record-first.** `create` writes the `~/.totem/worktrees.json` entry
14
+ * BEFORE invoking git. A phantom entry fails visibly (`wt list` prints it
15
+ * `missing`); an unrecorded worktree fails invisibly, which is exactly the
16
+ * class this issue exists for.
17
+ * - **Check-first.** A path is passed to `git worktree remove` only when
18
+ * git's own list names it (the `author-sandbox.ts:112` precedent). A path
19
+ * git does not track never reaches a git removal verb, and a path that is
20
+ * in NEITHER git's list nor the registry is never touched at all.
21
+ * - **Accounting, not classification.** `wt list` reports what was recorded
22
+ * plus whether it is on disk. Deciding whether a worktree is active or
23
+ * stale stays with `totem doctor --estate` — derive-once, one sensor.
24
+ *
25
+ * Classification of what a seat may delete is deliberately narrow at the ECL
26
+ * boundary: untracked content under `<wt>/.totem/orchestration/**` blocks
27
+ * removal outright, with no bypass flag (the operator ruling — a bypass would
28
+ * re-open the silently-deleted-ECL hole this verb closes).
29
+ */
30
+ import * as fs from 'node:fs';
31
+ import * as os from 'node:os';
32
+ import * as path from 'node:path';
33
+ import { bold, log, success as successColor, warn as warnColor } from '../ui.js';
34
+ // ─── Constants ──────────────────────────────────────────
35
+ const TAG = 'Worktree';
36
+ /** Env var that names the container root when `--root` is absent (the ruling). */
37
+ const ROOT_ENV_VAR = 'TOTEM_WORKTREE_ROOT';
38
+ /** Read probes carry a timeout; the mutation verbs do not (author-sandbox.ts:57). */
39
+ const GIT_READ_TIMEOUT_MS = 15_000;
40
+ const MS_PER_DAY = 86_400_000;
41
+ /**
42
+ * A slug is one path segment AND part of a branch name, so it stays in the
43
+ * intersection of what both accept: no separators, no `..`, no leading dash.
44
+ */
45
+ const SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
46
+ const MAX_SLUG_LENGTH = 64;
47
+ /** A ticket lands inside the default branch name — same conservative alphabet. */
48
+ const TICKET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
49
+ /**
50
+ * A light branch-name guard. `safeExec` already makes injection impossible
51
+ * (structured argv, never a shell string); this exists so a malformed ref
52
+ * fails with our message instead of a raw `git check-ref-format` complaint.
53
+ */
54
+ const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
55
+ /** The ECL path whose untracked content blocks a removal (ecl-discipline §4.6). */
56
+ const ECL_PATHSPEC = '.totem/orchestration';
57
+ // ─── Local helpers ──────────────────────────────────────
58
+ function describe(err) {
59
+ return err instanceof Error ? err.message : String(err);
60
+ }
61
+ function writeJson(payload) {
62
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
63
+ }
64
+ function ageDays(iso, now) {
65
+ const created = Date.parse(iso);
66
+ if (!Number.isFinite(created))
67
+ return undefined;
68
+ return Math.max(0, Math.floor((now - created) / MS_PER_DAY));
69
+ }
70
+ // ─── Core-bound helpers ─────────────────────────────────
71
+ /**
72
+ * Every helper that touches a core VALUE lives behind this factory and closes
73
+ * over the one dynamically-imported barrel instance (see the `Core` note: a
74
+ * static value import would defeat the lazy-loading contract). Bodies are
75
+ * otherwise ordinary module helpers — the factory exists only to carry `core`.
76
+ */
77
+ function coreHelpers(core) {
78
+ const { deleteWorktreeEntry, parseWorktreeListPorcelain, sanitizeForTerminal, TotemConfigError, TotemError, TotemGitError, worktreePathExists, worktreePathKey, } = core;
79
+ /**
80
+ * True when `child` lies strictly UNDER `base` (case-folded on win32). The
81
+ * trailing separator keeps sibling names apart — `totemville` is not under
82
+ * `totem` (#2580 slice-2 falsification, finding 3's containment shape).
83
+ */
84
+ function pathIsUnder(child, base) {
85
+ const baseKey = worktreePathKey(base);
86
+ return worktreePathKey(child).startsWith(baseKey.endsWith(path.sep) ? baseKey : baseKey + path.sep);
87
+ }
88
+ /**
89
+ * Paths, branch names, and git stderr all originate outside this process:
90
+ * sanitize + flatten before any terminal write so embedded ANSI or newlines
91
+ * cannot forge extra log lines (the doctor-estate.ts:186 render helper).
92
+ */
93
+ function render(text) {
94
+ return sanitizeForTerminal(text)
95
+ .replace(/[\t\n]+/g, ' ')
96
+ .replace(/ {2,}/g, ' ')
97
+ .trim();
98
+ }
99
+ /**
100
+ * Resolve the home repo, or fail loud — no verb here guesses at one. Asked
101
+ * through the injected exec seam rather than core's `resolveGitRoot` so a
102
+ * test can drive the whole verb without a real git tree, exactly as
103
+ * `scanEstate` takes its git through an injected seam.
104
+ */
105
+ function requireGitRoot(exec, cwd) {
106
+ // totem-context: intentional cleanup — a failed toplevel probe is re-thrown immediately as a named TotemGitError; the catch exists to attach the recovery hint, and nothing is swallowed.
107
+ try {
108
+ const out = exec('git', ['--no-optional-locks', '-C', cwd, 'rev-parse', '--show-toplevel'], {
109
+ timeout: GIT_READ_TIMEOUT_MS,
110
+ });
111
+ return path.resolve(out.trim());
112
+ // 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.
113
+ }
114
+ catch (err) {
115
+ throw new TotemGitError(`wt: ${render(cwd)} is not inside a readable git repository: ${render(describe(err))}`, 'Run the command from inside the home repository the worktree belongs to.', err);
116
+ }
117
+ }
118
+ /**
119
+ * `wt create` runs from the PRIMARY checkout only (cohort-overlay §2 — the
120
+ * same convention that keeps mail on the primary): inside a linked worktree,
121
+ * `rev-parse --show-toplevel` answers with the WORKTREE, so the entry would
122
+ * record a home repo that vanishes with it and every later removal of its
123
+ * children would resolve against nothing (#2580 slice-2 falsification,
124
+ * finding 10). The discriminator: `--git-dir` and `--git-common-dir` name
125
+ * the SAME directory for every primary shape — an in-tree `.git` and a
126
+ * `--separate-git-dir` layout alike — and diverge only in a linked worktree,
127
+ * whose git dir is `<common>/worktrees/<name>`. Comparing the common dir
128
+ * against `<toplevel>/.git` instead would misread separate-git-dir primaries
129
+ * as worktrees (re-verification round 2, finding 1).
130
+ */
131
+ function assertPrimaryCheckout(exec, cwd) {
132
+ let gitDir;
133
+ let commonDir;
134
+ // totem-context: intentional cleanup — a probe that cannot answer is re-thrown as a named TotemGitError; the catch attaches the recovery hint, nothing is swallowed.
135
+ try {
136
+ const out = exec('git', ['--no-optional-locks', '-C', cwd, 'rev-parse', '--git-dir', '--git-common-dir'], { timeout: GIT_READ_TIMEOUT_MS });
137
+ // One answer per line, and git may answer with a path RELATIVE to the
138
+ // cwd (a bare `.git` at the toplevel) — resolve both before comparing.
139
+ const lines = out
140
+ .split('\n')
141
+ .map((line) => line.trim())
142
+ .filter((line) => line.length > 0);
143
+ if (lines.length < 2) {
144
+ throw new Error(`expected two rev-parse answers, got ${lines.length}`);
145
+ }
146
+ gitDir = path.resolve(cwd, lines[0]);
147
+ commonDir = path.resolve(cwd, lines[1]);
148
+ // 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.
149
+ }
150
+ catch (err) {
151
+ throw new TotemGitError(`wt create: cannot determine whether ${render(cwd)} is the primary checkout: ${render(describe(err))}`, 'Run `totem wt create` from the primary checkout of the home repository.', err);
152
+ }
153
+ if (worktreePathKey(gitDir) !== worktreePathKey(commonDir)) {
154
+ throw new TotemConfigError(`wt create: ${render(cwd)} is inside a linked worktree, not the primary checkout`, 'Run `totem wt create` from the primary checkout (cohort-overlay §2 — worktrees are minted from the primary, the same convention that keeps mail there).', 'CONFIG_INVALID');
155
+ }
156
+ }
157
+ /** Every recorded worktree path git names for this repo, folded for comparison. */
158
+ function gitListedPaths(exec, repoRoot) {
159
+ const raw = exec('git', ['--no-optional-locks', '-C', repoRoot, 'worktree', 'list', '--porcelain'], { timeout: GIT_READ_TIMEOUT_MS });
160
+ return new Set(parseWorktreeListPorcelain(raw).map((entry) => worktreePathKey(entry.path)));
161
+ }
162
+ /**
163
+ * Best-effort rollback of the record-first entry after a failed
164
+ * `git worktree add`. Returns the failure text when the rollback ITSELF
165
+ * failed — the caller names the phantom entry loudly rather than letting the
166
+ * operator discover it later from `wt list`. Guarded by the entry's own
167
+ * `createdAt` so a concurrent create that re-recorded the path is never the
168
+ * one rolled back (bot round, Greptile P1).
169
+ */
170
+ async function rollbackCreateEntry(target, expectCreatedAt) {
171
+ // totem-context: intentional cleanup — a failed ROLLBACK must not replace the git failure that caused it; the outcome is RETURNED to the caller, which reports the phantom entry loudly and folds it into the thrown error (nothing is swallowed).
172
+ try {
173
+ await deleteWorktreeEntry(target, { expectCreatedAt });
174
+ return undefined;
175
+ // 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.
176
+ }
177
+ catch (err) {
178
+ return describe(err);
179
+ }
180
+ }
181
+ /**
182
+ * Resolve the command argument. A path-shaped argument resolves against the
183
+ * cwd; a bare name is matched against recorded BASENAMES, and an ambiguous
184
+ * name is a hard error listing the candidates rather than a coin flip.
185
+ */
186
+ function resolveRemoveTarget(arg, cwd, registryPaths) {
187
+ const looksLikePath = path.isAbsolute(arg) || arg.includes('/') || arg.includes('\\') || arg.startsWith('.');
188
+ if (looksLikePath)
189
+ return { resolvedPath: path.resolve(cwd, arg) };
190
+ const wanted = worktreePathKey(arg);
191
+ const matches = registryPaths.filter((recorded) => worktreePathKey(path.basename(recorded)) === wanted);
192
+ if (matches.length > 1) {
193
+ throw new TotemConfigError(`wt remove: "${render(arg)}" matches ${matches.length} recorded worktrees`, `Pass the full path instead. Candidates: ${matches.map((m) => render(m)).join(', ')}`, 'CONFIG_INVALID');
194
+ }
195
+ if (matches.length === 1)
196
+ return { resolvedPath: path.resolve(matches[0]) };
197
+ // No recorded basename — fall through to the cwd-relative reading so the
198
+ // "in neither git's list nor the registry" hard error is what reports it.
199
+ return { resolvedPath: path.resolve(cwd, arg) };
200
+ }
201
+ /**
202
+ * Untracked (including ignored) content under `<wt>/.totem/orchestration/**`
203
+ * blocks removal. `--ignored` is deliberate: an ignored ECL file is still
204
+ * ECL, and the whole point of the refusal is that consolidation is cheap
205
+ * while a silently deleted dispatch is not recoverable. No bypass flag
206
+ * exists (ruling). `-uall` is load-bearing twice over (#2580 slice-2
207
+ * falsification, finding 2): it names the exact FILES rather than one
208
+ * collapsed `?? .totem/orchestration/` row, and it overrides a
209
+ * `status.showUntrackedFiles=no` in the shared repo config — which would
210
+ * otherwise silence the probe entirely and turn the no-bypass ruling into a
211
+ * config-reachable bypass with unrecoverable loss.
212
+ *
213
+ * When the probe itself cannot run (a husk whose `.git` pointer is dangling
214
+ * is not a git worktree at all), the fallback is conservative: refuse if the
215
+ * orchestration directory exists on disk, proceed with a disclosed note if
216
+ * it does not. Never treat an unanswerable probe as a clean answer.
217
+ */
218
+ function assertEclClear(exec, worktreePath, notes) {
219
+ let output;
220
+ // totem-context: intentional cleanup — a status probe that cannot RUN (unlisted husk, dangling gitdir) is not a clean ECL answer; the catch falls through to the on-disk fallback below, which refuses whenever orchestration content is present.
221
+ try {
222
+ output = exec('git', [
223
+ '--no-optional-locks',
224
+ '-C',
225
+ worktreePath,
226
+ 'status',
227
+ '--porcelain',
228
+ '--ignored',
229
+ '-uall',
230
+ '--',
231
+ ECL_PATHSPEC,
232
+ ], { timeout: GIT_READ_TIMEOUT_MS });
233
+ // 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.
234
+ }
235
+ catch (err) {
236
+ const eclDir = path.join(worktreePath, ECL_PATHSPEC.split('/').join(path.sep));
237
+ if (worktreePathExists(eclDir)) {
238
+ throw new TotemError('CHECK_FAILED', `wt remove: refusing — the ECL probe could not run in ${render(worktreePath)} (${render(describe(err))}) and ${render(eclDir)} exists`, 'Consolidate or copy the orchestration content out, delete the directory, then re-run (ecl-discipline §4.6).', err);
239
+ }
240
+ notes.push(`ECL probe could not run (${render(describe(err))}); no ${ECL_PATHSPEC} directory on disk, so nothing was at risk`);
241
+ return;
242
+ }
243
+ const rows = output
244
+ .split('\n')
245
+ .map((line) => line.trimEnd())
246
+ .filter((line) => line.length > 0);
247
+ if (rows.length === 0)
248
+ return;
249
+ throw new TotemError('CHECK_FAILED', `wt remove: refusing — ${rows.length} uncommitted file(s) under ${render(worktreePath)}${path.sep}${ECL_PATHSPEC}: ${rows.map((r) => render(r)).join(' · ')}`, 'Consolidate the ECL (commit or move the dispatches out), then re-run. There is no bypass flag: a silently deleted dispatch is not recoverable (ecl-discipline §4.6).');
250
+ }
251
+ return {
252
+ render,
253
+ pathIsUnder,
254
+ requireGitRoot,
255
+ assertPrimaryCheckout,
256
+ gitListedPaths,
257
+ rollbackCreateEntry,
258
+ resolveRemoveTarget,
259
+ assertEclClear,
260
+ };
261
+ }
262
+ // ─── create ─────────────────────────────────────────────
263
+ /**
264
+ * Create a worktree at `<root>/<repo>-<seat>-<slug>` on a NEW branch.
265
+ *
266
+ * `git worktree add -b` always: an existing branch is a hard error in v1 (the
267
+ * ruling), so a create can never silently adopt someone else's in-flight work.
268
+ * The registry entry is written first and rolled back best-effort on a git
269
+ * failure; a rollback that ALSO fails names the phantom entry loudly rather
270
+ * than leaving the operator to discover it from `wt list`.
271
+ */
272
+ export async function wtCreateCommand(options) {
273
+ const core = await import('@mmnto/totem');
274
+ const { addWorktreeEntry, defaultWorktreeRoot, isPathSafeAgentId, TotemConfigError, TotemGitError, worktreePathExists, worktreePathKey, worktreeRegistryPath, } = core;
275
+ const { render, pathIsUnder, requireGitRoot, assertPrimaryCheckout, rollbackCreateEntry } = coreHelpers(core);
276
+ const cwd = options.cwdForTest ?? process.cwd();
277
+ const env = options.envForTest ?? process.env;
278
+ const exec = options.execForTest ?? core.safeExec;
279
+ const createdAt = options.nowForTest ?? new Date().toISOString();
280
+ const slug = options.slug.trim();
281
+ if (!SLUG_PATTERN.test(slug) || slug.length > MAX_SLUG_LENGTH) {
282
+ throw new TotemConfigError(`wt create: invalid slug ${JSON.stringify(options.slug)}`, `A slug is one path segment: letters, digits, dot, dash, underscore; at most ${MAX_SLUG_LENGTH} characters.`, 'CONFIG_INVALID');
283
+ }
284
+ if (options.ticket !== undefined && !TICKET_PATTERN.test(options.ticket)) {
285
+ throw new TotemConfigError(`wt create: invalid --ticket ${JSON.stringify(options.ticket)}`, 'Pass a plain issue reference such as 2580.', 'CONFIG_INVALID');
286
+ }
287
+ const repoRoot = requireGitRoot(exec, cwd);
288
+ assertPrimaryCheckout(exec, cwd);
289
+ const repoName = path.basename(repoRoot);
290
+ let seat;
291
+ if (options.seat !== undefined) {
292
+ seat = options.seat.trim();
293
+ }
294
+ else {
295
+ const { resolveSelfSender } = await import('./mail.js');
296
+ // totem-context: intentional cleanup — the resolver's failure is re-thrown as a wt-scoped error whose message carries this verb's `--seat` guidance (the mail verb's `--from` lives in the resolver's recovery-hint field, which this wrapper never surfaces), and nothing is swallowed.
297
+ try {
298
+ seat = resolveSelfSender(repoRoot, env);
299
+ // 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.
300
+ }
301
+ catch (err) {
302
+ throw new TotemConfigError(`wt create: cannot derive the creating seat: ${render(describe(err))} — pass --seat <agent-id> or set TOTEM_SELF_AGENT`, 'Pass --seat <agent-id> (e.g. totem-claude) or set TOTEM_SELF_AGENT.', 'CONFIG_INVALID');
303
+ }
304
+ }
305
+ if (!isPathSafeAgentId(seat)) {
306
+ throw new TotemConfigError(`wt create: invalid seat ${JSON.stringify(seat)}`, 'Pass a plain agent-id such as "totem-claude" via --seat.', 'CONFIG_INVALID');
307
+ }
308
+ const branch = options.branch?.trim() ?? defaultBranchName(slug, options.ticket);
309
+ // Trailing-dot and `.lock` components are `git check-ref-format` refusals
310
+ // that would otherwise surface AFTER the record-first write, via the
311
+ // rollback path — checked per PATH COMPONENT, since git refuses them
312
+ // mid-ref too (re-verification round 2, finding 9).
313
+ if (!BRANCH_PATTERN.test(branch) ||
314
+ branch.includes('..') ||
315
+ branch.endsWith('/') ||
316
+ branch.split('/').some((part) => part.endsWith('.') || part.endsWith('.lock'))) {
317
+ throw new TotemConfigError(`wt create: invalid branch name ${JSON.stringify(branch)}`, 'Use a plain ref name such as feat/2580-wt-verbs.', 'CONFIG_INVALID');
318
+ }
319
+ // Precedence (the ruling's Q1): --root > TOTEM_WORKTREE_ROOT > ~/.totem/worktrees.
320
+ const envRoot = env[ROOT_ENV_VAR];
321
+ const rootSource = options.root !== undefined
322
+ ? '--root'
323
+ : envRoot !== undefined && envRoot.trim().length > 0
324
+ ? ROOT_ENV_VAR
325
+ : 'default';
326
+ const rawRoot = options.root ??
327
+ (envRoot !== undefined && envRoot.trim().length > 0 ? envRoot.trim() : defaultWorktreeRoot());
328
+ // A shell-quoted `--root '~/x'` or an env-carried `~` reaches this process
329
+ // unexpanded, and `path.resolve` would mint a literal `~` directory that
330
+ // then accretes into the recorded roots FOREVER (roots survive entry
331
+ // removal) — expand it against the home dir first (bot round, CR finding 6).
332
+ const expandedRoot = rawRoot === '~' || rawRoot.startsWith('~/') || rawRoot.startsWith(`~${path.sep}`)
333
+ ? path.join(os.homedir(), rawRoot.slice(1))
334
+ : rawRoot;
335
+ const root = path.resolve(cwd, expandedRoot);
336
+ // The location class the estate audit indicts (cohort-overlay §2): a
337
+ // worktree beside its own repo, or inside it. Checked against the RESOLVED
338
+ // root with CONTAINMENT, not just equality — `<repo>/.claude/worktrees` is
339
+ // still inside the repo (#2580 slice-2 falsification, finding 3) — so no
340
+ // flag/env combination reaches any of the three.
341
+ const workspaceRoot = path.dirname(repoRoot);
342
+ if (worktreePathKey(root) === worktreePathKey(repoRoot) || pathIsUnder(root, repoRoot)) {
343
+ throw new TotemConfigError(`wt create: refusing to create a worktree inside the repo itself (${render(root)})`, `Use the default root (~/.totem/worktrees) or set ${ROOT_ENV_VAR} to a dedicated container directory (cohort-overlay §2).`, 'CONFIG_INVALID');
344
+ }
345
+ if (worktreePathKey(root) === worktreePathKey(workspaceRoot)) {
346
+ throw new TotemConfigError(`wt create: refusing to create a worktree in the workspace root (${render(root)})`, `The workspace root is scanned for sibling repos and cohort mail; use the default root (~/.totem/worktrees) or set ${ROOT_ENV_VAR} (cohort-overlay §2).`, 'CONFIG_INVALID');
347
+ }
348
+ const target = path.join(root, `${repoName}-${seat}-${slug}`);
349
+ if (worktreePathExists(target)) {
350
+ throw new TotemConfigError(`wt create: ${render(target)} already exists`, 'Choose a different slug, or remove the existing worktree first with `totem wt remove`.', 'CONFIG_INVALID');
351
+ }
352
+ fs.mkdirSync(root, { recursive: true });
353
+ const entry = {
354
+ repo: repoRoot,
355
+ seat,
356
+ branch,
357
+ ...(options.ticket === undefined ? {} : { ticket: options.ticket }),
358
+ createdAt,
359
+ };
360
+ // Record-first: a registry write that fails here has touched no git state,
361
+ // and there is nothing to clean up.
362
+ await addWorktreeEntry({ worktreePath: target, entry, root });
363
+ try {
364
+ exec('git', ['-C', repoRoot, 'worktree', 'add', '-b', branch, target], {});
365
+ }
366
+ catch (err) {
367
+ // A partial directory left by the failed add means the record must STAY:
368
+ // deleting it would convert a VISIBLE phantom into exactly the invisible
369
+ // unrecorded-worktree class this verb exists to prevent (#2580 slice-2
370
+ // falsification, finding 6). Rollback only when nothing landed on disk.
371
+ const partialLeft = worktreePathExists(target);
372
+ const rollbackError = partialLeft ? undefined : await rollbackCreateEntry(target, createdAt);
373
+ if (rollbackError !== undefined) {
374
+ // `log.error` carries the 'Totem Error' tag — the packages/cli path
375
+ // instruction (styleguide Rule 129), not this command's own TAG.
376
+ log.error('Totem Error', `PHANTOM ENTRY — ${render(target)} is recorded in ${render(worktreeRegistryPath())} but was never created, and the rollback failed: ${render(rollbackError)}`);
377
+ }
378
+ const disposition = partialLeft
379
+ ? ` (a partial directory was left on disk; the registry entry is RETAINED — clean up with \`totem wt remove ${render(target)}\`)`
380
+ : rollbackError === undefined
381
+ ? ' (registry entry rolled back)'
382
+ : ` (registry entry NOT rolled back — remove it with \`totem wt remove ${render(target)}\`)`;
383
+ throw new TotemGitError(`wt create: git worktree add failed for ${render(target)}: ${render(describe(err))}${disposition}`, `The branch ${branch} must not already exist and ${render(root)} must be writable.`, err);
384
+ }
385
+ if (options.json === true) {
386
+ writeJson({
387
+ action: 'create',
388
+ path: target,
389
+ repo: repoRoot,
390
+ seat,
391
+ branch,
392
+ ...(options.ticket === undefined ? {} : { ticket: options.ticket }),
393
+ root,
394
+ 'root-source': rootSource,
395
+ 'created-at': createdAt,
396
+ });
397
+ return;
398
+ }
399
+ log.info(TAG, `${successColor(bold('created'))} ${render(target)}`);
400
+ log.dim(TAG, `branch ${render(branch)} · seat ${render(seat)} · root ${render(root)} (${rootSource}) · recorded in ${render(worktreeRegistryPath())}`);
401
+ }
402
+ function defaultBranchName(slug, ticket) {
403
+ return ticket === undefined ? `wt/${slug}` : `feat/${ticket}-${slug}`;
404
+ }
405
+ /**
406
+ * Remove a worktree and PROVE it is gone. Exit 0 is granted only by a
407
+ * no-follow existence probe returning absent; every failure path retains the
408
+ * registry entry so the problem stays visible in `wt list`.
409
+ */
410
+ export async function wtRemoveCommand(options) {
411
+ const core = await import('@mmnto/totem');
412
+ const { deleteWorktreeEntry, findWorktreeEntry, readWorktreeRegistry, removeWorktreeResidue, TotemConfigError, TotemError, TotemGitError, worktreePathExists, worktreePathKey, worktreeRegistryPath, } = core;
413
+ const { render, pathIsUnder, requireGitRoot, gitListedPaths, resolveRemoveTarget, assertEclClear, } = coreHelpers(core);
414
+ const cwd = options.cwdForTest ?? process.cwd();
415
+ const exec = options.execForTest ?? core.safeExec;
416
+ const finishResidue = options.residueForTest ?? ((dir) => removeWorktreeResidue({ dir }));
417
+ const warnings = [];
418
+ const notes = [];
419
+ const file = readWorktreeRegistry((msg) => warnings.push(msg));
420
+ for (const warning of warnings)
421
+ log.warn(TAG, render(warning));
422
+ const { resolvedPath } = resolveRemoveTarget(options.target, cwd, Object.keys(file.worktrees));
423
+ const found = findWorktreeEntry(file, resolvedPath);
424
+ const resolved = {
425
+ resolvedPath,
426
+ ...(found === undefined ? {} : { entryKey: found.key, entry: found.entry }),
427
+ };
428
+ // The home repo is the recorded one when there is an entry (the worktree's
429
+ // OWN repo, which need not be the cwd's), else the repo we are standing in.
430
+ const homeRepo = resolved.entry === undefined ? requireGitRoot(exec, cwd) : path.resolve(resolved.entry.repo);
431
+ const onDisk = worktreePathExists(resolvedPath);
432
+ // ECL first: a refusal must happen before anything is removed, and only a
433
+ // directory that exists can hold orchestration content.
434
+ if (onDisk)
435
+ assertEclClear(exec, resolvedPath, notes);
436
+ // Check-first. An unanswerable worktree list is a hard error WHEN the
437
+ // directory exists — without git's answer we cannot tell a live worktree
438
+ // (whose metadata a raw delete would strand) from residue. With nothing on
439
+ // disk there is nothing to strand and nothing to delete, and the ruled
440
+ // idempotent recovery (verified-absent + entry present ⇒ delete entry) must
441
+ // not be blocked by a home repo that is itself gone (#2580 slice-2
442
+ // falsification, finding 7).
443
+ let listed;
444
+ try {
445
+ listed = gitListedPaths(exec, homeRepo);
446
+ }
447
+ catch (err) {
448
+ if (onDisk) {
449
+ throw new TotemGitError(`wt remove: cannot enumerate worktrees in ${render(homeRepo)}: ${render(describe(err))}`, `Check that the home repository still exists and is a readable git repo, then re-run. If the home repo itself is gone, the recorded entry has no removal to perform — delete it from ${render(worktreeRegistryPath())} by hand.`, err);
450
+ }
451
+ listed = undefined;
452
+ notes.push(`home repo ${render(homeRepo)} could not enumerate worktrees (${render(describe(err))}); nothing is on disk at ${render(resolvedPath)}, so no removal was owed`);
453
+ }
454
+ const gitListed = listed !== undefined && listed.has(worktreePathKey(resolvedPath));
455
+ if (!gitListed && resolved.entry === undefined) {
456
+ throw new TotemConfigError(`wt remove: ${render(resolvedPath)} is in neither git's worktree list nor ${render(worktreeRegistryPath())} — nothing was touched`, 'Pass a path this repo tracks as a worktree, or a name recorded by `totem wt list`. Unrecorded directories are never deleted by this verb.', 'CONFIG_INVALID');
457
+ }
458
+ let outcome;
459
+ let residue;
460
+ if (gitListed) {
461
+ try {
462
+ exec('git', ['-C', homeRepo, 'worktree', 'remove', '--force', resolvedPath], {});
463
+ }
464
+ catch (err) {
465
+ throw new TotemGitError(`wt remove: git worktree remove failed for ${render(resolvedPath)}: ${render(describe(err))}`, 'Resolve the cause (a locked worktree, a busy file) and re-run; the registry entry is retained.', err);
466
+ }
467
+ outcome = 'git-removed';
468
+ }
469
+ else if (onDisk) {
470
+ // Registry-known but git-unlisted: git has nothing to remove, so the
471
+ // finish runs directly. `git worktree remove` is never invoked here — and
472
+ // because the finish is an unconditional recursive delete, it only runs
473
+ // where the registry itself proves worktrees live: strictly under a
474
+ // recorded root. An entry OUTSIDE every recorded root is a hand-edit
475
+ // anomaly, and handing it to a recursive delete would make a corrupted
476
+ // worktrees.json an arbitrary-delete primitive (#2580 slice-2
477
+ // falsification, finding 13).
478
+ if (!file.roots.some((recorded) => pathIsUnder(resolvedPath, recorded))) {
479
+ throw new TotemConfigError(`wt remove: ${render(resolvedPath)} is recorded but lies under none of the recorded roots — refusing the residue delete`, `Recorded roots: ${file.roots.map((r) => render(r)).join(', ') || '(none)'}. If the entry is a stale hand-edit, remove it from ${render(worktreeRegistryPath())} by hand.`, 'CONFIG_INVALID');
480
+ }
481
+ outcome = 'residue-removed';
482
+ }
483
+ else {
484
+ outcome = 'already-gone';
485
+ notes.push('directory was already absent; the registry entry was stale');
486
+ }
487
+ // Verify. Git's exit code is not evidence — this probe is.
488
+ if (worktreePathExists(resolvedPath)) {
489
+ residue = await finishResidue(resolvedPath);
490
+ if (outcome === 'git-removed')
491
+ outcome = 'residue-removed';
492
+ // Re-verify: the finish reports, it does not assert.
493
+ if (!residue.removed || worktreePathExists(resolvedPath)) {
494
+ const survivors = residue.survivors.length > 0 ? residue.survivors : [resolvedPath];
495
+ throw new TotemError('CHECK_FAILED', `wt remove: ${render(resolvedPath)} still exists after the residue finish — surviving: ${survivors.map((s) => render(s)).join(', ')}${residue.lastError === undefined ? '' : ` (last error: ${render(residue.lastError)})`}`, 'Remove the survivors manually, then re-run `totem wt remove` — the registry entry is retained so the failure stays visible in `totem wt list`.');
496
+ }
497
+ }
498
+ // Verified absent from here down: the entry may go — but only the ENTRY THIS
499
+ // RUN FOUND. A concurrent `wt create` can re-record the same path inside the
500
+ // verify→delete window, and an unguarded delete-by-path would erase the
501
+ // replacement's durable record (bot round, Greptile P1) — the `createdAt`
502
+ // guard makes the delete conditional on identity.
503
+ let entryDeleted = false;
504
+ let entryDisposition = 'not recorded (nothing to delete)';
505
+ if (resolved.entryKey !== undefined && resolved.entry !== undefined) {
506
+ // totem-context: intentional cleanup — the directory is verifiably gone, so a failed registry delete is a stale RECORD, not a failed removal; it warns and the exit stays 0 (re-running the verb is idempotent and clears it).
507
+ try {
508
+ entryDeleted = await deleteWorktreeEntry(resolved.entryKey, {
509
+ expectCreatedAt: resolved.entry.createdAt,
510
+ });
511
+ if (entryDeleted) {
512
+ entryDisposition = 'deleted';
513
+ }
514
+ else if (findWorktreeEntry(readWorktreeRegistry(), resolvedPath) !== undefined) {
515
+ // The guard refused: a fresh entry now stands at the path, and it
516
+ // belongs to the replacement — leaving it is the correct outcome, but
517
+ // never a silent one.
518
+ entryDisposition = 'replaced concurrently — left in place';
519
+ log.warn(TAG, `${render(resolvedPath)}: the registry entry was replaced concurrently (a fresh \`wt create\` re-recorded the path) — left in place for the replacement`);
520
+ }
521
+ else {
522
+ entryDisposition = 'already cleared (concurrent removal)';
523
+ }
524
+ // 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.
525
+ }
526
+ catch (err) {
527
+ entryDisposition = 'retained (delete failed)';
528
+ log.warn(TAG, `${render(resolvedPath)} is gone but its registry entry could not be deleted (${render(describe(err))}) — re-run \`totem wt remove\` to clear it`);
529
+ }
530
+ }
531
+ // totem-context: intentional cleanup — prune is registry HYGIENE in the home repo, run after the removal is already verified; a prune failure must not flip an exit code whose whole contract is "the directory is gone".
532
+ try {
533
+ exec('git', ['-C', homeRepo, 'worktree', 'prune'], {});
534
+ // 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.
535
+ }
536
+ catch (err) {
537
+ log.warn(TAG, `git worktree prune failed in ${render(homeRepo)} (${render(describe(err))}) — stale metadata may remain`);
538
+ }
539
+ if (options.json === true) {
540
+ writeJson({
541
+ action: 'remove',
542
+ path: resolvedPath,
543
+ repo: homeRepo,
544
+ outcome,
545
+ 'git-listed': gitListed,
546
+ // Re-derived at write time — the artifact carries evidence, not a
547
+ // control-flow assumption (a literal `true` would survive a race).
548
+ 'verified-absent': !worktreePathExists(resolvedPath),
549
+ 'registry-entry-deleted': entryDeleted,
550
+ ...(residue === undefined ? {} : { 'stripped-links': residue.strippedLinks }),
551
+ ...(notes.length === 0 ? {} : { notes }),
552
+ });
553
+ return;
554
+ }
555
+ log.info(TAG, `${successColor(bold('removed'))} ${render(resolvedPath)} (${outcome})`);
556
+ if (residue !== undefined && residue.strippedLinks.length > 0) {
557
+ log.dim(TAG, `stripped ${residue.strippedLinks.length} link(s) without following them: ${residue.strippedLinks.map((l) => render(l)).join(', ')}`);
558
+ }
559
+ for (const note of notes)
560
+ log.dim(TAG, note);
561
+ log.dim(TAG, `registry entry ${entryDisposition} · verified absent`);
562
+ }
563
+ // ─── list ───────────────────────────────────────────────
564
+ /**
565
+ * Accounting only: what was recorded, and whether it is on disk. Whether a
566
+ * worktree is active or stale is NOT decided here — that classification lives
567
+ * in the slice-1 sensor (`totem doctor --estate`), and duplicating it would
568
+ * mint a second, drifting answer to the same question.
569
+ */
570
+ export async function wtListCommand(options = {}) {
571
+ const core = await import('@mmnto/totem');
572
+ const { existingWorktreeRoots, readWorktreeRegistry, worktreePathExists, worktreeRegistryPath } = core;
573
+ const { render } = coreHelpers(core);
574
+ const now = options.nowForTest ?? Date.now();
575
+ const warnings = [];
576
+ const file = readWorktreeRegistry((msg) => warnings.push(msg));
577
+ // A warning always implies zero entries — a broken file must never render as
578
+ // "no worktrees recorded" (the doctor-estate registry-status precedent).
579
+ for (const warning of warnings)
580
+ log.warn(TAG, render(warning));
581
+ const rows = Object.entries(file.worktrees)
582
+ .map(([wtPath, entry]) => ({
583
+ path: path.resolve(wtPath),
584
+ entry,
585
+ present: worktreePathExists(wtPath),
586
+ age: ageDays(entry.createdAt, now),
587
+ }))
588
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
589
+ if (options.json === true) {
590
+ writeJson({
591
+ action: 'list',
592
+ 'registry-status': warnings.length > 0 ? 'unreadable' : rows.length === 0 ? 'empty' : 'ok',
593
+ 'schema-version': file.schemaVersion,
594
+ roots: file.roots.map((root) => path.resolve(root)),
595
+ worktrees: rows.map((row) => ({
596
+ path: row.path,
597
+ repo: row.entry.repo,
598
+ seat: row.entry.seat,
599
+ branch: row.entry.branch,
600
+ ...(row.entry.ticket === undefined ? {} : { ticket: row.entry.ticket }),
601
+ 'created-at': row.entry.createdAt,
602
+ ...(row.age === undefined ? {} : { 'age-days': row.age }),
603
+ present: row.present,
604
+ })),
605
+ });
606
+ return;
607
+ }
608
+ if (rows.length === 0) {
609
+ log.dim(TAG, warnings.length > 0
610
+ ? `no listing — ${render(worktreeRegistryPath())} could not be read`
611
+ : `no worktrees recorded in ${render(worktreeRegistryPath())}`);
612
+ return;
613
+ }
614
+ log.info(TAG, bold(`── recorded worktrees (${rows.length}) ──`));
615
+ for (const row of rows) {
616
+ const state = row.present ? successColor('present') : warnColor('missing');
617
+ const age = row.age === undefined ? 'age unknown' : `${row.age}d old`;
618
+ const ticket = row.entry.ticket === undefined ? '' : ` · #${render(row.entry.ticket)}`;
619
+ log.info(TAG, `${state} ${render(row.path)} [${render(row.entry.branch)}] · seat ${render(row.entry.seat)}${ticket} · ${age}`);
620
+ }
621
+ const roots = existingWorktreeRoots(file);
622
+ log.dim(TAG, `${file.roots.length} recorded root(s), ${roots.length} present on disk: ${roots.map((r) => render(r)).join(', ')}`);
623
+ log.dim(TAG, 'accounting only — `present`/`missing` is disk state, not liveness. Whether a worktree is active or stale is `totem doctor --estate`.');
624
+ }
625
+ //# sourceMappingURL=wt.js.map