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,204 @@
1
+ 'use strict';
2
+ // S6: `worktree_close` removes a checkout with `git worktree remove` and NOTHING
3
+ // else. The Herdr workspace reconcile it used to run — closing every workspace
4
+ // of the checkout's group and opening a fresh one at the principal — is gone:
5
+ // this package never closes or opens a Herdr panel, and it never asks Herdr to
6
+ // remove a worktree (that route retires the workspace behind the human's back).
7
+ //
8
+ // What is pinned here:
9
+ // - the removal argv is git's, fixed, with the absolute target and `--force`
10
+ // only when the caller asked for it;
11
+ // - the removal helper reports the backend's own reason and never throws;
12
+ // - the module exports no panel-mutating or session-evidence helper;
13
+ // - the extension's close surface contains no Herdr workspace action and no
14
+ // `herdr worktree remove`, and a real close spawns no Herdr process at all.
15
+
16
+ const { test, describe, before, after } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const { spawnSync } = require('node:child_process');
22
+ const { SRC, loadExtension, stubbedSource } = require('./fixtures/load-extension.js');
23
+
24
+ const lib = require('../lib/worktree-close-herdr.js');
25
+
26
+ const TARGET = '/tmp/repo-worktrees/feat-doomed';
27
+ const PRINCIPAL = '/tmp/repo';
28
+
29
+ const roots = [];
30
+ after(() => {
31
+ for (const dir of roots) {
32
+ fs.rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ });
35
+
36
+ function track(dir) {
37
+ roots.push(dir);
38
+ return dir;
39
+ }
40
+
41
+ describe('S6: the removal surface is git-only', () => {
42
+ test('the removal argv is git worktree remove with the absolute target', () => {
43
+ assert.deepEqual(lib.gitRemovalArgv(TARGET, false), ['worktree', 'remove', TARGET]);
44
+ assert.deepEqual(lib.gitRemovalArgv(TARGET, true), ['worktree', 'remove', '--force', TARGET]);
45
+ assert.throws(() => lib.gitRemovalArgv('relative/path', false), TypeError);
46
+ });
47
+
48
+ test('a removal asks git exactly once, with the caller root and timeout', async () => {
49
+ const calls = [];
50
+ const out = await lib.removeCheckout({
51
+ runAsync: async (bin, argv, options) => {
52
+ calls.push({ bin, argv, options });
53
+ return { status: 0, stdout: '', stderr: '' };
54
+ },
55
+ repoRoot: PRINCIPAL,
56
+ resolvedTarget: TARGET,
57
+ force: true,
58
+ });
59
+ assert.equal(calls.length, 1);
60
+ assert.equal(calls[0].bin, 'git');
61
+ assert.deepEqual(calls[0].argv, ['worktree', 'remove', '--force', TARGET]);
62
+ assert.equal(calls[0].options.cwd, PRINCIPAL);
63
+ assert.equal(calls[0].options.timeoutMs, lib.REMOVE_TIMEOUT_MS);
64
+ assert.deepEqual(out, { removed: true, removedBy: 'git' });
65
+ });
66
+
67
+ test('the backend reason travels back verbatim, and no failure ever throws', async () => {
68
+ const failed = await lib.removeCheckout({
69
+ runAsync: async () => ({ status: 1, stdout: '', stderr: "fatal: '/x' contains modified or untracked files\nsecond line\n" }),
70
+ repoRoot: PRINCIPAL,
71
+ resolvedTarget: TARGET,
72
+ force: false,
73
+ });
74
+ assert.equal(failed.removed, false);
75
+ assert.equal(failed.error, "fatal: '/x' contains modified or untracked files\nsecond line");
76
+
77
+ const noRunner = await lib.removeCheckout({ runAsync: null, repoRoot: PRINCIPAL, resolvedTarget: TARGET, force: false });
78
+ assert.equal(noRunner.removed, false);
79
+ assert.match(noRunner.error, /runner/);
80
+
81
+ const thrown = await lib.removeCheckout({
82
+ runAsync: () => { throw new Error('spawn exploded'); },
83
+ repoRoot: PRINCIPAL,
84
+ resolvedTarget: TARGET,
85
+ force: false,
86
+ });
87
+ assert.equal(thrown.removed, false);
88
+ assert.equal(thrown.error, 'spawn exploded');
89
+ });
90
+
91
+ test('no panel-mutating or session-evidence helper survives in the module', () => {
92
+ for (const gone of [
93
+ 'reconcileHerdrWorkspace',
94
+ 'reconcileHerdrWorkspaceAsync',
95
+ 'workspaceEntryForPath',
96
+ 'workspaceEntriesForPath',
97
+ 'orderedGroupMembers',
98
+ 'parseWorkspaceList',
99
+ 'parseWorkspaceCreateId',
100
+ 'agentsInCheckout',
101
+ ]) {
102
+ assert.equal(lib[gone], undefined, `${gone} is not part of the removal surface`);
103
+ }
104
+ assert.deepEqual(Object.keys(lib).sort(), ['REMOVE_TIMEOUT_MS', 'gitRemovalArgv', 'removeCheckout']);
105
+ const source = fs.readFileSync(path.resolve(__dirname, '../lib/worktree-close-herdr.js'), 'utf8');
106
+ for (const forbidden of ['workspace close', 'workspace create', "'workspace'", "'herdr'", '"herdr"']) {
107
+ assert.equal(source.includes(forbidden), false, `the module never names ${forbidden}`);
108
+ }
109
+ });
110
+
111
+ test('the extension contains no Herdr workspace action and no Herdr removal', () => {
112
+ const source = stubbedSource();
113
+ for (const gone of [
114
+ 'reconcileHerdrWorkspace',
115
+ 'herdrWorkspaceId',
116
+ 'herdrArgv("remove"',
117
+ '"workspace", "close"',
118
+ '"workspace", "create"',
119
+ "'workspace close'",
120
+ ]) {
121
+ assert.equal(source.includes(gone), false, `${gone} is gone from the close surface`);
122
+ }
123
+ assert.ok(source.includes('libRequire("worktree-close-herdr.js")'), 'the git removal helper is the removal surface');
124
+ assert.ok(source.includes('removeCheckout'), 'the close uses the git-only removal');
125
+ });
126
+ });
127
+
128
+ describe('S6: a real close spawns no Herdr process', () => {
129
+ const savedEnv = { ...process.env };
130
+ const NO_HERDR = path.join(os.tmpdir(), 'odd-worktree-no-such-herdr-binary');
131
+ const HOOK_BIN = track(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-close-hookbin-')));
132
+ fs.writeFileSync(path.join(HOOK_BIN, 'codegraph'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
133
+ const HERDR_LOG = path.join(track(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-close-herdr-log-'))), 'argv.log');
134
+ const HERDR_SHIM = path.join(track(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-close-herdr-bin-'))), 'herdr');
135
+ fs.writeFileSync(HERDR_SHIM, `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(HERDR_LOG)}\nexit 1\n`, { mode: 0o755 });
136
+ const AGENT_HOME = track(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-close-agent-home-')));
137
+
138
+ let tools;
139
+ before(async () => {
140
+ process.env.HERDR_BIN_PATH = NO_HERDR;
141
+ delete process.env.GENTLE_PI_AGENT_HOME;
142
+ process.env.PI_CODING_AGENT_DIR = AGENT_HOME;
143
+ process.env.PATH = `${HOOK_BIN}${path.delimiter}${savedEnv.PATH || ''}`;
144
+ ({ tools } = await loadExtension({}));
145
+ });
146
+
147
+ after(() => {
148
+ for (const key of Object.keys(process.env)) {
149
+ if (!(key in savedEnv)) delete process.env[key];
150
+ }
151
+ for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
152
+ });
153
+
154
+ function git(cwd, args) {
155
+ const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
156
+ if (out.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${out.stderr}`);
157
+ return out.stdout.trim();
158
+ }
159
+
160
+ function makeRepo() {
161
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'wt-close-repo-')));
162
+ git(dir, ['init', '-b', 'main']);
163
+ git(dir, ['config', 'user.email', 'test@example.test']);
164
+ git(dir, ['config', 'user.name', 'Test']);
165
+ fs.writeFileSync(path.join(dir, 'README.md'), '# fixture\n');
166
+ git(dir, ['add', 'README.md']);
167
+ git(dir, ['commit', '-m', 'init']);
168
+ return dir;
169
+ }
170
+
171
+ function makeCtx(repoRoot) {
172
+ return {
173
+ cwd: repoRoot,
174
+ hasUI: false,
175
+ mode: 'tui',
176
+ sessionManager: { getSessionId: () => 'close-session' },
177
+ ui: { confirm: async () => true, notify: () => {} },
178
+ };
179
+ }
180
+
181
+ function resultText(result) {
182
+ return (result.content || []).map((part) => (part && part.text) || '').join('\n');
183
+ }
184
+
185
+ test('closing a checkout never closes a panel, creates one, or asks Herdr to remove anything', async () => {
186
+ const repo = makeRepo();
187
+ const ctx = makeCtx(repo);
188
+ const created = await tools.get('worktree_create').execute('c1', { branch: 'feat/quiet', changes: 'none' }, undefined, undefined, ctx);
189
+ assert.notEqual(created.isError, true, resultText(created));
190
+ const target = path.join(path.dirname(repo), `${path.basename(repo)}-worktrees`, 'feat-quiet');
191
+
192
+ // From here on any Herdr spawn would leave a line in the log.
193
+ process.env.HERDR_BIN_PATH = HERDR_SHIM;
194
+ try {
195
+ const closed = await tools.get('worktree_close').execute('c2', { name: 'feat-quiet' }, undefined, undefined, ctx);
196
+ assert.notEqual(closed.isError, true, resultText(closed));
197
+ } finally {
198
+ process.env.HERDR_BIN_PATH = NO_HERDR;
199
+ }
200
+ assert.equal(fs.existsSync(target), false, 'git removed the checkout');
201
+ const log = fs.existsSync(HERDR_LOG) ? fs.readFileSync(HERDR_LOG, 'utf8').trim() : '';
202
+ assert.equal(log, '', `the close spawned no Herdr process at all: ${log}`);
203
+ });
204
+ });
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+ // T8 / S8b migration regressions: a registry row written by an older version
3
+ // carries `odd[]`. The entity rule keeps ONE document, so every read
4
+ // normalizes it deterministically to its FIRST entry, and a write that
5
+ // COLLAPSES an addressed row reports the entries left behind once
6
+ // (`dropped: [...]`) — a stored association never disappears silently.
7
+ //
8
+ // S1 narrowed that further: the worktree no longer carries an association at
9
+ // all, so the write paths that rewrite the whole file for an UNRELATED row
10
+ // must not normalize anything on the way through. Those rows keep their
11
+ // stored documents byte for byte, which is why no drop report reaches a
12
+ // worktree output any more. This file pins both halves: preservation for the
13
+ // rows a write does not address, and the one-document rule plus its report
14
+ // for the row it does.
15
+ //
16
+ // These cases are independent of tools/worktree-registry-odd.test.js: they
17
+ // pin the migration behaviour of the WRITE paths that rewrite the whole file
18
+ // (unrelated upsert, every removal writer, prune, discovery), of addOddDoc,
19
+ // and of the three ways `upsertWorktree` can talk about `odd` (omitted,
20
+ // null, empty). They also pin that the non-enumerable migration marker can
21
+ // never be serialized into the registry.
22
+ //
23
+ // Every test builds its own isolated fixture repository under os.tmpdir()
24
+ // and asserts through the registry's public API plus the bytes on disk.
25
+
26
+ const { test, describe, after } = require('node:test');
27
+ const assert = require('node:assert/strict');
28
+ const fs = require('node:fs');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const { spawnSync } = require('node:child_process');
32
+
33
+ const registryLib = require('../lib/worktree-registry.js');
34
+
35
+ const LEGACY = ['odd/tasks/first.md', 'odd/tasks/second.md', 'odd/tasks/third.md'];
36
+
37
+ const roots = [];
38
+ after(() => {
39
+ for (const dir of roots) {
40
+ fs.rmSync(dir, { recursive: true, force: true });
41
+ }
42
+ });
43
+
44
+ function track(dir) {
45
+ roots.push(dir);
46
+ return dir;
47
+ }
48
+
49
+ function git(cwd, args) {
50
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
51
+ assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`);
52
+ return (result.stdout || '').trim();
53
+ }
54
+
55
+ function makeRepo(tag) {
56
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), `wt-mig-${tag}-`)));
57
+ git(dir, ['init']);
58
+ git(dir, ['config', 'user.email', 'test@example.com']);
59
+ git(dir, ['config', 'user.name', 'Test']);
60
+ git(dir, ['commit', '--allow-empty', '-m', 'init']);
61
+ return fs.realpathSync(dir);
62
+ }
63
+
64
+ function checkoutPath(root, slug) {
65
+ return path.join(path.dirname(root), `${path.basename(root)}-${slug}`);
66
+ }
67
+
68
+ function row(branch, root) {
69
+ return { branch, path: checkoutPath(root, branch.replace(/[^a-z0-9]+/gi, '-')), thread: `thread-${branch}`, issues: [] };
70
+ }
71
+
72
+ function writeRegistryFile(root, rows) {
73
+ const file = registryLib.registryPath(root);
74
+ fs.mkdirSync(path.dirname(file), { recursive: true });
75
+ fs.writeFileSync(file, `${JSON.stringify({ version: registryLib.REGISTRY_VERSION, worktrees: rows }, null, 2)}\n`);
76
+ return file;
77
+ }
78
+
79
+ // A legacy registry: one row carrying the old array, plus one unrelated row
80
+ // the write paths can act on without touching the legacy row.
81
+ function legacyRepo(tag) {
82
+ const root = makeRepo(tag);
83
+ const legacy = row('feat/legacy', root);
84
+ const other = row('feat/other', root);
85
+ const file = writeRegistryFile(root, [
86
+ { ...legacy, odd: LEGACY.slice() },
87
+ { ...other, odd: 'odd/tasks/other.md' },
88
+ ]);
89
+ return { root, legacy, other, file };
90
+ }
91
+
92
+ function storedRow(root, branch) {
93
+ const raw = fs.readFileSync(registryLib.registryPath(root), 'utf8');
94
+ return JSON.parse(raw).worktrees.find((entry) => entry.branch === branch);
95
+ }
96
+
97
+ function oddOf(root, branch) {
98
+ return registryLib.findByBranch(root, branch).odd;
99
+ }
100
+
101
+ describe('S8b migration: legacy odd[] rows normalize to ONE document', { concurrency: false }, () => {
102
+ test('a READ of a legacy row never rewrites the file and never fails', () => {
103
+ const { root, file } = legacyRepo('read');
104
+ const before = fs.readFileSync(file, 'utf8');
105
+
106
+ const read = registryLib.readRegistry(root);
107
+ assert.equal(read.ok, true, `a read never fails on a legacy array: ${read.error}`);
108
+ assert.equal(read.worktrees.find((entry) => entry.branch === 'feat/legacy').odd, LEGACY[0]);
109
+ assert.equal(oddOf(root, 'feat/legacy'), LEGACY[0], 'the lookups inherit the same first entry');
110
+ assert.equal(fs.readFileSync(file, 'utf8'), before, 'reading migrated nothing: the bytes are identical');
111
+ });
112
+
113
+ test('a write to an UNRELATED row leaves the legacy row byte for byte, with no report', () => {
114
+ const { root, other } = legacyRepo('unrelated');
115
+ // Nothing about the legacy row is supplied: the write still persists the
116
+ // whole file, and it must carry that row through untouched.
117
+ const written = registryLib.upsertWorktree(root, {
118
+ branch: 'feat/other', path: other.path, thread: 'thread-feat/other', issues: [7],
119
+ });
120
+ assert.equal(written.ok, true, `expected the write to succeed: ${JSON.stringify(written)}`);
121
+ assert.equal('dropped' in written, false, 'a row nobody addressed lost nothing, so nothing is reported');
122
+ assert.equal(oddOf(root, 'feat/legacy'), LEGACY[0], 'the in-memory view keeps ONE document');
123
+ assert.deepEqual(storedRow(root, 'feat/legacy').odd, LEGACY, 'the stored list survives verbatim');
124
+
125
+ const again = registryLib.updateWorktree(root, 'feat/other', { issues: [7, 8] });
126
+ assert.equal(again.ok, true);
127
+ assert.equal('dropped' in again, false, 'and still nothing to report');
128
+ assert.deepEqual(storedRow(root, 'feat/legacy').odd, LEGACY, 'after every write');
129
+ });
130
+
131
+ test('removeWorktree leaves the surviving legacy row byte for byte', () => {
132
+ const { root, other } = legacyRepo('remove');
133
+ const removed = registryLib.removeWorktree(root, 'feat/other');
134
+ assert.equal(removed.ok, true, `expected the removal to succeed: ${JSON.stringify(removed)}`);
135
+ assert.equal('dropped' in removed, false, 'removing one row destroys nothing of another');
136
+ assert.deepEqual(storedRow(root, 'feat/legacy').odd, LEGACY, 'every stored document survives');
137
+ assert.equal(registryLib.findByBranch(root, 'feat/other'), null, 'the removed row is gone');
138
+ });
139
+
140
+ test('removeWorktreeByPath leaves the surviving legacy row byte for byte', () => {
141
+ const { root, other } = legacyRepo('remove-path');
142
+ const removed = registryLib.removeWorktreeByPath(root, other.path);
143
+ assert.equal(removed.ok, true, `expected the removal to succeed: ${JSON.stringify(removed)}`);
144
+ assert.equal('dropped' in removed, false);
145
+ assert.deepEqual(storedRow(root, 'feat/legacy').odd, LEGACY);
146
+ });
147
+
148
+ test('removeWorktreeExact leaves the surviving legacy row byte for byte', () => {
149
+ const { root, other } = legacyRepo('remove-exact');
150
+ const removed = registryLib.removeWorktreeExact(root, other.branch, other.path);
151
+ assert.equal(removed.ok, true, `expected the removal to succeed: ${JSON.stringify(removed)}`);
152
+ assert.equal('dropped' in removed, false);
153
+ assert.deepEqual(storedRow(root, 'feat/legacy').odd, LEGACY);
154
+ });
155
+
156
+ test('pruneWorktrees leaves the surviving legacy row byte for byte', () => {
157
+ const { root, other } = legacyRepo('prune');
158
+ const pruned = registryLib.pruneWorktrees(root, [other.path]);
159
+ assert.equal(pruned.ok, true, `expected the prune to succeed: ${JSON.stringify(pruned)}`);
160
+ assert.deepEqual(pruned.pruned, [other.path], 'the dead row was pruned');
161
+ assert.equal('dropped' in pruned, false, 'the prune write destroys nothing of a row it kept');
162
+ assert.deepEqual(storedRow(root, 'feat/legacy').odd, LEGACY);
163
+ });
164
+
165
+ test('addOddDoc over a legacy row replaces it, reporting the drop and the replacement', () => {
166
+ const { root } = legacyRepo('add');
167
+ const added = registryLib.addOddDoc(root, 'feat/legacy', 'odd/tasks/chosen.md');
168
+ assert.equal(added.ok, true, `expected the association to succeed: ${JSON.stringify(added)}`);
169
+ assert.equal(added.entry.odd, 'odd/tasks/chosen.md', 'the chosen document is the ONLY association');
170
+ assert.equal(added.added, true);
171
+ assert.equal(added.replaced, LEGACY[0], 'the first entry lost the association, and that is reported');
172
+ assert.deepEqual(added.dropped, LEGACY.slice(1), 'and the entries the legacy array lost are reported too');
173
+ assert.equal(storedRow(root, 'feat/legacy').odd, 'odd/tasks/chosen.md');
174
+
175
+ const repeat = registryLib.addOddDoc(root, 'feat/legacy', 'odd/tasks/chosen.md');
176
+ assert.equal(repeat.added, false, 'a repeat association is idempotent');
177
+ assert.equal(repeat.replaced, null);
178
+ assert.equal('dropped' in repeat, false, 'a normalized row drops nothing');
179
+ });
180
+
181
+ test('upsert says nothing, null or an empty list: keep, clear, clear', () => {
182
+ const root = makeRepo('upsert-forms');
183
+ const target = row('feat/forms', root);
184
+ assert.equal(registryLib.upsertWorktree(root, { ...target, odd: 'odd/tasks/kept.md' }).ok, true);
185
+
186
+ const omitted = registryLib.upsertWorktree(root, { ...target, issues: [1] });
187
+ assert.equal(omitted.ok, true);
188
+ assert.equal(omitted.entry.odd, 'odd/tasks/kept.md', 'an omitted odd keeps the association');
189
+ assert.equal('dropped' in omitted, false, 'and reports nothing');
190
+
191
+ const cleared = registryLib.upsertWorktree(root, { ...target, odd: null });
192
+ assert.equal(cleared.ok, true);
193
+ assert.equal(cleared.entry.odd, null, 'null clears the association');
194
+ assert.equal(storedRow(root, 'feat/forms').odd, null);
195
+
196
+ assert.equal(registryLib.addOddDoc(root, 'feat/forms', 'odd/tasks/back.md').ok, true);
197
+ const emptied = registryLib.upsertWorktree(root, { ...target, odd: [] });
198
+ assert.equal(emptied.ok, true, 'an empty list is the legacy spelling of "none"');
199
+ assert.equal(emptied.entry.odd, null, 'and normalizes to null, never to []');
200
+ assert.equal(storedRow(root, 'feat/forms').odd, null);
201
+ });
202
+
203
+ test('no migration marker is ever serialized into the registry', () => {
204
+ const { root, other } = legacyRepo('serialize');
205
+ registryLib.upsertWorktree(root, { branch: 'feat/other', path: other.path, thread: 'thread-feat/other', issues: [2] });
206
+ registryLib.removeWorktree(root, 'feat/other');
207
+
208
+ const raw = fs.readFileSync(registryLib.registryPath(root), 'utf8');
209
+ assert.ok(!raw.includes('oddDropped'), `the non-enumerable marker never reaches the file:\n${raw}`);
210
+ assert.ok(!raw.includes('"dropped"'), 'and neither does the report');
211
+ const stored = JSON.parse(raw).worktrees.find((entry) => entry.branch === 'feat/legacy');
212
+ assert.deepEqual(stored.odd, LEGACY, 'the row still holds every stored document');
213
+ assert.equal('oddDropped' in stored, false);
214
+
215
+ // The marker exists only on the in-memory read, where a write that
216
+ // addresses this row can still find it; it never survives serialization
217
+ // and is never an observable field of the row.
218
+ const reread = registryLib.readRegistry(root).worktrees.find((entry) => entry.branch === 'feat/legacy');
219
+ assert.equal(Object.keys(reread).includes('oddDropped'), false, 'the stamp is invisible to a caller');
220
+ assert.equal(JSON.stringify(reread).includes('oddDropped'), false, 'and never serializes');
221
+ assert.equal(reread.odd, LEGACY[0], 'the in-memory view keeps ONE document');
222
+ });
223
+ });
224
+
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+ // S22 (round 3): reading the registry must not spawn git.
3
+ //
4
+ // The reported symptom was a plugin that made every tool call and every
5
+ // repaint feel heavy. `readRegistry` resolved the storage path through
6
+ // `repoIdentity` — two `spawnSync` git probes, ~19 ms on this machine — on
7
+ // EVERY call, and the interactive paths read the registry several times per
8
+ // action (once per tool event, once per list/detail/selector read). The
9
+ // resolution is cached per checkout and the parsed file per (inode, mtime,
10
+ // size), so a repeated read costs no child and a write is still seen at once.
11
+
12
+ const test = 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 childProcess = require('node:child_process');
18
+
19
+ function makeRepo() {
20
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'registry-cache-'));
21
+ const run = (args) => {
22
+ const res = childProcess.spawnSync('git', args, { cwd: root, encoding: 'utf8' });
23
+ assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`);
24
+ return res.stdout;
25
+ };
26
+ run(['init', '-q', '-b', 'main']);
27
+ run(['config', 'user.email', 'test@example.com']);
28
+ run(['config', 'user.name', 'Test']);
29
+ fs.writeFileSync(path.join(root, 'README.md'), '# repo\n');
30
+ run(['add', '.']);
31
+ run(['commit', '-q', '-m', 'init']);
32
+ return root;
33
+ }
34
+
35
+ // A fresh module (no warm state) with every `spawnSync` counted, so the
36
+ // assertions describe one process, not a previous test's cache.
37
+ function loadWithCounter() {
38
+ const libPath = require.resolve('../lib/worktree-registry.js');
39
+ delete require.cache[libPath];
40
+ const seen = [];
41
+ const original = childProcess.spawnSync;
42
+ childProcess.spawnSync = (bin, args, opts) => {
43
+ seen.push({ bin: String(bin), args: Array.isArray(args) ? args.slice() : [] });
44
+ return original(bin, args, opts);
45
+ };
46
+ const registry = require('../lib/worktree-registry.js');
47
+ return {
48
+ registry,
49
+ seen,
50
+ gitSpawns: () => seen.filter((call) => call.bin === 'git').length,
51
+ restore: () => {
52
+ childProcess.spawnSync = original;
53
+ },
54
+ };
55
+ }
56
+
57
+ function entry(overrides = {}) {
58
+ return {
59
+ branch: 'feat/one',
60
+ path: '/tmp/one',
61
+ thread: 'test-thread',
62
+ issues: [],
63
+ odd: null,
64
+ createdAt: '2024-01-01T00:00:00.000Z',
65
+ updatedAt: '2024-01-01T00:00:00.000Z',
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ test('five registry reads in one checkout resolve the repository once, not once per read', () => {
71
+ const root = makeRepo();
72
+ const loaded = loadWithCounter();
73
+ try {
74
+ for (let index = 0; index < 5; index += 1) {
75
+ const read = loaded.registry.readRegistry(root);
76
+ assert.equal(read.ok, true);
77
+ }
78
+ // `git rev-parse --show-toplevel` + `--git-common-dir`, the first time only.
79
+ assert.equal(loaded.gitSpawns(), 2,
80
+ `one resolution for the checkout, not one per read: ${JSON.stringify(loaded.seen)}`);
81
+ } finally {
82
+ loaded.restore();
83
+ fs.rmSync(root, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ test('a write through the registry is visible to the very next read', () => {
88
+ const root = makeRepo();
89
+ const loaded = loadWithCounter();
90
+ try {
91
+ const written = loaded.registry.upsertWorktree(root, entry());
92
+ assert.equal(written.ok, true, `upsert: ${JSON.stringify(written)}`);
93
+ const read = loaded.registry.readRegistry(root);
94
+ assert.equal(read.ok, true);
95
+ assert.equal(read.worktrees.length, 1, 'the row written under the lock is read back');
96
+ assert.equal(read.worktrees[0].branch, 'feat/one');
97
+ } finally {
98
+ loaded.restore();
99
+ fs.rmSync(root, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test('another process writing the registry invalidates a cached read', () => {
104
+ const root = makeRepo();
105
+ const loaded = loadWithCounter();
106
+ try {
107
+ assert.equal(loaded.registry.readRegistry(root).worktrees.length, 0, 'nothing registered yet');
108
+ const file = path.join(fs.realpathSync(root), '.git', 'pi-worktrees.json');
109
+ // A different writer: same path, written in place, like a hand edit.
110
+ const doc = { version: 1, worktrees: [{ ...entry({ branch: 'feat/external', path: '/tmp/external' }) }] };
111
+ fs.writeFileSync(file, `${JSON.stringify(doc, null, 2)}\n`);
112
+ const read = loaded.registry.readRegistry(root);
113
+ assert.equal(read.worktrees.length, 1, 'the external write is seen, never a stale empty registry');
114
+ assert.equal(read.worktrees[0].branch, 'feat/external');
115
+ } finally {
116
+ loaded.restore();
117
+ fs.rmSync(root, { recursive: true, force: true });
118
+ }
119
+ });
120
+
121
+ test('two checkouts of the same repository never share one cache slot, and a caller cannot mutate a cached doc', () => {
122
+ const root = makeRepo();
123
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), 'registry-cache-wt-'));
124
+ const loaded = loadWithCounter();
125
+ try {
126
+ assert.equal(loaded.registry.upsertWorktree(root, entry()).ok, true);
127
+ const linked = path.join(other, 'linked');
128
+ const added = childProcess.spawnSync('git', ['worktree', 'add', '-q', '-b', 'feat/linked', linked, 'HEAD'],
129
+ { cwd: root, encoding: 'utf8' });
130
+ assert.equal(added.status, 0, added.stderr);
131
+ const first = loaded.registry.readRegistry(root);
132
+ first.worktrees.push(entry({ branch: 'feat/mutated' }));
133
+ const again = loaded.registry.readRegistry(root);
134
+ assert.equal(again.worktrees.length, 1, 'the cached document is never handed out for mutation');
135
+ const fromLinked = loaded.registry.readRegistry(linked);
136
+ assert.deepEqual(fromLinked.worktrees.map((row) => row.branch), ['feat/one'],
137
+ 'both checkouts read the same shared registry file');
138
+ } finally {
139
+ loaded.restore();
140
+ fs.rmSync(root, { recursive: true, force: true });
141
+ fs.rmSync(other, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test('a cached read still carries the hidden drop report a later write needs', () => {
146
+ const root = makeRepo();
147
+ const loaded = loadWithCounter();
148
+ try {
149
+ const file = path.join(fs.realpathSync(root), '.git', 'pi-worktrees.json');
150
+ const legacy = { version: 1, worktrees: [{ ...entry({ branch: 'feat/legacy' }), odd: ['odd/tasks/first.md', 'odd/tasks/second.md'] }] };
151
+ fs.writeFileSync(file, `${JSON.stringify(legacy, null, 2)}\n`);
152
+ // Read twice: the second one comes from the cache.
153
+ assert.equal(loaded.registry.readRegistry(root).worktrees[0].odd, 'odd/tasks/first.md');
154
+ assert.equal(loaded.registry.readRegistry(root).worktrees[0].odd, 'odd/tasks/first.md');
155
+ const updated = loaded.registry.updateWorktree(root, 'feat/legacy', { issues: [] });
156
+ assert.equal(updated.ok, true);
157
+ assert.deepEqual(updated.dropped, ['odd/tasks/second.md'],
158
+ 'the collapsing write still names the document it dropped');
159
+ } finally {
160
+ loaded.restore();
161
+ fs.rmSync(root, { recursive: true, force: true });
162
+ }
163
+ });