pi-odd-worktree 0.0.0-stage → 0.1.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 +223 -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,138 @@
1
+ 'use strict';
2
+ // S2/S4 surface: the standalone package registers exactly three commands
3
+ // (/warp, /odd-list and /issues) and the four agent worktree tools, loads
4
+ // without any Herdr binary, and ships none of the removed functionality
5
+ // (customize panel, plugin-tools toggles, worktree mode file, perf tracing).
6
+ // The issues reader, preview formatter and view are the migrated T2 units and
7
+ // are shipped; `/issues` is the T3 command that mounts them, without any
8
+ // worktree↔issue association.
9
+ //
10
+ // Everything here reads the observable surface: the registered command and
11
+ // tool objects, the manifest, the files on disk. No internal state is read.
12
+ const { test, describe, after } = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { LIB_DIR, SRC, loadExtension, stubbedSource } = require('./fixtures/load-extension.js');
18
+
19
+ const ROOT = path.resolve(__dirname, '..');
20
+ const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
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
+ });
29
+
30
+ // A nonexistent absolute path is provably not a live Herdr binary, so the
31
+ // shared resolver returns it and every Herdr call fails instead of reaching a
32
+ // real workspace.
33
+ const NO_HERDR = path.join(os.tmpdir(), 'odd-worktree-no-such-herdr-binary');
34
+
35
+ describe('manifest', () => {
36
+ test('declares one Pi extension entry point and ships only that surface', () => {
37
+ assert.equal(PKG.name, 'pi-odd-worktree');
38
+ assert.ok(PKG.keywords.includes('pi-package'), 'keywords name the pi-package kind');
39
+ assert.deepEqual(PKG.pi.extensions, ['./extensions/odd-worktree-tools.ts']);
40
+ for (const entry of ['extensions', 'lib', 'tools', 'README.md']) {
41
+ assert.ok(PKG.files.includes(entry), `files ships ${entry}`);
42
+ }
43
+ assert.equal(PKG.peerDependencies['@earendil-works/pi-coding-agent'], '*');
44
+ assert.equal(PKG.peerDependencies['@earendil-works/pi-tui'], '*');
45
+ assert.ok(!('dependencies' in PKG), 'no runtime dependencies are installed');
46
+ assert.ok(!JSON.stringify(PKG).includes('herdr'), 'the manifest never requires Herdr');
47
+ });
48
+
49
+ test('ships no module for the removed commands', () => {
50
+ const shipped = fs.readdirSync(path.join(ROOT, 'lib')).sort();
51
+ for (const gone of [
52
+ 'issues-cache.js',
53
+ 'herdr-plugin-check.js',
54
+ 'gentle-sidebar.js',
55
+ 'perf-trace.js',
56
+ 'perf-analyze.js',
57
+ 'host-settings.js',
58
+ 'worktree-mode.js',
59
+ 'moshi-prereq.js',
60
+ 'live-session-changes.js',
61
+ ]) {
62
+ assert.equal(shipped.includes(gone), false, `lib/${gone} is not shipped`);
63
+ }
64
+ // The issue reader, preview formatter and view are the migrated units (T2
65
+ // S1,S3): the per-tab cache is still gone, but the `gh` client, the
66
+ // formatter and the issues view `/issues` mounts are shipped, so their
67
+ // presence is asserted rather than forbidden.
68
+ for (const kept of ['gh-issues.js', 'issue-preview.js', 'issues-view.js', 'warp-selector-view.js', 'odd-view.js', 'worktree-git.js', 'worktree-registry.js', 'worktree-session-guard.js']) {
69
+ assert.ok(shipped.includes(kept), `lib/${kept} is shipped`);
70
+ }
71
+ });
72
+ });
73
+
74
+ describe('registration', () => {
75
+ test('registers /warp, /odd-list, /issues and the four worktree tools', async () => {
76
+ process.env.HERDR_BIN_PATH = NO_HERDR;
77
+ delete process.env.GENTLE_WORKTREE_THREAD;
78
+ const { commands, tools, messageRenderers } = await loadExtension({});
79
+ assert.deepEqual([...commands.keys()].sort(), ['issues', 'odd-list', 'warp']);
80
+ assert.deepEqual([...tools.keys()].sort(), ['worktree_close', 'worktree_create', 'worktree_list', 'worktree_warp']);
81
+ assert.deepEqual([...messageRenderers.keys()], ['worktree-warp']);
82
+ });
83
+
84
+ test('every tool keeps its declared name and description', async () => {
85
+ const { tools } = await loadExtension({});
86
+ const summary = (name) => {
87
+ const tool = tools.get(name);
88
+ return { name: tool.name, label: tool.label };
89
+ };
90
+ assert.deepEqual(summary('worktree_create'), { name: 'worktree_create', label: 'Worktree Create' });
91
+ assert.deepEqual(summary('worktree_list'), { name: 'worktree_list', label: 'Worktree List' });
92
+ assert.deepEqual(summary('worktree_close'), { name: 'worktree_close', label: 'Worktree Close' });
93
+ assert.deepEqual(summary('worktree_warp'), { name: 'worktree_warp', label: 'Worktree Warp' });
94
+ });
95
+
96
+ test('the close help text names the active-session refusal and no Herdr workspace', async () => {
97
+ const { tools } = await loadExtension({});
98
+ const description = tools.get('worktree_close').description;
99
+ assert.match(description, /live Pi session working in that checkout/);
100
+ assert.match(description, /warp to another checkout first/i);
101
+ assert.match(description, /git/);
102
+ assert.equal(/and its Herdr workspace/i.test(description), false, 'no Herdr workspace removal is promised');
103
+ assert.match(description, /no Herdr workspace is closed, opened or removed/);
104
+ });
105
+ });
106
+
107
+ describe('removed surface', () => {
108
+ test('the extension names no customize or Herdr-panel surface', () => {
109
+ const source = stubbedSource();
110
+ assert.equal(/herdr-gentle:/.test(source), false, 'no herdr-gentle: command name survives');
111
+ for (const gone of ['registerCustomizeCommand', 'executeWorktreeSessionOpen', 'startAgentSessionWatcher', 'customizePluginToolEnabled']) {
112
+ assert.equal(source.includes(gone), false, `${gone} is gone`);
113
+ }
114
+ for (const lib of ['worktree-mode.js', 'herdr-plugin-check.js', 'perf-trace.js']) {
115
+ assert.equal(source.includes(`libRequire("${lib}")`), false, `${lib} is not required`);
116
+ }
117
+ });
118
+
119
+ test('the /issues command keeps its migrated client and view', () => {
120
+ const source = stubbedSource();
121
+ assert.ok(source.includes('pi.registerCommand("issues"'), 'the /issues command is registered');
122
+ assert.ok(source.includes('function registerIssuesCommand'), 'its registration is a named step');
123
+ assert.ok(source.includes('libRequire("gh-issues.js")'), 'the reader uses the migrated gh client');
124
+ assert.ok(source.includes('libRequire("issues-view.js")'), 'the reader mounts the migrated view');
125
+ });
126
+
127
+ test('the loaded extension holds no reference to the removed packages', async () => {
128
+ const { commands } = await loadExtension({});
129
+ for (const gone of ['customize', 'odd', 'herdr-gentle:warp']) {
130
+ assert.equal(commands.has(gone), false, `no /${gone} command`);
131
+ }
132
+ assert.equal(commands.has('issues'), true, '/issues is the migrated command');
133
+ for (const file of fs.readdirSync(path.join(ROOT, 'lib'))) {
134
+ const text = fs.readFileSync(path.join(LIB_DIR, file), 'utf8');
135
+ assert.equal(/require\(['"]\.\/gh-issues\.js['"]\)/.test(text), false, `lib/${file} does not pull the issues reader`);
136
+ }
137
+ });
138
+ });
@@ -0,0 +1,306 @@
1
+ 'use strict';
2
+ // T5 durability regressions (verification blocker S7): a deletion refusal must
3
+ // name the REAL active session id, not a hash prefix and not a label.
4
+ //
5
+ // The identity is a JOIN across two published Gentle Shell stores:
6
+ // - the presence header, keyed by `sessionHash` = sha256(sessionId)
7
+ // (lib/orchestrator-presence.ts), which carries label, heartbeat and the
8
+ // session's workspace;
9
+ // - the transport activation record
10
+ // `<agent home>/gentle-agents/transport/presence/<sessionId>.<token>.json`
11
+ // (lib/agents-session-transport.ts), the only published place the raw id
12
+ // appears.
13
+ // A validated transport record whose id hashes to the header's sessionHash
14
+ // names that session. Nothing else may: no label, no prefix, no saved history.
15
+ //
16
+ // These cases are written against the PUBLIC surfaces only — the two exported
17
+ // guard functions and the registered `worktree_close` tool — so they keep
18
+ // working if internals move. Every fixture is a throwaway repository and agent
19
+ // home under the system temp directory; no real worktree, presence store or
20
+ // session is touched.
21
+
22
+ const { test, describe, before, after } = require('node:test');
23
+ const assert = require('node:assert/strict');
24
+ const crypto = require('node:crypto');
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+ const { spawnSync } = require('node:child_process');
29
+ const { loadExtension } = require('./fixtures/load-extension.js');
30
+
31
+ const guard = require('../lib/worktree-session-guard.js');
32
+
33
+ const NOW = 1_800_000_000_000;
34
+ const TOKEN_A = 'AbCdEfGhIjKlMnOpQrStUv';
35
+ const TOKEN_B = 'ZbCdEfGhIjKlMnOpQrStUv';
36
+ const roots = [];
37
+
38
+ after(() => {
39
+ for (const dir of roots) {
40
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
41
+ }
42
+ });
43
+
44
+ function track(dir) {
45
+ roots.push(dir);
46
+ return dir;
47
+ }
48
+
49
+ function agentHome() {
50
+ return track(fs.mkdtempSync(path.join(os.tmpdir(), 't5-identity-home-')));
51
+ }
52
+
53
+ function checkout(tag) {
54
+ return fs.realpathSync(track(fs.mkdtempSync(path.join(os.tmpdir(), `t5-identity-${tag}-`))));
55
+ }
56
+
57
+ const digest = (id) => crypto.createHash('sha256').update(id).digest('hex');
58
+ const incarnation = (n) => `3000000${n}-0000-4000-8000-00000000000${n}`;
59
+
60
+ function publishPresence(home, {
61
+ sessionId, label = 'Worker', workspace = null, heartbeat = NOW, incarnationIndex = 1, discovery = true,
62
+ } = {}) {
63
+ const dir = path.join(home, 'gentle-agents', 'presence');
64
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
65
+ try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
66
+ const sessionHash = digest(sessionId);
67
+ const inc = incarnation(incarnationIndex);
68
+ fs.writeFileSync(path.join(dir, `${sessionHash}.${inc}.header.json`), JSON.stringify({
69
+ schema: 1,
70
+ sessionHash,
71
+ incarnation: inc,
72
+ label,
73
+ heartbeat,
74
+ generation: 1,
75
+ counts: { running: 0, queued: 0, waiting: 0, finished: 0 },
76
+ digest: 'a'.repeat(64),
77
+ unavailable: null,
78
+ }));
79
+ if (discovery) {
80
+ fs.writeFileSync(path.join(dir, `${sessionHash}.${inc}.discovery.json`), JSON.stringify({
81
+ schema: 1,
82
+ sessionHash,
83
+ incarnation: inc,
84
+ generation: 1,
85
+ metadata: { activation: 'b'.repeat(64), workspace: workspace ?? '', tasks: [], omitted: 0 },
86
+ }));
87
+ }
88
+ return sessionHash;
89
+ }
90
+
91
+ function publishTransport(home, { sessionId, token = TOKEN_A, createdAt = NOW - 1000, endpoint = null } = {}) {
92
+ const base = path.join(home, 'gentle-agents');
93
+ const dir = path.join(base, 'transport', 'presence');
94
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
95
+ for (const owned of [base, path.join(base, 'transport'), dir]) {
96
+ try { fs.chmodSync(owned, 0o700); } catch { /* best effort */ }
97
+ }
98
+ const file = path.join(dir, `${sessionId}.${token}.json`);
99
+ fs.writeFileSync(file, JSON.stringify({
100
+ version: 1,
101
+ sessionId,
102
+ endpoint: endpoint ?? `/tmp/gentle-pi-1000/0123456789abcdef0123456789abcdef/${token}.sock`,
103
+ createdAt,
104
+ }), { mode: 0o600 });
105
+ try { fs.chmodSync(file, 0o600); } catch { /* best effort */ }
106
+ return file;
107
+ }
108
+
109
+ async function readStores(home) {
110
+ const env = { PI_CODING_AGENT_DIR: home };
111
+ return {
112
+ presence: await guard.readGentlePresence({ env, now: NOW }),
113
+ transport: await guard.readTransportSessions({ env }),
114
+ };
115
+ }
116
+
117
+ async function evaluate(target, home, extra = {}) {
118
+ const { presence, transport } = await readStores(home);
119
+ return guard.evaluateCheckoutSessions({
120
+ targetPath: target, presence, transport, now: NOW, ...extra,
121
+ });
122
+ }
123
+
124
+ describe('S7 identity join: hash -> raw session id', () => {
125
+ test('the raw id is resolved deterministically from its own transport record', async () => {
126
+ const home = agentHome();
127
+ const target = checkout('resolve');
128
+ const sessionHash = publishPresence(home, { sessionId: 'worker-alpha', label: 'Alpha', workspace: target });
129
+ publishTransport(home, { sessionId: 'worker-alpha' });
130
+ assert.equal(digest('worker-alpha'), sessionHash, 'the presence hash is sha256 of the published id');
131
+
132
+ const result = await evaluate(target, home);
133
+ assert.equal(result.verdict, 'foreign');
134
+ assert.equal(result.blockers.length, 1);
135
+ assert.equal(result.blockers[0].rawSessionId, 'worker-alpha');
136
+ assert.ok(result.text.includes('id worker-alpha'), result.text);
137
+ assert.equal(result.text.includes(sessionHash.slice(0, 12)), false, 'never the hash when the id resolves');
138
+ assert.equal(result.evidence.resolved, 1);
139
+ assert.equal(result.evidence.unresolved, 0);
140
+ });
141
+
142
+ test('another session never lends its id to this hash', async () => {
143
+ const home = agentHome();
144
+ const target = checkout('cross');
145
+ publishPresence(home, { sessionId: 'worker-alpha', label: 'Alpha', workspace: target });
146
+ publishTransport(home, { sessionId: 'worker-beta', token: TOKEN_B });
147
+ const result = await evaluate(target, home);
148
+ assert.equal(result.verdict, 'foreign');
149
+ assert.equal(result.blockers[0].rawSessionId, null);
150
+ assert.equal(result.text.includes('worker-beta'), false);
151
+ assert.match(result.text, /could not be resolved/);
152
+ });
153
+
154
+ test('no transport record means the refusal DISCLOSES the unresolved id', async () => {
155
+ const home = agentHome();
156
+ const target = checkout('nojoin');
157
+ const sessionHash = publishPresence(home, { sessionId: 'worker-alpha', label: 'Alpha', workspace: target });
158
+ const result = await evaluate(target, home);
159
+ assert.equal(result.verdict, 'foreign');
160
+ assert.equal(result.blockers[0].rawSessionId, null);
161
+ assert.ok(result.text.includes(`presence ${sessionHash.slice(0, 12)}`), result.text);
162
+ assert.match(result.text, /published no transport record/);
163
+ assert.match(result.text, /could not be resolved/);
164
+ assert.equal(result.evidence.unresolved, 1);
165
+ });
166
+
167
+ test('the record body and the endpoint are never read beyond the published keys', async () => {
168
+ const home = agentHome();
169
+ const target = checkout('no-secret');
170
+ publishPresence(home, { sessionId: 'worker-alpha', label: 'Alpha', workspace: target });
171
+ // A readable file where the endpoint string points: nothing may open it.
172
+ const endpointDir = track(fs.mkdtempSync(path.join(os.tmpdir(), 't5-identity-endpoint-')));
173
+ const secretFile = path.join(endpointDir, 'transcript.jsonl');
174
+ fs.writeFileSync(secretFile, '{"secret":"must-not-be-read"}\n');
175
+ publishTransport(home, { sessionId: 'worker-alpha', endpoint: secretFile });
176
+ const result = await evaluate(target, home);
177
+ assert.equal(result.verdict, 'foreign');
178
+ assert.equal(result.blockers[0].rawSessionId, 'worker-alpha', 'the join only needs sessionId');
179
+ assert.equal(JSON.stringify(result).includes('must-not-be-read'), false);
180
+ });
181
+
182
+ test('the current session keeps its warp-first refusal, joined or not', async () => {
183
+ const home = agentHome();
184
+ const target = checkout('self');
185
+ publishPresence(home, { sessionId: 'mine', label: 'Me', workspace: target });
186
+ publishTransport(home, { sessionId: 'mine' });
187
+ const result = await evaluate(target, home, { currentSessionId: 'mine' });
188
+ assert.equal(result.verdict, 'current');
189
+ assert.match(result.text, /this session is working in that checkout/);
190
+ assert.match(result.text, /\/warp/);
191
+ });
192
+ });
193
+
194
+ describe('S7 identity join through the registered tool', () => {
195
+ const savedEnv = { ...process.env };
196
+ const NO_HERDR = path.join(os.tmpdir(), 't5-identity-no-such-herdr');
197
+ const HOOK_BIN = track(fs.mkdtempSync(path.join(os.tmpdir(), 't5-identity-hookbin-')));
198
+ fs.writeFileSync(path.join(HOOK_BIN, 'codegraph'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
199
+ const AGENT_HOME = agentHome();
200
+
201
+ let tools;
202
+ before(async () => {
203
+ process.env.HERDR_BIN_PATH = NO_HERDR;
204
+ process.env.GENTLE_WORKTREE_THREAD = 't5-thread';
205
+ delete process.env.GENTLE_PI_AGENT_HOME;
206
+ process.env.PI_CODING_AGENT_DIR = AGENT_HOME;
207
+ process.env.PATH = `${HOOK_BIN}${path.delimiter}${process.env.PATH || ''}`;
208
+ ({ tools } = await loadExtension({}));
209
+ });
210
+
211
+ after(() => {
212
+ for (const key of Object.keys(process.env)) {
213
+ if (!(key in savedEnv)) delete process.env[key];
214
+ }
215
+ for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
216
+ });
217
+
218
+ function git(cwd, args) {
219
+ const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
220
+ if (out.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${out.stderr}`);
221
+ return out.stdout.trim();
222
+ }
223
+
224
+ function makeRepo(tag) {
225
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), `t5-identity-repo-${tag}-`)));
226
+ git(dir, ['init', '-b', 'main']);
227
+ git(dir, ['config', 'user.email', 'test@example.test']);
228
+ git(dir, ['config', 'user.name', 'Test']);
229
+ fs.writeFileSync(path.join(dir, 'README.md'), '# fixture\n');
230
+ git(dir, ['add', 'README.md']);
231
+ git(dir, ['commit', '-m', 'init']);
232
+ return dir;
233
+ }
234
+
235
+ function makeCtx(repoRoot) {
236
+ return {
237
+ cwd: repoRoot,
238
+ hasUI: false,
239
+ mode: 'tui',
240
+ sessionManager: { getSessionId: () => 't5-session' },
241
+ ui: { confirm: async () => true, notify: () => {} },
242
+ };
243
+ }
244
+
245
+ function resultText(result) {
246
+ return (result.content || []).map((part) => (part && part.text) || '').join('\n');
247
+ }
248
+
249
+ test('the tool refusal shows the raw id and leaves the checkout, branch and registry intact', async () => {
250
+ const repo = makeRepo('joined');
251
+ const ctx = makeCtx(repo);
252
+ const created = await tools.get('worktree_create').execute('t5-c1', { branch: 'feat/t5-joined', changes: 'none' }, undefined, undefined, ctx);
253
+ assert.notEqual(created.isError, true, resultText(created));
254
+ const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-t5-joined');
255
+ const sessionHash = publishPresence(AGENT_HOME, { sessionId: 'raw-session-7', label: 'Worker', workspace: target, heartbeat: Date.now(), incarnationIndex: 4 });
256
+ publishTransport(AGENT_HOME, { sessionId: 'raw-session-7' });
257
+
258
+ const closed = await tools.get('worktree_close').execute('t5-c2', { name: 'feat-t5-joined' }, undefined, undefined, ctx);
259
+ assert.equal(closed.isError, true);
260
+ const text = resultText(closed);
261
+ assert.ok(text.includes('id raw-session-7'), text);
262
+ assert.equal(text.includes(sessionHash.slice(0, 12)), false);
263
+ assert.equal(closed.details.sessionCheck.resolved, 1);
264
+ assert.equal(closed.details.sessionCheck.unresolved, 0);
265
+ // Nothing was mutated: the checkout, git's listing, the branch and the row.
266
+ assert.ok(fs.existsSync(target), 'the checkout survives');
267
+ assert.ok(git(repo, ['worktree', 'list', '--porcelain']).includes(target), 'git still lists it');
268
+ assert.ok(git(repo, ['branch', '--format=%(refname:short)']).split('\n').includes('feat/t5-joined'), 'the branch survives');
269
+ const listed = await tools.get('worktree_list').execute('t5-c3', {}, undefined, undefined, ctx);
270
+ assert.equal(resultText(listed).split('\n').length, 3, 'the registry row survives');
271
+ });
272
+
273
+ test('the tool refusal discloses an unresolved id instead of inventing one', async () => {
274
+ const repo = makeRepo('unjoined');
275
+ const ctx = makeCtx(repo);
276
+ await tools.get('worktree_create').execute('t5-c4', { branch: 'feat/t5-unjoined', changes: 'none' }, undefined, undefined, ctx);
277
+ const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-t5-unjoined');
278
+ const sessionHash = publishPresence(AGENT_HOME, { sessionId: 'raw-session-8', label: 'Worker', workspace: target, heartbeat: Date.now(), incarnationIndex: 5 });
279
+
280
+ const closed = await tools.get('worktree_close').execute('t5-c5', { name: 'feat-t5-unjoined' }, undefined, undefined, ctx);
281
+ assert.equal(closed.isError, true);
282
+ const text = resultText(closed);
283
+ assert.ok(text.includes(`presence ${sessionHash.slice(0, 12)}`), text);
284
+ assert.match(text, /could not be resolved/);
285
+ assert.equal(closed.details.sessionCheck.unresolved, 1);
286
+ assert.ok(fs.existsSync(target), 'the checkout survives');
287
+ });
288
+
289
+ test('the clear path shows the best-effort notices and still removes the checkout', async () => {
290
+ const repo = makeRepo('notices');
291
+ const ctx = makeCtx(repo);
292
+ await tools.get('worktree_create').execute('t5-c6', { branch: 'feat/t5-notice', changes: 'none' }, undefined, undefined, ctx);
293
+ const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-t5-notice');
294
+ // A live peer whose discovery sidecar never published a workspace: it can
295
+ // never be attributed to this checkout, and it must never block.
296
+ publishPresence(AGENT_HOME, { sessionId: 'legacy-peer', label: 'Legacy', heartbeat: Date.now(), incarnationIndex: 6, discovery: false });
297
+
298
+ const closed = await tools.get('worktree_close').execute('t5-c7', { name: 'feat-t5-notice' }, undefined, undefined, ctx);
299
+ assert.notEqual(closed.isError, true, resultText(closed));
300
+ const text = resultText(closed);
301
+ assert.match(text, /published no workspace/);
302
+ assert.match(text, /best effort/);
303
+ assert.ok(Array.isArray(closed.details.warnings) && closed.details.warnings.length > 0, 'the notice travels in details');
304
+ assert.equal(fs.existsSync(target), false, 'the notice never blocks the removal');
305
+ });
306
+ });
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+ // The create panel's live preview: the branch and the target path it derives
3
+ // from the two fields the form asks for.
4
+ //
5
+ // A human picking a change type wants to see WHICH type they picked before
6
+ // typing anything: `preview branch: feat/` and the checkout directory it will
7
+ // occupy (`…/odd-worktree-worktrees/feat-`), completed as the name is typed.
8
+ // The preview is DISPLAY ONLY while the name is empty — an untouched form is
9
+ // still not submittable — so the refusal keeps its own words while the two
10
+ // preview rows stay neutral. Picking another type moves both rows on the same
11
+ // keystroke.
12
+ //
13
+ // Every test renders headlessly against a real temporary repository (the path
14
+ // derivation reads the real repository identity through the same helpers the
15
+ // create path uses) and asserts each line is exactly the requested width.
16
+ // The temporary repository is removed when the suite ends.
17
+
18
+ const { test, describe, after } = require('node:test');
19
+ const assert = require('node:assert/strict');
20
+ const fs = require('node:fs');
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+ const { execFileSync } = require('node:child_process');
24
+
25
+ const panel = require('../lib/warp-create-panel.js');
26
+ const realGit = require('../lib/worktree-git.js');
27
+
28
+ const scratch = [];
29
+ after(() => {
30
+ for (const dir of scratch) {
31
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
32
+ }
33
+ });
34
+
35
+ function scratchDir(tag) {
36
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `wt-panel-${tag}-`));
37
+ scratch.push(dir);
38
+ return dir;
39
+ }
40
+
41
+ const REPO = scratchDir('repo');
42
+ execFileSync('git', ['init', '-q', REPO], { stdio: 'ignore' });
43
+
44
+ // The worktree root the panel derives its target from: `<repo-parent>` +
45
+ // `<repo-name>-worktrees`, exactly what lib/worktree-git.js lays out.
46
+ const WORKTREE_ROOT = path.join(path.dirname(REPO), `${path.basename(REPO)}-worktrees`);
47
+
48
+ function stripAnsi(text) {
49
+ return String(text)
50
+ .replace(/\x1b\[[0-9;]*m/g, '')
51
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
52
+ .replace(/\x1b_[^\x07\x1b]*\x07/g, '');
53
+ }
54
+
55
+ function renderedLines(view, width = 120) {
56
+ const lines = view.render(width);
57
+ assert.ok(lines.length > 0, 'the panel renders something');
58
+ for (const line of lines) {
59
+ assert.equal([...stripAnsi(line)].length, width, `line is not ${width} wide: ${JSON.stringify(line)}`);
60
+ }
61
+ return lines;
62
+ }
63
+
64
+ function findLine(lines, needle) {
65
+ const line = lines.find((entry) => stripAnsi(entry).includes(needle));
66
+ assert.ok(line !== undefined, `no line contains ${JSON.stringify(needle)}\n${lines.map(stripAnsi).join('\n')}`);
67
+ return stripAnsi(line);
68
+ }
69
+
70
+ function allText(lines) {
71
+ return lines.map(stripAnsi).join('\n');
72
+ }
73
+
74
+ // A rendered row is padded to the panel width, so the comparison drops that
75
+ // padding and keeps the derived value itself.
76
+ function previewText(view, which) {
77
+ return findLine(renderedLines(view), `preview ${which}:`).replace(`preview ${which}: `, '').trimEnd();
78
+ }
79
+
80
+ function openPanel(overrides = {}) {
81
+ const finished = [];
82
+ const view = panel.createWarpCreatePanel({
83
+ cwd: REPO,
84
+ git: realGit,
85
+ helpers: null,
86
+ hostTui: null,
87
+ theme: null,
88
+ rowsAvailable: () => 24,
89
+ done: (value) => { finished.push(value); },
90
+ ...overrides,
91
+ });
92
+ return { view, finished };
93
+ }
94
+
95
+ function focusName(view) {
96
+ view.handleInput('\t'); // type -> name
97
+ }
98
+
99
+ function typeText(view, text) {
100
+ for (const char of text) view.handleInput(char);
101
+ }
102
+
103
+ function createNow(view) {
104
+ if (view.state.focus !== 'name') view.handleInput('\t');
105
+ view.handleInput('enter');
106
+ }
107
+
108
+ describe('the preview shows the selected type before any name is typed', { concurrency: false }, () => {
109
+ test('an untouched form previews the type branch prefix and its checkout directory', () => {
110
+ const { view } = openPanel();
111
+ assert.equal(previewText(view, 'branch'), 'feat/', 'the branch preview names the picked type');
112
+ assert.equal(previewText(view, 'path'), path.join(WORKTREE_ROOT, 'feat-'), 'the path preview names the directory that type starts');
113
+ const text = allText(renderedLines(view));
114
+ assert.ok(!text.includes('preview branch: -'), 'the branch preview is never blank once the type is known');
115
+ assert.ok(!text.includes('preview path: -'), 'the path preview is never blank once the type is known');
116
+ });
117
+
118
+ test('every change type previews its own prefix', () => {
119
+ for (const type of panel.CHANGE_TYPES) {
120
+ const { view } = openPanel({ initialType: type.id });
121
+ assert.equal(previewText(view, 'branch'), `${type.id}/`, `${type.id} previews its branch prefix`);
122
+ assert.equal(previewText(view, 'path'), path.join(WORKTREE_ROOT, `${type.id}-`), `${type.id} previews its checkout directory`);
123
+ }
124
+ });
125
+
126
+ test('typing the name completes both previews as it goes', () => {
127
+ const { view } = openPanel();
128
+ focusName(view);
129
+ typeText(view, 'my');
130
+ assert.equal(previewText(view, 'branch'), 'feat/my', 'the branch preview follows the first typed characters');
131
+ assert.equal(previewText(view, 'path'), path.join(WORKTREE_ROOT, 'feat-my'), 'the path preview follows them too');
132
+ typeText(view, '-name');
133
+ assert.equal(previewText(view, 'branch'), 'feat/my-name');
134
+ assert.equal(previewText(view, 'path'), path.join(WORKTREE_ROOT, 'feat-my-name'));
135
+ });
136
+
137
+ test('switching the type moves both previews on the same keystroke, typed name included', () => {
138
+ const { view, finished } = openPanel();
139
+ assert.equal(previewText(view, 'branch'), 'feat/');
140
+ view.handleInput('\x1b[B'); // down: Bug fix
141
+ assert.equal(previewText(view, 'branch'), 'fix/', 'both rows follow the picked type immediately');
142
+ assert.equal(previewText(view, 'path'), path.join(WORKTREE_ROOT, 'fix-'));
143
+
144
+ focusName(view);
145
+ typeText(view, 'my-name');
146
+ view.handleInput('\t'); // back to the type
147
+ assert.equal(view.state.focus, 'type');
148
+ view.handleInput('\x1b[A'); // up: back to feat
149
+ assert.equal(previewText(view, 'branch'), 'feat/my-name', 'the typed name survives the type change');
150
+ assert.equal(previewText(view, 'path'), path.join(WORKTREE_ROOT, 'feat-my-name'));
151
+ view.handleInput('\x1b[B'); // down: fix again
152
+ assert.equal(previewText(view, 'branch'), 'fix/my-name');
153
+ assert.equal(previewText(view, 'path'), path.join(WORKTREE_ROOT, 'fix-my-name'));
154
+ assert.deepEqual(finished, [], 'changing the type never submits the form');
155
+ });
156
+
157
+ test('the prefix preview never submits: an empty name still refuses with its own words', () => {
158
+ const { view, finished } = openPanel();
159
+ createNow(view);
160
+ assert.deepEqual(finished, [], 'an empty name creates nothing');
161
+ const text = allText(renderedLines(view));
162
+ assert.match(text, /cannot create: name is empty/, 'the refusal keeps its own words');
163
+ assert.ok(!text.includes('preview branch: -'), 'the preview rows stay neutral, not blank');
164
+ assert.equal(previewText(view, 'branch'), 'feat/');
165
+ });
166
+
167
+ test('a name with nothing slug-worthy still previews nothing', () => {
168
+ const { view, finished } = openPanel();
169
+ focusName(view);
170
+ typeText(view, '!!!');
171
+ assert.equal(previewText(view, 'branch'), '-', 'an underivable name previews no branch');
172
+ assert.equal(previewText(view, 'path'), '-', 'and no path');
173
+ createNow(view);
174
+ assert.deepEqual(finished, [], 'an invalid name still never creates');
175
+ assert.match(allText(renderedLines(view)), /cannot create: name has no letters or digits/);
176
+ });
177
+
178
+ test('a complete form previews and creates the same branch and path', () => {
179
+ const { view, finished } = openPanel({ initialName: 'new-change' });
180
+ assert.equal(previewText(view, 'branch'), 'feat/new-change');
181
+ assert.equal(previewText(view, 'path'), path.join(WORKTREE_ROOT, 'feat-new-change'));
182
+ createNow(view);
183
+ assert.deepEqual(finished, [{ ok: true, type: 'feat', name: 'new-change' }], 'the completed form creates with the type id and the typed name');
184
+ });
185
+ });