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,231 @@
1
+ 'use strict';
2
+ // T8 / S8-S8b: the ENTITY rule — a worktree carries AT MOST ONE odd
3
+ // document. It is an invariant of the registry row, not a UI convention:
4
+ // the row holds one document path string or `null`, and the registry
5
+ // enforces it on EVERY read and EVERY write so no caller can reintroduce a
6
+ // second one.
7
+ //
8
+ // S8b migration: a legacy row that already stores an array normalizes
9
+ // deterministically to its FIRST entry, reads never fail on it, and the
10
+ // write that normalized it REPORTS the dropped entries once (`dropped`)
11
+ // instead of letting a stored association disappear silently.
12
+ //
13
+ // Every test builds its own isolated fixture repository under os.tmpdir()
14
+ // and asserts through the public registry interface plus the bytes on disk,
15
+ // never an internal helper. Only the fixture dirs tracked in `roots` are
16
+ // removed.
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 { spawnSync } = require('node:child_process');
24
+
25
+ const registryLib = require('../lib/worktree-registry.js');
26
+
27
+ const roots = [];
28
+ after(() => {
29
+ for (const dir of roots) {
30
+ fs.rmSync(dir, { recursive: true, force: true });
31
+ }
32
+ });
33
+
34
+ function track(dir) {
35
+ roots.push(dir);
36
+ return dir;
37
+ }
38
+
39
+ function git(cwd, args) {
40
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
41
+ assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`);
42
+ return (result.stdout || '').trim();
43
+ }
44
+
45
+ function makeRepo(tag) {
46
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), `wt-regodd-${tag}-`)));
47
+ git(dir, ['init']);
48
+ git(dir, ['config', 'user.email', 'test@example.com']);
49
+ git(dir, ['config', 'user.name', 'Test']);
50
+ git(dir, ['commit', '--allow-empty', '-m', 'init']);
51
+ return fs.realpathSync(dir);
52
+ }
53
+
54
+ function checkoutPath(root, slug) {
55
+ return path.join(path.dirname(root), `${path.basename(root)}-${slug}-wt`);
56
+ }
57
+
58
+ function entry(branch, root, extra = {}) {
59
+ return {
60
+ branch,
61
+ path: checkoutPath(root, branch.replace(/[^a-z0-9]+/gi, '-')),
62
+ thread: `thread-${branch}`,
63
+ issues: [],
64
+ ...extra,
65
+ };
66
+ }
67
+
68
+ // The row exactly as it sits in the registry FILE: proof that the entity —
69
+ // not just the in-memory read — holds one document or none.
70
+ function storedRows(root) {
71
+ const raw = fs.readFileSync(registryLib.registryPath(root), 'utf8');
72
+ return JSON.parse(raw).worktrees;
73
+ }
74
+
75
+ function storedRow(root, branch) {
76
+ return storedRows(root).find((row) => row.branch === branch);
77
+ }
78
+
79
+ // Hand-write a registry file, exactly as an older version of this package
80
+ // left it (rows carrying `odd[]`).
81
+ function writeLegacyRegistry(root, rows) {
82
+ const file = registryLib.registryPath(root);
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ const payload = JSON.stringify({ version: registryLib.REGISTRY_VERSION, worktrees: rows }, null, 2);
85
+ fs.writeFileSync(file, `${payload}\n`);
86
+ return file;
87
+ }
88
+
89
+ describe('S8: a worktree carries at most ONE odd document', { concurrency: false }, () => {
90
+ test('a write of one document stores it as a single path string, never a list', () => {
91
+ const root = makeRepo('one');
92
+ const written = registryLib.upsertWorktree(root, entry('feat/one', root, { odd: 'odd/tasks/a.md' }));
93
+ assert.equal(written.ok, true, `expected the write to succeed: ${JSON.stringify(written)}`);
94
+ assert.equal(written.entry.odd, 'odd/tasks/a.md', 'the write result carries the single path');
95
+ assert.ok(!Array.isArray(written.entry.odd), 'the write result never carries a list');
96
+
97
+ const read = registryLib.readRegistry(root);
98
+ assert.equal(read.ok, true);
99
+ assert.equal(read.worktrees.length, 1);
100
+ assert.equal(read.worktrees[0].odd, 'odd/tasks/a.md', 'a read returns the same single path');
101
+ assert.equal(registryLib.findByBranch(root, 'feat/one').odd, 'odd/tasks/a.md', 'the lookups inherit it');
102
+
103
+ const stored = storedRow(root, 'feat/one');
104
+ assert.equal(stored.odd, 'odd/tasks/a.md', 'the row on disk holds the string');
105
+ assert.equal(Array.isArray(stored.odd), false, 'the row on disk never holds a list');
106
+ });
107
+
108
+ test('an empty list is "no association": the row stores null, not []', () => {
109
+ const root = makeRepo('empty');
110
+ const written = registryLib.upsertWorktree(root, entry('feat/empty', root, { odd: [] }));
111
+ assert.equal(written.ok, true);
112
+ assert.equal(written.entry.odd, null, 'an empty list normalizes to null');
113
+ assert.equal(storedRow(root, 'feat/empty').odd, null, 'the row on disk holds null');
114
+ assert.equal(registryLib.findByBranch(root, 'feat/empty').odd, null);
115
+ });
116
+
117
+ test('a write of two legacy entries collapses to the FIRST and reports the dropped ones once', () => {
118
+ const root = makeRepo('legacy-write');
119
+ const written = registryLib.upsertWorktree(root, entry('feat/drop', root, {
120
+ odd: ['odd/tasks/first.md', 'odd/tasks/second.md', 'odd/tasks/third.md'],
121
+ }));
122
+ assert.equal(written.ok, true, 'a legacy list is normalized, never refused by the registry');
123
+ assert.equal(written.entry.odd, 'odd/tasks/first.md', 'the FIRST stored entry wins deterministically');
124
+ assert.deepEqual(written.dropped, ['odd/tasks/second.md', 'odd/tasks/third.md'], 'the dropped entries are reported');
125
+ assert.equal(storedRow(root, 'feat/drop').odd, 'odd/tasks/first.md', 'only one document reaches the file');
126
+
127
+ // Reported ONCE: the row is normalized now, so the next write drops nothing.
128
+ const again = registryLib.updateWorktree(root, 'feat/drop', { issues: [] });
129
+ assert.equal(again.ok, true);
130
+ assert.equal('dropped' in again, false, 'a write that normalizes nothing reports nothing');
131
+ assert.equal(registryLib.findByBranch(root, 'feat/drop').odd, 'odd/tasks/first.md', 'and the association survives');
132
+ });
133
+
134
+ test('a legacy row already storing a list reads as its FIRST entry and never fails', () => {
135
+ const root = makeRepo('legacy-read');
136
+ const file = writeLegacyRegistry(root, [
137
+ { ...entry('feat/legacy', root), odd: ['odd/tasks/first.md', 'odd/tasks/second.md'] },
138
+ { ...entry('feat/blank', root), odd: [] },
139
+ { ...entry('feat/missing', root) },
140
+ ]);
141
+ const before = fs.readFileSync(file, 'utf8');
142
+
143
+ const read = registryLib.readRegistry(root);
144
+ assert.equal(read.ok, true, 'a read never fails on a legacy array');
145
+ assert.equal(read.error, null);
146
+ const oddOf = (branch) => read.worktrees.find((row) => row.branch === branch).odd;
147
+ assert.equal(oddOf('feat/legacy'), 'odd/tasks/first.md', 'the legacy list reads as one document');
148
+ assert.equal(oddOf('feat/blank'), null, 'an empty list reads as no association');
149
+ assert.equal(oddOf('feat/missing'), null, 'a row without the field reads as no association');
150
+ assert.equal(registryLib.findByBranch(root, 'feat/legacy').odd, 'odd/tasks/first.md');
151
+ assert.equal(fs.readFileSync(file, 'utf8'), before, 'a read never rewrites the file');
152
+ });
153
+
154
+ test('the write over a legacy row normalizes the file and reports the drop', () => {
155
+ const root = makeRepo('legacy-migrate');
156
+ writeLegacyRegistry(root, [
157
+ { ...entry('feat/legacy', root), odd: ['odd/tasks/first.md', 'odd/tasks/second.md'] },
158
+ ]);
159
+ const updated = registryLib.updateWorktree(root, 'feat/legacy', { issues: [] });
160
+ assert.equal(updated.ok, true);
161
+ assert.deepEqual(updated.dropped, ['odd/tasks/second.md'], 'the collapsing write reports what it dropped');
162
+ assert.equal(storedRow(root, 'feat/legacy').odd, 'odd/tasks/first.md', 'the file now holds one document');
163
+ assert.equal(registryLib.updateWorktree(root, 'feat/legacy', { issues: [] }).dropped, undefined, 'reported once');
164
+ });
165
+
166
+ test('associating another document REPLACES the stored one and never adds a second', () => {
167
+ const root = makeRepo('replace');
168
+ registryLib.upsertWorktree(root, entry('feat/replace', root, { odd: 'odd/tasks/a.md' }));
169
+
170
+ const swapped = registryLib.addOddDoc(root, 'feat/replace', 'odd/tasks/b.md');
171
+ assert.equal(swapped.ok, true);
172
+ assert.equal(swapped.entry.odd, 'odd/tasks/b.md', 'the new document is the ONLY association');
173
+ assert.equal(swapped.added, true);
174
+ assert.equal(swapped.replaced, 'odd/tasks/a.md', 'the replaced association is reported, never dropped silently');
175
+ assert.equal(storedRow(root, 'feat/replace').odd, 'odd/tasks/b.md');
176
+
177
+ const same = registryLib.addOddDoc(root, 'feat/replace', 'odd/tasks/b.md');
178
+ assert.equal(same.ok, true);
179
+ assert.equal(same.added, false, 'a repeat association is idempotent');
180
+ assert.equal(same.replaced, null, 'and replaces nothing');
181
+ assert.equal(registryLib.findByBranch(root, 'feat/replace').odd, 'odd/tasks/b.md');
182
+ });
183
+
184
+ test('no write path can leave two documents behind: update, add and upsert agree', () => {
185
+ const root = makeRepo('all-writes');
186
+ registryLib.upsertWorktree(root, entry('feat/w', root, { odd: 'odd/tasks/a.md' }));
187
+
188
+ const patched = registryLib.updateWorktree(root, 'feat/w', { odd: ['odd/tasks/x.md', 'odd/tasks/y.md'] });
189
+ assert.equal(patched.ok, true, `the registry normalizes a legacy list instead of failing: ${JSON.stringify(patched)}`);
190
+ assert.equal(patched.entry.odd, 'odd/tasks/x.md');
191
+ assert.deepEqual(patched.dropped, ['odd/tasks/y.md']);
192
+
193
+ const cleared = registryLib.updateWorktree(root, 'feat/w', { odd: null });
194
+ assert.equal(cleared.ok, true);
195
+ assert.equal(cleared.entry.odd, null, 'null clears the association');
196
+
197
+ const replaced = registryLib.updateWorktree(root, 'feat/w', { odd: 'odd/tasks/z.md' });
198
+ assert.equal(replaced.ok, true);
199
+ assert.equal(replaced.entry.odd, 'odd/tasks/z.md');
200
+
201
+ // An upsert that says nothing about odd keeps the stored association.
202
+ const untouched = registryLib.upsertWorktree(root, entry('feat/w', root, {}));
203
+ assert.equal(untouched.ok, true);
204
+ assert.equal(untouched.entry.odd, 'odd/tasks/z.md', 'an omitted odd keeps the single association');
205
+
206
+ for (const row of storedRows(root)) {
207
+ assert.ok(row.odd === null || typeof row.odd === 'string', `row holds one document or none: ${JSON.stringify(row.odd)}`);
208
+ }
209
+ });
210
+
211
+ test('startup discovery registers rows with no association, never an empty list', () => {
212
+ const root = makeRepo('discovery');
213
+ const live = [{ branch: 'feat/live', path: root }];
214
+ const registered = registryLib.registerMissingWorktrees(root, live, root);
215
+ assert.equal(registered.ok, true, `expected discovery to register: ${JSON.stringify(registered)}`);
216
+ assert.deepEqual(registered.registered.map((row) => row.branch), ['feat/live']);
217
+ const row = registryLib.findByBranch(root, 'feat/live');
218
+ assert.equal(row.odd, null, 'a discovered row carries no association');
219
+ assert.equal(storedRow(root, 'feat/live').odd, null, 'and the file holds null');
220
+ });
221
+
222
+ test('a value that is neither a path nor a list is refused without writing', () => {
223
+ const root = makeRepo('refuse');
224
+ registryLib.upsertWorktree(root, entry('feat/r', root, { odd: 'odd/tasks/a.md' }));
225
+ const before = fs.readFileSync(registryLib.registryPath(root), 'utf8');
226
+ const refused = registryLib.updateWorktree(root, 'feat/r', { odd: 42 });
227
+ assert.equal(refused.ok, false, 'a non-path odd value is refused');
228
+ assert.match(refused.error, /odd/i, `the refusal names odd: ${refused.error}`);
229
+ assert.equal(fs.readFileSync(registryLib.registryPath(root), 'utf8'), before, 'a refused write changes nothing');
230
+ });
231
+ });
@@ -0,0 +1,244 @@
1
+ 'use strict';
2
+ // S1: the worktree registry never destroys data a write was not asked to
3
+ // change.
4
+ //
5
+ // A registry row can still hold the legacy `odd[]` array an older version
6
+ // wrote (S8b), and it can hold fields this version knows nothing about. A
7
+ // write that addresses ONE row used to rewrite the whole file, normalizing
8
+ // every other row on the way through: the stored documents of rows nobody
9
+ // touched were destroyed, and the writer had to report that loss. S1 removes
10
+ // the association from the product, so the report has no place left to
11
+ // travel — which means the loss itself must go.
12
+ //
13
+ // The contract these tests pin, through the public API plus the bytes on
14
+ // disk:
15
+ // - a row a write does NOT address keeps its stored association, its
16
+ // `issues` list and every unknown field, byte for byte, including a
17
+ // legacy `odd[]` array and a row that never had the field at all;
18
+ // - the row a write DOES address keeps the one-document rule: a supplied
19
+ // legacy list collapses to its first entry and the rest are reported
20
+ // once (S8b), and an omitted association leaves the stored value as it
21
+ // is on disk;
22
+ // - an unreadable (malformed) registry is still replaced with a best-effort
23
+ // backup rather than merged.
24
+ //
25
+ // Every test builds its own isolated fixture repository under os.tmpdir().
26
+
27
+ const { test, describe, after } = require('node:test');
28
+ const assert = require('node:assert/strict');
29
+ const fs = require('node:fs');
30
+ const os = require('node:os');
31
+ const path = require('node:path');
32
+ const { spawnSync } = require('node:child_process');
33
+
34
+ const registryLib = require('../lib/worktree-registry.js');
35
+
36
+ const LEGACY_ODD = ['odd/tasks/first.md', 'odd/tasks/second.md', 'odd/tasks/third.md'];
37
+
38
+ const roots = [];
39
+ after(() => {
40
+ for (const dir of roots) {
41
+ try {
42
+ fs.rmSync(dir, { recursive: true, force: true });
43
+ } catch {}
44
+ }
45
+ });
46
+
47
+ function track(dir) {
48
+ roots.push(dir);
49
+ return dir;
50
+ }
51
+
52
+ function git(cwd, args) {
53
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
54
+ assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`);
55
+ return (result.stdout || '').trim();
56
+ }
57
+
58
+ function makeRepo(tag) {
59
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), `wt-reg-${tag}-`)));
60
+ git(dir, ['init']);
61
+ git(dir, ['config', 'user.email', 'test@example.com']);
62
+ git(dir, ['config', 'user.name', 'Test']);
63
+ git(dir, ['commit', '--allow-empty', '-m', 'init']);
64
+ return fs.realpathSync(dir);
65
+ }
66
+
67
+ function checkoutPath(root, slug) {
68
+ return path.join(path.dirname(root), `${path.basename(root)}-${slug}`);
69
+ }
70
+
71
+ function row(branch, root) {
72
+ return { branch, path: checkoutPath(root, branch.replace(/[^a-z0-9]+/gi, '-')), thread: `thread-${branch}`, issues: [] };
73
+ }
74
+
75
+ // A row exactly as an older version left it: the legacy association list,
76
+ // an `issues` list, and a field this version does not model at all.
77
+ function legacyRow(root) {
78
+ return {
79
+ branch: 'feat/legacy',
80
+ path: checkoutPath(root, 'legacy'),
81
+ thread: 'thread-legacy',
82
+ issues: [11, 12],
83
+ odd: LEGACY_ODD.slice(),
84
+ note: 'kept verbatim',
85
+ createdAt: '2024-01-01T00:00:00.000Z',
86
+ };
87
+ }
88
+
89
+ function writeRegistryFile(root, rows) {
90
+ const file = registryLib.registryPath(root);
91
+ fs.mkdirSync(path.dirname(file), { recursive: true });
92
+ fs.writeFileSync(file, `${JSON.stringify({ version: registryLib.REGISTRY_VERSION, worktrees: rows }, null, 2)}\n`);
93
+ return file;
94
+ }
95
+
96
+ function legacyRepo(tag) {
97
+ const root = makeRepo(tag);
98
+ const other = row('feat/other', root);
99
+ const legacy = legacyRow(root);
100
+ writeRegistryFile(root, [legacy, { ...other, odd: 'odd/tasks/other.md' }]);
101
+ return { root, legacy, other };
102
+ }
103
+
104
+ function storedRow(root, branch) {
105
+ const raw = fs.readFileSync(registryLib.registryPath(root), 'utf8');
106
+ return JSON.parse(raw).worktrees.find((entry) => entry.branch === branch);
107
+ }
108
+
109
+ // Byte-for-byte: the parsed row equals the original in values AND key order.
110
+ function assertKeptVerbatim(root, original) {
111
+ const stored = storedRow(root, original.branch);
112
+ assert.deepEqual(stored, original, `the unaddressed row is preserved verbatim: ${JSON.stringify(stored)}`);
113
+ assert.equal(
114
+ JSON.stringify(stored),
115
+ JSON.stringify(original),
116
+ 'not one byte of the stored row changed, key order included',
117
+ );
118
+ }
119
+
120
+ // Each entry is one whole-file write that addresses a DIFFERENT row.
121
+ const UNRELATED_WRITES = [
122
+ ['upsertWorktree', ({ root, other }) => registryLib.upsertWorktree(root, { ...other, issues: [7] })],
123
+ ['appendWorktree', ({ root, other }) => registryLib.appendWorktree(root, { ...other, issues: [7] })],
124
+ ['updateWorktree', ({ root }) => registryLib.updateWorktree(root, 'feat/other', { issues: [7, 8] })],
125
+ ['removeWorktree', ({ root }) => registryLib.removeWorktree(root, 'feat/other')],
126
+ ['removeWorktreeByPath', ({ root, other }) => registryLib.removeWorktreeByPath(root, other.path)],
127
+ ['removeWorktreeExact', ({ root, other }) => registryLib.removeWorktreeExact(root, other.branch, other.path)],
128
+ ['pruneWorktrees', ({ root, other }) => registryLib.pruneWorktrees(root, [other.path])],
129
+ ['registerMissingWorktrees', ({ root }) => {
130
+ const live = checkoutPath(root, 'new-live');
131
+ fs.mkdirSync(live, { recursive: true });
132
+ roots.push(live);
133
+ return registryLib.registerMissingWorktrees(root, [{ branch: 'feat/new-live', path: live }], null);
134
+ }],
135
+ ];
136
+
137
+ describe('S1: a registry write never destroys an association it does not address', { concurrency: false }, () => {
138
+ for (const [name, run] of UNRELATED_WRITES) {
139
+ test(`${name} leaves another row's stored documents, issues and fields byte for byte`, () => {
140
+ const fixture = legacyRepo(`keep-${name}`);
141
+ const before = fs.readFileSync(registryLib.registryPath(fixture.root), 'utf8');
142
+ const result = run(fixture);
143
+ assert.equal(result.ok, true, `expected the write to succeed: ${JSON.stringify(result)}`);
144
+ assert.equal('dropped' in result, false, 'nothing was lost, so nothing is reported');
145
+ assertKeptVerbatim(fixture.root, fixture.legacy);
146
+ // The row that WAS addressed is the only one that changed.
147
+ assert.notEqual(fs.readFileSync(registryLib.registryPath(fixture.root), 'utf8'), before);
148
+ assert.equal(registryLib.findByBranch(fixture.root, 'feat/legacy').odd, LEGACY_ODD[0],
149
+ 'reads still resolve the legacy list to ONE document in memory');
150
+ });
151
+ }
152
+
153
+ test('a row without an odd or issues field gains neither', () => {
154
+ const root = makeRepo('bare-row');
155
+ const other = row('feat/other', root);
156
+ const bare = { branch: 'feat/bare', path: checkoutPath(root, 'bare'), thread: 'thread-bare' };
157
+ writeRegistryFile(root, [bare, { ...other, odd: null }]);
158
+ const result = registryLib.upsertWorktree(root, { ...other, issues: [3] });
159
+ assert.equal(result.ok, true, JSON.stringify(result));
160
+ const stored = storedRow(root, 'feat/bare');
161
+ assert.deepEqual(Object.keys(stored), ['branch', 'path', 'thread'],
162
+ 'a write invents no `issues`/`odd` on a row it did not address');
163
+ assert.equal(JSON.stringify(stored), JSON.stringify(bare), 'the row is byte-identical');
164
+ });
165
+
166
+ test('a malformed registry is still replaced behind a backup, never merged', () => {
167
+ const root = makeRepo('malformed');
168
+ const file = registryLib.registryPath(root);
169
+ fs.mkdirSync(path.dirname(file), { recursive: true });
170
+ fs.writeFileSync(file, '{ not json');
171
+ const written = registryLib.upsertWorktree(root, { branch: 'feat/new', path: checkoutPath(root, 'new'), thread: 't', issues: [] });
172
+ assert.equal(written.ok, true, JSON.stringify(written));
173
+ assert.equal(typeof written.backup, 'string', 'the damaged bytes are preserved beside the registry');
174
+ assert.equal(fs.readFileSync(written.backup, 'utf8'), '{ not json', 'the backup holds the previous bytes');
175
+ assert.deepEqual(
176
+ JSON.parse(fs.readFileSync(file, 'utf8')).worktrees.map((entry) => entry.branch),
177
+ ['feat/new'],
178
+ 'the incoming row is the only trustworthy data',
179
+ );
180
+ });
181
+ });
182
+
183
+ describe('S1/S8: the row a write addresses keeps the one-document rule', { concurrency: false }, () => {
184
+ test('an addressed legacy row collapses to its FIRST entry and reports the rest once', () => {
185
+ const { root } = legacyRepo('addressed-update');
186
+ const updated = registryLib.updateWorktree(root, 'feat/legacy', { issues: [11] });
187
+ assert.equal(updated.ok, true, JSON.stringify(updated));
188
+ assert.deepEqual(updated.dropped, LEGACY_ODD.slice(1), 'the collapse of the addressed row is reported');
189
+ assert.equal(storedRow(root, 'feat/legacy').odd, LEGACY_ODD[0], 'the file holds one document');
190
+ const again = registryLib.updateWorktree(root, 'feat/legacy', { issues: [11, 12] });
191
+ assert.equal('dropped' in again, false, 'reported once, never on every later write');
192
+ });
193
+
194
+ test('an upsert that omits the association leaves the stored list exactly as it is', () => {
195
+ const { root, legacy } = legacyRepo('addressed-omitted');
196
+ const written = registryLib.upsertWorktree(root, {
197
+ branch: legacy.branch, path: legacy.path, thread: legacy.thread, issues: [11, 12, 13],
198
+ });
199
+ assert.equal(written.ok, true, JSON.stringify(written));
200
+ assert.equal('dropped' in written, false, 'an association nobody changed is not a loss');
201
+ const stored = storedRow(root, legacy.branch);
202
+ assert.deepEqual(stored.odd, LEGACY_ODD, 'the stored list is untouched');
203
+ assert.deepEqual(stored.issues, [11, 12, 13], 'the supplied field is the one that changes');
204
+ assert.equal(stored.note, 'kept verbatim', 'a field this version does not model survives an addressed write too');
205
+ });
206
+
207
+ test('a supplied legacy list still collapses to ONE document and reports the rest', () => {
208
+ const root = makeRepo('supplied-list');
209
+ const target = row('feat/supplied', root);
210
+ const written = registryLib.upsertWorktree(root, {
211
+ ...target, issues: [], odd: ['odd/tasks/x.md', 'odd/tasks/y.md'],
212
+ });
213
+ assert.equal(written.ok, true, JSON.stringify(written));
214
+ assert.deepEqual(written.dropped, ['odd/tasks/y.md'], 'the caller-supplied list keeps its first entry only');
215
+ assert.equal(storedRow(root, 'feat/supplied').odd, 'odd/tasks/x.md');
216
+ });
217
+
218
+ test('addOddDoc replaces the addressed row\'s document and reports the replacement', () => {
219
+ const { root } = legacyRepo('addressed-add');
220
+ const added = registryLib.addOddDoc(root, 'feat/legacy', 'odd/tasks/chosen.md');
221
+ assert.equal(added.ok, true, JSON.stringify(added));
222
+ assert.equal(added.entry.odd, 'odd/tasks/chosen.md');
223
+ assert.equal(added.replaced, LEGACY_ODD[0], 'the document that lost the association is named');
224
+ assert.deepEqual(added.dropped, LEGACY_ODD.slice(1), 'and the legacy entries the selection replaced');
225
+ assert.equal(storedRow(root, 'feat/legacy').odd, 'odd/tasks/chosen.md');
226
+ // The other row of the fixture is untouched by the addressed write.
227
+ assert.equal(storedRow(root, 'feat/other').odd, 'odd/tasks/other.md');
228
+ });
229
+ });
230
+
231
+ describe('S1: the registry file carries no report and no marker', { concurrency: false }, () => {
232
+ test('neither the drop report nor the migration stamp ever reaches the file', () => {
233
+ const { root, other } = legacyRepo('serialize');
234
+ registryLib.upsertWorktree(root, { ...other, issues: [2] });
235
+ registryLib.removeWorktree(root, 'feat/other');
236
+ const raw = fs.readFileSync(registryLib.registryPath(root), 'utf8');
237
+ assert.ok(!raw.includes('oddDropped'), `the non-enumerable marker never reaches the file:\n${raw}`);
238
+ assert.ok(!raw.includes('"dropped"'), 'and neither does the report');
239
+ const reread = registryLib.readRegistry(root).worktrees.find((entry) => entry.branch === 'feat/legacy');
240
+ assert.equal(Object.keys(reread).includes('oddDropped'), false, 'the stamp is never an observable field');
241
+ assert.equal(JSON.stringify(reread).includes('oddDropped'), false, 'and never serializes');
242
+ assert.equal(reread.odd, LEGACY_ODD[0], 'the in-memory view keeps the single-document rule');
243
+ });
244
+ });