@mmnto/cli 1.111.1 → 1.113.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/commands/doctor-estate.d.ts +43 -0
- package/dist/commands/doctor-estate.d.ts.map +1 -0
- package/dist/commands/doctor-estate.js +262 -0
- package/dist/commands/doctor-estate.js.map +1 -0
- package/dist/commands/doctor-estate.test.d.ts +10 -0
- package/dist/commands/doctor-estate.test.d.ts.map +1 -0
- package/dist/commands/doctor-estate.test.js +611 -0
- package/dist/commands/doctor-estate.test.js.map +1 -0
- package/dist/commands/doctor.d.ts +48 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +171 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +410 -11
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/wt.d.ts +100 -0
- package/dist/commands/wt.d.ts.map +1 -0
- package/dist/commands/wt.js +625 -0
- package/dist/commands/wt.js.map +1 -0
- package/dist/commands/wt.test.d.ts +21 -0
- package/dist/commands/wt.test.d.ts.map +1 -0
- package/dist/commands/wt.test.js +884 -0
- package/dist/commands/wt.test.js.map +1 -0
- package/dist/index.js +76 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `totem wt create|remove|list` tests (mmnto-ai/totem#2580 slice-2).
|
|
3
|
+
*
|
|
4
|
+
* The design's "Invariants to lock in via tests" list is the spine of this
|
|
5
|
+
* file — each `describe` names the invariant it pins, because those are the
|
|
6
|
+
* claims these verbs are allowed to make.
|
|
7
|
+
*
|
|
8
|
+
* Git is injected (a fake that actually mutates the temp filesystem, so the
|
|
9
|
+
* verify-absence contract is exercised against real disk state rather than a
|
|
10
|
+
* mocked answer), and the user-level registry is redirected by pinning
|
|
11
|
+
* HOME/USERPROFILE at a temp home — the fixture shape doctor.test.ts already
|
|
12
|
+
* uses for `readRegistry`.
|
|
13
|
+
*
|
|
14
|
+
* Invariant 3 (residue never follows a link out of the tree) is pinned in
|
|
15
|
+
* `packages/core/src/worktree-residue.test.ts`, against a real junction
|
|
16
|
+
* fixture; here the residue finish is exercised through its seam so the
|
|
17
|
+
* still-present FAILURE arm — which no portable filesystem fixture can force —
|
|
18
|
+
* is reachable.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as os from 'node:os';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
25
|
+
import { readWorktreeRegistry, worktreeRegistryPath, } from '@mmnto/totem';
|
|
26
|
+
import { cleanTmpDir } from '../test-utils.js';
|
|
27
|
+
import { wtCreateCommand, wtListCommand, wtRemoveCommand } from './wt.js';
|
|
28
|
+
const IS_WIN32 = process.platform === 'win32';
|
|
29
|
+
const NOW = Date.parse('2026-08-07T12:00:00.000Z');
|
|
30
|
+
const CREATED_AT = '2026-08-05T12:00:00.000Z';
|
|
31
|
+
/** Strip ANSI so assertions are colour-independent (doctor-estate.test.ts:29). */
|
|
32
|
+
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
|
33
|
+
let root;
|
|
34
|
+
let home;
|
|
35
|
+
let repo;
|
|
36
|
+
let container;
|
|
37
|
+
let lines;
|
|
38
|
+
let errSpy;
|
|
39
|
+
let prevHome;
|
|
40
|
+
let prevProfile;
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-wt-')));
|
|
43
|
+
home = path.join(root, 'home');
|
|
44
|
+
// `<root>/ws` is the workspace; the repo lives inside it, so `<root>/ws` is
|
|
45
|
+
// the workspace root a create must refuse.
|
|
46
|
+
repo = path.join(root, 'ws', 'repo');
|
|
47
|
+
container = path.join(root, 'container');
|
|
48
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
49
|
+
fs.mkdirSync(home, { recursive: true });
|
|
50
|
+
fs.mkdirSync(container, { recursive: true });
|
|
51
|
+
prevHome = process.env['HOME'];
|
|
52
|
+
prevProfile = process.env['USERPROFILE'];
|
|
53
|
+
process.env['HOME'] = home;
|
|
54
|
+
process.env['USERPROFILE'] = home;
|
|
55
|
+
lines = [];
|
|
56
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation((msg) => {
|
|
57
|
+
lines.push(String(msg).replace(ANSI, ''));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
errSpy.mockRestore();
|
|
62
|
+
if (prevHome === undefined)
|
|
63
|
+
delete process.env['HOME'];
|
|
64
|
+
else
|
|
65
|
+
process.env['HOME'] = prevHome;
|
|
66
|
+
if (prevProfile === undefined)
|
|
67
|
+
delete process.env['USERPROFILE'];
|
|
68
|
+
else
|
|
69
|
+
process.env['USERPROFILE'] = prevProfile;
|
|
70
|
+
cleanTmpDir(root);
|
|
71
|
+
});
|
|
72
|
+
function output() {
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
function fold(p) {
|
|
76
|
+
return IS_WIN32 ? path.resolve(p).toLowerCase() : path.resolve(p);
|
|
77
|
+
}
|
|
78
|
+
/** Capture the `--json` artifact written to stdout. */
|
|
79
|
+
async function captureStdout(fn) {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
82
|
+
chunks.push(String(chunk));
|
|
83
|
+
return true;
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
await fn();
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
spy.mockRestore();
|
|
90
|
+
}
|
|
91
|
+
return chunks.join('');
|
|
92
|
+
}
|
|
93
|
+
function fakeGit(options = {}) {
|
|
94
|
+
const listed = new Set((options.listed ?? []).map(fold));
|
|
95
|
+
const state = { calls: [], exec: (() => '') };
|
|
96
|
+
state.exec = ((_command, args = []) => {
|
|
97
|
+
state.calls.push(args);
|
|
98
|
+
const cwdIdx = args.indexOf('-C');
|
|
99
|
+
const cwd = args[cwdIdx + 1] ?? '';
|
|
100
|
+
const verbs = args.slice(cwdIdx + 2);
|
|
101
|
+
if (verbs[0] === 'rev-parse' && verbs[1] === '--show-toplevel') {
|
|
102
|
+
if (fold(cwd) === fold(repo) || fold(cwd).startsWith(fold(repo) + path.sep)) {
|
|
103
|
+
return repo.split(path.sep).join('/');
|
|
104
|
+
}
|
|
105
|
+
throw new Error('fatal: not a git repository');
|
|
106
|
+
}
|
|
107
|
+
if (verbs[0] === 'rev-parse' && verbs.includes('--git-common-dir')) {
|
|
108
|
+
// Two answers, one per line, exactly as `rev-parse --git-dir
|
|
109
|
+
// --git-common-dir` emits them. They are EQUAL for every primary shape
|
|
110
|
+
// and diverge only in a linked worktree.
|
|
111
|
+
const gitDir = options.gitDir ?? path.join(repo, '.git');
|
|
112
|
+
const commonDir = options.commonDir ?? gitDir;
|
|
113
|
+
return [gitDir, commonDir].map((p) => p.split(path.sep).join('/')).join('\n');
|
|
114
|
+
}
|
|
115
|
+
if (verbs[0] === 'status') {
|
|
116
|
+
if (options.statusThrows === true)
|
|
117
|
+
throw new Error('fatal: not a git repository');
|
|
118
|
+
return (options.statusRows ?? []).join('\n');
|
|
119
|
+
}
|
|
120
|
+
if (verbs[0] === 'worktree' && verbs[1] === 'list') {
|
|
121
|
+
if (options.listThrows === true)
|
|
122
|
+
throw new Error('fatal: not a git repository');
|
|
123
|
+
const blocks = [
|
|
124
|
+
`worktree ${repo.split(path.sep).join('/')}`,
|
|
125
|
+
`HEAD ${'a'.repeat(40)}`,
|
|
126
|
+
'branch refs/heads/main',
|
|
127
|
+
'',
|
|
128
|
+
];
|
|
129
|
+
for (const wt of listed) {
|
|
130
|
+
blocks.push(`worktree ${path.resolve(wt).split(path.sep).join('/')}`, `HEAD ${'b'.repeat(40)}`, 'branch refs/heads/wt/x', '');
|
|
131
|
+
}
|
|
132
|
+
return blocks.join('\n');
|
|
133
|
+
}
|
|
134
|
+
if (verbs[0] === 'worktree' && verbs[1] === 'add') {
|
|
135
|
+
// Snapshot the registry AS GIT IS INVOKED — invariant 6's evidence.
|
|
136
|
+
state.registryAtAdd = readWorktreeRegistry();
|
|
137
|
+
if (options.add === 'throw') {
|
|
138
|
+
throw new Error("fatal: a branch named 'wt/demo' already exists");
|
|
139
|
+
}
|
|
140
|
+
if (options.add === 'throw-after-create') {
|
|
141
|
+
// Git failed MID-POPULATE: the target directory landed, the checkout
|
|
142
|
+
// did not — the partial-directory arm of the create rollback.
|
|
143
|
+
fs.mkdirSync(args[args.length - 1], { recursive: true });
|
|
144
|
+
throw new Error('fatal: disk exploded mid-checkout');
|
|
145
|
+
}
|
|
146
|
+
const target = args[args.length - 1];
|
|
147
|
+
fs.mkdirSync(target, { recursive: true });
|
|
148
|
+
fs.writeFileSync(path.join(target, 'file.txt'), 'work', 'utf-8');
|
|
149
|
+
listed.add(fold(target));
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
if (verbs[0] === 'worktree' && verbs[1] === 'remove') {
|
|
153
|
+
const target = args[args.length - 1];
|
|
154
|
+
if (options.remove === 'throw')
|
|
155
|
+
throw new Error('fatal: worktree is locked');
|
|
156
|
+
listed.delete(fold(target));
|
|
157
|
+
// `husk`: git exits 0 and the directory survives — the exact fail-open
|
|
158
|
+
// this verb exists to close.
|
|
159
|
+
if (options.remove !== 'husk') {
|
|
160
|
+
fs.rmSync(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
161
|
+
}
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
if (verbs[0] === 'worktree' && verbs[1] === 'prune')
|
|
165
|
+
return '';
|
|
166
|
+
return '';
|
|
167
|
+
});
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
/** Every `git` invocation whose verb sequence starts with these tokens. */
|
|
171
|
+
function callsMatching(git, ...verbs) {
|
|
172
|
+
return git.calls.filter((args) => {
|
|
173
|
+
const cwdIdx = args.indexOf('-C');
|
|
174
|
+
const tail = args.slice(cwdIdx + 2);
|
|
175
|
+
return verbs.every((v, i) => tail[i] === v);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function createOne(git, overrides = {}) {
|
|
179
|
+
const opts = {
|
|
180
|
+
slug: 'demo',
|
|
181
|
+
root: container,
|
|
182
|
+
seat: 'totem-claude',
|
|
183
|
+
cwdForTest: repo,
|
|
184
|
+
envForTest: {},
|
|
185
|
+
execForTest: git.exec,
|
|
186
|
+
nowForTest: CREATED_AT,
|
|
187
|
+
...overrides,
|
|
188
|
+
};
|
|
189
|
+
await wtCreateCommand(opts);
|
|
190
|
+
// Derived from the EFFECTIVE options: an override of slug/seat/root must
|
|
191
|
+
// return the path that was actually created (bot round, CR finding 4) —
|
|
192
|
+
// including a tilde root, which the command expands against the pinned home
|
|
193
|
+
// (round 2, CR finding).
|
|
194
|
+
const effectiveRoot = opts.root ?? container;
|
|
195
|
+
const expandedRoot = effectiveRoot === '~' ||
|
|
196
|
+
effectiveRoot.startsWith('~/') ||
|
|
197
|
+
effectiveRoot.startsWith(`~${path.sep}`)
|
|
198
|
+
? path.join(home, effectiveRoot.slice(1))
|
|
199
|
+
: effectiveRoot;
|
|
200
|
+
return path.join(expandedRoot, `repo-${opts.seat ?? 'totem-claude'}-${opts.slug ?? 'demo'}`);
|
|
201
|
+
}
|
|
202
|
+
// ─── create ─────────────────────────────────────────────
|
|
203
|
+
describe('wt create', () => {
|
|
204
|
+
it('creates <root>/<repo>-<seat>-<slug> on a NEW branch and records it', async () => {
|
|
205
|
+
const git = fakeGit();
|
|
206
|
+
const target = await createOne(git);
|
|
207
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
208
|
+
const add = callsMatching(git, 'worktree', 'add');
|
|
209
|
+
expect(add).toHaveLength(1);
|
|
210
|
+
// Always `-b`: an existing branch is a hard error in v1 (the ruling).
|
|
211
|
+
expect(add[0]).toContain('-b');
|
|
212
|
+
expect(add[0]).toContain('wt/demo');
|
|
213
|
+
const file = readWorktreeRegistry();
|
|
214
|
+
const entry = file.worktrees[target];
|
|
215
|
+
expect(entry).toMatchObject({
|
|
216
|
+
repo,
|
|
217
|
+
seat: 'totem-claude',
|
|
218
|
+
branch: 'wt/demo',
|
|
219
|
+
createdAt: CREATED_AT,
|
|
220
|
+
});
|
|
221
|
+
expect(file.roots).toEqual([path.resolve(container)]);
|
|
222
|
+
});
|
|
223
|
+
it('names the branch feat/<ticket>-<slug> when a ticket is given', async () => {
|
|
224
|
+
const git = fakeGit();
|
|
225
|
+
await createOne(git, { ticket: '2580' });
|
|
226
|
+
expect(readWorktreeRegistry().worktrees[path.join(container, 'repo-totem-claude-demo')]).toMatchObject({ branch: 'feat/2580-demo', ticket: '2580' });
|
|
227
|
+
});
|
|
228
|
+
it('honours an explicit --branch over both defaults', async () => {
|
|
229
|
+
const git = fakeGit();
|
|
230
|
+
await createOne(git, { ticket: '2580', branch: 'chore/custom' });
|
|
231
|
+
expect(callsMatching(git, 'worktree', 'add')[0]).toContain('chore/custom');
|
|
232
|
+
});
|
|
233
|
+
it('resolves the root by precedence: --root > TOTEM_WORKTREE_ROOT > default', async () => {
|
|
234
|
+
const envRoot = path.join(root, 'env-root');
|
|
235
|
+
const git = fakeGit();
|
|
236
|
+
// --root wins over the env var.
|
|
237
|
+
await createOne(git, { envForTest: { TOTEM_WORKTREE_ROOT: envRoot } });
|
|
238
|
+
expect(fs.existsSync(path.join(container, 'repo-totem-claude-demo'))).toBe(true);
|
|
239
|
+
// With no --root, the env var is used.
|
|
240
|
+
await wtCreateCommand({
|
|
241
|
+
slug: 'envy',
|
|
242
|
+
seat: 'totem-claude',
|
|
243
|
+
cwdForTest: repo,
|
|
244
|
+
envForTest: { TOTEM_WORKTREE_ROOT: envRoot },
|
|
245
|
+
execForTest: git.exec,
|
|
246
|
+
nowForTest: CREATED_AT,
|
|
247
|
+
});
|
|
248
|
+
expect(fs.existsSync(path.join(envRoot, 'repo-totem-claude-envy'))).toBe(true);
|
|
249
|
+
// With neither, the default is ~/.totem/worktrees (HOME is the temp home).
|
|
250
|
+
await wtCreateCommand({
|
|
251
|
+
slug: 'defaulted',
|
|
252
|
+
seat: 'totem-claude',
|
|
253
|
+
cwdForTest: repo,
|
|
254
|
+
envForTest: {},
|
|
255
|
+
execForTest: git.exec,
|
|
256
|
+
nowForTest: CREATED_AT,
|
|
257
|
+
});
|
|
258
|
+
expect(fs.existsSync(path.join(home, '.totem', 'worktrees', 'repo-totem-claude-defaulted'))).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
it('expands a leading ~ in --root and TOTEM_WORKTREE_ROOT against the home dir', async () => {
|
|
261
|
+
const git = fakeGit();
|
|
262
|
+
// A quoted `--root '~/x'` reaches the process unexpanded; resolving it
|
|
263
|
+
// cwd-relative would mint a literal `~` dir and record it as a root
|
|
264
|
+
// FOREVER (bot round, CR finding 6).
|
|
265
|
+
await createOne(git, { root: '~/tilde-root' });
|
|
266
|
+
expect(fs.existsSync(path.join(home, 'tilde-root', 'repo-totem-claude-demo'))).toBe(true);
|
|
267
|
+
await wtCreateCommand({
|
|
268
|
+
slug: 'tilde-env',
|
|
269
|
+
seat: 'totem-claude',
|
|
270
|
+
cwdForTest: repo,
|
|
271
|
+
envForTest: { TOTEM_WORKTREE_ROOT: '~/tilde-env-root' },
|
|
272
|
+
execForTest: git.exec,
|
|
273
|
+
nowForTest: CREATED_AT,
|
|
274
|
+
});
|
|
275
|
+
expect(fs.existsSync(path.join(home, 'tilde-env-root', 'repo-totem-claude-tilde-env'))).toBe(true);
|
|
276
|
+
// No literal `~` directory was minted anywhere, and no recorded root
|
|
277
|
+
// carries one.
|
|
278
|
+
expect(fs.existsSync(path.join(repo, '~'))).toBe(false);
|
|
279
|
+
for (const recorded of readWorktreeRegistry().roots) {
|
|
280
|
+
expect(path.basename(recorded)).not.toBe('~');
|
|
281
|
+
expect(recorded.includes(`${path.sep}~${path.sep}`)).toBe(false);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
it('emits a --json artifact naming the root source', async () => {
|
|
285
|
+
const git = fakeGit();
|
|
286
|
+
const raw = await captureStdout(() => createOne(git, { json: true, ticket: '2580' }));
|
|
287
|
+
const artifact = JSON.parse(raw);
|
|
288
|
+
expect(artifact).toMatchObject({
|
|
289
|
+
action: 'create',
|
|
290
|
+
seat: 'totem-claude',
|
|
291
|
+
branch: 'feat/2580-demo',
|
|
292
|
+
ticket: '2580',
|
|
293
|
+
'root-source': '--root',
|
|
294
|
+
'created-at': CREATED_AT,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
it('refuses a target directory that already exists', async () => {
|
|
298
|
+
const git = fakeGit();
|
|
299
|
+
fs.mkdirSync(path.join(container, 'repo-totem-claude-demo'), { recursive: true });
|
|
300
|
+
await expect(createOne(git)).rejects.toThrow(/already exists/);
|
|
301
|
+
// Nothing recorded, nothing invoked.
|
|
302
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
303
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(0);
|
|
304
|
+
});
|
|
305
|
+
it('rejects a slug that is not a single path segment', async () => {
|
|
306
|
+
const git = fakeGit();
|
|
307
|
+
for (const slug of ['../escape', 'a/b', '.hidden', '']) {
|
|
308
|
+
await expect(createOne(git, { slug })).rejects.toThrow(/invalid slug/);
|
|
309
|
+
}
|
|
310
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(0);
|
|
311
|
+
});
|
|
312
|
+
it('rejects branch names git check-ref-format would bounce AFTER the record', async () => {
|
|
313
|
+
const git = fakeGit();
|
|
314
|
+
// The `.lock` / trailing-dot refusals apply per PATH COMPONENT, exactly as
|
|
315
|
+
// git's own check does (re-verification round 2, finding 9).
|
|
316
|
+
for (const branch of ['wt/demo.', 'wt/demo.lock', 'wt/../demo', 'feat/x.lock/y', 'wt/x./y']) {
|
|
317
|
+
await expect(createOne(git, { branch })).rejects.toThrow(/invalid branch name/);
|
|
318
|
+
}
|
|
319
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(0);
|
|
320
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
321
|
+
});
|
|
322
|
+
it('refuses to run from inside a linked worktree (primary checkout only)', async () => {
|
|
323
|
+
// A linked worktree is the ONE shape where git-dir and common-dir diverge.
|
|
324
|
+
const git = fakeGit({
|
|
325
|
+
gitDir: path.join(root, 'elsewhere', '.git', 'worktrees', 'wt-x'),
|
|
326
|
+
commonDir: path.join(root, 'elsewhere', '.git'),
|
|
327
|
+
});
|
|
328
|
+
await expect(createOne(git)).rejects.toThrow(/primary checkout/);
|
|
329
|
+
// Refused BEFORE anything landed: no record, no git mutation.
|
|
330
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(0);
|
|
331
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
332
|
+
});
|
|
333
|
+
it('accepts a separate-git-dir primary: git-dir == common-dir, both external', async () => {
|
|
334
|
+
// `git init --separate-git-dir` is a PRIMARY whose git dir is nowhere near
|
|
335
|
+
// `<toplevel>/.git` — the shape the old `<toplevel>/.git` comparison
|
|
336
|
+
// misread as a worktree (re-verification round 2, finding 1).
|
|
337
|
+
const sep = path.join(root, 'sepgit');
|
|
338
|
+
const git = fakeGit({ gitDir: sep, commonDir: sep });
|
|
339
|
+
await createOne(git);
|
|
340
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(1);
|
|
341
|
+
});
|
|
342
|
+
it('rewords a failed seat resolution to --seat, never the mail verb’s --from', async () => {
|
|
343
|
+
const git = fakeGit();
|
|
344
|
+
// Two orchestration seats and no TOTEM_SELF_AGENT: the resolver's
|
|
345
|
+
// ambiguity arm — the default outcome on a real multi-seat repo.
|
|
346
|
+
fs.mkdirSync(path.join(repo, '.totem', 'orchestration', 'totem-claude'), { recursive: true });
|
|
347
|
+
fs.mkdirSync(path.join(repo, '.totem', 'orchestration', 'totem-codex'), { recursive: true });
|
|
348
|
+
let message = '';
|
|
349
|
+
try {
|
|
350
|
+
await wtCreateCommand({
|
|
351
|
+
slug: 'demo',
|
|
352
|
+
root: container,
|
|
353
|
+
cwdForTest: repo,
|
|
354
|
+
envForTest: {},
|
|
355
|
+
execForTest: git.exec,
|
|
356
|
+
nowForTest: CREATED_AT,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
message = err instanceof Error ? err.message : String(err);
|
|
361
|
+
}
|
|
362
|
+
expect(message).toContain('--seat');
|
|
363
|
+
expect(message).not.toContain('--from');
|
|
364
|
+
// Seat resolution precedes the record-first write: nothing recorded.
|
|
365
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
// ─── Invariant 6 ────────────────────────────────────────
|
|
369
|
+
describe('invariant 6: the entry is written BEFORE git runs, and rolls back', () => {
|
|
370
|
+
it('has the entry already recorded at the instant `worktree add` is invoked', async () => {
|
|
371
|
+
const git = fakeGit();
|
|
372
|
+
const target = await createOne(git);
|
|
373
|
+
expect(git.registryAtAdd).toBeDefined();
|
|
374
|
+
// Record-first: a phantom entry fails visibly, an unrecorded worktree does not.
|
|
375
|
+
expect(Object.keys(git.registryAtAdd.worktrees)).toContain(target);
|
|
376
|
+
expect(git.registryAtAdd.roots).toEqual([path.resolve(container)]);
|
|
377
|
+
});
|
|
378
|
+
it('rolls the entry back when `git worktree add` fails', async () => {
|
|
379
|
+
const git = fakeGit({ add: 'throw' });
|
|
380
|
+
await expect(createOne(git)).rejects.toThrow(/git worktree add failed/);
|
|
381
|
+
const file = readWorktreeRegistry();
|
|
382
|
+
expect(file.worktrees).toEqual({});
|
|
383
|
+
// The ROOT stays recorded even though the create failed — roots accrete.
|
|
384
|
+
expect(file.roots).toEqual([path.resolve(container)]);
|
|
385
|
+
});
|
|
386
|
+
it('KEEPS the entry when git failed but left a partial directory', async () => {
|
|
387
|
+
const git = fakeGit({ add: 'throw-after-create' });
|
|
388
|
+
await expect(createOne(git)).rejects.toThrow(/registry entry is RETAINED/);
|
|
389
|
+
// Rolling back here would convert a visible phantom into the invisible
|
|
390
|
+
// unrecorded-worktree class — the record must outlive the partial dir.
|
|
391
|
+
const target = path.join(container, 'repo-totem-claude-demo');
|
|
392
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
393
|
+
expect(Object.keys(readWorktreeRegistry().worktrees)).toContain(target);
|
|
394
|
+
expect(output()).not.toContain('PHANTOM ENTRY');
|
|
395
|
+
});
|
|
396
|
+
it('names the phantom entry loudly when the rollback itself fails', async () => {
|
|
397
|
+
const git = fakeGit({ add: 'throw' });
|
|
398
|
+
// Make the registry unreadable AFTER the record-first write, so the
|
|
399
|
+
// rollback's read-modify-write is what fails.
|
|
400
|
+
const originalAdd = git.exec;
|
|
401
|
+
const exec = ((command, args = [], opts) => {
|
|
402
|
+
const cwdIdx = args.indexOf('-C');
|
|
403
|
+
if (args[cwdIdx + 2] === 'worktree' && args[cwdIdx + 3] === 'add') {
|
|
404
|
+
fs.writeFileSync(worktreeRegistryPath(), '{ corrupt', 'utf-8');
|
|
405
|
+
}
|
|
406
|
+
return originalAdd(command, args, opts);
|
|
407
|
+
});
|
|
408
|
+
await expect(createOne(git, { execForTest: exec })).rejects.toThrow(/NOT rolled back/);
|
|
409
|
+
expect(output()).toContain('PHANTOM ENTRY');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
// ─── Invariant 10 ───────────────────────────────────────
|
|
413
|
+
describe('invariant 10: the root is never the repo, under the repo, or the workspace root itself', () => {
|
|
414
|
+
const workspace = () => path.dirname(repo);
|
|
415
|
+
it('refuses --root pointing at the repo itself', async () => {
|
|
416
|
+
const git = fakeGit();
|
|
417
|
+
await expect(createOne(git, { root: repo })).rejects.toThrow(/inside the repo itself/);
|
|
418
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(0);
|
|
419
|
+
});
|
|
420
|
+
it('refuses --root UNDER the repo — containment, not just equality', async () => {
|
|
421
|
+
const git = fakeGit();
|
|
422
|
+
// `<repo>/.claude/worktrees` is the known in-repo worktree location shape;
|
|
423
|
+
// an equality-only guard would wave it through (falsification finding 3).
|
|
424
|
+
await expect(createOne(git, { root: path.join(repo, '.claude', 'worktrees') })).rejects.toThrow(/inside the repo itself/);
|
|
425
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(0);
|
|
426
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
427
|
+
// A SIBLING whose name merely extends the repo's is NOT under it.
|
|
428
|
+
await createOne(git, { root: `${repo}-ville` });
|
|
429
|
+
expect(fs.existsSync(path.join(`${repo}-ville`, 'repo-totem-claude-demo'))).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
it('refuses --root pointing at the workspace root', async () => {
|
|
432
|
+
const git = fakeGit();
|
|
433
|
+
await expect(createOne(git, { root: workspace() })).rejects.toThrow(/workspace root/);
|
|
434
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(0);
|
|
435
|
+
});
|
|
436
|
+
it('refuses the same locations via TOTEM_WORKTREE_ROOT', async () => {
|
|
437
|
+
const git = fakeGit();
|
|
438
|
+
await expect(wtCreateCommand({
|
|
439
|
+
slug: 'demo',
|
|
440
|
+
seat: 'totem-claude',
|
|
441
|
+
cwdForTest: repo,
|
|
442
|
+
envForTest: { TOTEM_WORKTREE_ROOT: workspace() },
|
|
443
|
+
execForTest: git.exec,
|
|
444
|
+
nowForTest: CREATED_AT,
|
|
445
|
+
})).rejects.toThrow(/workspace root/);
|
|
446
|
+
expect(callsMatching(git, 'worktree', 'add')).toHaveLength(0);
|
|
447
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
448
|
+
});
|
|
449
|
+
it('refuses under every other flag combination too', async () => {
|
|
450
|
+
const git = fakeGit();
|
|
451
|
+
await expect(createOne(git, { root: repo, ticket: '2580', branch: 'feat/x', seat: 'other-seat' })).rejects.toThrow(/inside the repo itself/);
|
|
452
|
+
// Case-folded on win32: a shouted spelling is the same directory there.
|
|
453
|
+
const shouted = IS_WIN32 ? repo.toUpperCase() : repo;
|
|
454
|
+
await expect(createOne(git, { root: shouted })).rejects.toThrow(IS_WIN32 ? /inside the repo itself/ : /inside the repo itself/);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
// ─── remove: check-first (invariant 4) ──────────────────
|
|
458
|
+
describe('invariant 4: check-first — only a git-listed path reaches `worktree remove`', () => {
|
|
459
|
+
it('routes a git-listed worktree through `git worktree remove --force`', async () => {
|
|
460
|
+
const git = fakeGit();
|
|
461
|
+
const target = await createOne(git);
|
|
462
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec });
|
|
463
|
+
const removes = callsMatching(git, 'worktree', 'remove');
|
|
464
|
+
expect(removes).toHaveLength(1);
|
|
465
|
+
expect(removes[0]).toContain('--force');
|
|
466
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
467
|
+
expect(callsMatching(git, 'worktree', 'prune')).toHaveLength(1);
|
|
468
|
+
});
|
|
469
|
+
it('never invokes `worktree remove` for a path git does not list, and still verifies absence', async () => {
|
|
470
|
+
// Registry-known but git-unlisted: the estate's deregistered-husk shape.
|
|
471
|
+
const git = fakeGit();
|
|
472
|
+
const target = await createOne(git);
|
|
473
|
+
const unlisted = fakeGit({ listed: [] });
|
|
474
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: unlisted.exec });
|
|
475
|
+
expect(callsMatching(unlisted, 'worktree', 'remove')).toHaveLength(0);
|
|
476
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
477
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
478
|
+
});
|
|
479
|
+
it('refuses a path in NEITHER git’s list nor the registry, touching nothing', async () => {
|
|
480
|
+
const stranger = path.join(root, 'not-a-worktree');
|
|
481
|
+
fs.mkdirSync(stranger, { recursive: true });
|
|
482
|
+
const git = fakeGit();
|
|
483
|
+
await expect(wtRemoveCommand({ target: stranger, cwdForTest: repo, execForTest: git.exec })).rejects.toThrow(/in neither git/);
|
|
484
|
+
expect(fs.existsSync(stranger)).toBe(true);
|
|
485
|
+
expect(callsMatching(git, 'worktree', 'remove')).toHaveLength(0);
|
|
486
|
+
});
|
|
487
|
+
it('fails loud rather than fail-open when the worktree list cannot be read', async () => {
|
|
488
|
+
const git = fakeGit();
|
|
489
|
+
const target = await createOne(git);
|
|
490
|
+
const broken = fakeGit({ listThrows: true });
|
|
491
|
+
await expect(wtRemoveCommand({ target, cwdForTest: repo, execForTest: broken.exec })).rejects.toThrow(/cannot enumerate worktrees/);
|
|
492
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
493
|
+
expect(callsMatching(broken, 'worktree', 'remove')).toHaveLength(0);
|
|
494
|
+
});
|
|
495
|
+
it('recovers idempotently when the home repo is gone and nothing is on disk', async () => {
|
|
496
|
+
const git = fakeGit();
|
|
497
|
+
const target = await createOne(git);
|
|
498
|
+
fs.rmSync(target, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
499
|
+
const broken = fakeGit({ listThrows: true });
|
|
500
|
+
// The ruled recovery (verified-absent + entry present ⇒ delete entry) must
|
|
501
|
+
// not be blocked by an unanswerable list when there is nothing to strand.
|
|
502
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: broken.exec });
|
|
503
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
504
|
+
expect(output()).toContain('already-gone');
|
|
505
|
+
expect(output()).toContain('could not enumerate worktrees');
|
|
506
|
+
expect(callsMatching(broken, 'worktree', 'remove')).toHaveLength(0);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
// ─── remove: invariants 1 + 2 ───────────────────────────
|
|
510
|
+
describe('invariants 1 + 2: exit 0 requires verified absence; the entry survives failure', () => {
|
|
511
|
+
it('finishes the residue when git exits 0 but leaves the directory standing', async () => {
|
|
512
|
+
const git = fakeGit({ remove: 'husk' });
|
|
513
|
+
const target = await createOne(git);
|
|
514
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec });
|
|
515
|
+
// The fail-open git exit code did NOT decide the outcome — the finish did.
|
|
516
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
517
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
518
|
+
expect(output()).toContain('residue-removed');
|
|
519
|
+
});
|
|
520
|
+
it('throws (never exits 0) and RETAINS the entry when the directory survives', async () => {
|
|
521
|
+
const git = fakeGit({ remove: 'husk' });
|
|
522
|
+
const target = await createOne(git);
|
|
523
|
+
const stubbornResidue = async (dir) => Promise.resolve({
|
|
524
|
+
removed: false,
|
|
525
|
+
strippedLinks: [],
|
|
526
|
+
survivors: [dir, path.join(dir, 'file.txt')],
|
|
527
|
+
lastError: 'EBUSY: resource busy or locked',
|
|
528
|
+
attempts: 3,
|
|
529
|
+
});
|
|
530
|
+
// The failure names the survivors AND renders the finish's last error —
|
|
531
|
+
// the manual-cleanup hint is only actionable with both.
|
|
532
|
+
await expect(wtRemoveCommand({
|
|
533
|
+
target,
|
|
534
|
+
cwdForTest: repo,
|
|
535
|
+
execForTest: git.exec,
|
|
536
|
+
residueForTest: stubbornResidue,
|
|
537
|
+
})).rejects.toThrow(/still exists after the residue finish[\s\S]*EBUSY: resource busy or locked/);
|
|
538
|
+
// Invariant 2: every removal failure retains the entry, so the husk stays
|
|
539
|
+
// visible in `wt list` instead of becoming untracked residue.
|
|
540
|
+
expect(Object.keys(readWorktreeRegistry().worktrees)).toContain(target);
|
|
541
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
542
|
+
});
|
|
543
|
+
it('names the survivors in the failure so the manual cleanup is possible', async () => {
|
|
544
|
+
const git = fakeGit({ remove: 'husk' });
|
|
545
|
+
const target = await createOne(git);
|
|
546
|
+
await expect(wtRemoveCommand({
|
|
547
|
+
target,
|
|
548
|
+
cwdForTest: repo,
|
|
549
|
+
execForTest: git.exec,
|
|
550
|
+
residueForTest: async (dir) => Promise.resolve({
|
|
551
|
+
removed: false,
|
|
552
|
+
strippedLinks: [],
|
|
553
|
+
survivors: [path.join(dir, 'node_modules')],
|
|
554
|
+
attempts: 1,
|
|
555
|
+
}),
|
|
556
|
+
})).rejects.toThrow(/node_modules/);
|
|
557
|
+
});
|
|
558
|
+
it('throws even when the residue finish LIES about removal', async () => {
|
|
559
|
+
const git = fakeGit({ remove: 'husk' });
|
|
560
|
+
const target = await createOne(git);
|
|
561
|
+
// A reporter claiming success while the directory stands must not buy
|
|
562
|
+
// exit 0 — the re-probe disjunct, not the report, is the authority.
|
|
563
|
+
await expect(wtRemoveCommand({
|
|
564
|
+
target,
|
|
565
|
+
cwdForTest: repo,
|
|
566
|
+
execForTest: git.exec,
|
|
567
|
+
residueForTest: async () => Promise.resolve({ removed: true, strippedLinks: [], survivors: [], attempts: 1 }),
|
|
568
|
+
})).rejects.toThrow(/still exists after the residue finish/);
|
|
569
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
570
|
+
expect(Object.keys(readWorktreeRegistry().worktrees)).toContain(target);
|
|
571
|
+
});
|
|
572
|
+
it('retains the entry when `git worktree remove` itself fails', async () => {
|
|
573
|
+
const git = fakeGit({ remove: 'throw' });
|
|
574
|
+
const target = await createOne(git);
|
|
575
|
+
await expect(wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec })).rejects.toThrow(/git worktree remove failed/);
|
|
576
|
+
expect(Object.keys(readWorktreeRegistry().worktrees)).toContain(target);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
// ─── remove: already-gone + resolution ──────────────────
|
|
580
|
+
describe('wt remove resolution', () => {
|
|
581
|
+
it('treats a recorded path that is already off disk as already-gone', async () => {
|
|
582
|
+
const git = fakeGit();
|
|
583
|
+
const target = await createOne(git);
|
|
584
|
+
// Simulate the husk having been hand-deleted, with git still listing it.
|
|
585
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
586
|
+
const unlisted = fakeGit({ listed: [] });
|
|
587
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: unlisted.exec });
|
|
588
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
589
|
+
expect(output()).toContain('already-gone');
|
|
590
|
+
expect(callsMatching(unlisted, 'worktree', 'remove')).toHaveLength(0);
|
|
591
|
+
});
|
|
592
|
+
it('resolves a bare basename against the registry', async () => {
|
|
593
|
+
const git = fakeGit();
|
|
594
|
+
const target = await createOne(git);
|
|
595
|
+
await wtRemoveCommand({
|
|
596
|
+
target: 'repo-totem-claude-demo',
|
|
597
|
+
cwdForTest: repo,
|
|
598
|
+
execForTest: git.exec,
|
|
599
|
+
});
|
|
600
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
601
|
+
});
|
|
602
|
+
it('refuses an AMBIGUOUS basename, listing the candidates', async () => {
|
|
603
|
+
const git = fakeGit();
|
|
604
|
+
const other = path.join(root, 'container-2');
|
|
605
|
+
fs.mkdirSync(other, { recursive: true });
|
|
606
|
+
await createOne(git);
|
|
607
|
+
await createOne(git, { root: other });
|
|
608
|
+
await expect(wtRemoveCommand({
|
|
609
|
+
target: 'repo-totem-claude-demo',
|
|
610
|
+
cwdForTest: repo,
|
|
611
|
+
execForTest: git.exec,
|
|
612
|
+
})).rejects.toThrow(/matches 2 recorded worktrees/);
|
|
613
|
+
// Nothing removed while the ambiguity stands.
|
|
614
|
+
expect(fs.existsSync(path.join(container, 'repo-totem-claude-demo'))).toBe(true);
|
|
615
|
+
expect(fs.existsSync(path.join(other, 'repo-totem-claude-demo'))).toBe(true);
|
|
616
|
+
});
|
|
617
|
+
it('refuses the residue delete for a recorded entry OUTSIDE every recorded root', async () => {
|
|
618
|
+
const git = fakeGit();
|
|
619
|
+
const target = await createOne(git);
|
|
620
|
+
// Hand-edit the registry: an entry pointing outside all recorded roots is
|
|
621
|
+
// exactly the shape that would turn a corrupted worktrees.json into an
|
|
622
|
+
// arbitrary-recursive-delete primitive (falsification finding 13).
|
|
623
|
+
const stray = path.join(root, 'stray-location', 'repo-totem-claude-demo');
|
|
624
|
+
fs.mkdirSync(stray, { recursive: true });
|
|
625
|
+
const raw = JSON.parse(fs.readFileSync(worktreeRegistryPath(), 'utf-8'));
|
|
626
|
+
raw.worktrees[stray] = raw.worktrees[target];
|
|
627
|
+
fs.writeFileSync(worktreeRegistryPath(), JSON.stringify(raw, null, 2), 'utf-8');
|
|
628
|
+
const unlisted = fakeGit({ listed: [] });
|
|
629
|
+
await expect(wtRemoveCommand({ target: stray, cwdForTest: repo, execForTest: unlisted.exec })).rejects.toThrow(/none of the recorded roots/);
|
|
630
|
+
expect(fs.existsSync(stray)).toBe(true);
|
|
631
|
+
expect(callsMatching(unlisted, 'worktree', 'remove')).toHaveLength(0);
|
|
632
|
+
});
|
|
633
|
+
it('leaves a CONCURRENTLY REPLACED entry in place, loudly (Greptile P1)', async () => {
|
|
634
|
+
const git = fakeGit();
|
|
635
|
+
const target = await createOne(git);
|
|
636
|
+
const unlisted = fakeGit({ listed: [] });
|
|
637
|
+
const replacedAt = '2026-08-07T18:00:00.000Z';
|
|
638
|
+
// The residue seam models the race window: the directory really goes, and
|
|
639
|
+
// a concurrent `wt create` re-records the SAME path before this removal's
|
|
640
|
+
// registry delete acquires the lock.
|
|
641
|
+
const racingResidue = async (dir) => {
|
|
642
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
643
|
+
const raw = JSON.parse(fs.readFileSync(worktreeRegistryPath(), 'utf-8'));
|
|
644
|
+
raw.worktrees[target].createdAt = replacedAt;
|
|
645
|
+
fs.writeFileSync(worktreeRegistryPath(), JSON.stringify(raw, null, 2), 'utf-8');
|
|
646
|
+
return { removed: true, strippedLinks: [], survivors: [], attempts: 1 };
|
|
647
|
+
};
|
|
648
|
+
// Exit 0: the directory IS verifiably gone. But the fresh entry belongs to
|
|
649
|
+
// the replacement — deleting it would strand the new worktree unrecorded.
|
|
650
|
+
await wtRemoveCommand({
|
|
651
|
+
target,
|
|
652
|
+
cwdForTest: repo,
|
|
653
|
+
execForTest: unlisted.exec,
|
|
654
|
+
residueForTest: racingResidue,
|
|
655
|
+
});
|
|
656
|
+
expect(readWorktreeRegistry().worktrees[target]?.createdAt).toBe(replacedAt);
|
|
657
|
+
expect(output()).toContain('replaced concurrently');
|
|
658
|
+
});
|
|
659
|
+
it('accepts a git-listed worktree with NO registry entry (legacy estate)', async () => {
|
|
660
|
+
const legacy = path.join(container, 'legacy-worktree');
|
|
661
|
+
fs.mkdirSync(legacy, { recursive: true });
|
|
662
|
+
const git = fakeGit({ listed: [legacy] });
|
|
663
|
+
await wtRemoveCommand({ target: legacy, cwdForTest: repo, execForTest: git.exec });
|
|
664
|
+
expect(fs.existsSync(legacy)).toBe(false);
|
|
665
|
+
// Registry deletion simply no-ops — it was never recorded.
|
|
666
|
+
expect(output()).toContain('not recorded');
|
|
667
|
+
});
|
|
668
|
+
it('emits a --json artifact for the removal outcome', async () => {
|
|
669
|
+
const git = fakeGit();
|
|
670
|
+
const target = await createOne(git);
|
|
671
|
+
const raw = await captureStdout(() => wtRemoveCommand({ target, json: true, cwdForTest: repo, execForTest: git.exec }));
|
|
672
|
+
expect(JSON.parse(raw)).toMatchObject({
|
|
673
|
+
action: 'remove',
|
|
674
|
+
outcome: 'git-removed',
|
|
675
|
+
'git-listed': true,
|
|
676
|
+
'verified-absent': true,
|
|
677
|
+
'registry-entry-deleted': true,
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
// ─── Invariant 5 ────────────────────────────────────────
|
|
682
|
+
describe('invariant 5: untracked ECL content blocks removal', () => {
|
|
683
|
+
it('refuses when `status --porcelain --ignored` reports ANY row', async () => {
|
|
684
|
+
const git = fakeGit({ statusRows: ['?? .totem/orchestration/totem-claude/outbox/x.md'] });
|
|
685
|
+
const target = await createOne(git);
|
|
686
|
+
await expect(wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec })).rejects.toThrow(/refusing/);
|
|
687
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
688
|
+
expect(callsMatching(git, 'worktree', 'remove')).toHaveLength(0);
|
|
689
|
+
expect(Object.keys(readWorktreeRegistry().worktrees)).toContain(target);
|
|
690
|
+
});
|
|
691
|
+
it('blocks on an IGNORED file too — an ignored dispatch is still ECL', async () => {
|
|
692
|
+
const git = fakeGit({ statusRows: ['!! .totem/orchestration/totem-claude/journal/j.md'] });
|
|
693
|
+
const target = await createOne(git);
|
|
694
|
+
await expect(wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec })).rejects.toThrow(/journal/);
|
|
695
|
+
});
|
|
696
|
+
it('does NOT block when the orchestration content is tracked and clean', async () => {
|
|
697
|
+
const git = fakeGit({ statusRows: [] });
|
|
698
|
+
const target = await createOne(git);
|
|
699
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec });
|
|
700
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
701
|
+
});
|
|
702
|
+
it('runs the probe with the ruled pathspec, -uall included', async () => {
|
|
703
|
+
const git = fakeGit();
|
|
704
|
+
const target = await createOne(git);
|
|
705
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec });
|
|
706
|
+
const status = callsMatching(git, 'status');
|
|
707
|
+
expect(status).toHaveLength(1);
|
|
708
|
+
expect(status[0]).toEqual(expect.arrayContaining([
|
|
709
|
+
'status',
|
|
710
|
+
'--porcelain',
|
|
711
|
+
'--ignored',
|
|
712
|
+
'-uall',
|
|
713
|
+
'--',
|
|
714
|
+
'.totem/orchestration',
|
|
715
|
+
]));
|
|
716
|
+
// `-uall` is load-bearing on its own: without it a shared
|
|
717
|
+
// `status.showUntrackedFiles=no` silences the probe (finding 2).
|
|
718
|
+
expect(status[0]).toContain('-uall');
|
|
719
|
+
});
|
|
720
|
+
it('refuses when the probe cannot run AND orchestration content is on disk', async () => {
|
|
721
|
+
const git = fakeGit();
|
|
722
|
+
const target = await createOne(git);
|
|
723
|
+
fs.mkdirSync(path.join(target, '.totem', 'orchestration'), { recursive: true });
|
|
724
|
+
const blind = fakeGit({ listed: [target], statusThrows: true });
|
|
725
|
+
await expect(wtRemoveCommand({ target, cwdForTest: repo, execForTest: blind.exec })).rejects.toThrow(/ECL probe could not run/);
|
|
726
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
727
|
+
});
|
|
728
|
+
it('proceeds with a disclosed note when the probe cannot run and nothing is there', async () => {
|
|
729
|
+
const git = fakeGit();
|
|
730
|
+
const target = await createOne(git);
|
|
731
|
+
const blind = fakeGit({ listed: [target], statusThrows: true });
|
|
732
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: blind.exec });
|
|
733
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
734
|
+
expect(output()).toContain('ECL probe could not run');
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
// ─── Invariant 5 against REAL git (finding 2) ───────────
|
|
738
|
+
describe('ECL probe against real git', () => {
|
|
739
|
+
it('names the exact untracked file and defeats status.showUntrackedFiles=no', async () => {
|
|
740
|
+
const realRepo = path.join(root, 'real-repo');
|
|
741
|
+
fs.mkdirSync(realRepo, { recursive: true });
|
|
742
|
+
// Argv arrays, never a shell string (the repo's exec guidance) — and the
|
|
743
|
+
// status assertion means a failed setup step fails HERE, not three
|
|
744
|
+
// assertions later (bot round, CR finding 5).
|
|
745
|
+
const run = (...args) => {
|
|
746
|
+
const done = spawnSync('git', args, { cwd: realRepo, stdio: 'ignore' });
|
|
747
|
+
expect(done.status).toBe(0);
|
|
748
|
+
};
|
|
749
|
+
run('init');
|
|
750
|
+
run('config', 'user.email', 'wt-test@totem.invalid');
|
|
751
|
+
run('config', 'user.name', 'wt-test');
|
|
752
|
+
fs.writeFileSync(path.join(realRepo, 'a.txt'), 'seed', 'utf-8');
|
|
753
|
+
run('add', '--', 'a.txt');
|
|
754
|
+
run('commit', '-m', 'seed');
|
|
755
|
+
// The bypass shape (finding 2, arm a): the HOME repo's config is shared
|
|
756
|
+
// by every linked worktree, and `showUntrackedFiles=no` silences a
|
|
757
|
+
// default `git status` completely — only `-uall` still answers.
|
|
758
|
+
run('config', 'status.showUntrackedFiles', 'no');
|
|
759
|
+
const wt = path.join(root, 'real-wt');
|
|
760
|
+
const added = spawnSync('git', ['worktree', 'add', '-b', 'wt/real', wt], {
|
|
761
|
+
cwd: realRepo,
|
|
762
|
+
stdio: 'ignore',
|
|
763
|
+
});
|
|
764
|
+
expect(added.status).toBe(0);
|
|
765
|
+
const dispatch = path.join(wt, '.totem', 'orchestration', 'totem-claude', 'outbox');
|
|
766
|
+
fs.mkdirSync(dispatch, { recursive: true });
|
|
767
|
+
fs.writeFileSync(path.join(dispatch, 'dispatch.md'), 'ecl content', 'utf-8');
|
|
768
|
+
// Real safeExec, real git. The refusal must name the FILE — not one
|
|
769
|
+
// collapsed `?? .totem/orchestration/` row (finding 2, arm b) — and must
|
|
770
|
+
// fire despite the config bypass. Nothing may be deleted.
|
|
771
|
+
let message = '';
|
|
772
|
+
try {
|
|
773
|
+
await wtRemoveCommand({ target: wt, cwdForTest: realRepo });
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
message = err instanceof Error ? err.message : String(err);
|
|
777
|
+
}
|
|
778
|
+
expect(message).toContain('refusing');
|
|
779
|
+
expect(message).toContain('dispatch.md');
|
|
780
|
+
expect(fs.existsSync(path.join(dispatch, 'dispatch.md'))).toBe(true);
|
|
781
|
+
}, 30_000);
|
|
782
|
+
});
|
|
783
|
+
// ─── Invariant 9 ────────────────────────────────────────
|
|
784
|
+
describe('invariant 9: path comparisons case-fold on win32 only', () => {
|
|
785
|
+
it('matches a shouted path to its recorded entry on win32, and not on POSIX', async () => {
|
|
786
|
+
const git = fakeGit();
|
|
787
|
+
const target = await createOne(git);
|
|
788
|
+
const shouted = target.toUpperCase();
|
|
789
|
+
if (IS_WIN32) {
|
|
790
|
+
await wtRemoveCommand({ target: shouted, cwdForTest: repo, execForTest: git.exec });
|
|
791
|
+
expect(fs.existsSync(target)).toBe(false);
|
|
792
|
+
expect(readWorktreeRegistry().worktrees).toEqual({});
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
// POSIX filesystems are case-sensitive: the shouted path is a DIFFERENT
|
|
796
|
+
// path, recorded nowhere, and must be refused rather than deleted.
|
|
797
|
+
await expect(wtRemoveCommand({ target: shouted, cwdForTest: repo, execForTest: git.exec })).rejects.toThrow(/in neither git/);
|
|
798
|
+
expect(fs.existsSync(target)).toBe(true);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
// ─── Invariant 7 ────────────────────────────────────────
|
|
803
|
+
describe('invariant 7: no wt verb ever writes registry.json', () => {
|
|
804
|
+
it('leaves the sync registry byte-identical across create, list, and remove', async () => {
|
|
805
|
+
const registryFile = path.join(home, '.totem', 'registry.json');
|
|
806
|
+
fs.mkdirSync(path.dirname(registryFile), { recursive: true });
|
|
807
|
+
const original = JSON.stringify({
|
|
808
|
+
[repo]: { path: repo, chunkCount: 7, lastSync: '2026-08-01T00:00:00.000Z', embedder: 'x' },
|
|
809
|
+
}, null, 2);
|
|
810
|
+
fs.writeFileSync(registryFile, original, 'utf-8');
|
|
811
|
+
const git = fakeGit();
|
|
812
|
+
const target = await createOne(git);
|
|
813
|
+
await wtListCommand({ nowForTest: NOW });
|
|
814
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec });
|
|
815
|
+
expect(fs.readFileSync(registryFile, 'utf-8')).toBe(original);
|
|
816
|
+
// And the two files really are separate on disk.
|
|
817
|
+
expect(fs.existsSync(worktreeRegistryPath())).toBe(true);
|
|
818
|
+
expect(worktreeRegistryPath()).not.toBe(registryFile);
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
// ─── list ───────────────────────────────────────────────
|
|
822
|
+
describe('wt list', () => {
|
|
823
|
+
it('reports recorded entries with disk presence and age, never a classification', async () => {
|
|
824
|
+
const git = fakeGit();
|
|
825
|
+
const present = await createOne(git);
|
|
826
|
+
const ghost = await createOne(git, { slug: 'ghost' });
|
|
827
|
+
fs.rmSync(ghost, { recursive: true, force: true });
|
|
828
|
+
await wtListCommand({ nowForTest: NOW });
|
|
829
|
+
const text = output();
|
|
830
|
+
expect(text).toContain(present);
|
|
831
|
+
expect(text).toContain('present');
|
|
832
|
+
expect(text).toContain('missing');
|
|
833
|
+
expect(text).toContain('2d old');
|
|
834
|
+
// Classification is the sensor's charge, and the hint says where. No ROW
|
|
835
|
+
// may carry a class — only the hint line is allowed to say the words.
|
|
836
|
+
expect(text).toContain('totem doctor --estate');
|
|
837
|
+
const rows = lines.filter((line) => line.includes(container));
|
|
838
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
839
|
+
for (const row of rows) {
|
|
840
|
+
expect(row).not.toMatch(/\b(stale|active|indeterminate)\b/);
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
it('emits a --json listing with age-days and presence', async () => {
|
|
844
|
+
const git = fakeGit();
|
|
845
|
+
await createOne(git, { ticket: '2580' });
|
|
846
|
+
const raw = await captureStdout(() => wtListCommand({ nowForTest: NOW, json: true }));
|
|
847
|
+
const artifact = JSON.parse(raw);
|
|
848
|
+
expect(artifact['registry-status']).toBe('ok');
|
|
849
|
+
expect(artifact.roots).toEqual([path.resolve(container)]);
|
|
850
|
+
expect(artifact.worktrees).toHaveLength(1);
|
|
851
|
+
expect(artifact.worktrees[0]).toMatchObject({
|
|
852
|
+
seat: 'totem-claude',
|
|
853
|
+
branch: 'feat/2580-demo',
|
|
854
|
+
ticket: '2580',
|
|
855
|
+
'age-days': 2,
|
|
856
|
+
present: true,
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
it('warns LOUDLY and lists nothing when worktrees.json cannot be read', async () => {
|
|
860
|
+
fs.mkdirSync(path.dirname(worktreeRegistryPath()), { recursive: true });
|
|
861
|
+
fs.writeFileSync(worktreeRegistryPath(), '{ not json', 'utf-8');
|
|
862
|
+
await wtListCommand({ nowForTest: NOW });
|
|
863
|
+
expect(output()).toContain('Cannot read worktree registry');
|
|
864
|
+
expect(output()).toContain('could not be read');
|
|
865
|
+
});
|
|
866
|
+
it('says so plainly when nothing has been recorded yet', async () => {
|
|
867
|
+
await wtListCommand({ nowForTest: NOW });
|
|
868
|
+
expect(output()).toContain('no worktrees recorded');
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
// ─── Invariant 8 (registry half) ────────────────────────
|
|
872
|
+
describe('invariant 8: recorded roots survive entry removal', () => {
|
|
873
|
+
it('keeps the container root recorded after the last entry under it is gone', async () => {
|
|
874
|
+
const git = fakeGit();
|
|
875
|
+
const target = await createOne(git);
|
|
876
|
+
await wtRemoveCommand({ target, cwdForTest: repo, execForTest: git.exec });
|
|
877
|
+
const file = readWorktreeRegistry();
|
|
878
|
+
expect(file.worktrees).toEqual({});
|
|
879
|
+
// The `%TEMP%\claude`-class reachability fix: zero live entries, root still
|
|
880
|
+
// recorded, so `doctor --estate` still sweeps the location.
|
|
881
|
+
expect(file.roots).toEqual([path.resolve(container)]);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
//# sourceMappingURL=wt.test.js.map
|