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.
Files changed (60) hide show
  1. package/README.md +234 -2
  2. package/extensions/odd-worktree-tools.ts +7593 -0
  3. package/lib/checkout-facts.js +598 -0
  4. package/lib/clipboard.js +226 -0
  5. package/lib/gh-issues.js +1555 -0
  6. package/lib/issue-preview.js +273 -0
  7. package/lib/issues-view.js +2274 -0
  8. package/lib/odd-docs.js +505 -0
  9. package/lib/odd-markdown.js +413 -0
  10. package/lib/odd-view.js +990 -0
  11. package/lib/open-external.js +106 -0
  12. package/lib/preview-scroll-keys.js +192 -0
  13. package/lib/session-auto-name.js +292 -0
  14. package/lib/session-worktree-register.js +62 -0
  15. package/lib/tui-paint.js +273 -0
  16. package/lib/view-layout.js +368 -0
  17. package/lib/warp-create-panel.js +722 -0
  18. package/lib/warp-create-picker.js +906 -0
  19. package/lib/warp-delete-confirm.js +465 -0
  20. package/lib/warp-selector-view.js +1265 -0
  21. package/lib/worktree-card.js +1117 -0
  22. package/lib/worktree-changes.js +1395 -0
  23. package/lib/worktree-close-herdr.js +73 -0
  24. package/lib/worktree-git.js +1048 -0
  25. package/lib/worktree-hooks.js +738 -0
  26. package/lib/worktree-model-result.js +291 -0
  27. package/lib/worktree-registry.js +886 -0
  28. package/lib/worktree-session-guard.js +595 -0
  29. package/lib/worktree-symlinks.js +336 -0
  30. package/lib/worktree-warp-sessions.js +566 -0
  31. package/package.json +36 -4
  32. package/tools/commands.test.js +176 -0
  33. package/tools/fixtures/gh-api-user.json +1 -0
  34. package/tools/fixtures/gh-issue-list.json +1 -0
  35. package/tools/fixtures/gh-issue-view.json +1 -0
  36. package/tools/fixtures/gh-label-list.json +1 -0
  37. package/tools/fixtures/gh-repo-view.json +1 -0
  38. package/tools/fixtures/load-extension.js +66 -0
  39. package/tools/gh-issues-feedback.test.js +359 -0
  40. package/tools/gh-issues.test.js +1502 -0
  41. package/tools/issue-preview.test.js +216 -0
  42. package/tools/issues-command.test.js +1217 -0
  43. package/tools/issues-view.test.js +4401 -0
  44. package/tools/odd-docs.test.js +803 -0
  45. package/tools/odd-markdown.test.js +236 -0
  46. package/tools/odd-view.test.js +2328 -0
  47. package/tools/package-surface.test.js +138 -0
  48. package/tools/t5-session-identity.test.js +306 -0
  49. package/tools/warp-create-panel.test.js +185 -0
  50. package/tools/warp-create-picker.test.js +1357 -0
  51. package/tools/warp-delete-confirm.test.js +616 -0
  52. package/tools/warp-selector-view.test.js +1463 -0
  53. package/tools/worktree-changes.test.js +951 -0
  54. package/tools/worktree-close-herdr-async.test.js +204 -0
  55. package/tools/worktree-odd-migration.test.js +224 -0
  56. package/tools/worktree-registry-cache.test.js +163 -0
  57. package/tools/worktree-registry-odd.test.js +231 -0
  58. package/tools/worktree-registry.test.js +244 -0
  59. package/tools/worktree-session-guard.test.js +507 -0
  60. package/tools/worktree-tools.test.js +434 -0
@@ -0,0 +1,616 @@
1
+ 'use strict';
2
+ // S4 (UI consistency round 3): lib/warp-delete-confirm.js — the ONE builder
3
+ // both warp keyboard surfaces use, tested through its exported functions.
4
+ //
5
+ // What is pinned here is the honesty of the disclosure, which the extension's
6
+ // wiring tests can only reach with a Git repository that answers every read:
7
+ // - pending changes and commits ahead of `main` are named when they exist;
8
+ // - a clean checkout adds no warning, so the dialog never cries wolf;
9
+ // - a read that FAILS is reported as unknown with its reason, never as 0,
10
+ // never as "no pending changes" and never as safe;
11
+ // - `force` travels only on positive evidence of pending changes;
12
+ // - the ahead-of-main comparison is asked for `main` explicitly, so a
13
+ // repository without `main` says so instead of silently comparing with
14
+ // `origin/main` or `master`.
15
+ //
16
+ // Every fixture is a throwaway Git repository under the system temp directory:
17
+ // nothing here deletes a real checkout, and no test touches a real registry.
18
+
19
+ const { test, describe, before, after } = require('node:test');
20
+ const assert = require('node:assert/strict');
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+ const { spawnSync } = require('node:child_process');
25
+
26
+ const confirmLib = require('../lib/warp-delete-confirm.js');
27
+ const changesLib = require('../lib/worktree-changes.js');
28
+
29
+ const roots = [];
30
+
31
+ after(() => {
32
+ for (const dir of roots) {
33
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
34
+ }
35
+ });
36
+
37
+ function track(dir) {
38
+ roots.push(dir);
39
+ return dir;
40
+ }
41
+
42
+ function git(cwd, args) {
43
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
44
+ assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`);
45
+ return (result.stdout || '').trim();
46
+ }
47
+
48
+ // Deterministic incompressible bytes: `--binary` zlib-compresses the patch, so
49
+ // a repeating pattern would produce a tiny patch and never reach the budget.
50
+ function noise(length, seed = 0x9e3779b9) {
51
+ const buffer = Buffer.allocUnsafe(length);
52
+ let state = seed >>> 0;
53
+ for (let index = 0; index < length; index += 1) {
54
+ state ^= state << 13;
55
+ state >>>= 0;
56
+ state ^= state >>> 17;
57
+ state ^= state << 5;
58
+ state >>>= 0;
59
+ buffer[index] = state & 0xff;
60
+ }
61
+ return buffer;
62
+ }
63
+
64
+ function makeRepo(tag) {
65
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), `wt-confirm-${tag}-`)));
66
+ git(dir, ['init']);
67
+ git(dir, ['config', 'user.email', 'test@example.com']);
68
+ git(dir, ['config', 'user.name', 'Test']);
69
+ git(dir, ['commit', '--allow-empty', '-m', 'init']);
70
+ return fs.realpathSync(dir);
71
+ }
72
+
73
+ const TARGET = { name: 'feat-del', path: '/checkouts/feat-del', branch: 'feat/del' };
74
+ const BASE_BODY = `Delete worktree checkout at ${TARGET.path} for branch '${TARGET.branch}'? This removes the checkout but keeps the branch.`;
75
+
76
+ describe('S4: the keyboard delete dialog is always asked and always honest', () => {
77
+ test('the title names the worktree and a clean, zero-ahead checkout adds no warning', async () => {
78
+ const repo = makeRepo('clean');
79
+ git(repo, ['branch', 'main', 'HEAD']);
80
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo);
81
+ assert.deepEqual(evidence.changes, { status: 'clean', files: 0, added: 0, deleted: 0 });
82
+ assert.equal(evidence.aheadMain.status, 'none');
83
+ assert.equal(evidence.aheadMain.ahead, 0);
84
+ const prompt = confirmLib.warpDeletePrompt(TARGET, evidence);
85
+ assert.equal(prompt.title, "Delete worktree 'feat-del'?");
86
+ assert.equal(prompt.body, BASE_BODY);
87
+ assert.equal(prompt.force, false, 'a clean checkout is never forced');
88
+ });
89
+
90
+ test('pending changes and commits ahead of main are both named, and only dirty evidence forces', async () => {
91
+ const repo = makeRepo('dirty-ahead');
92
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'one\n');
93
+ git(repo, ['add', 'a.txt']);
94
+ git(repo, ['commit', '-m', 'seed']);
95
+ git(repo, ['branch', 'main', 'HEAD']);
96
+ // Two commits `main` does not have, then one pending line.
97
+ git(repo, ['commit', '--allow-empty', '-m', 'ahead one']);
98
+ git(repo, ['commit', '--allow-empty', '-m', 'ahead two']);
99
+ fs.appendFileSync(path.join(repo, 'a.txt'), 'dirty\n');
100
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo);
101
+ assert.equal(evidence.changes.status, 'dirty');
102
+ assert.equal(evidence.changes.files, 1);
103
+ assert.equal(evidence.changes.added, 1);
104
+ assert.equal(evidence.aheadMain.status, 'ahead');
105
+ assert.equal(evidence.aheadMain.ahead, 2);
106
+ const prompt = confirmLib.warpDeletePrompt(TARGET, evidence);
107
+ assert.equal(prompt.body, `${BASE_BODY} Pending changes: 1 file, +1/-0. Commits ahead of main: 2.`);
108
+ assert.equal(prompt.force, true, 'positive dirty evidence is what authorizes force');
109
+ });
110
+
111
+ test('commits ahead of main are named on their own for a clean checkout', async () => {
112
+ const repo = makeRepo('ahead-only');
113
+ git(repo, ['branch', 'main', 'HEAD']);
114
+ git(repo, ['commit', '--allow-empty', '-m', 'ahead one']);
115
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo);
116
+ assert.equal(evidence.changes.status, 'clean');
117
+ assert.equal(evidence.aheadMain.status, 'ahead');
118
+ const prompt = confirmLib.warpDeletePrompt(TARGET, evidence);
119
+ assert.equal(prompt.body, `${BASE_BODY} Commits ahead of main: 1.`);
120
+ assert.equal(prompt.force, false, 'ahead-of-main alone never forces');
121
+ });
122
+
123
+ test('a missing main branch is reported as unknown instead of being compared with another base', async () => {
124
+ const repo = makeRepo('no-main');
125
+ git(repo, ['commit', '--allow-empty', '-m', 'only on master']);
126
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo);
127
+ assert.equal(evidence.aheadMain.status, 'unknown');
128
+ assert.equal(evidence.aheadMain.ahead, null, 'unknown stays distinguishable from zero');
129
+ assert.match(evidence.aheadMain.reason, /main/);
130
+ const prompt = confirmLib.warpDeletePrompt(TARGET, evidence);
131
+ assert.equal(prompt.body, `${BASE_BODY} Commits ahead of main: unknown (base branch not found: main).`);
132
+ assert.ok(!/Commits ahead of main: 0/.test(prompt.body), 'unknown is never reported as zero');
133
+ });
134
+
135
+ test('a failed change summary is reported as unknown and never authorizes force', async () => {
136
+ const repo = makeRepo('changes-fail');
137
+ git(repo, ['branch', 'main', 'HEAD']);
138
+ const failing = {
139
+ summarizeChanges: async () => ({ ok: false, error: 'cannot diff this checkout' }),
140
+ };
141
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo, { changesLib: failing });
142
+ assert.equal(evidence.changes.status, 'unknown');
143
+ assert.equal(evidence.aheadMain.status, 'none', 'the other read is unaffected');
144
+ const prompt = confirmLib.warpDeletePrompt(TARGET, evidence);
145
+ assert.equal(prompt.body, `${BASE_BODY} Uncommitted changes: unknown (cannot diff this checkout).`);
146
+ assert.equal(prompt.force, false, 'unknown evidence never forces');
147
+ });
148
+
149
+ test('a change summary that throws is reported as unknown, not as clean', async () => {
150
+ const repo = makeRepo('changes-throw');
151
+ git(repo, ['branch', 'main', 'HEAD']);
152
+ const throwing = {
153
+ summarizeChanges: async () => { throw new Error('git exploded'); },
154
+ };
155
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo, { changesLib: throwing });
156
+ assert.equal(evidence.changes.status, 'unknown');
157
+ assert.match(evidence.changes.reason, /git exploded/);
158
+ const prompt = confirmLib.warpDeletePrompt(TARGET, evidence);
159
+ assert.match(prompt.body, /Uncommitted changes: unknown \(git exploded\)\./);
160
+ assert.equal(prompt.force, false);
161
+ });
162
+
163
+ test('a path that is not a checkout reports both reads as unknown with reasons and never rejects', async () => {
164
+ const outside = fs.realpathSync(track(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-confirm-outside-'))));
165
+ const evidence = await confirmLib.readWarpDeleteEvidence(outside);
166
+ assert.equal(evidence.changes.status, 'unknown');
167
+ assert.ok(typeof evidence.changes.reason === 'string' && evidence.changes.reason.length > 0);
168
+ assert.equal(evidence.aheadMain.status, 'unknown');
169
+ assert.ok(typeof evidence.aheadMain.reason === 'string' && evidence.aheadMain.reason.length > 0);
170
+ const prompt = confirmLib.warpDeletePrompt(TARGET, evidence);
171
+ assert.match(prompt.body, /Uncommitted changes: unknown \(/);
172
+ assert.match(prompt.body, /Commits ahead of main: unknown \(/);
173
+ assert.equal(prompt.force, false);
174
+ });
175
+
176
+ test('an empty checkout path is refused as unknown evidence instead of being probed', async () => {
177
+ const evidence = await confirmLib.readWarpDeleteEvidence('');
178
+ assert.equal(evidence.changes.status, 'unknown');
179
+ assert.equal(evidence.aheadMain.status, 'unknown');
180
+ assert.equal(evidence.aheadMain.ahead, null);
181
+ assert.equal(confirmLib.warpDeletePrompt(TARGET, evidence).force, false);
182
+ });
183
+
184
+ test('the production change summary is the module the extension already uses', () => {
185
+ // The builder must consume the shared summary, not a second dirty check.
186
+ assert.equal(typeof changesLib.summarizeChanges, 'function');
187
+ assert.equal(typeof confirmLib.readWarpDeleteEvidence, 'function');
188
+ assert.equal(typeof confirmLib.warpDeletePrompt, 'function');
189
+ });
190
+ });
191
+
192
+ // The dialog is answered BEFORE the removal, so the evidence the human read can
193
+ // be stale by the time the checkout would be deleted. The key below is what
194
+ // makes that comparison possible: two reads with the same key described the
195
+ // same pending work and the same ahead-of-main state, so the confirmation still
196
+ // describes the checkout; any difference means the human was shown something
197
+ // that is no longer true and the caller must ask again.
198
+ describe('S4: a confirmed disclosure can be compared with the read after it', () => {
199
+ const CLEAN = { changes: { status: 'clean', files: 0, added: 0, deleted: 0 }, aheadMain: { status: 'none', ahead: 0 } };
200
+ const DIRTY = { changes: { status: 'dirty', files: 1, added: 1, deleted: 0 }, aheadMain: { status: 'none', ahead: 0 } };
201
+
202
+ test('the same reading is the same key, whatever else the object carries', () => {
203
+ const copy = JSON.parse(JSON.stringify(DIRTY));
204
+ assert.equal(confirmLib.warpDeleteEvidenceKey(DIRTY), confirmLib.warpDeleteEvidenceKey(copy));
205
+ });
206
+
207
+ test('a clean read, a changed count and an unreadable read are all different keys', () => {
208
+ assert.notEqual(confirmLib.warpDeleteEvidenceKey(CLEAN), confirmLib.warpDeleteEvidenceKey(DIRTY));
209
+ const moreFiles = { changes: { ...DIRTY.changes, files: 2, added: 4 }, aheadMain: DIRTY.aheadMain };
210
+ assert.notEqual(confirmLib.warpDeleteEvidenceKey(DIRTY), confirmLib.warpDeleteEvidenceKey(moreFiles));
211
+ const unreadable = { changes: { status: 'unknown', reason: 'git exploded' }, aheadMain: DIRTY.aheadMain };
212
+ assert.notEqual(confirmLib.warpDeleteEvidenceKey(DIRTY), confirmLib.warpDeleteEvidenceKey(unreadable));
213
+ const aheadChanged = { changes: DIRTY.changes, aheadMain: { status: 'ahead', ahead: 3 } };
214
+ assert.notEqual(confirmLib.warpDeleteEvidenceKey(DIRTY), confirmLib.warpDeleteEvidenceKey(aheadChanged));
215
+ });
216
+
217
+ test('a read of the same checkout before and after it gained a line changes the key', async () => {
218
+ // No `main` here on purpose: both ahead-of-main reads answer the same
219
+ // `unknown`, so the key moves only because the PENDING WORK moved.
220
+ const repo = makeRepo('key-changes');
221
+ const clean = await confirmLib.readWarpDeleteEvidence(repo);
222
+ assert.equal(clean.changes.status, 'clean');
223
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'one\n');
224
+ const dirty = await confirmLib.readWarpDeleteEvidence(repo);
225
+ assert.equal(dirty.changes.status, 'dirty');
226
+ assert.notEqual(confirmLib.warpDeleteEvidenceKey(clean), confirmLib.warpDeleteEvidenceKey(dirty));
227
+ git(repo, ['add', 'a.txt']);
228
+ git(repo, ['commit', '-m', 'seed']);
229
+ const cleanAgain = await confirmLib.readWarpDeleteEvidence(repo);
230
+ assert.equal(cleanAgain.changes.status, 'clean');
231
+ assert.equal(confirmLib.warpDeleteEvidenceKey(clean), confirmLib.warpDeleteEvidenceKey(cleanAgain),
232
+ 'the same pending-work statement is the same key, so a stable read never refuses');
233
+ });
234
+ });
235
+
236
+ // A1 (verification): `+A/-D` is not the pending work. Rewriting a line leaves
237
+ // the counts identical while the bytes differ, so a confirmation bound only to
238
+ // the counts authorizes deleting something the human never saw. The evidence
239
+ // therefore carries a CONTENT fingerprint, and a read that cannot produce one
240
+ // is `unknown` — never "unchanged".
241
+ describe('A1: the confirmation is bound to the pending CONTENT, not to a line count', () => {
242
+ test('the same counts with different bytes are a different reading', async () => {
243
+ const repo = makeRepo('content-swap');
244
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'one\n');
245
+ git(repo, ['add', 'a.txt']);
246
+ git(repo, ['commit', '-m', 'seed']);
247
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'two\n');
248
+ const first = await confirmLib.readWarpDeleteEvidence(repo);
249
+ assert.equal(first.changes.status, 'dirty');
250
+ assert.deepEqual(
251
+ { files: first.changes.files, added: first.changes.added, deleted: first.changes.deleted },
252
+ { files: 1, added: 1, deleted: 1 },
253
+ 'the fixture really keeps the counts identical',
254
+ );
255
+ assert.equal(first.content.status, 'known');
256
+ // Same added/deleted counts, different bytes.
257
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'three\n');
258
+ const second = await confirmLib.readWarpDeleteEvidence(repo);
259
+ assert.deepEqual(
260
+ { files: second.changes.files, added: second.changes.added, deleted: second.changes.deleted },
261
+ { files: first.changes.files, added: first.changes.added, deleted: first.changes.deleted },
262
+ 'the counts are still identical',
263
+ );
264
+ assert.notEqual(first.content.fingerprint, second.content.fingerprint, 'but the content fingerprint moved');
265
+ assert.notEqual(confirmLib.warpDeleteEvidenceKey(first), confirmLib.warpDeleteEvidenceKey(second));
266
+ });
267
+
268
+ test('an unchanged checkout reads the same fingerprint every time', async () => {
269
+ const repo = makeRepo('content-stable');
270
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'one\n');
271
+ git(repo, ['add', 'a.txt']);
272
+ git(repo, ['commit', '-m', 'seed']);
273
+ fs.appendFileSync(path.join(repo, 'a.txt'), 'two\n');
274
+ const first = await confirmLib.readWarpDeleteEvidence(repo);
275
+ const second = await confirmLib.readWarpDeleteEvidence(repo);
276
+ assert.equal(first.content.status, 'known');
277
+ assert.equal(first.content.fingerprint, second.content.fingerprint);
278
+ assert.equal(confirmLib.warpDeleteEvidenceKey(first), confirmLib.warpDeleteEvidenceKey(second));
279
+ });
280
+
281
+ test('untracked files are part of the content fingerprint, so a silent edit is caught', async () => {
282
+ const repo = makeRepo('content-untracked');
283
+ fs.writeFileSync(path.join(repo, 'new.txt'), 'alpha\n');
284
+ const first = await confirmLib.readWarpDeleteEvidence(repo);
285
+ const second = await confirmLib.readWarpDeleteEvidence(repo);
286
+ assert.equal(confirmLib.warpDeleteEvidenceKey(first), confirmLib.warpDeleteEvidenceKey(second), 'a stable read is stable');
287
+ fs.writeFileSync(path.join(repo, 'new.txt'), 'alpha\nbeta\n');
288
+ const grown = await confirmLib.readWarpDeleteEvidence(repo);
289
+ assert.notEqual(confirmLib.warpDeleteEvidenceKey(first), confirmLib.warpDeleteEvidenceKey(grown));
290
+ });
291
+
292
+ test('a fingerprint that cannot be read is unknown, never an unchanged reading', async () => {
293
+ const repo = makeRepo('content-unknown');
294
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'one\n');
295
+ const failing = {
296
+ aheadBehind: async () => ({ ok: true, ahead: 0, base: 'main' }),
297
+ runArgvAsync: async (bin, argv) => (argv[0] === 'diff' && argv.includes('--binary')
298
+ ? { status: 128, stdout: '', stderr: 'diff exploded', error: null }
299
+ : { status: 0, stdout: '', stderr: '', error: null }),
300
+ };
301
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo, {
302
+ git: failing,
303
+ changesLib: { summarizeChanges: async () => ({ ok: true, files: 0, added: 0, deleted: 0 }) },
304
+ });
305
+ assert.equal(evidence.content.status, 'unknown');
306
+ assert.match(evidence.content.reason, /diff exploded/);
307
+ });
308
+
309
+ test('pending work past the fingerprint budget is unknown instead of being guessed', async () => {
310
+ const repo = makeRepo('content-budget');
311
+ fs.writeFileSync(path.join(repo, 'big.bin'), 'x'.repeat(4096));
312
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo, { maxFingerprintBytes: 64 });
313
+ assert.equal(evidence.content.status, 'unknown');
314
+ assert.match(evidence.content.reason, /larger than/);
315
+ });
316
+
317
+ // The budget used to be checked only inside the UNTRACKED loop, after the
318
+ // tracked patch had already been collected from stdout and decoded: a 40 MiB
319
+ // binary patch cost ~247 MiB of RSS and was never refused at all, because a
320
+ // checkout with no untracked files never entered that loop.
321
+ test('a tracked patch alone past the budget is unknown, and never authorizes a delete', async () => {
322
+ const repo = makeRepo('content-tracked-budget');
323
+ // Wholesale replacement, so the binary delta cannot shrink the patch under
324
+ // the budget the way a single flipped byte does.
325
+ const before = noise(8192, 0x9e3779b9);
326
+ const after = noise(8192, 0x51ed270b);
327
+ fs.writeFileSync(path.join(repo, 'a.bin'), before);
328
+ git(repo, ['add', 'a.bin']);
329
+ git(repo, ['commit', '-m', 'seed']);
330
+ fs.writeFileSync(path.join(repo, 'a.bin'), after);
331
+ assert.equal(git(repo, ['status', '--porcelain=v1', '--untracked-files=all']), 'M a.bin',
332
+ "the fixture's pending work is the TRACKED patch, with no untracked file to notice the cap");
333
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo, { maxFingerprintBytes: 1024 });
334
+ assert.equal(evidence.content.status, 'unknown');
335
+ assert.equal(evidence.content.fingerprint, null);
336
+ assert.match(evidence.content.reason, /larger than 1024 bytes/);
337
+ });
338
+
339
+ test('a tracked binary patch within the budget is fingerprinted by its bytes', async () => {
340
+ const repo = makeRepo('content-tracked-binary');
341
+ const seed = noise(256, 0x12345678);
342
+ fs.writeFileSync(path.join(repo, 'a.bin'), seed);
343
+ git(repo, ['add', 'a.bin']);
344
+ git(repo, ['commit', '-m', 'seed']);
345
+ fs.writeFileSync(path.join(repo, 'a.bin'), Buffer.concat([seed, Buffer.from([0, 255, 7])]));
346
+ const first = await confirmLib.readWarpDeleteEvidence(repo);
347
+ const second = await confirmLib.readWarpDeleteEvidence(repo);
348
+ assert.equal(first.content.status, 'known');
349
+ assert.equal(first.content.fingerprint, second.content.fingerprint, 'a stable binary patch is stable');
350
+ fs.writeFileSync(path.join(repo, 'a.bin'), Buffer.concat([seed, Buffer.from([0, 255, 8])]));
351
+ const changed = await confirmLib.readWarpDeleteEvidence(repo);
352
+ assert.equal(changed.content.status, 'known');
353
+ assert.notEqual(first.content.fingerprint, changed.content.fingerprint,
354
+ 'one different byte in a binary patch changes the reading');
355
+ });
356
+
357
+ test('an untracked file past the budget is refused by its size, without being read whole', async () => {
358
+ const repo = makeRepo('content-untracked-budget');
359
+ const big = path.join(repo, 'huge.bin');
360
+ // Sparse on every filesystem this suite runs on: the SIZE is what must be
361
+ // refused before a read, so the fixture does not need 64 MiB of real data.
362
+ const fd = fs.openSync(big, 'w');
363
+ try {
364
+ fs.ftruncateSync(fd, 64 * 1024 * 1024);
365
+ } finally {
366
+ fs.closeSync(fd);
367
+ }
368
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo, { maxFingerprintBytes: 1024 * 1024 });
369
+ assert.equal(evidence.content.status, 'unknown');
370
+ assert.equal(evidence.content.fingerprint, null);
371
+ assert.match(evidence.content.reason, /larger than 1048576 bytes/);
372
+ });
373
+
374
+ // The memory guarantee itself: the patch is written by git to a private
375
+ // file, so the shared runner never collects its bytes on stdout. Its size is
376
+ // then what the cap decides on, and the scratch file never outlives the read.
377
+ test('the tracked patch is staged in a private file, never buffered on the child stdout', async () => {
378
+ const repo = makeRepo('content-patch-file');
379
+ fs.writeFileSync(path.join(repo, 'a.bin'), Buffer.from([0, 1, 2, 3]));
380
+ git(repo, ['add', 'a.bin']);
381
+ git(repo, ['commit', '-m', 'seed']);
382
+ fs.writeFileSync(path.join(repo, 'a.bin'), Buffer.from([9, 8, 7, 6]));
383
+ const patchText = 'diff --binary staged bytes\n';
384
+ let diffArgv = null;
385
+ let stagedPath = null;
386
+ const recording = {
387
+ aheadBehind: async () => ({ ok: true, ahead: 0, base: 'main' }),
388
+ runArgvAsync: async (bin, argv) => {
389
+ if (argv[0] !== 'diff') return { status: 0, stdout: '', stderr: '', error: null };
390
+ diffArgv = argv;
391
+ const output = argv.find((entry) => entry.startsWith('--output='));
392
+ assert.ok(output, 'the patch is addressed to a file instead of the child stdout');
393
+ stagedPath = output.slice('--output='.length);
394
+ fs.writeFileSync(stagedPath, patchText);
395
+ return { status: 0, stdout: '', stderr: '', error: null };
396
+ },
397
+ };
398
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo, {
399
+ git: recording,
400
+ changesLib: { summarizeChanges: async () => ({ ok: true, files: 0, added: 0, deleted: 0 }) },
401
+ });
402
+ assert.ok(diffArgv.includes('--binary'), 'binary edits still count');
403
+ assert.equal(evidence.content.status, 'known');
404
+ assert.equal(evidence.content.bytes, Buffer.byteLength(patchText),
405
+ 'the byte count comes from the staged file, not from a decoded stdout string');
406
+ assert.equal(fs.existsSync(stagedPath), false, 'the scratch patch is removed before the read resolves');
407
+ });
408
+ });
409
+
410
+ // A2 (verification): a checkout can read CLEAN while git ignores files that
411
+ // only exist inside it, and removing the checkout deletes them. They are
412
+ // therefore disclosed in the question, and their path list is part of the
413
+ // reading so a file that appears during the dialog is noticed.
414
+ describe('A2: ignored files are disclosed, never silently deleted', () => {
415
+ function withIgnored(tag) {
416
+ const repo = makeRepo(tag);
417
+ git(repo, ['branch', 'main', 'HEAD']);
418
+ fs.writeFileSync(path.join(repo, '.gitignore'), '*.log\nbuild/\n');
419
+ git(repo, ['add', '.gitignore']);
420
+ git(repo, ['commit', '-m', 'ignore rules']);
421
+ fs.writeFileSync(path.join(repo, 'debug.log'), 'log\n');
422
+ fs.mkdirSync(path.join(repo, 'build'));
423
+ fs.writeFileSync(path.join(repo, 'build', 'out.txt'), 'artifact\n');
424
+ return repo;
425
+ }
426
+
427
+ test('a checkout that reads clean but holds ignored files discloses them', async () => {
428
+ const repo = withIgnored('ignored-present');
429
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo);
430
+ assert.equal(evidence.changes.status, 'clean', 'nothing git tracks is pending');
431
+ assert.equal(evidence.ignored.status, 'present');
432
+ assert.equal(evidence.ignored.files, 2);
433
+ assert.deepEqual(evidence.ignored.samples, ['build/out.txt', 'debug.log']);
434
+ const prompt = confirmLib.warpDeletePrompt(TARGET, evidence);
435
+ assert.match(prompt.body, /Ignored files \(deleted with the checkout\): build\/out\.txt, debug\.log\./);
436
+ assert.equal(prompt.force, false, 'ignored files do not force: git needs no --force for them');
437
+ });
438
+
439
+ test('a checkout with no ignored files keeps the dialog byte-identical', async () => {
440
+ const repo = makeRepo('ignored-none');
441
+ git(repo, ['branch', 'main', 'HEAD']);
442
+ const evidence = await confirmLib.readWarpDeleteEvidence(repo);
443
+ assert.equal(evidence.ignored.status, 'none');
444
+ assert.equal(evidence.ignored.files, 0);
445
+ assert.equal(confirmLib.warpDeletePrompt(TARGET, evidence).body, BASE_BODY);
446
+ assert.equal(confirmLib.warpDeletePrompt(TARGET, evidence).force, false);
447
+ });
448
+
449
+ test('an unreadable ignored listing is unknown, and the key notices a file that appeared', async () => {
450
+ const repo = withIgnored('ignored-listing');
451
+ const before = await confirmLib.readWarpDeleteEvidence(repo);
452
+ fs.writeFileSync(path.join(repo, 'late.log'), 'late\n');
453
+ const after = await confirmLib.readWarpDeleteEvidence(repo);
454
+ assert.equal(after.ignored.files, 3);
455
+ assert.notEqual(confirmLib.warpDeleteEvidenceKey(before), confirmLib.warpDeleteEvidenceKey(after),
456
+ 'an ignored file that appeared changes the reading');
457
+
458
+ const failing = {
459
+ aheadBehind: async () => ({ ok: true, ahead: 0, base: 'main' }),
460
+ runArgvAsync: async (bin, argv) => (argv[0] === 'ls-files'
461
+ ? { status: 1, stdout: '', stderr: 'ls-files exploded', error: null }
462
+ : { status: 0, stdout: '', stderr: '', error: null }),
463
+ };
464
+ const unreadable = await confirmLib.readWarpDeleteEvidence(repo, {
465
+ git: failing,
466
+ changesLib: { summarizeChanges: async () => ({ ok: true, files: 0, added: 0, deleted: 0 }) },
467
+ });
468
+ assert.equal(unreadable.ignored.status, 'unknown');
469
+ assert.match(unreadable.ignored.reason, /ls-files exploded/);
470
+ });
471
+ });
472
+
473
+ // S7/S8: the warp panel's Ctrl+D runs the SAME active-session guard as the
474
+ // `worktree_close` tool, and it runs it BEFORE the pending-change question.
475
+ // These cases drive the real `/warp` command with a host double: `ui.custom`
476
+ // answers the marker the selector's `ctrl+d` emits, `ui.confirm` records the
477
+ // one dialog, and every temp worktree is created through the registered tool.
478
+ describe('S7/S8: Ctrl+D checks live sessions before the deletion question', () => {
479
+ const crypto = require('node:crypto');
480
+ const { loadExtension } = require('./fixtures/load-extension.js');
481
+
482
+ const NO_HERDR = path.join(os.tmpdir(), 'odd-worktree-no-such-herdr-binary');
483
+ const HOOK_BIN = track(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-confirm-hookbin-')));
484
+ fs.writeFileSync(path.join(HOOK_BIN, 'codegraph'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
485
+ const AGENT_HOME = track(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-confirm-agent-home-')));
486
+ const PRESENCE = path.join(AGENT_HOME, 'gentle-agents', 'presence');
487
+
488
+ const savedEnv = { ...process.env };
489
+ let commands;
490
+ let tools;
491
+ let restorePath = null;
492
+
493
+ before(async () => {
494
+ process.env.HERDR_BIN_PATH = NO_HERDR;
495
+ delete process.env.GENTLE_PI_AGENT_HOME;
496
+ process.env.PI_CODING_AGENT_DIR = AGENT_HOME;
497
+ restorePath = process.env.PATH;
498
+ process.env.PATH = `${HOOK_BIN}${path.delimiter}${process.env.PATH || ''}`;
499
+ ({ commands, tools } = await loadExtension({}));
500
+ });
501
+
502
+ after(() => {
503
+ for (const key of Object.keys(process.env)) {
504
+ if (!(key in savedEnv)) delete process.env[key];
505
+ }
506
+ for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
507
+ });
508
+
509
+ function publishSession({ sessionId, label = 'Worker', workspace, heartbeat = Date.now(), incarnationIndex = 1 }) {
510
+ fs.mkdirSync(PRESENCE, { recursive: true, mode: 0o700 });
511
+ try { fs.chmodSync(PRESENCE, 0o700); } catch { /* best effort */ }
512
+ const sessionHash = crypto.createHash('sha256').update(sessionId).digest('hex');
513
+ const incarnation = `2000000${incarnationIndex}-0000-4000-8000-00000000000${incarnationIndex}`;
514
+ fs.writeFileSync(path.join(PRESENCE, `${sessionHash}.${incarnation}.header.json`), JSON.stringify({
515
+ schema: 1,
516
+ sessionHash,
517
+ incarnation,
518
+ label,
519
+ heartbeat,
520
+ generation: 1,
521
+ counts: { running: 0, queued: 0, waiting: 0, finished: 0 },
522
+ digest: 'a'.repeat(64),
523
+ unavailable: null,
524
+ }));
525
+ fs.writeFileSync(path.join(PRESENCE, `${sessionHash}.${incarnation}.discovery.json`), JSON.stringify({
526
+ schema: 1,
527
+ sessionHash,
528
+ incarnation,
529
+ generation: 1,
530
+ metadata: { activation: 'b'.repeat(64), workspace, tasks: [], omitted: 0 },
531
+ }));
532
+ return sessionHash;
533
+ }
534
+
535
+ // One registered worktree plus the host double that presses Ctrl+D on it.
536
+ async function panelFixture(tag, { confirm } = {}) {
537
+ const repo = makeRepo(`ctrl-d-${tag}`);
538
+ git(repo, ['branch', 'main', 'HEAD']);
539
+ const created = await tools.get('worktree_create').execute(
540
+ `create-${tag}`, { branch: `feat/${tag}`, changes: 'none' }, undefined, undefined,
541
+ {
542
+ cwd: repo,
543
+ hasUI: false,
544
+ mode: 'tui',
545
+ sessionManager: { getSessionId: () => 'panel-session' },
546
+ ui: { confirm: async () => false, notify: () => {} },
547
+ },
548
+ );
549
+ assert.notEqual(created.isError, true, JSON.stringify(created));
550
+ const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, `feat-${tag}`);
551
+ const notifications = [];
552
+ const confirmations = [];
553
+ let mounts = 0;
554
+ const ctx = {
555
+ cwd: repo,
556
+ hasUI: true,
557
+ mode: 'tui',
558
+ sessionManager: { getSessionId: () => 'panel-session' },
559
+ ui: {
560
+ // The selector's `ctrl+d` emits the delete marker once; the loop then
561
+ // reopens the panel, and closing it (undefined) ends the flow.
562
+ custom: async () => (mounts++ === 0 ? { delete: target } : undefined),
563
+ confirm: async (title, body) => {
564
+ confirmations.push({ title, body });
565
+ if (typeof confirm === 'function') return confirm({ title, body, target });
566
+ return true;
567
+ },
568
+ notify: (text, level) => { notifications.push([level, text]); },
569
+ },
570
+ };
571
+ return { repo, target, notifications, confirmations, ctx };
572
+ }
573
+
574
+ test('a live session in the target refuses before the pending-change question', async () => {
575
+ const { target, notifications, confirmations, ctx } = await panelFixture('busy');
576
+ const sessionHash = publishSession({ sessionId: 'panel-busy-worker', label: 'Busy', workspace: target, incarnationIndex: 5 });
577
+ await commands.get('warp').handler('', ctx);
578
+ assert.equal(confirmations.length, 0, 'no deletion question is asked for a checkout a session is working in');
579
+ const refusal = notifications.find(([level]) => level === 'error');
580
+ assert.ok(refusal, JSON.stringify(notifications));
581
+ assert.match(refusal[1], /working in that checkout/);
582
+ assert.ok(refusal[1].includes(sessionHash.slice(0, 12)), refusal[1]);
583
+ assert.ok(fs.existsSync(target), 'the checkout survives the refusal');
584
+ });
585
+
586
+ test('Ctrl+D still asks the pending-change question for a clear checkout and removes it after approval', async () => {
587
+ const { repo, target, notifications, confirmations, ctx } = await panelFixture('clear');
588
+ await commands.get('warp').handler('', ctx);
589
+ assert.equal(confirmations.length, 1, 'exactly one deletion question');
590
+ assert.match(confirmations[0].title, /^Delete worktree '/);
591
+ assert.match(confirmations[0].body, /Delete worktree checkout at /);
592
+ assert.match(confirmations[0].body, /keeps the branch/);
593
+ assert.equal(fs.existsSync(target), false, 'the approved checkout is removed');
594
+ assert.ok(notifications.some(([level, text]) => level === 'info' && /worktree deleted/.test(text)), JSON.stringify(notifications));
595
+ assert.equal(
596
+ git(repo, ['branch', '--format=%(refname:short)']).split('\n').includes('feat/clear'),
597
+ true,
598
+ 'the branch is kept',
599
+ );
600
+ });
601
+
602
+ test('a session that appears while the question is open refuses the removal after approval', async () => {
603
+ const { target, notifications, confirmations, ctx } = await panelFixture('late', {
604
+ confirm: ({ target: doomed }) => {
605
+ publishSession({ sessionId: 'late-worker', label: 'Late', workspace: doomed, incarnationIndex: 6 });
606
+ return true;
607
+ },
608
+ });
609
+ await commands.get('warp').handler('', ctx);
610
+ assert.equal(confirmations.length, 1, 'the human was asked exactly once');
611
+ const failure = notifications.find(([level]) => level === 'error');
612
+ assert.ok(failure, JSON.stringify(notifications));
613
+ assert.match(failure[1], /working in that checkout/);
614
+ assert.ok(fs.existsSync(target), 'the post-approval recheck refuses the removal');
615
+ });
616
+ });