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,951 @@
1
+ 'use strict';
2
+ // S61/T47 tests: lib/worktree-changes.js — the shared pending-changes
3
+ // helper (dirty summary + safe copy). Real isolated git repos; no live
4
+ // Herdr/gh/Pi/UI. Every assertion goes through the public functions,
5
+ // never internals.
6
+
7
+ const { test, describe, after } = require('node:test');
8
+ const assert = require('node:assert/strict');
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawnSync } = require('node:child_process');
13
+
14
+ const changes = require('../lib/worktree-changes.js');
15
+
16
+ const roots = [];
17
+ after(() => {
18
+ for (const dir of roots) {
19
+ fs.rmSync(dir, { recursive: true, force: true });
20
+ }
21
+ });
22
+
23
+ function git(cwd, args) {
24
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } });
25
+ assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`);
26
+ return result.stdout;
27
+ }
28
+
29
+ // A directory OUTSIDE every checkout, tracked for teardown: the isolation these
30
+ // tests need to prove that nothing escaped the destination.
31
+ function trackOutsideDir(tag) {
32
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `wt-outside-${tag}-`));
33
+ roots.push(dir);
34
+ return dir;
35
+ }
36
+
37
+ function makeRepo(name) {
38
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `wt-changes-${name}-`));
39
+ roots.push(dir);
40
+ git(dir, ['init', '-b', 'main']);
41
+ git(dir, ['config', 'user.email', 'test@example.com']);
42
+ git(dir, ['config', 'user.name', 'Test']);
43
+ git(dir, ['config', 'commit.gpgsign', 'false']);
44
+ fs.writeFileSync(path.join(dir, 'a.txt'), 'one\ntwo\n');
45
+ fs.writeFileSync(path.join(dir, 'b.txt'), 'alpha\nbeta\n');
46
+ git(dir, ['add', '.']);
47
+ git(dir, ['commit', '-m', 'init']);
48
+ return dir;
49
+ }
50
+
51
+ function addWorktree(root, branch) {
52
+ git(root, ['worktree', 'add', '-b', branch, path.join(root, '..', `wt-${branch.replace(/[^a-z0-9]+/gi, '-')}-${Date.now()}`)]);
53
+ const list = git(root, ['worktree', 'list', '--porcelain']).split('\n');
54
+ let current = null;
55
+ for (const line of list) {
56
+ if (line.startsWith('worktree ')) current = line.slice('worktree '.length).trim();
57
+ if (line === `branch refs/heads/${branch}`) return current;
58
+ }
59
+ throw new Error(`worktree for ${branch} not found`);
60
+ }
61
+
62
+ describe('summarizeChanges', { concurrency: false }, () => {
63
+ test('a clean checkout reports zero files', async () => {
64
+ const root = makeRepo('clean');
65
+ const summary = await changes.summarizeChanges(root);
66
+ assert.equal(summary.ok, true);
67
+ assert.equal(summary.files, 0);
68
+ assert.equal(summary.added, 0);
69
+ assert.equal(summary.deleted, 0);
70
+ });
71
+
72
+ test('a tracked modification counts staged and unstaged once, not twice', async () => {
73
+ const root = makeRepo('staged-unstaged');
74
+ // Staged change plus a further unstaged change to the SAME file:
75
+ // the HEAD diff holds both, counted once.
76
+ fs.writeFileSync(path.join(root, 'a.txt'), 'one\nTWO-staged\n');
77
+ git(root, ['add', 'a.txt']);
78
+ fs.appendFileSync(path.join(root, 'a.txt'), 'three-unstaged\n');
79
+ const summary = await changes.summarizeChanges(root);
80
+ assert.equal(summary.ok, true);
81
+ assert.equal(summary.files, 1, JSON.stringify(summary.entries));
82
+ const entry = summary.entries[0];
83
+ assert.equal(entry.rel, 'a.txt');
84
+ assert.equal(entry.added, 2);
85
+ assert.equal(entry.deleted, 1);
86
+ assert.equal(summary.added, 2);
87
+ assert.equal(summary.deleted, 1);
88
+ });
89
+
90
+ test('untracked files count added lines and deletions remove lines', async () => {
91
+ const root = makeRepo('untracked-deletion');
92
+ fs.writeFileSync(path.join(root, 'new.txt'), 'l1\nl2\nl3\n');
93
+ fs.writeFileSync(path.join(root, 'b.txt'), 'alpha\n');
94
+ const summary = await changes.summarizeChanges(root);
95
+ assert.equal(summary.ok, true);
96
+ assert.equal(summary.files, 2);
97
+ const byRel = Object.fromEntries(summary.entries.map((e) => [e.rel, e]));
98
+ assert.equal(byRel['new.txt'].added, 3);
99
+ assert.equal(byRel['new.txt'].untracked, true);
100
+ assert.equal(byRel['b.txt'].added, 0);
101
+ assert.equal(byRel['b.txt'].deleted, 1);
102
+ });
103
+
104
+ test('a rename is one entry and binary files never fake line counts', async () => {
105
+ const root = makeRepo('rename-binary');
106
+ git(root, ['mv', 'a.txt', 'renamed.txt']);
107
+ const binary = Buffer.from([0x00, 0x01, 0x02, 0x89, 0x50, 0x4e, 0x47, 0x0a]);
108
+ fs.writeFileSync(path.join(root, 'blob.bin'), binary);
109
+ const summary = await changes.summarizeChanges(root);
110
+ assert.equal(summary.ok, true);
111
+ const byRel = Object.fromEntries(summary.entries.map((e) => [e.rel, e]));
112
+ assert.ok(byRel['renamed.txt'], `rename entry present: ${JSON.stringify(summary.entries)}`);
113
+ assert.equal(byRel['renamed.txt'].status, 'renamed');
114
+ assert.equal(byRel['blob.bin'].binary, true);
115
+ assert.equal(byRel['blob.bin'].added, 0, 'binary added lines are never faked');
116
+ assert.equal(summary.binaryCount, 1);
117
+ assert.match(changes.formatChangeSummary(summary), /\(1 binary\)/);
118
+ });
119
+
120
+ test('a tracked binary modification labels binary with zero counts', async () => {
121
+ const root = makeRepo('tracked-binary');
122
+ const start = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0a, 0x0b]);
123
+ fs.writeFileSync(path.join(root, 'img.bin'), start);
124
+ git(root, ['add', 'img.bin']);
125
+ git(root, ['commit', '-m', 'add binary']);
126
+ fs.appendFileSync(path.join(root, 'img.bin'), Buffer.from([0x00, 0xff, 0x01]));
127
+ const summary = await changes.summarizeChanges(root);
128
+ assert.equal(summary.ok, true);
129
+ const entry = summary.entries.find((e) => e.rel === 'img.bin');
130
+ assert.ok(entry, 'binary modification is reported');
131
+ assert.equal(entry.binary, true);
132
+ assert.equal(entry.added, 0);
133
+ assert.equal(entry.deleted, 0);
134
+ });
135
+
136
+ test('formatChangeSummary is exact on clean and dirty checkouts', async () => {
137
+ assert.equal(changes.formatChangeSummary({ files: 0, added: 0, deleted: 0, binaryCount: 0 }), '0 files, +0/-0');
138
+ assert.equal(changes.formatChangeSummary({ files: 1, added: 2, deleted: 1, binaryCount: 0 }), '1 file, +2/-1');
139
+ assert.equal(changes.formatChangeSummary({ files: 2, added: 3, deleted: 1, binaryCount: 0 }), '2 files, +3/-1');
140
+ });
141
+
142
+ test('timeout/failure reports precisely and mutates nothing', async () => {
143
+ const root = makeRepo('timeout');
144
+ fs.writeFileSync(path.join(root, 'a.txt'), 'changed\n');
145
+ const before = fs.readFileSync(path.join(root, 'a.txt'), 'utf8');
146
+ const summary = await changes.summarizeChanges(root, {
147
+ runAsync: async () => ({ status: null, stdout: '', stderr: '', error: 'timed out after 5ms' }),
148
+ });
149
+ assert.equal(summary.ok, false);
150
+ assert.match(summary.error, /timed out/);
151
+ assert.equal(fs.readFileSync(path.join(root, 'a.txt'), 'utf8'), before, 'a failed read mutates nothing');
152
+ });
153
+ });
154
+
155
+ describe('normalizeChangesSelection', { concurrency: false }, () => {
156
+ test('omitted, empty string and empty list all mean no copy', () => {
157
+ assert.deepEqual(changes.normalizeChangesSelection(undefined), { ok: true, mode: 'none', paths: [] });
158
+ assert.deepEqual(changes.normalizeChangesSelection(''), { ok: true, mode: 'none', paths: [] });
159
+ assert.deepEqual(changes.normalizeChangesSelection([]), { ok: true, mode: 'none', paths: [] });
160
+ });
161
+ test('"all" copies everything and a list copies those files', () => {
162
+ assert.deepEqual(changes.normalizeChangesSelection('all'), { ok: true, mode: 'all', paths: [] });
163
+ const listed = changes.normalizeChangesSelection(['a.txt']);
164
+ assert.equal(listed.ok, true);
165
+ assert.equal(listed.mode, 'paths');
166
+ });
167
+ // PRESERVE (S61): the shared helper is the WARP's contract and is not part of
168
+ // the S3/S4 create change, so it keeps its original value set and its original
169
+ // wording byte for byte. The create accepts the word "none" by adapting
170
+ // LOCALLY before calling this helper (tools/worktree-create-tool.test.js).
171
+ test('the helper keeps its original contract: "none" is not a value, and the wording is unchanged', () => {
172
+ assert.equal(changes.normalizeChangesSelection('none').ok, false);
173
+ assert.equal(
174
+ changes.normalizeChangesSelection('none').error,
175
+ 'invalid changes selection \"none\": pass \"all\", a list of files, or leave it empty for no copy',
176
+ );
177
+ assert.equal(
178
+ changes.normalizeChangesSelection('some').error,
179
+ 'invalid changes selection \"some\": pass \"all\", a list of files, or leave it empty for no copy',
180
+ );
181
+ assert.equal(
182
+ changes.normalizeChangesSelection(['']).error,
183
+ 'invalid changes selection: every entry must be a non-empty file path',
184
+ );
185
+ assert.equal(
186
+ changes.normalizeChangesSelection(42).error,
187
+ 'invalid changes selection: pass \"all\", a list of files, or leave it empty for no copy',
188
+ );
189
+ });
190
+ test('anything else fails closed', () => {
191
+ assert.equal(changes.normalizeChangesSelection('some').ok, false);
192
+ assert.equal(changes.normalizeChangesSelection(42).ok, false);
193
+ assert.equal(changes.normalizeChangesSelection(['']).ok, false);
194
+ });
195
+ });
196
+
197
+ describe('copyChanges', { concurrency: false }, () => {
198
+ test('copy all moves tracked and untracked changes; source retained, destination index preserved', async () => {
199
+ const root = makeRepo('copy-all');
200
+ const src = addWorktree(root, 'feat/copy-src');
201
+ const dest = addWorktree(root, 'feat/copy-dest');
202
+ try {
203
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nTWO\n');
204
+ fs.writeFileSync(path.join(src, 'new.txt'), 'fresh\nfile\n');
205
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
206
+ assert.equal(copied.ok, true, copied.error);
207
+ assert.deepEqual(copied.copied.sort(), ['a.txt', 'new.txt']);
208
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), 'one\nTWO\n');
209
+ assert.equal(fs.readFileSync(path.join(dest, 'new.txt'), 'utf8'), 'fresh\nfile\n');
210
+ // Source retained: nothing was stashed, moved or reset.
211
+ assert.equal(fs.readFileSync(path.join(src, 'a.txt'), 'utf8'), 'one\nTWO\n');
212
+ assert.ok(fs.existsSync(path.join(src, 'new.txt')));
213
+ // Destination index preserved: working-tree changes only, nothing staged.
214
+ const cached = git(dest, ['diff', '--cached', '--name-only']).trim();
215
+ assert.equal(cached, '', 'the destination index stays untouched');
216
+ const sourceCached = git(src, ['diff', '--cached', '--name-only']).trim();
217
+ assert.equal(sourceCached, '', 'the source index stays untouched');
218
+ } finally {
219
+ git(root, ['worktree', 'remove', '--force', src]);
220
+ git(root, ['worktree', 'remove', '--force', dest]);
221
+ }
222
+ });
223
+
224
+ test('selected files copy only those files', async () => {
225
+ const root = makeRepo('copy-selected');
226
+ const src = addWorktree(root, 'feat/sel-src');
227
+ const dest = addWorktree(root, 'feat/sel-dest');
228
+ try {
229
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nTWO\n');
230
+ fs.writeFileSync(path.join(src, 'b.txt'), 'ALPHA\nbeta\n');
231
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'paths', selection: ['a.txt'] });
232
+ assert.equal(copied.ok, true, copied.error);
233
+ assert.deepEqual(copied.copied, ['a.txt']);
234
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), 'one\nTWO\n');
235
+ assert.equal(fs.readFileSync(path.join(dest, 'b.txt'), 'utf8'), 'alpha\nbeta\n', 'unselected files stay clean');
236
+ } finally {
237
+ git(root, ['worktree', 'remove', '--force', src]);
238
+ git(root, ['worktree', 'remove', '--force', dest]);
239
+ }
240
+ });
241
+
242
+ test('a destination that already modifies a selected path rejects with zero mutation', async () => {
243
+ const root = makeRepo('copy-overlap');
244
+ const src = addWorktree(root, 'feat/over-src');
245
+ const dest = addWorktree(root, 'feat/over-dest');
246
+ try {
247
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nSRC\n');
248
+ fs.writeFileSync(path.join(dest, 'a.txt'), 'one\nDEST\n');
249
+ const beforeDest = fs.readFileSync(path.join(dest, 'a.txt'), 'utf8');
250
+ const beforeSrc = fs.readFileSync(path.join(src, 'a.txt'), 'utf8');
251
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
252
+ assert.equal(copied.ok, false);
253
+ assert.match(copied.error, /destination already modifies/);
254
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), beforeDest, 'destination untouched');
255
+ assert.equal(fs.readFileSync(path.join(src, 'a.txt'), 'utf8'), beforeSrc, 'source untouched');
256
+ } finally {
257
+ git(root, ['worktree', 'remove', '--force', src]);
258
+ git(root, ['worktree', 'remove', '--force', dest]);
259
+ }
260
+ });
261
+
262
+ test('a deletion and a binary copy travel safely', async () => {
263
+ const root = makeRepo('copy-del-bin');
264
+ const src = addWorktree(root, 'feat/db-src');
265
+ const dest = addWorktree(root, 'feat/db-dest');
266
+ try {
267
+ fs.rmSync(path.join(src, 'b.txt'));
268
+ const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x0a, 0x01]);
269
+ fs.writeFileSync(path.join(src, 'blob.bin'), binary);
270
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
271
+ assert.equal(copied.ok, true, copied.error);
272
+ assert.equal(fs.existsSync(path.join(dest, 'b.txt')), false, 'the deletion applies');
273
+ assert.ok(fs.readFileSync(path.join(dest, 'blob.bin')).equals(binary), 'binary bytes survive');
274
+ } finally {
275
+ git(root, ['worktree', 'remove', '--force', src]);
276
+ git(root, ['worktree', 'remove', '--force', dest]);
277
+ }
278
+ });
279
+
280
+ test('foreign checkouts, traversal, symlink escape and .git all refuse without mutation', async () => {
281
+ const first = makeRepo('foreign-a');
282
+ const second = makeRepo('foreign-b');
283
+ fs.writeFileSync(path.join(first, 'a.txt'), 'changed\n');
284
+ const beforeFirst = fs.readFileSync(path.join(first, 'a.txt'), 'utf8');
285
+ const beforeSecond = fs.readFileSync(path.join(second, 'a.txt'), 'utf8');
286
+ const foreign = await changes.copyChanges({ from: first, to: second, selectMode: 'all' });
287
+ assert.equal(foreign.ok, false);
288
+ assert.match(foreign.error, /different repositories/);
289
+ assert.equal(fs.readFileSync(path.join(first, 'a.txt'), 'utf8'), beforeFirst);
290
+ assert.equal(fs.readFileSync(path.join(second, 'a.txt'), 'utf8'), beforeSecond);
291
+
292
+ const root = makeRepo('escape');
293
+ const src = addWorktree(root, 'feat/esc-src');
294
+ const dest = addWorktree(root, 'feat/esc-dest');
295
+ try {
296
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nTWO\n');
297
+ const traversal = await changes.copyChanges({ from: src, to: dest, selectMode: 'paths', selection: ['../a.txt'] });
298
+ assert.equal(traversal.ok, false, 'traversal refuses');
299
+ const gitInternal = await changes.copyChanges({ from: src, to: dest, selectMode: 'paths', selection: ['.git/HEAD'] });
300
+ assert.equal(gitInternal.ok, false, '.git refuses');
301
+ // A symlink escaping the checkout never enters the summary, so
302
+ // selecting it fails closed as unknown.
303
+ fs.symlinkSync(path.join(os.tmpdir()), path.join(src, 'link-out'));
304
+ const summary = await changes.summarizeChanges(src);
305
+ assert.equal(summary.ok, true);
306
+ assert.ok(!summary.entries.some((e) => e.rel === 'link-out'), 'escape symlinks are not summarized');
307
+ const escaped = await changes.copyChanges({ from: src, to: dest, selectMode: 'paths', selection: ['link-out'] });
308
+ assert.equal(escaped.ok, false, 'symlink escape refuses');
309
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), 'one\ntwo\n', 'rejected copies change nothing');
310
+ } finally {
311
+ git(root, ['worktree', 'remove', '--force', src]);
312
+ git(root, ['worktree', 'remove', '--force', dest]);
313
+ }
314
+ });
315
+ });
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // The destination a warp or create lands in is routinely built from ANOTHER
319
+ // base: `worktree_create` seeds the new branch from LOCAL MAIN, and a worktree
320
+ // made before the source branch's last commit carries none of its files. The
321
+ // PENDING edit must still travel there — and the source branch's COMMITTED
322
+ // delta must not: diffing the destination HEAD against the source working tree
323
+ // would import it and overwrite destination-only content. Both consumers (the
324
+ // human `/herdr-gentle:warp` command and the agent `worktree_warp` /
325
+ // `worktree_create` tools) go through this one helper, so these cases are the
326
+ // regression evidence for both.
327
+ describe('copyChanges onto a destination built from another base', { concurrency: false }, () => {
328
+ const EIGHT = 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n';
329
+
330
+ function removeWorktrees(root, ...checkouts) {
331
+ for (const checkout of checkouts) {
332
+ git(root, ['worktree', 'remove', '--force', checkout]);
333
+ }
334
+ }
335
+
336
+ test('a file only the source branch has is created, and its committed siblings stay behind', async () => {
337
+ const root = makeRepo('div-new-file');
338
+ const src = addWorktree(root, 'feat/div-new-src');
339
+ const dest = addWorktree(root, 'feat/div-new-dest');
340
+ try {
341
+ // The source branch COMMITS a new file plus a change to a file both
342
+ // branches share, then edits the new file without committing. The
343
+ // destination, created from main, has neither.
344
+ fs.writeFileSync(path.join(src, 'late.js'), 'export const one = 1;\n');
345
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nCOMMITTED-ON-THE-SOURCE-BRANCH\n');
346
+ git(src, ['add', '.']);
347
+ git(src, ['commit', '-m', 'source branch: add late.js and touch a.txt']);
348
+ fs.writeFileSync(path.join(src, 'late.js'), 'export const one = 1;\nexport const two = 2;\n');
349
+
350
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
351
+ assert.equal(copied.ok, true, copied.error);
352
+ assert.deepEqual(copied.copied, ['late.js'], 'only the pending file is selected');
353
+ assert.equal(
354
+ fs.readFileSync(path.join(dest, 'late.js'), 'utf8'),
355
+ 'export const one = 1;\nexport const two = 2;\n',
356
+ 'the pending version of the file the destination never had is created there',
357
+ );
358
+ assert.equal(
359
+ fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'),
360
+ 'one\ntwo\n',
361
+ "the source branch's COMMITTED change never travels",
362
+ );
363
+ assert.equal(git(dest, ['diff', '--cached', '--name-only']).trim(), '', 'the destination index stays untouched');
364
+ assert.equal(git(src, ['diff', '--cached', '--name-only']).trim(), '', 'the source index stays untouched');
365
+ assert.equal(
366
+ fs.readFileSync(path.join(src, 'late.js'), 'utf8'),
367
+ 'export const one = 1;\nexport const two = 2;\n',
368
+ 'the source keeps its pending work',
369
+ );
370
+ } finally {
371
+ removeWorktrees(root, src, dest);
372
+ }
373
+ });
374
+
375
+ test('the pending edit merges where the destination committed its own change nearby', async () => {
376
+ const root = makeRepo('div-context');
377
+ fs.writeFileSync(path.join(root, 'long.txt'), EIGHT);
378
+ git(root, ['add', 'long.txt']);
379
+ git(root, ['commit', '-m', 'long.txt on main']);
380
+ const src = addWorktree(root, 'feat/div-ctx-src');
381
+ const dest = addWorktree(root, 'feat/div-ctx-dest');
382
+ try {
383
+ // Destination-only COMMITTED work on line 5 ...
384
+ fs.writeFileSync(path.join(dest, 'long.txt'), 'l1\nl2\nl3\nl4\nDEST-L5\nl6\nl7\nl8\n');
385
+ git(dest, ['commit', '-am', 'destination: line 5']);
386
+ // ... while the source's PENDING hunk sits on line 8, three context
387
+ // lines away: the raw patch no longer matches, a three-way merge does.
388
+ fs.writeFileSync(path.join(src, 'long.txt'), 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nSRC-L8-PENDING\n');
389
+
390
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
391
+ assert.equal(copied.ok, true, copied.error);
392
+ assert.deepEqual(copied.copied, ['long.txt']);
393
+ assert.equal(
394
+ fs.readFileSync(path.join(dest, 'long.txt'), 'utf8'),
395
+ 'l1\nl2\nl3\nl4\nDEST-L5\nl6\nl7\nSRC-L8-PENDING\n',
396
+ "the destination's own line and the source's pending edit both survive",
397
+ );
398
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), 'one\ntwo\n', 'unselected files stay clean');
399
+ assert.equal(git(dest, ['diff', '--cached', '--name-only']).trim(), '', 'the destination index stays untouched');
400
+ assert.equal(git(src, ['diff', '--cached', '--name-only']).trim(), '', 'the source index stays untouched');
401
+ } finally {
402
+ removeWorktrees(root, src, dest);
403
+ }
404
+ });
405
+
406
+ test('a real conflict refuses the whole selection and mutates nothing', async () => {
407
+ const root = makeRepo('div-conflict');
408
+ fs.writeFileSync(path.join(root, 'long.txt'), EIGHT);
409
+ git(root, ['add', 'long.txt']);
410
+ git(root, ['commit', '-m', 'long.txt on main']);
411
+ const src = addWorktree(root, 'feat/div-conf-src');
412
+ const dest = addWorktree(root, 'feat/div-conf-dest');
413
+ try {
414
+ // Both sides rework the SAME line, so no automatic answer exists.
415
+ fs.writeFileSync(path.join(dest, 'long.txt'), 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nDEST-L8\n');
416
+ git(dest, ['commit', '-am', 'destination: line 8']);
417
+ fs.writeFileSync(path.join(src, 'long.txt'), 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nSRC-L8-PENDING\n');
418
+ // A second, perfectly copyable file in the same selection: it must NOT
419
+ // land either, because a refused selection is refused whole.
420
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nSRC-PENDING\n');
421
+ const destLongBefore = fs.readFileSync(path.join(dest, 'long.txt'), 'utf8');
422
+ const destABefore = fs.readFileSync(path.join(dest, 'a.txt'), 'utf8');
423
+
424
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
425
+ assert.equal(copied.ok, false, 'a conflict is never resolved silently');
426
+ assert.match(copied.error, /conflicts/, `the reason names the conflict: ${copied.error}`);
427
+ assert.match(copied.error, /long\.txt/);
428
+ // The human answer the caller cannot compose for itself: this is the
429
+ // shared helper's own wording, and the warp command and the agent tools
430
+ // both surface it verbatim after their own refusal checks.
431
+ assert.match(copied.error, /resolve the conflict first/, `the error says how to get unstuck: ${copied.error}`);
432
+ assert.match(copied.error, /warp again without copying/, `the error offers the do-not-copy retry: ${copied.error}`);
433
+ assert.match(copied.error, /nothing was copied/);
434
+ assert.equal(fs.readFileSync(path.join(dest, 'long.txt'), 'utf8'), destLongBefore, 'the destination keeps its own line');
435
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), destABefore, 'the copyable sibling never lands either');
436
+ assert.equal(git(dest, ['status', '--porcelain']).trim(), '', 'no conflict markers, no partial write');
437
+ assert.equal(git(dest, ['diff', '--cached', '--name-only']).trim(), '', 'the destination index stays untouched');
438
+ assert.equal(fs.readFileSync(path.join(src, 'long.txt'), 'utf8'), 'l1\nl2\nl3\nl4\nl5\nl6\nl7\nSRC-L8-PENDING\n', 'the source keeps its work');
439
+ } finally {
440
+ removeWorktrees(root, src, dest);
441
+ }
442
+ });
443
+
444
+ test('a path the destination branch removed is refused, never resurrected', async () => {
445
+ const root = makeRepo('div-dest-removed');
446
+ const src = addWorktree(root, 'feat/div-rm-src');
447
+ const dest = addWorktree(root, 'feat/div-rm-dest');
448
+ try {
449
+ fs.rmSync(path.join(dest, 'b.txt'));
450
+ git(dest, ['commit', '-am', 'destination drops b.txt']);
451
+ fs.writeFileSync(path.join(src, 'b.txt'), 'alpha\nBETA-PENDING\n');
452
+
453
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
454
+ assert.equal(copied.ok, false, 'a file the other side deleted is not silently restored');
455
+ assert.match(copied.error, /b\.txt/);
456
+ assert.match(copied.error, /nothing was copied/);
457
+ assert.match(copied.error, /resolve the conflict first/, `the refusal also says how to get unstuck: ${copied.error}`);
458
+ assert.match(copied.error, /warp again without copying/, `the refusal offers the do-not-copy retry: ${copied.error}`);
459
+ assert.equal(fs.existsSync(path.join(dest, 'b.txt')), false, "the destination's deletion survives");
460
+ assert.equal(git(dest, ['status', '--porcelain']).trim(), '', 'the destination is untouched');
461
+ assert.equal(fs.readFileSync(path.join(src, 'b.txt'), 'utf8'), 'alpha\nBETA-PENDING\n', 'the source keeps its edit');
462
+ } finally {
463
+ removeWorktrees(root, src, dest);
464
+ }
465
+ });
466
+
467
+ test('a destination symbolic link at a selected path is refused, never written through', async () => {
468
+ const root = makeRepo('div-symlink');
469
+ const src = addWorktree(root, 'feat/div-lnk-src');
470
+ const dest = addWorktree(root, 'feat/div-lnk-dest');
471
+ try {
472
+ // The destination's a.txt is a symlink pointing at another checkout's
473
+ // file, and its target still holds the source base byte for byte, so the
474
+ // destination reports no change on that path. Writing the pending edit
475
+ // there would follow the link out of the checkout.
476
+ const victim = path.join(root, 'victim.txt');
477
+ fs.writeFileSync(victim, 'one\ntwo\n');
478
+ fs.unlinkSync(path.join(dest, 'a.txt'));
479
+ fs.symlinkSync(victim, path.join(dest, 'a.txt'));
480
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nTWO-PENDING\n');
481
+
482
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'paths', selection: ['a.txt'] });
483
+ assert.equal(copied.ok, false, 'a link at the destination path is never followed');
484
+ assert.match(copied.error, /symbolic link/);
485
+ assert.match(copied.error, /nothing was copied/);
486
+ assert.equal(fs.readFileSync(victim, 'utf8'), 'one\ntwo\n', 'the link target outside the destination is untouched');
487
+ assert.equal(fs.lstatSync(path.join(dest, 'a.txt')).isSymbolicLink(), true, 'the destination link survives');
488
+ } finally {
489
+ removeWorktrees(root, src, dest);
490
+ }
491
+ });
492
+
493
+ // Content that does not survive a UTF-8 roundtrip must never be rewritten,
494
+ // and content that does must never be rewritten either: the merge output is
495
+ // read back from disk as BYTES, and the base is only used when git can prove
496
+ // it reproduces the recorded blob exactly.
497
+ test('a non-UTF-8 base is refused instead of being rewritten with replacement characters', async () => {
498
+ const root = makeRepo('merge-latin1');
499
+ const latin = (lines) => Buffer.from(`${lines.join('\n')}\n`, 'latin1'); // 0xE9, never a NUL byte
500
+ const baseLines = ['latin-1', 'caf\u00e9-line', 'L3', 'L4', 'L5', 'L6', 'L7', 'L8'];
501
+ fs.writeFileSync(path.join(root, 'bin.txt'), latin(baseLines));
502
+ git(root, ['add', 'bin.txt']);
503
+ git(root, ['commit', '-m', 'latin-1 file on main']);
504
+ const src = addWorktree(root, 'feat/div-l1-src');
505
+ const dest = addWorktree(root, 'feat/div-l1-dest');
506
+ try {
507
+ // Destination-only COMMITTED edit at L5; the source's pending edit is at
508
+ // L8, whose hunk context covers L5, so the raw patch cannot apply.
509
+ fs.writeFileSync(path.join(dest, 'bin.txt'), latin([...baseLines.slice(0, 4), 'DEST-L5', ...baseLines.slice(5)]));
510
+ git(dest, ['commit', '-am', 'destination: L5']);
511
+ fs.writeFileSync(path.join(src, 'bin.txt'), latin([...baseLines.slice(0, 7), 'SRC-L8-PENDING']));
512
+ const destBefore = fs.readFileSync(path.join(dest, 'bin.txt'));
513
+
514
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
515
+ assert.equal(copied.ok, false, 'a base that is not plain UTF-8 text is never merged');
516
+ assert.match(copied.error, /UTF-8/, `the reason names the encoding limit: ${copied.error}`);
517
+ assert.match(copied.error, /warp again without copying/, 'the refusal still says how to get unstuck');
518
+ assert.match(copied.error, /nothing was copied/);
519
+ const destAfter = fs.readFileSync(path.join(dest, 'bin.txt'));
520
+ assert.deepEqual(destAfter, destBefore, "the destination keeps its own bytes, its own line included");
521
+ assert.equal(
522
+ destAfter.includes(Buffer.from([0xef, 0xbf, 0xbd])),
523
+ false,
524
+ 'no replacement characters were written anywhere',
525
+ );
526
+ assert.equal(git(dest, ['status', '--porcelain']).trim(), '', 'the destination worktree and index are untouched');
527
+ } finally {
528
+ removeWorktrees(root, src, dest);
529
+ }
530
+ });
531
+
532
+ // PRESERVE (byte fidelity): a UTF-8 file merges byte for byte, multi-byte
533
+ // characters included, and the destination's own line survives — the result
534
+ // is never "the source's file".
535
+ test('a UTF-8 merge keeps every byte and the destination edit', async () => {
536
+ const root = makeRepo('merge-utf8');
537
+ const text = (lines) => Buffer.from(`${lines.join('\n')}\n`, 'utf8');
538
+ const baseLines = ['caf\u00e9 \u65e5\u672c\u8a9e', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7', 'L8'];
539
+ fs.writeFileSync(path.join(root, 'u.txt'), text(baseLines));
540
+ git(root, ['add', 'u.txt']);
541
+ git(root, ['commit', '-m', 'utf-8 file on main']);
542
+ const src = addWorktree(root, 'feat/div-u8-src');
543
+ const dest = addWorktree(root, 'feat/div-u8-dest');
544
+ try {
545
+ fs.writeFileSync(path.join(dest, 'u.txt'), text([...baseLines.slice(0, 4), 'DEST-L5', ...baseLines.slice(5)]));
546
+ git(dest, ['commit', '-am', 'destination: L5']);
547
+ fs.writeFileSync(path.join(src, 'u.txt'), text([...baseLines.slice(0, 7), 'SRC-L8-PENDING']));
548
+
549
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
550
+ assert.equal(copied.ok, true, copied.error);
551
+ assert.deepEqual(
552
+ fs.readFileSync(path.join(dest, 'u.txt')),
553
+ text([...baseLines.slice(0, 4), 'DEST-L5', ...baseLines.slice(5, 7), 'SRC-L8-PENDING']),
554
+ "the destination's own line and the pending edit are both there, byte for byte",
555
+ );
556
+ assert.equal(git(dest, ['diff', '--cached', '--name-only']).trim(), '', 'the destination index stays untouched');
557
+ } finally {
558
+ removeWorktrees(root, src, dest);
559
+ }
560
+ });
561
+
562
+ // The destination's own pending work is refused by the overlap preflight, but
563
+ // an UNTRACKED destination entry that git cannot see as a change (a dangling
564
+ // symlink) must never be written through: `copyFileSync` would follow it out
565
+ // of the checkout.
566
+ test('an untracked copy never writes through a dangling symlink in the destination', async () => {
567
+ const root = makeRepo('dangling-link');
568
+ const src = addWorktree(root, 'feat/div-dg-src');
569
+ const dest = addWorktree(root, 'feat/div-dg-dest');
570
+ try {
571
+ fs.writeFileSync(path.join(src, 'new.txt'), 'fresh\ncontent\n');
572
+ const outside = path.join(root, 'outside.txt'); // does not exist yet
573
+ fs.symlinkSync(outside, path.join(dest, 'new.txt'));
574
+
575
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
576
+ assert.equal(copied.ok, false, 'a dangling link at the destination path is refused');
577
+ assert.match(copied.error, /symbolic link/);
578
+ assert.match(copied.error, /nothing was copied/);
579
+ assert.equal(fs.existsSync(outside), false, 'nothing was written outside the destination checkout');
580
+ assert.equal(fs.lstatSync(path.join(dest, 'new.txt')).isSymbolicLink(), true, 'the destination link survives');
581
+ } finally {
582
+ removeWorktrees(root, src, dest);
583
+ }
584
+ });
585
+
586
+ // An unreadable selected file is not a deletion: reporting it as one would
587
+ // tell the human the source deleted a file it still has.
588
+ test('an unreadable selected file is reported as unreadable, never as a deletion', async () => {
589
+ const root = makeRepo('unreadable-src');
590
+ const src = addWorktree(root, 'feat/div-un-src');
591
+ const dest = addWorktree(root, 'feat/div-un-dest');
592
+ try {
593
+ fs.writeFileSync(path.join(dest, 'a.txt'), 'one\nDEST\n');
594
+ git(dest, ['commit', '-am', 'destination: diverged']);
595
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nSRC-PENDING\n');
596
+ const srcFile = fs.realpathSync(path.join(src, 'a.txt'));
597
+ const poisoned = {
598
+ ...fs,
599
+ readFileSync(target, ...rest) {
600
+ if (path.resolve(String(target)) === srcFile) {
601
+ const err = new Error(`EACCES: permission denied, open '${target}'`);
602
+ err.code = 'EACCES';
603
+ throw err;
604
+ }
605
+ return fs.readFileSync(target, ...rest);
606
+ },
607
+ };
608
+
609
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' }, { fs: poisoned });
610
+ assert.equal(copied.ok, false);
611
+ assert.match(copied.error, /cannot be read/, `the reason is the unreadable file: ${copied.error}`);
612
+ assert.doesNotMatch(copied.error, /deletes/, `an unreadable file is never called a deletion: ${copied.error}`);
613
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), 'one\nDEST\n', "the destination's own line survives");
614
+ } finally {
615
+ removeWorktrees(root, src, dest);
616
+ }
617
+ });
618
+
619
+ // PRESERVE: creating the missing directories level by level must still build
620
+ // an ordinary nested path — the guard only refuses a component that already
621
+ // escapes the checkout.
622
+ test('PRESERVE: an untracked file in a directory the destination lacks is still created', async () => {
623
+ const root = makeRepo('nested-create');
624
+ const src = addWorktree(root, 'feat/div-nc-src');
625
+ const dest = addWorktree(root, 'feat/div-nc-dest');
626
+ try {
627
+ fs.mkdirSync(path.join(src, 'deep/nested'), { recursive: true });
628
+ fs.writeFileSync(path.join(src, 'deep/nested/new.txt'), 'fresh\n');
629
+
630
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
631
+ assert.equal(copied.ok, true, copied.error);
632
+ assert.equal(fs.readFileSync(path.join(dest, 'deep/nested/new.txt'), 'utf8'), 'fresh\n');
633
+ assert.equal(fs.lstatSync(path.join(dest, 'deep')).isSymbolicLink(), false, 'a real directory was created');
634
+ assert.equal(fs.lstatSync(path.join(dest, 'deep/nested')).isDirectory(), true);
635
+ } finally {
636
+ removeWorktrees(root, src, dest);
637
+ }
638
+ });
639
+
640
+ // A path is only safe to write when every component between the destination
641
+ // checkout and the target is a real directory INSIDE that checkout: a
642
+ // symlinked parent would carry the write out of the worktree, and the file's
643
+ // own lstat cannot see it.
644
+ test('an untracked copy never writes through a symlinked parent directory', async () => {
645
+ const root = makeRepo('link-parent-untracked');
646
+ const src = addWorktree(root, 'feat/div-lpu-src');
647
+ const dest = addWorktree(root, 'feat/div-lpu-dest');
648
+ const outside = trackOutsideDir('link-parent-untracked');
649
+ try {
650
+ fs.mkdirSync(path.join(src, 'sub'));
651
+ fs.writeFileSync(path.join(src, 'sub/new.txt'), 'fresh\ncontent\n');
652
+ fs.symlinkSync(outside, path.join(dest, 'sub'));
653
+
654
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
655
+ assert.equal(copied.ok, false, 'a symlinked parent at the destination is refused');
656
+ assert.match(copied.error, /symbolic link/);
657
+ assert.match(copied.error, /nothing was copied/);
658
+ assert.deepEqual(fs.readdirSync(outside), [], 'nothing was written outside the destination checkout');
659
+ assert.equal(fs.lstatSync(path.join(dest, 'sub')).isSymbolicLink(), true, 'the destination link survives');
660
+ } finally {
661
+ removeWorktrees(root, src, dest);
662
+ }
663
+ });
664
+
665
+ test('a created tracked file never writes through a symlinked parent directory', async () => {
666
+ const root = makeRepo('link-parent-create');
667
+ const src = addWorktree(root, 'feat/div-lpc-src');
668
+ const dest = addWorktree(root, 'feat/div-lpc-dest');
669
+ const outside = trackOutsideDir('link-parent-create');
670
+ try {
671
+ // The file is new on the source branch, so the destination (built from
672
+ // main) gets it as a create — and its parent there is a link out.
673
+ fs.mkdirSync(path.join(src, 'pkg'));
674
+ fs.writeFileSync(path.join(src, 'pkg/late.js'), 'export const a = 1;\n');
675
+ git(src, ['add', 'pkg/late.js']);
676
+ git(src, ['commit', '-m', 'source branch adds pkg/late.js']);
677
+ fs.writeFileSync(path.join(src, 'pkg/late.js'), 'export const a = 1;\nexport const b = 2;\n');
678
+ fs.symlinkSync(outside, path.join(dest, 'pkg'));
679
+
680
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
681
+ assert.equal(copied.ok, false, 'a symlinked parent at the destination is refused');
682
+ assert.match(copied.error, /symbolic link/);
683
+ assert.match(copied.error, /nothing was copied/);
684
+ assert.deepEqual(fs.readdirSync(outside), [], 'nothing was written outside the destination checkout');
685
+ assert.equal(fs.lstatSync(path.join(dest, 'pkg')).isSymbolicLink(), true, 'the destination link survives');
686
+ } finally {
687
+ removeWorktrees(root, src, dest);
688
+ }
689
+ });
690
+
691
+ // The same escape through a LOCATION a link cannot cover, because it does not
692
+ // exist yet: `mkdir -p` under a link would create the directory outside.
693
+ test('a new nested directory is never created through a symlinked parent', async () => {
694
+ const root = makeRepo('link-parent-nested');
695
+ const src = addWorktree(root, 'feat/div-lpn-src');
696
+ const dest = addWorktree(root, 'feat/div-lpn-dest');
697
+ const outside = trackOutsideDir('link-parent-nested');
698
+ try {
699
+ fs.mkdirSync(path.join(src, 'deep/nested'), { recursive: true });
700
+ fs.writeFileSync(path.join(src, 'deep/nested/new.txt'), 'fresh\n');
701
+ fs.symlinkSync(outside, path.join(dest, 'deep'));
702
+
703
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
704
+ assert.equal(copied.ok, false, 'a symlinked ancestor is refused before anything is created');
705
+ assert.match(copied.error, /symbolic link/);
706
+ assert.deepEqual(fs.readdirSync(outside), [], 'no directory and no file were created outside');
707
+ assert.equal(fs.existsSync(path.join(dest, 'deep/nested')), false, 'no directory was created through the link');
708
+ } finally {
709
+ removeWorktrees(root, src, dest);
710
+ }
711
+ });
712
+
713
+ // PRESERVE: the ordinary case — the destination still holds the source's own
714
+ // base — keeps copying exactly as before, modes and indexes included. This is
715
+ // the behaviour the divergent-base work must not disturb.
716
+ test('PRESERVE: an identical base still carries staged content, the unstaged sibling and the mode', async () => {
717
+ const root = makeRepo('div-preserve');
718
+ const src = addWorktree(root, 'feat/div-pre-src');
719
+ const dest = addWorktree(root, 'feat/div-pre-dest');
720
+ try {
721
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nSTAGED\n');
722
+ fs.chmodSync(path.join(src, 'a.txt'), 0o755);
723
+ git(src, ['add', 'a.txt']);
724
+ fs.writeFileSync(path.join(src, 'b.txt'), 'alpha\nUNSTAGED\n');
725
+
726
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
727
+ assert.equal(copied.ok, true, copied.error);
728
+ assert.deepEqual(copied.copied.sort(), ['a.txt', 'b.txt']);
729
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), 'one\nSTAGED\n');
730
+ assert.equal(fs.readFileSync(path.join(dest, 'b.txt'), 'utf8'), 'alpha\nUNSTAGED\n');
731
+ assert.notEqual(fs.statSync(path.join(dest, 'a.txt')).mode & 0o111, 0, 'the pending executable bit travels');
732
+ assert.equal(git(dest, ['diff', '--cached', '--name-only']).trim(), '', 'the destination index stays untouched');
733
+ assert.equal(git(src, ['diff', '--cached', '--name-only']).trim(), 'a.txt', 'the source keeps its staged entry');
734
+ } finally {
735
+ removeWorktrees(root, src, dest);
736
+ }
737
+ });
738
+ });
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // Three failure shapes the divergent-base work left behind:
742
+ // * a POSIX file name may legally contain a backslash, so splitting a path on
743
+ // BOTH separators invents a directory component the selection never named
744
+ // and leaves the file one level too deep;
745
+ // * an apply-time failure can strike AFTER an earlier plan already landed, so
746
+ // "nothing was copied" is then false and must not be printed;
747
+ // * an lstat that fails for a reason other than "nothing is there" says
748
+ // nothing about deletion, and must never be read as one.
749
+ describe('copyChanges: path spelling, partial-copy reporting and deletion diagnosis', { concurrency: false }, () => {
750
+ function removeWorktrees(root, ...checkouts) {
751
+ for (const checkout of checkouts) {
752
+ git(root, ['worktree', 'remove', '--force', checkout]);
753
+ }
754
+ }
755
+
756
+ // A backslash is a separator on Windows ONLY. On POSIX it is an ordinary
757
+ // character inside a file name, so the copy must treat `back\slash.txt` as
758
+ // ONE component: no stray directory, and the file at the top level.
759
+ test('a POSIX file name containing a backslash stays one component, with no stray directory', async (t) => {
760
+ if (path.sep !== '/') return t.skip('POSIX-only: on Windows a backslash IS a path separator');
761
+ const root = makeRepo('backslash-name');
762
+ const src = addWorktree(root, 'feat/bs-src');
763
+ const dest = addWorktree(root, 'feat/bs-dest');
764
+ const weird = 'back\\slash.txt';
765
+ try {
766
+ fs.writeFileSync(path.join(src, weird), 'root-backslash\n');
767
+ fs.mkdirSync(path.join(src, 'nested'));
768
+ fs.writeFileSync(path.join(src, 'nested', weird), 'nested-backslash\n');
769
+
770
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
771
+ assert.equal(copied.ok, true, copied.error);
772
+ assert.deepEqual(copied.copied.sort(), [weird, `nested/${weird}`].sort());
773
+
774
+ assert.equal(
775
+ fs.readFileSync(path.join(dest, weird), 'utf8'),
776
+ 'root-backslash\n',
777
+ 'the file keeps its own name, backslash included',
778
+ );
779
+ assert.equal(
780
+ fs.existsSync(path.join(dest, 'back')),
781
+ false,
782
+ 'a backslash inside a file name never becomes a directory',
783
+ );
784
+ assert.deepEqual(
785
+ fs.readdirSync(dest).filter((name) => name !== '.git').sort(),
786
+ ['a.txt', 'b.txt', 'nested', weird].sort(),
787
+ 'the destination holds exactly the selection plus its own files',
788
+ );
789
+ assert.deepEqual(fs.readdirSync(path.join(dest, 'nested')), [weird], 'the created directory holds the file itself');
790
+ assert.equal(
791
+ fs.existsSync(path.join(dest, 'nested', 'back')),
792
+ false,
793
+ 'no stray directory inside the created directory either',
794
+ );
795
+ } finally {
796
+ removeWorktrees(root, src, dest);
797
+ }
798
+ });
799
+
800
+ // PRESERVE (platform separator): an ordinary slash-separated path still
801
+ // creates its missing directories level by level.
802
+ test('PRESERVE: a slash-separated untracked path still creates one real directory level', async () => {
803
+ const root = makeRepo('slash-levels');
804
+ const src = addWorktree(root, 'feat/sl-src');
805
+ const dest = addWorktree(root, 'feat/sl-dest');
806
+ try {
807
+ fs.mkdirSync(path.join(src, 'one/two'), { recursive: true });
808
+ fs.writeFileSync(path.join(src, 'one/two/three.txt'), 'deep\n');
809
+
810
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' });
811
+ assert.equal(copied.ok, true, copied.error);
812
+ assert.equal(fs.readFileSync(path.join(dest, 'one/two/three.txt'), 'utf8'), 'deep\n');
813
+ assert.equal(fs.lstatSync(path.join(dest, 'one')).isDirectory(), true);
814
+ assert.equal(fs.lstatSync(path.join(dest, 'one/two')).isDirectory(), true);
815
+ } finally {
816
+ removeWorktrees(root, src, dest);
817
+ }
818
+ });
819
+
820
+ // An apply-time failure can land after a plan already succeeded. The report
821
+ // must name what is actually on disk; "nothing was copied" would be a claim
822
+ // the human cannot verify and that is simply false.
823
+ test('an apply-time failure names the files that already landed', async () => {
824
+ const root = makeRepo('partial-copy');
825
+ const src = addWorktree(root, 'feat/pc-src');
826
+ const dest = addWorktree(root, 'feat/pc-dest');
827
+ const blockedDir = path.resolve(path.join(dest, 'blocked'));
828
+ try {
829
+ fs.writeFileSync(path.join(src, 'a-first.txt'), 'first\n');
830
+ fs.mkdirSync(path.join(src, 'blocked'));
831
+ fs.writeFileSync(path.join(src, 'blocked/second.txt'), 'second\n');
832
+ // The first copy lands; creating the second one's directory fails at
833
+ // apply time (a plan may not create anything, so this is only visible
834
+ // once the writes begin).
835
+ const poisoned = {
836
+ ...fs,
837
+ mkdirSync(target, ...rest) {
838
+ if (path.resolve(String(target)) === blockedDir) {
839
+ const err = new Error(`EACCES: permission denied, mkdir '${target}'`);
840
+ err.code = 'EACCES';
841
+ throw err;
842
+ }
843
+ return fs.mkdirSync(target, ...rest);
844
+ },
845
+ };
846
+
847
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' }, { fs: poisoned });
848
+ assert.equal(copied.ok, false, 'the copy is reported as failed');
849
+ assert.doesNotMatch(
850
+ copied.error,
851
+ /nothing was copied/,
852
+ `a copy that landed one file never claims nothing was copied: ${copied.error}`,
853
+ );
854
+ assert.match(copied.error, /1 of 2 files already copied/, `the report counts what landed: ${copied.error}`);
855
+ assert.match(copied.error, /"a-first\.txt"/, `the report names what landed: ${copied.error}`);
856
+ assert.match(copied.error, /the rest were not copied/, `the report says the rest did not land: ${copied.error}`);
857
+ assert.match(copied.error, /inspect the destination before retrying/, 'the human is told to look before retrying');
858
+
859
+ // The report matches the disk: that is the whole point.
860
+ assert.equal(fs.readFileSync(path.join(dest, 'a-first.txt'), 'utf8'), 'first\n', 'the first file really did land');
861
+ assert.equal(fs.existsSync(path.join(dest, 'blocked')), false, 'the second file did not land');
862
+ assert.equal(fs.existsSync(path.join(dest, 'blocked/second.txt')), false);
863
+ } finally {
864
+ removeWorktrees(root, src, dest);
865
+ }
866
+ });
867
+
868
+ // A tracked entry whose metadata cannot be inspected says nothing about
869
+ // whether the file is there: it is a refusal, never a deletion (treating it
870
+ // as one would silently drop the file from the copy).
871
+ test('a source entry that cannot be inspected is refused, never treated as a deletion', async () => {
872
+ const root = makeRepo('uninspectable-src');
873
+ const src = addWorktree(root, 'feat/ui-src');
874
+ const dest = addWorktree(root, 'feat/ui-dest');
875
+ const srcFile = path.resolve(path.join(src, 'a.txt'));
876
+ try {
877
+ fs.writeFileSync(path.join(src, 'a.txt'), 'one\nSRC-PENDING\n');
878
+ const poisoned = {
879
+ ...fs,
880
+ lstatSync(target, ...rest) {
881
+ if (path.resolve(String(target)) === srcFile) {
882
+ const err = new Error(`EACCES: permission denied, lstat '${target}'`);
883
+ err.code = 'EACCES';
884
+ throw err;
885
+ }
886
+ return fs.lstatSync(target, ...rest);
887
+ },
888
+ };
889
+
890
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' }, { fs: poisoned });
891
+ assert.equal(copied.ok, false, 'an entry that cannot be inspected is not copied blindly');
892
+ assert.match(copied.error, /could not be inspected/, `the reason is the inspection failure: ${copied.error}`);
893
+ assert.match(copied.error, /never treated as a deletion/, `it is not diagnosed as a deletion: ${copied.error}`);
894
+ assert.match(copied.error, /nothing was copied/, 'nothing was written');
895
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), 'one\ntwo\n', 'the destination is untouched');
896
+ assert.equal(fs.readFileSync(path.join(src, 'a.txt'), 'utf8'), 'one\nSRC-PENDING\n', 'the source keeps its edit');
897
+ } finally {
898
+ removeWorktrees(root, src, dest);
899
+ }
900
+ });
901
+
902
+ // The mirror case: a path with nothing behind it is GONE (a deletion), and
903
+ // calling it unreadable would describe a file the source no longer has.
904
+ test('a source file that vanished is reported as gone, not as unreadable', async () => {
905
+ const root = makeRepo('vanished-src');
906
+ const src = addWorktree(root, 'feat/vn-src');
907
+ const dest = addWorktree(root, 'feat/vn-dest');
908
+ const srcFile = path.resolve(path.join(src, 'new.txt'));
909
+ try {
910
+ fs.writeFileSync(path.join(src, 'new.txt'), 'fresh\n');
911
+ const poisoned = {
912
+ ...fs,
913
+ lstatSync(target, ...rest) {
914
+ if (path.resolve(String(target)) === srcFile) {
915
+ const err = new Error(`ENOENT: no such file or directory, lstat '${target}'`);
916
+ err.code = 'ENOENT';
917
+ throw err;
918
+ }
919
+ return fs.lstatSync(target, ...rest);
920
+ },
921
+ };
922
+
923
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'all' }, { fs: poisoned });
924
+ assert.equal(copied.ok, false, 'a vanished file cannot be copied');
925
+ assert.match(copied.error, /is gone/, `the reason names the deletion: ${copied.error}`);
926
+ assert.doesNotMatch(copied.error, /unreadable/, `a deletion is never called unreadable: ${copied.error}`);
927
+ assert.match(copied.error, /nothing was copied/);
928
+ assert.equal(fs.existsSync(path.join(dest, 'new.txt')), false, 'nothing was written');
929
+ } finally {
930
+ removeWorktrees(root, src, dest);
931
+ }
932
+ });
933
+
934
+ // PRESERVE: a real tracked deletion still travels (the ENOENT path the fix
935
+ // keeps treating as a deletion).
936
+ test('PRESERVE: a tracked deletion still applies to the destination', async () => {
937
+ const root = makeRepo('deletion-preserve');
938
+ const src = addWorktree(root, 'feat/dp-src');
939
+ const dest = addWorktree(root, 'feat/dp-dest');
940
+ try {
941
+ fs.rmSync(path.join(src, 'b.txt'));
942
+ const copied = await changes.copyChanges({ from: src, to: dest, selectMode: 'paths', selection: ['b.txt'] });
943
+ assert.equal(copied.ok, true, copied.error);
944
+ assert.deepEqual(copied.copied, ['b.txt']);
945
+ assert.equal(fs.existsSync(path.join(dest, 'b.txt')), false, 'the deletion travels');
946
+ assert.equal(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8'), 'one\ntwo\n', 'the sibling is untouched');
947
+ } finally {
948
+ removeWorktrees(root, src, dest);
949
+ }
950
+ });
951
+ });