pi-odd-worktree 0.0.0-stage → 1.0.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/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- package/tools/worktree-tools.test.js +434 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// S2/S4 behavior of the three checkout tools, exercised through the REGISTERED
|
|
3
|
+
// tool objects against a real temporary repository. Herdr is deliberately
|
|
4
|
+
// unusable (HERDR_BIN_PATH names a binary that does not exist), so every
|
|
5
|
+
// create/list/close below proves the plain git route: the package has no Herdr
|
|
6
|
+
// requirement at all.
|
|
7
|
+
const { test, describe, before, after } = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { spawnSync } = require('node:child_process');
|
|
14
|
+
const { loadExtension } = require('./fixtures/load-extension.js');
|
|
15
|
+
|
|
16
|
+
const scratch = [];
|
|
17
|
+
function track(dir) {
|
|
18
|
+
scratch.push(dir);
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const savedEnv = { ...process.env };
|
|
23
|
+
after(() => {
|
|
24
|
+
for (const key of Object.keys(process.env)) {
|
|
25
|
+
if (!(key in savedEnv)) delete process.env[key];
|
|
26
|
+
}
|
|
27
|
+
for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
|
|
28
|
+
for (const dir of scratch) {
|
|
29
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// A deterministic `codegraph` shim keeps the creation hooks off the developer's
|
|
34
|
+
// PATH-dependent tooling; the rest of PATH is preserved so git still resolves.
|
|
35
|
+
const HOOK_BIN = path.resolve(track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-wt-hookbin-'))));
|
|
36
|
+
fs.writeFileSync(path.join(HOOK_BIN, 'codegraph'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
37
|
+
// No Herdr workspace can be created from this path: it does not exist.
|
|
38
|
+
const NO_HERDR = path.join(os.tmpdir(), 'odd-worktree-no-such-herdr-binary');
|
|
39
|
+
|
|
40
|
+
function git(cwd, args) {
|
|
41
|
+
const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
42
|
+
if (out.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${out.stderr}`);
|
|
43
|
+
return out.stdout.trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeRepo() {
|
|
47
|
+
const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-wt-repo-')));
|
|
48
|
+
git(dir, ['init', '-b', 'main']);
|
|
49
|
+
git(dir, ['config', 'user.email', 'test@example.test']);
|
|
50
|
+
git(dir, ['config', 'user.name', 'Test']);
|
|
51
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# fixture\n');
|
|
52
|
+
git(dir, ['add', 'README.md']);
|
|
53
|
+
git(dir, ['commit', '-m', 'init']);
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeCtx(repoRoot, thread = 'thread-1') {
|
|
58
|
+
const notifications = [];
|
|
59
|
+
return {
|
|
60
|
+
notifications,
|
|
61
|
+
ctx: {
|
|
62
|
+
cwd: repoRoot,
|
|
63
|
+
hasUI: false,
|
|
64
|
+
mode: 'tui',
|
|
65
|
+
sessionManager: { getSessionId: () => thread },
|
|
66
|
+
ui: {
|
|
67
|
+
confirm: async () => true,
|
|
68
|
+
notify: (text, level) => { notifications.push([level, text]); },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resultText(result) {
|
|
75
|
+
return (result.content || []).map((part) => (part && part.text) || '').join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function listWorktreePaths(root) {
|
|
79
|
+
return git(root, ['worktree', 'list', '--porcelain'])
|
|
80
|
+
.split('\n')
|
|
81
|
+
.filter((line) => line.startsWith('worktree '))
|
|
82
|
+
.map((line) => line.slice('worktree '.length));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function branches(root) {
|
|
86
|
+
return git(root, ['branch', '--format=%(refname:short)']).split('\n').filter(Boolean).sort();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// S7/S8/S9: the active-session guard reads Gentle Shell's published presence
|
|
90
|
+
// store. Pin it to a throwaway agent home so no test can see (or be blocked by)
|
|
91
|
+
// the developer's real sessions, and publish synthetic records into it.
|
|
92
|
+
const AGENT_HOME = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-wt-agent-home-')));
|
|
93
|
+
const PRESENCE = path.join(AGENT_HOME, 'gentle-agents', 'presence');
|
|
94
|
+
// One published session: the heartbeat header plus the discovery sidecar that
|
|
95
|
+
// carries its workspace (the shape upstream orchestrator-presence.ts writes).
|
|
96
|
+
function publishSession({
|
|
97
|
+
sessionId, label = 'Worker', workspace, heartbeat = Date.now(), incarnationIndex = 1, discovery = true,
|
|
98
|
+
} = {}) {
|
|
99
|
+
fs.mkdirSync(PRESENCE, { recursive: true, mode: 0o700 });
|
|
100
|
+
try { fs.chmodSync(PRESENCE, 0o700); } catch { /* explicit mode is best effort */ }
|
|
101
|
+
const sessionHash = crypto.createHash('sha256').update(sessionId).digest('hex');
|
|
102
|
+
const incarnation = `1000000${incarnationIndex}-0000-4000-8000-00000000000${incarnationIndex}`;
|
|
103
|
+
fs.writeFileSync(path.join(PRESENCE, `${sessionHash}.${incarnation}.header.json`), JSON.stringify({
|
|
104
|
+
schema: 1,
|
|
105
|
+
sessionHash,
|
|
106
|
+
incarnation,
|
|
107
|
+
label,
|
|
108
|
+
heartbeat,
|
|
109
|
+
generation: 1,
|
|
110
|
+
counts: { running: 0, queued: 0, waiting: 0, finished: 0 },
|
|
111
|
+
digest: 'a'.repeat(64),
|
|
112
|
+
unavailable: null,
|
|
113
|
+
}));
|
|
114
|
+
if (discovery) {
|
|
115
|
+
fs.writeFileSync(path.join(PRESENCE, `${sessionHash}.${incarnation}.discovery.json`), JSON.stringify({
|
|
116
|
+
schema: 1,
|
|
117
|
+
sessionHash,
|
|
118
|
+
incarnation,
|
|
119
|
+
generation: 1,
|
|
120
|
+
metadata: { activation: 'b'.repeat(64), workspace, tasks: [], omitted: 0 },
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
return sessionHash;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// One published transport activation: the ONLY place a raw session id is
|
|
127
|
+
// published. The guard joins it to a presence header by hashing the id.
|
|
128
|
+
function publishTransport({ sessionId, token = 'AbCdEfGhIjKlMnOpQrStUv', createdAt = Date.now() }) {
|
|
129
|
+
const base = path.join(AGENT_HOME, 'gentle-agents');
|
|
130
|
+
const dir = path.join(base, 'transport', 'presence');
|
|
131
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
132
|
+
try {
|
|
133
|
+
fs.chmodSync(base, 0o700);
|
|
134
|
+
fs.chmodSync(path.join(base, 'transport'), 0o700);
|
|
135
|
+
fs.chmodSync(dir, 0o700);
|
|
136
|
+
} catch { /* explicit modes are best effort */ }
|
|
137
|
+
const file = path.join(dir, `${sessionId}.${token}.json`);
|
|
138
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
139
|
+
version: 1,
|
|
140
|
+
sessionId,
|
|
141
|
+
endpoint: `/tmp/gentle-pi-1000/0123456789abcdef0123456789abcdef/${token}.sock`,
|
|
142
|
+
createdAt,
|
|
143
|
+
}), { mode: 0o600 });
|
|
144
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
145
|
+
return file;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function makeUiCtx(repoRoot, thread = 'thread-1') {
|
|
149
|
+
const notifications = [];
|
|
150
|
+
const confirmations = [];
|
|
151
|
+
return {
|
|
152
|
+
notifications,
|
|
153
|
+
confirmations,
|
|
154
|
+
ctx: {
|
|
155
|
+
cwd: repoRoot,
|
|
156
|
+
hasUI: true,
|
|
157
|
+
mode: 'tui',
|
|
158
|
+
sessionManager: { getSessionId: () => thread },
|
|
159
|
+
ui: {
|
|
160
|
+
confirm: async (title, body) => {
|
|
161
|
+
confirmations.push({ title, body });
|
|
162
|
+
return true;
|
|
163
|
+
},
|
|
164
|
+
notify: (text, level) => { notifications.push([level, text]); },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let tools;
|
|
171
|
+
before(async () => {
|
|
172
|
+
process.env.HERDR_BIN_PATH = NO_HERDR;
|
|
173
|
+
process.env.GENTLE_WORKTREE_THREAD = 'thread-1';
|
|
174
|
+
process.env.PATH = `${HOOK_BIN}${path.delimiter}${savedEnv.PATH || ''}`;
|
|
175
|
+
delete process.env.GENTLE_PI_AGENT_HOME;
|
|
176
|
+
process.env.PI_CODING_AGENT_DIR = AGENT_HOME;
|
|
177
|
+
({ tools } = await loadExtension({}));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('worktree_list', () => {
|
|
181
|
+
test('a fresh repository lists its principal checkout only', async () => {
|
|
182
|
+
const repo = makeRepo();
|
|
183
|
+
const { ctx } = makeCtx(repo);
|
|
184
|
+
const result = await tools.get('worktree_list').execute('call-1', {}, undefined, undefined, ctx);
|
|
185
|
+
assert.notEqual(result.isError, true, resultText(result));
|
|
186
|
+
assert.equal(
|
|
187
|
+
resultText(result),
|
|
188
|
+
`worktrees: 1 (name · branch · path)\nprincipal · main · ${fs.realpathSync(repo)}`,
|
|
189
|
+
);
|
|
190
|
+
assert.equal(result.details.count, 1);
|
|
191
|
+
assert.deepEqual(result.details.worktrees.map((row) => row.kind), ['principal']);
|
|
192
|
+
assert.equal(result.details.worktrees[0].path, fs.realpathSync(repo));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a directory outside a repository is refused by the help text', async () => {
|
|
196
|
+
const loose = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-wt-loose-')));
|
|
197
|
+
const { ctx } = makeCtx(loose);
|
|
198
|
+
const result = await tools.get('worktree_list').execute('call-2', {}, undefined, undefined, ctx);
|
|
199
|
+
assert.equal(result.isError, true);
|
|
200
|
+
assert.equal(resultText(result), `not a git repository: ${loose}`);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('worktree_create', () => {
|
|
205
|
+
test('creates the checkout and its registry row without Herdr', async () => {
|
|
206
|
+
const repo = makeRepo();
|
|
207
|
+
const { ctx } = makeCtx(repo);
|
|
208
|
+
const result = await tools.get('worktree_create').execute(
|
|
209
|
+
'call-3',
|
|
210
|
+
{ branch: 'feat/x', changes: 'none' },
|
|
211
|
+
undefined,
|
|
212
|
+
undefined,
|
|
213
|
+
ctx,
|
|
214
|
+
);
|
|
215
|
+
assert.notEqual(result.isError, true, resultText(result));
|
|
216
|
+
const rows = resultText(result).split('\n');
|
|
217
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-x');
|
|
218
|
+
assert.equal(rows[0], 'created: feat-x · feat/x');
|
|
219
|
+
assert.equal(rows[1], `path: ${target}`);
|
|
220
|
+
assert.equal(rows[2], 'registered: new');
|
|
221
|
+
assert.ok(fs.existsSync(target), 'the checkout exists on disk');
|
|
222
|
+
assert.ok(listWorktreePaths(repo).includes(target), 'git knows the checkout');
|
|
223
|
+
assert.ok(branches(repo).includes('feat/x'), 'the branch exists');
|
|
224
|
+
|
|
225
|
+
const listed = await tools.get('worktree_list').execute('call-4', {}, undefined, undefined, ctx);
|
|
226
|
+
assert.equal(
|
|
227
|
+
resultText(listed),
|
|
228
|
+
`worktrees: 2 (name · branch · path)\nprincipal · main · ${fs.realpathSync(repo)}\nfeat-x · feat/x · ${target}`,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('refuses an invalid branch name and writes nothing', async () => {
|
|
233
|
+
const repo = makeRepo();
|
|
234
|
+
const { ctx } = makeCtx(repo);
|
|
235
|
+
const before = { paths: listWorktreePaths(repo), branches: branches(repo) };
|
|
236
|
+
const result = await tools.get('worktree_create').execute(
|
|
237
|
+
'call-5',
|
|
238
|
+
{ branch: 'bad..name', changes: 'none' },
|
|
239
|
+
undefined,
|
|
240
|
+
undefined,
|
|
241
|
+
ctx,
|
|
242
|
+
);
|
|
243
|
+
assert.equal(result.isError, true);
|
|
244
|
+
assert.match(resultText(result), /^invalid branch name/);
|
|
245
|
+
assert.deepEqual({ paths: listWorktreePaths(repo), branches: branches(repo) }, before);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('refuses a call that never states what to copy', async () => {
|
|
249
|
+
const repo = makeRepo();
|
|
250
|
+
const { ctx } = makeCtx(repo);
|
|
251
|
+
const result = await tools.get('worktree_create').execute('call-6', { branch: 'feat/y' }, undefined, undefined, ctx);
|
|
252
|
+
assert.equal(result.isError, true);
|
|
253
|
+
assert.match(resultText(result), /^invalid changes selection: 'changes' is required/);
|
|
254
|
+
assert.equal(listWorktreePaths(repo).length, 1, 'no checkout was added');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('worktree_close', () => {
|
|
259
|
+
test('removes the checkout and keeps the branch', async () => {
|
|
260
|
+
const repo = makeRepo();
|
|
261
|
+
const { ctx } = makeCtx(repo);
|
|
262
|
+
const created = await tools.get('worktree_create').execute(
|
|
263
|
+
'call-7',
|
|
264
|
+
{ branch: 'feat/close-me', changes: 'none' },
|
|
265
|
+
undefined,
|
|
266
|
+
undefined,
|
|
267
|
+
ctx,
|
|
268
|
+
);
|
|
269
|
+
assert.notEqual(created.isError, true, resultText(created));
|
|
270
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-close-me');
|
|
271
|
+
|
|
272
|
+
const closed = await tools.get('worktree_close').execute('call-8', { name: 'feat-close-me' }, undefined, undefined, ctx);
|
|
273
|
+
assert.notEqual(closed.isError, true, resultText(closed));
|
|
274
|
+
assert.equal(
|
|
275
|
+
resultText(closed),
|
|
276
|
+
`outcome: removed\nname: feat-close-me\nbranch: feat/close-me\npath: ${target}\nbranch state: kept`,
|
|
277
|
+
);
|
|
278
|
+
assert.equal(fs.existsSync(target), false, 'the checkout is gone');
|
|
279
|
+
assert.equal(listWorktreePaths(repo).includes(target), false);
|
|
280
|
+
assert.ok(branches(repo).includes('feat/close-me'), 'the branch is kept');
|
|
281
|
+
|
|
282
|
+
const listed = await tools.get('worktree_list').execute('call-9', {}, undefined, undefined, ctx);
|
|
283
|
+
assert.equal(
|
|
284
|
+
resultText(listed),
|
|
285
|
+
`worktrees: 1 (name · branch · path)\nprincipal · main · ${fs.realpathSync(repo)}`,
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('an unknown name is refused and leaves the repository untouched', async () => {
|
|
290
|
+
const repo = makeRepo();
|
|
291
|
+
const { ctx } = makeCtx(repo);
|
|
292
|
+
const result = await tools.get('worktree_close').execute('call-10', { name: 'nope' }, undefined, undefined, ctx);
|
|
293
|
+
assert.equal(result.isError, true);
|
|
294
|
+
assert.equal(resultText(result), 'no registered worktree for "nope": no worktrees are registered');
|
|
295
|
+
assert.equal(listWorktreePaths(repo).length, 1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// S7/S8/S9: the shared active-session guard. Every case asserts the checkout,
|
|
299
|
+
// the branch and the registry row survive a refusal, and that a refusal never
|
|
300
|
+
// reaches the approval question.
|
|
301
|
+
test('another live Gentle Shell session in the target refuses the close', async () => {
|
|
302
|
+
const repo = makeRepo();
|
|
303
|
+
const { ctx } = makeCtx(repo);
|
|
304
|
+
const created = await tools.get('worktree_create').execute('call-s1', { branch: 'feat/busy', changes: 'none' }, undefined, undefined, ctx);
|
|
305
|
+
assert.notEqual(created.isError, true, resultText(created));
|
|
306
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-busy');
|
|
307
|
+
const sessionHash = publishSession({ sessionId: 'worker-9', label: 'Worker', workspace: target });
|
|
308
|
+
|
|
309
|
+
const closed = await tools.get('worktree_close').execute('call-s2', { name: 'feat-busy' }, undefined, undefined, ctx);
|
|
310
|
+
assert.equal(closed.isError, true, 'a live session blocks the close');
|
|
311
|
+
const text = resultText(closed);
|
|
312
|
+
assert.match(text, /working in that checkout/);
|
|
313
|
+
assert.match(text, /'Worker'/);
|
|
314
|
+
assert.ok(text.includes(sessionHash.slice(0, 12)), `the refusal names the session: ${text}`);
|
|
315
|
+
assert.match(text, /finish/);
|
|
316
|
+
assert.match(text, /\/warp/);
|
|
317
|
+
assert.match(text, /left untouched/);
|
|
318
|
+
assert.ok(fs.existsSync(target), 'the checkout survives a refusal');
|
|
319
|
+
assert.ok(listWorktreePaths(repo).includes(target), 'git still knows the checkout');
|
|
320
|
+
assert.ok(branches(repo).includes('feat/busy'), 'the branch is untouched');
|
|
321
|
+
const listed = await tools.get('worktree_list').execute('call-s3', {}, undefined, undefined, ctx);
|
|
322
|
+
assert.equal(resultText(listed).split('\n').length, 3, 'the registry row is kept');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('this session\u2019s own checkout is refused with a warp instruction, never auto-warped', async () => {
|
|
326
|
+
const repo = makeRepo();
|
|
327
|
+
const { ctx: creator } = makeCtx(repo);
|
|
328
|
+
const created = await tools.get('worktree_create').execute('call-s4', { branch: 'feat/mine', changes: 'none' }, undefined, undefined, creator);
|
|
329
|
+
assert.notEqual(created.isError, true, resultText(created));
|
|
330
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-mine');
|
|
331
|
+
// The session now STANDS in the checkout: no presence record is needed for
|
|
332
|
+
// the deterministic cwd check to catch it.
|
|
333
|
+
const { ctx } = makeCtx(target);
|
|
334
|
+
const closed = await tools.get('worktree_close').execute('call-s5', { name: 'feat-mine' }, undefined, undefined, ctx);
|
|
335
|
+
assert.equal(closed.isError, true);
|
|
336
|
+
const text = resultText(closed);
|
|
337
|
+
assert.match(text, /this session is working in that checkout/);
|
|
338
|
+
assert.match(text, /\/warp/);
|
|
339
|
+
assert.match(text, /left untouched/);
|
|
340
|
+
assert.equal(/session warped/.test(text), false, 'the tool never warps by itself');
|
|
341
|
+
assert.ok(fs.existsSync(target), 'the checkout survives the refusal');
|
|
342
|
+
assert.ok(branches(repo).includes('feat/mine'), 'the branch is untouched');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('a blocked close never asks for approval', async () => {
|
|
346
|
+
const repo = makeRepo();
|
|
347
|
+
const { ctx: creator } = makeCtx(repo);
|
|
348
|
+
await tools.get('worktree_create').execute('call-s6', { branch: 'feat/blocked-ui', changes: 'none' }, undefined, undefined, creator);
|
|
349
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-blocked-ui');
|
|
350
|
+
publishSession({ sessionId: 'ui-worker', label: 'UI Worker', workspace: target, incarnationIndex: 2 });
|
|
351
|
+
const { ctx, confirmations } = makeUiCtx(repo);
|
|
352
|
+
const closed = await tools.get('worktree_close').execute('call-s7', { name: 'feat/blocked-ui' }, undefined, undefined, ctx);
|
|
353
|
+
assert.equal(closed.isError, true);
|
|
354
|
+
assert.equal(confirmations.length, 0, 'the session check runs before the approval question');
|
|
355
|
+
assert.ok(fs.existsSync(target));
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('a live session in another checkout does not block', async () => {
|
|
359
|
+
const repo = makeRepo();
|
|
360
|
+
const { ctx } = makeCtx(repo);
|
|
361
|
+
await tools.get('worktree_create').execute('call-s8', { branch: 'feat/free', changes: 'none' }, undefined, undefined, ctx);
|
|
362
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-free');
|
|
363
|
+
publishSession({ sessionId: 'other-worker', label: 'Other', workspace: repo, incarnationIndex: 3 });
|
|
364
|
+
const closed = await tools.get('worktree_close').execute('call-s9', { name: 'feat-free' }, undefined, undefined, ctx);
|
|
365
|
+
assert.notEqual(closed.isError, true, resultText(closed));
|
|
366
|
+
assert.equal(fs.existsSync(target), false);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test('the refusal names the RAW session id joined from the transport registry', async () => {
|
|
370
|
+
const repo = makeRepo();
|
|
371
|
+
const { ctx } = makeCtx(repo);
|
|
372
|
+
await tools.get('worktree_create').execute('call-s12', { branch: 'feat/joined', changes: 'none' }, undefined, undefined, ctx);
|
|
373
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-joined');
|
|
374
|
+
const sessionHash = publishSession({ sessionId: 'raw-worker-9', label: 'Raw', workspace: target, incarnationIndex: 7 });
|
|
375
|
+
publishTransport({ sessionId: 'raw-worker-9' });
|
|
376
|
+
|
|
377
|
+
const closed = await tools.get('worktree_close').execute('call-s13', { name: 'feat-joined' }, undefined, undefined, ctx);
|
|
378
|
+
assert.equal(closed.isError, true);
|
|
379
|
+
const text = resultText(closed);
|
|
380
|
+
assert.ok(text.includes('id raw-worker-9'), text);
|
|
381
|
+
assert.equal(text.includes(sessionHash.slice(0, 12)), false, 'a joined id is shown, not the hash');
|
|
382
|
+
assert.equal(text.includes('could not be resolved'), false);
|
|
383
|
+
assert.ok(fs.existsSync(target), 'the checkout survives the refusal');
|
|
384
|
+
assert.ok(branches(repo).includes('feat/joined'), 'the branch is untouched');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('without a transport record the refusal discloses that the id could not be resolved', async () => {
|
|
388
|
+
const repo = makeRepo();
|
|
389
|
+
const { ctx } = makeCtx(repo);
|
|
390
|
+
await tools.get('worktree_create').execute('call-s14', { branch: 'feat/unjoined', changes: 'none' }, undefined, undefined, ctx);
|
|
391
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-unjoined');
|
|
392
|
+
const sessionHash = publishSession({ sessionId: 'unjoined-worker', label: 'Unjoined', workspace: target, incarnationIndex: 8 });
|
|
393
|
+
|
|
394
|
+
const closed = await tools.get('worktree_close').execute('call-s15', { name: 'feat-unjoined' }, undefined, undefined, ctx);
|
|
395
|
+
assert.equal(closed.isError, true);
|
|
396
|
+
const text = resultText(closed);
|
|
397
|
+
assert.ok(text.includes(sessionHash.slice(0, 12)), text);
|
|
398
|
+
assert.match(text, /could not be resolved/);
|
|
399
|
+
assert.match(text, /presence hash/);
|
|
400
|
+
assert.ok(fs.existsSync(target), 'the checkout survives the refusal');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('live-but-unattributable evidence is disclosed in the close output and never blocks', async () => {
|
|
404
|
+
const repo = makeRepo();
|
|
405
|
+
const { ctx } = makeCtx(repo);
|
|
406
|
+
await tools.get('worktree_create').execute('call-s16', { branch: 'feat/advisory', changes: 'none' }, undefined, undefined, ctx);
|
|
407
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-advisory');
|
|
408
|
+
publishSession({ sessionId: 'legacy-peer', label: 'Legacy', workspace: null, heartbeat: Date.now(), incarnationIndex: 9, discovery: false });
|
|
409
|
+
|
|
410
|
+
const closed = await tools.get('worktree_close').execute('call-s17', { name: 'feat-advisory' }, undefined, undefined, ctx);
|
|
411
|
+
assert.notEqual(closed.isError, true, resultText(closed));
|
|
412
|
+
assert.match(resultText(closed), /published no workspace/);
|
|
413
|
+
assert.match(resultText(closed), /best effort/);
|
|
414
|
+
assert.equal(fs.existsSync(target), false, 'the unattributable record does not block removal');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('a stale record is disclosed as best effort and does not block', async () => {
|
|
418
|
+
const repo = makeRepo();
|
|
419
|
+
const { ctx } = makeCtx(repo);
|
|
420
|
+
await tools.get('worktree_create').execute('call-s10', { branch: 'feat/stale', changes: 'none' }, undefined, undefined, ctx);
|
|
421
|
+
const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-stale');
|
|
422
|
+
publishSession({
|
|
423
|
+
sessionId: 'crashed-worker', label: 'Crashed', workspace: target,
|
|
424
|
+
heartbeat: Date.now() - 10 * 60 * 1000, incarnationIndex: 4,
|
|
425
|
+
});
|
|
426
|
+
const closed = await tools.get('worktree_close').execute('call-s11', { name: 'feat-stale' }, undefined, undefined, ctx);
|
|
427
|
+
assert.notEqual(closed.isError, true, resultText(closed));
|
|
428
|
+
const text = resultText(closed);
|
|
429
|
+
assert.match(text, /stale Gentle Shell presence record ignored/);
|
|
430
|
+
assert.match(text, /best effort/);
|
|
431
|
+
assert.equal(fs.existsSync(target), false);
|
|
432
|
+
assert.ok(branches(repo).includes('feat/stale'), 'the branch is kept');
|
|
433
|
+
});
|
|
434
|
+
});
|