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,1395 @@
1
+ 'use strict';
2
+ // Reusable pending-changes helper (customize-command S61/T47): dirty
3
+ // summary (N files +N/-N) plus the safe working-tree copy warp and the
4
+ // tool use. One module, never two implementations: the warp command,
5
+ // the worktree_warp tool and the close/delete confirmations all read
6
+ // through here.
7
+ //
8
+ // Why this shape: staged AND unstaged changes are read as ONE diff
9
+ // against HEAD (`git diff --numstat -M HEAD`), so a file changed in both
10
+ // places counts once, never twice. Untracked files never appear in that
11
+ // diff, so they are listed separately through `git status --porcelain -z`
12
+ // and measured from disk (text line counts; binary only labelled, never
13
+ // faked). Every external process is a fixed argv array with a bounded
14
+ // timeout — never a shell string, never a long sync block of the UI.
15
+ // Nothing here mutates the source or its index: the summary only reads,
16
+ // and the copy writes only into the destination.
17
+ //
18
+ // Containment: every selected path must resolve inside the source
19
+ // checkout, must not be (or hide behind) `.git`, must not traverse
20
+ // (`..`) out, and must not escape through a symlink. The destination
21
+ // overlap rule is a hard reject: when any selected path already carries
22
+ // destination modifications, nothing is copied, warped or switched.
23
+ // Writes are contained on the destination side too: the target entry must not
24
+ // be a symlink, and EVERY component between the checkout and the target must be
25
+ // a real directory whose realpath stays inside the checkout, proved again level
26
+ // by level while missing directories are created one at a time (a recursive
27
+ // mkdir would create the tree wherever a symlinked component points). Node has
28
+ // no `openat`/`RESOLVE_BENEATH`, so the checks run synchronously immediately
29
+ // before each write and the result is re-proved afterwards; a component swapped
30
+ // for a symlink in the instant between the last check and the write is the one
31
+ // case this architecture cannot exclude, and it is reported if the write lands
32
+ // outside.
33
+ // A dry-run (`git apply --check`) guards branch-base mismatch before any
34
+ // write; an unsupported case fails BEFORE the write and says so instead
35
+ // of leaving a partial copy. A failure DURING the apply is different: an earlier
36
+ // plan may already have landed, so it is reported against what is actually on
37
+ // disk (the files named, the rest counted) instead of claiming nothing was
38
+ // copied.
39
+ //
40
+ // A destination built from ANOTHER base is resolved file by file, never by
41
+ // re-diffing against its own HEAD: the committed source-branch delta stays
42
+ // behind, the pending edit travels, destination-only content survives, and a
43
+ // real conflict refuses with zero mutation and says how to get unstuck.
44
+ // `Divergent-base transfer` below is that leg.
45
+
46
+ const path = require('node:path');
47
+ const fs = require('node:fs');
48
+ const os = require('node:os');
49
+ const crypto = require('node:crypto');
50
+
51
+ const SUMMARY_TIMEOUT_MS = 30000;
52
+ const COPY_TIMEOUT_MS = 60000;
53
+
54
+ // Parse `git diff --numstat -z -M HEAD` output. With -z a plain record
55
+ // is one `<added>\t<deleted>\t<path>` token, while a rename (or copy)
56
+ // is `<added>\t<deleted>\t` with an EMPTY path followed by TWO tokens:
57
+ // the old path and the new path (`0\t0\t\0old\0new\0`). `-` counts
58
+ // mean binary. Returns [{added, deleted, binary, oldRel, rel}].
59
+ function parseNumstatZ(stdout) {
60
+ const out = [];
61
+ if (typeof stdout !== 'string' || stdout.length === 0) return out;
62
+ const isRecord = (token) => typeof token === 'string' && /^(\d+|-)\t(\d+|-)\t/.test(token);
63
+ const tokens = stdout.split('\0');
64
+ for (let i = 0; i < tokens.length; i += 1) {
65
+ const token = tokens[i];
66
+ if (token === '') continue;
67
+ const match = token.match(/^(\d+|-)\t(\d+|-)\t([\s\S]*)$/);
68
+ if (!match) continue;
69
+ const binary = match[1] === '-' || match[2] === '-';
70
+ const added = binary ? 0 : Number(match[1]);
71
+ const deleted = binary ? 0 : Number(match[2]);
72
+ let oldRel = match[3];
73
+ let rel = match[3];
74
+ const next1 = tokens[i + 1];
75
+ const next2 = tokens[i + 2];
76
+ const plain1 = typeof next1 === 'string' && next1 !== '' && !isRecord(next1);
77
+ const plain2 = typeof next2 === 'string' && next2 !== '' && !isRecord(next2);
78
+ if (match[3] === '' && plain1 && plain2) {
79
+ // A rename pair: the empty path plus old and new.
80
+ oldRel = next1;
81
+ rel = next2;
82
+ i += 2;
83
+ } else if (match[3] === '' && plain1) {
84
+ // Defensive: an empty path with a single follower names one file.
85
+ oldRel = next1;
86
+ rel = next1;
87
+ i += 1;
88
+ }
89
+ out.push({
90
+ added: Number.isFinite(added) ? added : 0,
91
+ deleted: Number.isFinite(deleted) ? deleted : 0,
92
+ binary,
93
+ oldRel,
94
+ rel,
95
+ });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Default async git runner: fixed argv through worktree-git's async
101
+ // runner (bounded timeout, never a shell string). Injectable in tests
102
+ // via opts.runAsync(bin, args, cwd).
103
+ async function defaultRunAsync(bin, args, cwd, timeoutMs) {
104
+ let git = null;
105
+ try {
106
+ git = require('./worktree-git.js');
107
+ } catch {
108
+ git = null;
109
+ }
110
+ if (git && typeof git.runArgvAsync === 'function') {
111
+ return git.runArgvAsync(bin, args, { cwd, timeoutMs });
112
+ }
113
+ return { status: null, stdout: '', stderr: '', error: 'async git runner is unavailable' };
114
+ }
115
+
116
+ // Is `abs` inside `base` (or equal)? Pure lexical check on resolved
117
+ // paths; symlink escape is checked separately through realpath.
118
+ function isInsideOrEqual(base, abs) {
119
+ const relative = path.relative(base, abs);
120
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
121
+ }
122
+
123
+ // True when the checkout-relative path touches repository metadata.
124
+ function isGitInternal(rel) {
125
+ if (typeof rel !== 'string' || rel.length === 0) return true;
126
+ const parts = rel.split(path.sep);
127
+ return parts.includes('.git');
128
+ }
129
+
130
+ // Binary probe: a NUL byte in the first bytes. Never fakes line counts.
131
+ function probeBinary(absPath, fsImpl) {
132
+ try {
133
+ const fd = fsImpl.openSync(absPath, 'r');
134
+ try {
135
+ const size = Math.min(8192, fsImpl.fstatSync(fd).size);
136
+ if (size <= 0) return { binary: false };
137
+ const buf = Buffer.alloc(size);
138
+ fsImpl.readSync(fd, buf, 0, size, 0);
139
+ return { binary: buf.includes(0) };
140
+ } finally {
141
+ try { fsImpl.closeSync(fd); } catch { /* a close failure never fails the probe */ }
142
+ }
143
+ } catch {
144
+ return { binary: false };
145
+ }
146
+ }
147
+
148
+ // Count added lines of an untracked text file from disk.
149
+ function countAddedLines(absPath, fsImpl) {
150
+ try {
151
+ const text = fsImpl.readFileSync(absPath, 'utf8');
152
+ if (text.length === 0) return 0;
153
+ const parts = text.split('\n');
154
+ return text.endsWith('\n') ? parts.length - 1 : parts.length;
155
+ } catch {
156
+ return 0;
157
+ }
158
+ }
159
+
160
+ // Resolve the repository root for a checkout without trusting the
161
+ // process cwd: through the shared repoIdentity helper.
162
+ function resolveRepoRoot(checkoutPath, git) {
163
+ const identity = git.repoIdentity(checkoutPath);
164
+ return identity.root;
165
+ }
166
+
167
+ // Resolve the shared git common dir for a checkout: the identity two
168
+ // checkouts of one repository (principal or linked worktrees) share,
169
+ // while two repositories never do.
170
+ function repoCommonDir(checkoutPath, git) {
171
+ // S22 (round 3): the shared memoized resolver when the caller passed the
172
+ // real git module; an injected test double keeps its own `repoIdentity`.
173
+ const commonDir = typeof git.commonDirFor === 'function'
174
+ ? git.commonDirFor(checkoutPath)
175
+ : (git.repoIdentity(checkoutPath) || {}).commonDir;
176
+ if (typeof commonDir !== 'string' || commonDir.length === 0) {
177
+ throw new Error(`cannot resolve git common dir: ${checkoutPath}`);
178
+ }
179
+ return commonDir;
180
+ }
181
+
182
+ // Summarize the pending working-tree changes of ONE checkout.
183
+ // Reads only: never mutates the source or its index. Staged and
184
+ // unstaged changes combine through the single HEAD diff (no
185
+ // double-counting); untracked files come from `git status -z` and are
186
+ // measured from disk. Returns
187
+ // {ok, checkout, root, files, added, deleted, binaryCount, entries}
188
+ // where each entry is {path, rel, status, added, deleted, binary,
189
+ // untracked}. Failures are {ok:false, error}.
190
+ async function summarizeChanges(checkoutPath, opts = {}) {
191
+ const fsImpl = opts.fs || fs;
192
+ const git = opts.git || (() => { try { return require('./worktree-git.js'); } catch { return null; } })();
193
+ if (!git || typeof git.repoIdentity !== 'function') {
194
+ return { ok: false, error: 'git helper is unavailable' };
195
+ }
196
+ if (typeof checkoutPath !== 'string' || checkoutPath.length === 0) {
197
+ return { ok: false, error: 'checkout path is empty' };
198
+ }
199
+ let checkout;
200
+ let root;
201
+ try {
202
+ checkout = path.resolve(checkoutPath);
203
+ root = resolveRepoRoot(checkout, git);
204
+ } catch (err) {
205
+ return { ok: false, error: (err && err.message) || String(err) };
206
+ }
207
+ // The checkout must exist as a directory; anything else is not a
208
+ // checkout we summarize.
209
+ try {
210
+ const st = fsImpl.statSync(checkout);
211
+ if (!st.isDirectory()) return { ok: false, error: `not a checkout directory: ${checkout}` };
212
+ } catch (err) {
213
+ return { ok: false, error: `cannot read checkout at ${checkout}: ${(err && err.message) || err}` };
214
+ }
215
+ const runAsync = typeof opts.runAsync === 'function'
216
+ ? opts.runAsync
217
+ : (bin, args, cwd) => defaultRunAsync(bin, args, cwd, opts.timeoutMs || SUMMARY_TIMEOUT_MS);
218
+ const timeoutMs = opts.timeoutMs || SUMMARY_TIMEOUT_MS;
219
+ void timeoutMs;
220
+ let diff;
221
+ try {
222
+ diff = await runAsync('git', ['diff', '--numstat', '-z', '-M', 'HEAD', '--'], checkout);
223
+ } catch (err) {
224
+ return { ok: false, error: `cannot diff ${checkout}: ${(err && err.message) || err}` };
225
+ }
226
+ if (!diff || diff.status !== 0) {
227
+ const reason = (diff && ((diff.stderr || '').trim().split('\n')[0] || diff.error)) || `exit ${diff && diff.status}`;
228
+ return { ok: false, error: `cannot diff ${checkout}: ${reason}` };
229
+ }
230
+ let status;
231
+ try {
232
+ status = await runAsync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--'], checkout);
233
+ } catch (err) {
234
+ return { ok: false, error: `cannot status ${checkout}: ${(err && err.message) || err}` };
235
+ }
236
+ if (!status || status.status !== 0) {
237
+ const reason = (status && ((status.stderr || '').trim().split('\n')[0] || status.error)) || `exit ${status && status.status}`;
238
+ return { ok: false, error: `cannot status ${checkout}: ${reason}` };
239
+ }
240
+ const entries = [];
241
+ const seen = new Set();
242
+ for (const record of parseNumstatZ(diff.stdout || '')) {
243
+ const rel = String(record.rel || '');
244
+ if (rel.length === 0 || isGitInternal(rel)) continue;
245
+ const abs = path.resolve(checkout, rel);
246
+ if (!isInsideOrEqual(checkout, abs)) continue;
247
+ // A record that resolves through a symlink outside the checkout is
248
+ // refused: the copy must never follow it out.
249
+ try {
250
+ const real = fsImpl.realpathSync(abs);
251
+ if (!isInsideOrEqual(checkout, real) && !isInsideOrEqual(root, real)) {
252
+ // Realpath inside the REPO but outside this checkout (a linked
253
+ // worktree reaching another checkout through a symlink) still
254
+ // refuses: the change belongs to another checkout.
255
+ if (!isInsideOrEqual(checkout, real)) continue;
256
+ }
257
+ } catch {
258
+ // A missing path (a deletion) has no realpath; keep it.
259
+ }
260
+ entries.push({
261
+ path: abs,
262
+ rel,
263
+ status: record.oldRel !== rel ? 'renamed' : 'modified',
264
+ added: record.added,
265
+ deleted: record.deleted,
266
+ binary: record.binary,
267
+ untracked: false,
268
+ });
269
+ seen.add(abs);
270
+ }
271
+ // Untracked files from `git status -z`: `?? <path>` tokens. Renames in
272
+ // status (`R old -> new`) are already covered by the diff; only `??`
273
+ // rows add entries here.
274
+ const statusTokens = String(status.stdout || '').split('\0');
275
+ const untrackedRels = [];
276
+ for (const token of statusTokens) {
277
+ if (token.length < 4) continue;
278
+ const code = token.slice(0, 2);
279
+ if (code !== '??') continue;
280
+ const rel = token.slice(3);
281
+ if (rel.length === 0 || isGitInternal(rel)) continue;
282
+ untrackedRels.push(rel);
283
+ }
284
+ for (const rel of untrackedRels) {
285
+ const abs = path.resolve(checkout, rel);
286
+ if (!isInsideOrEqual(checkout, abs)) continue;
287
+ if (seen.has(abs)) continue;
288
+ let st = null;
289
+ try {
290
+ st = fsImpl.statSync(abs);
291
+ } catch {
292
+ continue;
293
+ }
294
+ if (st.isDirectory()) {
295
+ // With --untracked-files=all directories arrive as files, but a
296
+ // defensive walk covers the spelling that lists a directory.
297
+ let nested = [];
298
+ try {
299
+ nested = walkFiles(abs, fsImpl);
300
+ } catch {
301
+ continue;
302
+ }
303
+ for (const file of nested) {
304
+ const fileRel = path.relative(checkout, file);
305
+ if (fileRel.length === 0 || fileRel.startsWith('..') || path.isAbsolute(fileRel)) continue;
306
+ if (isGitInternal(fileRel)) continue;
307
+ if (seen.has(file)) continue;
308
+ entries.push(measureUntracked(file, fileRel, fsImpl));
309
+ seen.add(file);
310
+ }
311
+ continue;
312
+ }
313
+ if (!st.isFile() && !st.isSymbolicLink()) continue;
314
+ // A symlink at the path escapes when its referent leaves the
315
+ // checkout: refuse it rather than copying through it.
316
+ try {
317
+ if (st.isSymbolicLink()) {
318
+ const real = fsImpl.realpathSync(abs);
319
+ if (!isInsideOrEqual(checkout, real)) continue;
320
+ }
321
+ } catch {
322
+ // A dangling symlink names nothing to copy; skip it.
323
+ continue;
324
+ }
325
+ entries.push(measureUntracked(abs, rel, fsImpl));
326
+ seen.add(abs);
327
+ }
328
+ entries.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
329
+ let added = 0;
330
+ let deleted = 0;
331
+ let binaryCount = 0;
332
+ for (const entry of entries) {
333
+ added += entry.added;
334
+ deleted += entry.deleted;
335
+ if (entry.binary) binaryCount += 1;
336
+ }
337
+ return {
338
+ ok: true, checkout, root, files: entries.length, added, deleted, binaryCount, entries,
339
+ };
340
+ }
341
+
342
+ function measureUntracked(abs, rel, fsImpl) {
343
+ let binary = false;
344
+ try {
345
+ binary = probeBinary(abs, fsImpl).binary;
346
+ } catch {
347
+ binary = false;
348
+ }
349
+ return {
350
+ path: abs,
351
+ rel,
352
+ status: 'untracked',
353
+ added: binary ? 0 : countAddedLines(abs, fsImpl),
354
+ deleted: 0,
355
+ binary,
356
+ untracked: true,
357
+ };
358
+ }
359
+
360
+ function walkFiles(dir, fsImpl) {
361
+ const out = [];
362
+ const stack = [dir];
363
+ let guard = 0;
364
+ while (stack.length > 0) {
365
+ guard += 1;
366
+ if (guard > 5000) break;
367
+ const current = stack.pop();
368
+ let names;
369
+ try {
370
+ names = fsImpl.readdirSync(current);
371
+ } catch {
372
+ continue;
373
+ }
374
+ for (const name of names || []) {
375
+ const full = path.join(current, name);
376
+ let st;
377
+ try {
378
+ st = fsImpl.statSync(full);
379
+ } catch {
380
+ continue;
381
+ }
382
+ if (st.isDirectory()) stack.push(full);
383
+ else if (st.isFile()) out.push(full);
384
+ }
385
+ }
386
+ return out;
387
+ }
388
+
389
+ // `3 files, +10/-2`, with ` (1 binary)` appended only when binary files
390
+ // are present (binary counts are never faked, only labelled).
391
+ function formatChangeSummary(summary) {
392
+ if (!summary || typeof summary.files !== 'number') return '0 files, +0/-0';
393
+ const base = `${summary.files} file${summary.files === 1 ? '' : 's'}, +${summary.added}/-${summary.deleted}`;
394
+ if (summary.binaryCount > 0) return `${base} (${summary.binaryCount} binary)`;
395
+ return base;
396
+ }
397
+
398
+ // Normalize the tool's `changes` option. Omitted, empty string and empty
399
+ // array all mean "copy nothing"; the string "all" means everything; an
400
+ // array of paths means exactly those files. Anything else fails closed.
401
+ function normalizeChangesSelection(value) {
402
+ if (value === undefined || value === null) return { ok: true, mode: 'none', paths: [] };
403
+ if (typeof value === 'string') {
404
+ const trimmed = value.trim();
405
+ if (trimmed === '') return { ok: true, mode: 'none', paths: [] };
406
+ if (trimmed === 'all') return { ok: true, mode: 'all', paths: [] };
407
+ return { ok: false, error: `invalid changes selection ${JSON.stringify(value)}: pass \"all\", a list of files, or leave it empty for no copy` };
408
+ }
409
+ if (Array.isArray(value)) {
410
+ if (value.length === 0) return { ok: true, mode: 'none', paths: [] };
411
+ for (const entry of value) {
412
+ if (typeof entry !== 'string' || entry.trim() === '') {
413
+ return { ok: false, error: 'invalid changes selection: every entry must be a non-empty file path' };
414
+ }
415
+ }
416
+ return { ok: true, mode: 'paths', paths: value.slice() };
417
+ }
418
+ return { ok: false, error: 'invalid changes selection: pass \"all\", a list of files, or leave it empty for no copy' };
419
+ }
420
+
421
+ // Match a selection entry against a summary entry: absolute path, or the
422
+ // checkout-relative spelling. Returns the matched summary entries, or
423
+ // {ok:false} naming the unknown file (fail closed, no partial match).
424
+ function matchSelection(summary, mode, paths) {
425
+ if (mode === 'none') return { ok: true, entries: [] };
426
+ if (mode === 'all') return { ok: true, entries: summary.entries.slice() };
427
+ const matched = [];
428
+ const unknown = [];
429
+ for (const raw of paths) {
430
+ const trimmed = String(raw).trim();
431
+ const asAbs = path.isAbsolute(trimmed) ? path.resolve(trimmed) : path.resolve(summary.checkout, trimmed);
432
+ const found = summary.entries.find((entry) => entry.path === asAbs || entry.rel === trimmed);
433
+ if (found) matched.push(found);
434
+ else unknown.push(trimmed);
435
+ }
436
+ if (unknown.length > 0) {
437
+ return { ok: false, error: `unknown changed file${unknown.length === 1 ? '' : 's'}: ${unknown.map((u) => JSON.stringify(u)).join(', ')} (nothing was copied)` };
438
+ }
439
+ // De-duplicate while keeping summary order.
440
+ const picked = new Set(matched.map((entry) => entry.path));
441
+ return { ok: true, entries: summary.entries.filter((entry) => picked.has(entry.path)) };
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // Divergent-base transfer (the second leg of a copy).
446
+ //
447
+ // The plain transfer is ONE patch: `git diff HEAD` in the source, applied to
448
+ // the destination working tree. That is exact while the destination still
449
+ // holds the source's own HEAD blob for every selected file — and it fails
450
+ // closed the moment it does not, which is the ORDINARY case for the checkout a
451
+ // warp or a create lands in: `worktree_create` builds the new branch from
452
+ // LOCAL main, and a worktree made before the source branch's last commit
453
+ // carries none of its files. The destination then either has no file at that
454
+ // path (the patch says "modify" and there is nothing to modify) or holds a
455
+ // different version of it (the hunks no longer match).
456
+ //
457
+ // The fallback keeps the intent EXACTLY the same — carry the PENDING edit,
458
+ // source HEAD -> source worktree — and never the source branch's committed
459
+ // delta. Diffing the destination HEAD against the source working tree would do
460
+ // the latter: it would import every committed source-branch change and
461
+ // overwrite destination-only content. So each file is resolved on its own:
462
+ //
463
+ // * the file's own patch applies -> apply it (git's own semantics for
464
+ // modes, renames and filters);
465
+ // * the destination has no file there -> create it from the source working
466
+ // and the path is absent from the tree, but ONLY when the path is also
467
+ // two branches' merge base absent from the branches' merge base,
468
+ // i.e. it is genuinely new on the source side. A path the destination
469
+ // branch removed is REFUSED: restoring it would bring back content that
470
+ // side deleted on purpose;
471
+ // * the destination holds a different -> three-way merge with the source HEAD
472
+ // version of the file blob as base, the destination's file
473
+ // as "ours" and the source's working tree as "theirs": the pending edit
474
+ // travels and destination-only content survives;
475
+ // * a real conflict, a rename, a mode -> refuse, naming every file, and say
476
+ // change or a binary divergence how the human gets unstuck: resolve
477
+ // the conflict first, or warp again
478
+ // without copying.
479
+ //
480
+ // Every check and every merge runs in a system temp directory BEFORE the first
481
+ // write, so a refusal mutates NOTHING; the applied result is then verified
482
+ // (destination-only files unchanged, every planned effect landed, no path that
483
+ // was not selected changed).
484
+ //
485
+ // Bytes are never round-tripped through text: the git runner hands back UTF-8
486
+ // STRINGS, so the merge writes into a temp file and the result is read back as
487
+ // bytes, and the base is used only when its text re-encodes to the exact blob
488
+ // git recorded (`git hash-object` against the oid from `git diff --raw`). A
489
+ // file whose bytes do not survive that roundtrip — a latin-1 file, anything
490
+ // with a stray high byte — is refused instead of being silently rewritten with
491
+ // U+FFFD replacement characters. Binary files are never merged by line at all:
492
+ // an exactly-matching base takes the patch, anything else refuses.
493
+
494
+ const CONFLICT_ADVICE = 'resolve the conflict first, or warp again without copying';
495
+
496
+ // Content identity for verification: never faked, binary safe, bounded memory.
497
+ function fingerprintBytes(bytes) {
498
+ return crypto.createHash('sha1').update(bytes).digest('hex');
499
+ }
500
+
501
+ function fingerprintFile(abs, fsImpl) {
502
+ try {
503
+ return fingerprintBytes(fsImpl.readFileSync(abs));
504
+ } catch {
505
+ return null;
506
+ }
507
+ }
508
+
509
+ // One worktree entry, byte for byte, with the facts a plan needs: what is there
510
+ // (a regular file, a symlink, a directory, nothing, or something unreadable) and
511
+ // whether it looks binary (the same NUL probe the summary uses). `missing` and
512
+ // `unreadable` are kept apart on purpose: an unreadable file is not a deletion.
513
+ function readWorktreeFile(abs, fsImpl) {
514
+ const absent = (kind) => ({ kind, exists: false, bytes: null, mode: null, binary: false });
515
+ let st;
516
+ try {
517
+ st = fsImpl.lstatSync(abs);
518
+ } catch (err) {
519
+ const code = err && err.code;
520
+ return absent(code === 'ENOENT' || code === 'ENOTDIR' ? 'missing' : 'unreadable');
521
+ }
522
+ if (st.isSymbolicLink()) return { ...absent('symlink'), mode: st.mode };
523
+ if (st.isDirectory()) return { ...absent('directory'), mode: st.mode };
524
+ if (!st.isFile()) return { ...absent('other'), mode: st.mode };
525
+ let bytes;
526
+ try {
527
+ bytes = fsImpl.readFileSync(abs);
528
+ } catch {
529
+ return { ...absent('unreadable'), mode: st.mode };
530
+ }
531
+ return { kind: 'file', exists: true, bytes, mode: st.mode, binary: bytes.includes(0) };
532
+ }
533
+
534
+ function isZeroOid(oid) {
535
+ return typeof oid !== 'string' || oid.length === 0 || /^0+$/.test(oid);
536
+ }
537
+
538
+ // What sits at the destination path, when it is not a regular file the copy may
539
+ // write. A symbolic link is named separately because `writeFileSync` would
540
+ // FOLLOW it out of the checkout, which no copy may ever do.
541
+ function destObstruction(abs, fsImpl) {
542
+ try {
543
+ const st = fsImpl.lstatSync(abs);
544
+ if (st.isSymbolicLink()) return 'a symbolic link';
545
+ if (st.isDirectory()) return 'a directory';
546
+ if (!st.isFile()) return 'something other than a regular file';
547
+ return null;
548
+ } catch {
549
+ return null;
550
+ }
551
+ }
552
+
553
+ // The real path of a checkout, so a checkout reached through a symlinked
554
+ // spelling compares equal to itself and a component's realpath can be proved to
555
+ // stay inside it.
556
+ function checkoutReal(abs, fsImpl) {
557
+ try {
558
+ return fsImpl.realpathSync(abs);
559
+ } catch {
560
+ return path.resolve(abs);
561
+ }
562
+ }
563
+
564
+ // The separators a checkout-relative path is built from on THIS platform: a
565
+ // backslash splits components on Windows only, while on POSIX it is an ordinary
566
+ // character inside a file name (splitting on it there would invent a directory
567
+ // component the selection never named, and leave the file one level too deep).
568
+ const PATH_COMPONENT_SEPARATORS = path.sep === '\\' ? /[\\/]+/ : /\/+/;
569
+
570
+ // Write-path containment for ONE destination-relative path.
571
+ //
572
+ // A path can leave the checkout without its own entry being a link: a PARENT
573
+ // DIRECTORY that is a symlink carries the write somewhere else, and the target's
574
+ // own lstat cannot see it. Node exposes neither `openat` nor
575
+ // `RESOLVE_BENEATH`, so the proof is made here, component by component, and it
576
+ // is repeated immediately before each write: every component between the
577
+ // checkout and the target must be a real directory whose realpath is inside the
578
+ // checkout, and the components this copy must create are created ONE LEVEL AT A
579
+ // TIME and re-proved right after creation (a plain `mkdirSync({recursive:true})`
580
+ // would create the tree wherever a link points).
581
+ function containWritePath({ rel, toAbs, baseReal, create, fsImpl }) {
582
+ const parts = String(rel).split(PATH_COMPONENT_SEPARATORS).filter((part) => part.length > 0 && part !== '.');
583
+ if (parts.length === 0) return { ok: false, error: 'the selected path is empty' };
584
+ if (parts.some((part) => part === '..')) {
585
+ return { ok: false, error: `${JSON.stringify(rel)} traverses out of the destination checkout` };
586
+ }
587
+ let current = toAbs;
588
+ for (let i = 0; i < parts.length - 1; i += 1) {
589
+ const next = path.join(current, parts[i]);
590
+ const relDir = parts.slice(0, i + 1).join('/');
591
+ let st = null;
592
+ try {
593
+ st = fsImpl.lstatSync(next);
594
+ } catch (err) {
595
+ const code = err && err.code;
596
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
597
+ return { ok: false, error: `the destination's ${JSON.stringify(relDir)} cannot be inspected` };
598
+ }
599
+ // Nothing is there yet: nothing can be traversed through it right now,
600
+ // and while planning the copy creates nothing at all.
601
+ if (!create) return { ok: true };
602
+ try {
603
+ fsImpl.mkdirSync(next);
604
+ } catch (mkdirErr) {
605
+ const mkdirCode = mkdirErr && mkdirErr.code;
606
+ if (mkdirCode !== 'EEXIST') {
607
+ return { ok: false, error: `the destination's ${JSON.stringify(relDir)} cannot be created` };
608
+ }
609
+ }
610
+ try {
611
+ st = fsImpl.lstatSync(next);
612
+ } catch {
613
+ return { ok: false, error: `the destination's ${JSON.stringify(relDir)} cannot be inspected after creation` };
614
+ }
615
+ }
616
+ if (st.isSymbolicLink()) {
617
+ return { ok: false, error: `the destination's ${JSON.stringify(relDir)} is a symbolic link, so a copy never writes through it` };
618
+ }
619
+ if (!st.isDirectory()) {
620
+ return { ok: false, error: `the destination's ${JSON.stringify(relDir)} is not a directory` };
621
+ }
622
+ let real = null;
623
+ try {
624
+ real = fsImpl.realpathSync(next);
625
+ } catch {
626
+ real = null;
627
+ }
628
+ if (real === null || !isInsideOrEqual(baseReal, real)) {
629
+ return { ok: false, error: `the destination's ${JSON.stringify(relDir)} resolves outside the destination checkout` };
630
+ }
631
+ current = next;
632
+ }
633
+ // The parent of the file itself, proved last so the check and the write are as
634
+ // close together as the architecture allows.
635
+ const parentReal = (() => {
636
+ try {
637
+ return fsImpl.realpathSync(current);
638
+ } catch {
639
+ return null;
640
+ }
641
+ })();
642
+ if (parentReal === null || !isInsideOrEqual(baseReal, parentReal)) {
643
+ return { ok: false, error: `the destination's parent directory for ${JSON.stringify(rel)} resolves outside the destination checkout` };
644
+ }
645
+ return { ok: true };
646
+ }
647
+
648
+ // Containment pass over the planned WRITES (the patch leg is git's own; it
649
+ // refuses paths outside the work tree). Planning runs it with `create: false`,
650
+ // so a refusal mutates nothing; the apply leg runs it again with `create: true`
651
+ // right before each write.
652
+ function containPlannedWrites({ plans, toAbs, baseReal, create, fsImpl }) {
653
+ for (const plan of plans) {
654
+ if (plan.kind !== 'copy' && plan.kind !== 'create' && plan.kind !== 'write') continue;
655
+ const contained = containWritePath({ rel: plan.rel, toAbs, baseReal, create, fsImpl });
656
+ if (!contained.ok) return contained;
657
+ }
658
+ return { ok: true };
659
+ }
660
+
661
+ // Parse `git diff --raw -z -M HEAD`: `:<old mode> <new mode> <old oid>
662
+ // <new oid> <status>` followed by the path (a rename or a copy carries BOTH
663
+ // paths). The old oid is the HEAD blob the pending edit started from — the base
664
+ // a three-way merge needs — and the status letter separates a content edit from
665
+ // an add, a delete, a rename or a mode change.
666
+ function parseRawZ(stdout) {
667
+ const out = [];
668
+ if (typeof stdout !== 'string' || stdout.length === 0) return out;
669
+ const tokens = stdout.split('\0');
670
+ for (let i = 0; i < tokens.length; i += 1) {
671
+ const token = tokens[i];
672
+ if (typeof token !== 'string' || token.charAt(0) !== ':') continue;
673
+ const fields = token.slice(1).split(' ');
674
+ if (fields.length < 5) continue;
675
+ const [oldMode, newMode, oldOid, newOid, statusField] = fields;
676
+ const status = statusField.charAt(0);
677
+ if (status === 'R' || status === 'C') {
678
+ const oldRel = tokens[i + 1];
679
+ const rel = tokens[i + 2];
680
+ if (typeof oldRel !== 'string' || typeof rel !== 'string' || rel.length === 0) break;
681
+ i += 2;
682
+ out.push({ oldMode, newMode, oldOid, newOid, status, oldRel, rel });
683
+ continue;
684
+ }
685
+ const rel = tokens[i + 1];
686
+ if (typeof rel !== 'string' || rel.length === 0) break;
687
+ i += 1;
688
+ out.push({ oldMode, newMode, oldOid, newOid, status, oldRel: rel, rel });
689
+ }
690
+ return out;
691
+ }
692
+
693
+ // The pending edit of exactly these paths, as a patch the destination can try.
694
+ // `--binary` keeps a binary file's change exact; `-M` keeps a rename one entry.
695
+ async function selectedPatch({ rels, fromAbs, runAsync }) {
696
+ let res;
697
+ try {
698
+ res = await runAsync('git', ['diff', '--binary', '-M', 'HEAD', '--', ...rels], fromAbs);
699
+ } catch (err) {
700
+ return { ok: false, error: `diff failed (${(err && err.message) || err})` };
701
+ }
702
+ if (!res || res.status !== 0) {
703
+ const reason = (res && ((res.stderr || '').trim().split('\n')[0] || res.error)) || 'diff failed';
704
+ return { ok: false, error: `diff failed (${reason})` };
705
+ }
706
+ if (typeof res.stdout !== 'string' || res.stdout.length === 0) {
707
+ return { ok: false, error: 'the tracked selection produced no patch' };
708
+ }
709
+ return { ok: true, text: res.stdout };
710
+ }
711
+
712
+ // Dry-run or apply one patch. The patch file lives in the system temp dir
713
+ // (never inside the worktree) and the apply never passes `--index`, so both
714
+ // indexes are preserved by construction.
715
+ async function applyPatchText({ text, toAbs, runAsync, fsImpl, tmpDir, name, checkOnly }) {
716
+ const file = path.join(tmpDir, `${name}.patch`);
717
+ try {
718
+ fsImpl.writeFileSync(file, text);
719
+ } catch (err) {
720
+ return { ok: false, error: `cannot stage the patch (${(err && err.message) || err})` };
721
+ }
722
+ const args = checkOnly
723
+ ? ['apply', '--check', '--whitespace=nowarn', file]
724
+ : ['apply', '--whitespace=nowarn', file];
725
+ let res;
726
+ try {
727
+ res = await runAsync('git', args, toAbs);
728
+ } catch (err) {
729
+ return { ok: false, error: `git apply failed (${(err && err.message) || err})` };
730
+ }
731
+ if (!res || res.status !== 0) {
732
+ const reason = (res && ((res.stderr || '').trim().split('\n')[0] || res.error)) || 'patch does not apply';
733
+ return { ok: false, error: reason };
734
+ }
735
+ return { ok: true };
736
+ }
737
+
738
+ // `git merge-file` over the three sides, merged IN PLACE in the temp directory
739
+ // and read back as bytes. The result never travels through stdout, because the
740
+ // git runner hands back text: a merged file holding a byte that is not valid
741
+ // UTF-8 would come back as U+FFFD and be written that way. Exit 0 is a clean
742
+ // merge; 1..127 is a real conflict, and the marker text is dropped on purpose —
743
+ // a conflict is reported, never written into anyone's file.
744
+ async function mergeThreeWay({ sides, tmpDir, runAsync, fsImpl, name }) {
745
+ const oursPath = path.join(tmpDir, `${name}-ours`);
746
+ const basePath = path.join(tmpDir, `${name}-base`);
747
+ const theirsPath = path.join(tmpDir, `${name}-theirs`);
748
+ try {
749
+ fsImpl.writeFileSync(oursPath, sides.ours);
750
+ fsImpl.writeFileSync(basePath, sides.base);
751
+ fsImpl.writeFileSync(theirsPath, sides.theirs);
752
+ } catch (err) {
753
+ return { ok: false, error: `cannot prepare the merge (${(err && err.message) || err})` };
754
+ }
755
+ let res;
756
+ try {
757
+ res = await runAsync('git', ['merge-file', oursPath, basePath, theirsPath], tmpDir);
758
+ } catch (err) {
759
+ return { ok: false, error: `merge failed (${(err && err.message) || err})` };
760
+ }
761
+ if (!res || typeof res.status !== 'number') {
762
+ return { ok: false, error: `merge failed (${(res && (res.error || (res.stderr || '').trim())) || 'no exit status'})` };
763
+ }
764
+ if (res.status === 0) {
765
+ let bytes;
766
+ try {
767
+ bytes = fsImpl.readFileSync(oursPath);
768
+ } catch (err) {
769
+ return { ok: false, error: `the merged result could not be read back (${(err && err.message) || err})` };
770
+ }
771
+ return { ok: true, bytes };
772
+ }
773
+ if (res.status > 0 && res.status < 128) return { ok: false, conflict: true };
774
+ const reason = (res.stderr || '').trim().split('\n')[0] || `exit ${res.status}`;
775
+ return { ok: false, error: `merge failed (${reason})` };
776
+ }
777
+
778
+ // The merge base reaches us as TEXT, so it is only usable when that text
779
+ // re-encodes to the exact blob git recorded. Proving it with the blob's own oid
780
+ // (never a heuristic about which bytes "look" like text) is what keeps a merge
781
+ // from rewriting content it never actually saw: any byte that does not survive
782
+ // the roundtrip fails here and the file is refused instead of being silently
783
+ // turned into U+FFFD.
784
+ async function baseIsByteExact({ baseBytes, oid, tmpDir, runAsync, fsImpl, name }) {
785
+ const file = path.join(tmpDir, `${name}-base.bin`);
786
+ try {
787
+ fsImpl.writeFileSync(file, baseBytes);
788
+ } catch {
789
+ return false;
790
+ }
791
+ let res;
792
+ try {
793
+ res = await runAsync('git', ['hash-object', '--no-filters', '--', file], tmpDir);
794
+ } catch {
795
+ return false;
796
+ }
797
+ if (!res || res.status !== 0 || typeof res.stdout !== 'string') return false;
798
+ return res.stdout.trim() === oid;
799
+ }
800
+
801
+ // The merge base of the two checkouts, resolved once per copy and only when a
802
+ // selected path is missing at the destination.
803
+ async function resolveMergeBase({ fromAbs, toAbs, runAsync }) {
804
+ let head = null;
805
+ try {
806
+ head = await runAsync('git', ['rev-parse', 'HEAD'], fromAbs);
807
+ } catch {
808
+ head = null;
809
+ }
810
+ const sha = head && head.status === 0 && typeof head.stdout === 'string' ? head.stdout.trim() : '';
811
+ if (sha === '') return null;
812
+ let base = null;
813
+ try {
814
+ base = await runAsync('git', ['merge-base', 'HEAD', sha], toAbs);
815
+ } catch {
816
+ base = null;
817
+ }
818
+ const tree = base && base.status === 0 && typeof base.stdout === 'string' ? base.stdout.trim() : '';
819
+ return tree === '' ? null : tree;
820
+ }
821
+
822
+ async function pathInMergeBase({ rel, fromAbs, toAbs, runAsync, cache }) {
823
+ if (cache.resolved !== true) {
824
+ cache.resolved = true;
825
+ cache.tree = await resolveMergeBase({ fromAbs, toAbs, runAsync });
826
+ }
827
+ if (cache.tree === null) return null;
828
+ let res;
829
+ try {
830
+ res = await runAsync('git', ['ls-tree', '-r', '-z', '--name-only', cache.tree, '--', rel], toAbs);
831
+ } catch {
832
+ return null;
833
+ }
834
+ if (!res || res.status !== 0) return null;
835
+ return typeof res.stdout === 'string' && res.stdout.length > 0;
836
+ }
837
+
838
+ // Resolve ONE selected tracked file whose destination copy no longer holds the
839
+ // source's base. Returns a plan, a conflict, or a refusal — never a write.
840
+ async function planDivergedFile({ entry, fact, index, fromAbs, toAbs, runAsync, fsImpl, tmpDir, mergeBaseCache }) {
841
+ const rel = entry.rel;
842
+ if (fact === null) {
843
+ return { refusal: `the pending change of ${JSON.stringify(rel)} could not be read` };
844
+ }
845
+ if (fact.status === 'R' || fact.status === 'C') {
846
+ return { refusal: `${JSON.stringify(rel)} is a rename or a copy and the destination's copy has diverged, so it cannot be carried automatically` };
847
+ }
848
+ if (fact.oldMode !== fact.newMode) {
849
+ return { refusal: `${JSON.stringify(rel)} carries a mode change and the destination's copy has diverged, so it cannot be carried automatically` };
850
+ }
851
+ const srcState = readWorktreeFile(path.resolve(fromAbs, rel), fsImpl);
852
+ const destState = readWorktreeFile(path.resolve(toAbs, rel), fsImpl);
853
+ const obstruction = destObstruction(path.resolve(toAbs, rel), fsImpl);
854
+ if (obstruction !== null) {
855
+ return { refusal: `the destination has ${obstruction} at ${JSON.stringify(rel)}, and a copy never writes through it` };
856
+ }
857
+ // "Nothing to read" is not the same as "the source deleted it": a path the
858
+ // copy cannot read is reported as unreadable (or as the kind of entry it is),
859
+ // never as a deletion.
860
+ if (srcState.kind === 'unreadable') {
861
+ return { refusal: `${JSON.stringify(rel)} cannot be read in the source checkout, so it cannot be carried` };
862
+ }
863
+ if (srcState.kind === 'symlink' || srcState.kind === 'directory' || srcState.kind === 'other') {
864
+ return { refusal: `the source's ${JSON.stringify(rel)} is ${srcState.kind === 'symlink' ? 'a symbolic link' : srcState.kind === 'directory' ? 'a directory' : 'neither a regular file nor a symlink'}, so it cannot be carried onto a diverged destination` };
865
+ }
866
+ if (destState.kind === 'unreadable') {
867
+ return { refusal: `the destination's copy of ${JSON.stringify(rel)} cannot be read, so it cannot be compared or merged` };
868
+ }
869
+ const hasBase = !isZeroOid(fact.oldOid);
870
+ const binary = entry.binary === true || srcState.binary === true || destState.binary === true;
871
+ if (!srcState.exists) {
872
+ // The pending change is a deletion.
873
+ if (!destState.exists) return { skip: true };
874
+ return { refusal: `the source deletes ${JSON.stringify(rel)} while the destination's own version of it has diverged, so deleting it would throw that work away` };
875
+ }
876
+ if (!destState.exists) {
877
+ // The destination has no file here at all: the only safe creation is a path
878
+ // the two branches never shared, i.e. genuinely new on the source side.
879
+ if (hasBase) {
880
+ const inBase = await pathInMergeBase({ rel, fromAbs, toAbs, runAsync, cache: mergeBaseCache });
881
+ if (inBase === null) {
882
+ return { refusal: `the two branches' merge base could not be read for ${JSON.stringify(rel)}` };
883
+ }
884
+ if (inBase) {
885
+ return { refusal: `the destination has no ${JSON.stringify(rel)} although both branches once had it, so it was removed on the destination side and copying would restore a file that side deleted` };
886
+ }
887
+ }
888
+ return { plan: { kind: 'create', rel, bytes: srcState.bytes, mode: srcState.mode } };
889
+ }
890
+ if (!hasBase) {
891
+ // A pending ADD landing on a file the destination already has.
892
+ if (destState.bytes.equals(srcState.bytes)) return { skip: true };
893
+ return { refusal: `the destination already holds a different file at ${JSON.stringify(rel)}` };
894
+ }
895
+ if (binary) {
896
+ return { refusal: `${JSON.stringify(rel)} is binary and the destination's copy has diverged, so it cannot be merged` };
897
+ }
898
+ let base = null;
899
+ try {
900
+ base = await runAsync('git', ['cat-file', 'blob', fact.oldOid], fromAbs);
901
+ } catch {
902
+ base = null;
903
+ }
904
+ if (!base || base.status !== 0 || typeof base.stdout !== 'string') {
905
+ return { refusal: `the base of ${JSON.stringify(rel)} could not be read` };
906
+ }
907
+ if (base.stdout.includes('\u0000')) {
908
+ return { refusal: `${JSON.stringify(rel)} is binary and the destination's copy has diverged, so it cannot be merged` };
909
+ }
910
+ const baseBytes = Buffer.from(base.stdout, 'utf8');
911
+ const baseName = `merge-${index}`;
912
+ if (!await baseIsByteExact({ baseBytes, oid: fact.oldOid, tmpDir, runAsync, fsImpl, name: baseName })) {
913
+ return { refusal: `${JSON.stringify(rel)} does not survive a UTF-8 roundtrip, so its base cannot be reproduced byte for byte and it cannot be merged byte-exactly` };
914
+ }
915
+ const merged = await mergeThreeWay({
916
+ sides: { ours: destState.bytes, base: baseBytes, theirs: srcState.bytes },
917
+ tmpDir,
918
+ runAsync,
919
+ fsImpl,
920
+ name: baseName,
921
+ });
922
+ if (merged.conflict) return { conflict: rel };
923
+ if (!merged.ok) return { refusal: `${merged.error} for ${JSON.stringify(rel)}` };
924
+ return { plan: { kind: 'write', rel, bytes: merged.bytes } };
925
+ }
926
+
927
+ // Everything the copy will do, decided BEFORE anything is written: one patch
928
+ // for the whole selection when the destination still holds the source base, or
929
+ // one plan per file when it does not.
930
+ async function planTransfer({ tracked, untracked, fromAbs, toAbs, runAsync, fsImpl, tmpDir }) {
931
+ const plans = [];
932
+ if (tracked.length > 0) {
933
+ const rels = tracked.map((entry) => entry.rel);
934
+ const whole = await selectedPatch({ rels, fromAbs, runAsync });
935
+ if (!whole.ok) {
936
+ return { ok: false, error: `cannot copy changes: ${whole.error} (nothing was copied)` };
937
+ }
938
+ const check = await applyPatchText({ text: whole.text, toAbs, runAsync, fsImpl, tmpDir, name: 'selection', checkOnly: true });
939
+ if (check.ok) {
940
+ plans.push({ kind: 'patch', text: whole.text, rels, name: 'selection' });
941
+ } else {
942
+ const perFile = await planTrackedFiles({ tracked, fromAbs, toAbs, runAsync, fsImpl, tmpDir });
943
+ if (!perFile.ok) return perFile;
944
+ plans.push(...perFile.plans);
945
+ }
946
+ }
947
+ for (const entry of untracked) {
948
+ const destPath = path.resolve(toAbs, entry.rel);
949
+ const obstruction = destObstruction(destPath, fsImpl);
950
+ if (obstruction !== null) {
951
+ return {
952
+ ok: false,
953
+ error: `cannot copy changes: the destination has ${obstruction} at ${JSON.stringify(entry.rel)}, and a copy never writes through it: ${CONFLICT_ADVICE} (nothing was copied)`,
954
+ };
955
+ }
956
+ const destState = readWorktreeFile(destPath, fsImpl);
957
+ if (destState.kind === 'unreadable') {
958
+ return {
959
+ ok: false,
960
+ error: `cannot copy changes: the destination's ${JSON.stringify(entry.rel)} cannot be read, so it cannot be proven identical: ${CONFLICT_ADVICE} (nothing was copied)`,
961
+ };
962
+ }
963
+ if (destState.exists) continue; // proven identical already
964
+ plans.push({ kind: 'copy', rel: entry.rel, from: entry.path, to: destPath });
965
+ }
966
+ // Nothing may be written through a symlinked parent, and planning proves it
967
+ // BEFORE a single byte lands (nothing here creates a directory).
968
+ const contained = containPlannedWrites({
969
+ plans, toAbs, baseReal: checkoutReal(toAbs, fsImpl), create: false, fsImpl,
970
+ });
971
+ if (!contained.ok) {
972
+ return {
973
+ ok: false,
974
+ error: `cannot copy changes: ${contained.error}: ${CONFLICT_ADVICE} (nothing was copied)`,
975
+ };
976
+ }
977
+ return { ok: true, plans };
978
+ }
979
+
980
+ async function planTrackedFiles({ tracked, fromAbs, toAbs, runAsync, fsImpl, tmpDir }) {
981
+ const rels = tracked.map((entry) => entry.rel);
982
+ let facts;
983
+ try {
984
+ // `--no-abbrev` is load-bearing: an abbreviated oid can never be compared
985
+ // with a blob hash, and the byte-exactness proof below does exactly that.
986
+ facts = await runAsync('git', ['diff', '--raw', '--no-abbrev', '-z', '-M', 'HEAD', '--', ...rels], fromAbs);
987
+ } catch (err) {
988
+ return { ok: false, error: `cannot copy changes: cannot read the selected paths (${(err && err.message) || err}; nothing was copied)` };
989
+ }
990
+ if (!facts || facts.status !== 0) {
991
+ const reason = (facts && ((facts.stderr || '').trim().split('\n')[0] || facts.error)) || 'diff failed';
992
+ return { ok: false, error: `cannot copy changes: cannot read the selected paths (${reason}; nothing was copied)` };
993
+ }
994
+ const byRel = new Map(parseRawZ(facts.stdout || '').map((record) => [record.rel, record]));
995
+ const mergeBaseCache = { resolved: false, tree: null };
996
+ const plans = [];
997
+ const conflicts = [];
998
+ const refusals = [];
999
+ let index = 0;
1000
+ for (const entry of tracked) {
1001
+ index += 1;
1002
+ const rel = entry.rel;
1003
+ const single = await selectedPatch({ rels: [rel], fromAbs, runAsync });
1004
+ if (!single.ok) {
1005
+ refusals.push(`${single.error} for ${JSON.stringify(rel)}`);
1006
+ continue;
1007
+ }
1008
+ const check = await applyPatchText({
1009
+ text: single.text, toAbs, runAsync, fsImpl, tmpDir, name: `file-${index}`, checkOnly: true,
1010
+ });
1011
+ if (check.ok) {
1012
+ plans.push({ kind: 'patch', text: single.text, rels: [rel], name: `file-${index}` });
1013
+ continue;
1014
+ }
1015
+ const resolved = await planDivergedFile({
1016
+ entry, fact: byRel.get(rel) || null, index, fromAbs, toAbs, runAsync, fsImpl, tmpDir, mergeBaseCache,
1017
+ });
1018
+ if (resolved.skip) continue;
1019
+ if (resolved.conflict) {
1020
+ conflicts.push(rel);
1021
+ continue;
1022
+ }
1023
+ if (resolved.refusal) {
1024
+ refusals.push(resolved.refusal);
1025
+ continue;
1026
+ }
1027
+ plans.push(resolved.plan);
1028
+ }
1029
+ if (conflicts.length > 0) {
1030
+ return {
1031
+ ok: false,
1032
+ error: `cannot copy changes: the pending change conflicts with the destination's own version of ${conflicts.map((rel) => JSON.stringify(rel)).join(', ')}: ${CONFLICT_ADVICE} (nothing was copied)`,
1033
+ };
1034
+ }
1035
+ if (refusals.length > 0) {
1036
+ return {
1037
+ ok: false,
1038
+ error: `cannot copy changes: ${refusals.join('; ')}: ${CONFLICT_ADVICE} (nothing was copied)`,
1039
+ };
1040
+ }
1041
+ return { ok: true, plans };
1042
+ }
1043
+
1044
+ // Every FILE one plan writes, so a failure can name exactly what already landed
1045
+ // (a patch plan covers one or more paths, a copy/create/write plan exactly one).
1046
+ function planEffectRels(plan) {
1047
+ if (plan.kind === 'patch') return plan.rels.slice();
1048
+ return [plan.rel];
1049
+ }
1050
+
1051
+ // The truthful state of a copy that stopped midway. An earlier plan may already
1052
+ // have landed, and "nothing was copied" would then be a claim the human cannot
1053
+ // check — so what landed is named, and what did not is said plainly.
1054
+ function partialCopyNote(landed, total) {
1055
+ if (landed.length === 0) return 'nothing was copied';
1056
+ const names = landed.map((rel) => JSON.stringify(rel)).join(', ');
1057
+ const rest = landed.length >= total ? 'those files are on disk' : 'the rest were not copied';
1058
+ return `${landed.length} of ${total} file${total === 1 ? '' : 's'} already copied (${names}); ${rest}`;
1059
+ }
1060
+
1061
+ async function applyPlans({ plans, toAbs, baseReal, runAsync, fsImpl, tmpDir }) {
1062
+ const total = plans.reduce((count, plan) => count + planEffectRels(plan).length, 0);
1063
+ const landed = [];
1064
+ // One exit for every apply-time failure: the detail, then the state actually
1065
+ // on disk, then the instruction that follows from it.
1066
+ const failed = (detail) => ({
1067
+ ok: false,
1068
+ error: `${detail}; ${partialCopyNote(landed, total)}${landed.length > 0 ? '; inspect the destination before retrying' : ''}`,
1069
+ });
1070
+ for (const plan of plans) {
1071
+ if (plan.kind === 'patch') {
1072
+ const applied = await applyPatchText({
1073
+ text: plan.text, toAbs, runAsync, fsImpl, tmpDir, name: plan.name, checkOnly: false,
1074
+ });
1075
+ if (!applied.ok) {
1076
+ return failed(`cannot copy changes: ${plan.rels.map((rel) => JSON.stringify(rel)).join(', ')} did not apply (${applied.error})`);
1077
+ }
1078
+ landed.push(...planEffectRels(plan));
1079
+ continue;
1080
+ }
1081
+ // Plan-level guards first, then containment, then the target, then the
1082
+ // write — all synchronous from here on, so the window an attacker could use
1083
+ // to swap a component is as small as this architecture allows.
1084
+ const target = path.resolve(toAbs, plan.rel);
1085
+ if (plan.kind === 'copy') {
1086
+ const obstruction = destObstruction(plan.to, fsImpl);
1087
+ if (obstruction !== null) {
1088
+ return failed(`cannot copy changes: the destination has ${obstruction} at ${JSON.stringify(plan.rel)}, and a copy never writes through it`);
1089
+ }
1090
+ }
1091
+ const contained = containWritePath({ rel: plan.rel, toAbs, baseReal, create: true, fsImpl });
1092
+ if (!contained.ok) {
1093
+ return failed(`cannot copy changes: ${contained.error}`);
1094
+ }
1095
+ if (plan.kind === 'copy') {
1096
+ try {
1097
+ fsImpl.copyFileSync(plan.from, plan.to);
1098
+ } catch (err) {
1099
+ return failed(`cannot copy ${JSON.stringify(plan.rel)}: ${(err && err.message) || err}`);
1100
+ }
1101
+ landed.push(plan.rel);
1102
+ continue;
1103
+ }
1104
+ const obstruction = destObstruction(target, fsImpl);
1105
+ if (obstruction !== null) {
1106
+ return failed(`cannot copy changes: the destination has ${obstruction} at ${JSON.stringify(plan.rel)}, and a copy never writes through it`);
1107
+ }
1108
+ try {
1109
+ fsImpl.writeFileSync(target, plan.bytes);
1110
+ if (plan.kind === 'create' && Number.isFinite(plan.mode)) {
1111
+ // A created file keeps the source's permission bits; a file that already
1112
+ // existed keeps its own.
1113
+ try { fsImpl.chmodSync(target, plan.mode & 0o777); } catch { /* a mode is never a copy failure */ }
1114
+ }
1115
+ } catch (err) {
1116
+ return failed(`cannot copy ${JSON.stringify(plan.rel)}: ${(err && err.message) || err}`);
1117
+ }
1118
+ landed.push(plan.rel);
1119
+ }
1120
+ return { ok: true };
1121
+ }
1122
+
1123
+ // Data-preservation proof, read back from disk: destination-only files are
1124
+ // byte-identical, every planned effect landed, and no path that was not
1125
+ // selected changed.
1126
+ async function verifyTransfer({ plans, destOnly, snapshot, selectedRels, toAbs, git, runAsync, fsImpl }) {
1127
+ const problems = [];
1128
+ const baseReal = checkoutReal(toAbs, fsImpl);
1129
+ for (const rel of destOnly) {
1130
+ if (snapshot.get(rel) !== fingerprintFile(path.resolve(toAbs, rel), fsImpl)) {
1131
+ problems.push(`${JSON.stringify(rel)} (a destination-only file changed)`);
1132
+ }
1133
+ }
1134
+ for (const plan of plans) {
1135
+ if (plan.kind === 'patch') continue;
1136
+ const expected = plan.kind === 'copy' ? fingerprintFile(plan.from, fsImpl) : fingerprintBytes(plan.bytes);
1137
+ const landed = fingerprintFile(path.resolve(toAbs, plan.rel), fsImpl);
1138
+ if (expected === null || landed !== expected) {
1139
+ problems.push(`${JSON.stringify(plan.rel)} (the copied bytes are not on disk)`);
1140
+ }
1141
+ // Last line of defence: a write that somehow left the checkout must be
1142
+ // reported loudly, never passed off as a normal copy.
1143
+ let real = null;
1144
+ try {
1145
+ real = fsImpl.realpathSync(path.resolve(toAbs, plan.rel));
1146
+ } catch {
1147
+ real = null;
1148
+ }
1149
+ if (real === null || !isInsideOrEqual(baseReal, real)) {
1150
+ problems.push(`${JSON.stringify(plan.rel)} (written outside the destination checkout)`);
1151
+ }
1152
+ }
1153
+ const after = await summarizeChanges(toAbs, { fs: fsImpl, git, runAsync });
1154
+ if (!after.ok) {
1155
+ return { ok: false, error: `cannot copy changes: the copy landed but the destination could not be re-read (${after.error}; inspect the destination before retrying)` };
1156
+ }
1157
+ const allowed = new Set([...destOnly, ...selectedRels]);
1158
+ const unexpected = after.entries.map((entry) => entry.rel).filter((rel) => !allowed.has(rel));
1159
+ if (unexpected.length > 0) {
1160
+ problems.push(`${unexpected.map((rel) => JSON.stringify(rel)).join(', ')} (changed without being selected)`);
1161
+ }
1162
+ for (const plan of plans) {
1163
+ if (plan.kind !== 'patch') continue;
1164
+ for (const rel of plan.rels) {
1165
+ if (!after.entries.some((entry) => entry.rel === rel || entry.oldRel === rel)) {
1166
+ problems.push(`${JSON.stringify(rel)} (the patch did not land)`);
1167
+ }
1168
+ }
1169
+ }
1170
+ if (problems.length > 0) {
1171
+ return { ok: false, error: `cannot copy changes: the copy could not be verified: ${problems.join('; ')}; inspect the destination before retrying` };
1172
+ }
1173
+ return { ok: true };
1174
+ }
1175
+
1176
+ // Plan, apply and verify in one place: a refused plan writes nothing.
1177
+ async function transferSelection({ tracked, untracked, destOnly, selectedRels, fromAbs, toAbs, git, runAsync, fsImpl }) {
1178
+ let tmpDir = null;
1179
+ try {
1180
+ tmpDir = fsImpl.mkdtempSync(path.join(os.tmpdir(), 'worktree-changes-'));
1181
+ } catch (err) {
1182
+ return { ok: false, error: `cannot copy changes: cannot create a scratch directory (${(err && err.message) || err}; nothing was copied)` };
1183
+ }
1184
+ try {
1185
+ const planned = await planTransfer({ tracked, untracked, fromAbs, toAbs, runAsync, fsImpl, tmpDir });
1186
+ if (!planned.ok) return planned;
1187
+ if (planned.plans.length === 0) return { ok: true };
1188
+ const snapshot = new Map();
1189
+ for (const rel of destOnly) snapshot.set(rel, fingerprintFile(path.resolve(toAbs, rel), fsImpl));
1190
+ const baseReal = checkoutReal(toAbs, fsImpl);
1191
+ const applied = await applyPlans({ plans: planned.plans, toAbs, baseReal, runAsync, fsImpl, tmpDir });
1192
+ if (!applied.ok) return applied;
1193
+ return await verifyTransfer({
1194
+ plans: planned.plans, destOnly, snapshot, selectedRels, toAbs, git, runAsync, fsImpl,
1195
+ });
1196
+ } finally {
1197
+ try { fsImpl.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
1198
+ }
1199
+ }
1200
+
1201
+ // Copy working-tree changes from one checkout to another of the SAME
1202
+ // repository. Never mutates the source or either index: tracked changes
1203
+ // travel through a file patch applied to the working tree only
1204
+ // (`git apply`, never `--index`), untracked files are copied byte for
1205
+ // byte, and the source checkout is only read. Snapshot preflight is
1206
+ // revalidated immediately before the write; a drifted snapshot, a
1207
+ // destination overlap on any selected path, or a failed dry-run rejects
1208
+ // with ZERO destination mutation. Unsupported shapes (for example a
1209
+ // selection that cannot be expressed as a patch plus file copies)
1210
+ // fail BEFORE any write and say so. Returns
1211
+ // {ok, copied:[rel], files, added, deleted} or {ok:false, error}.
1212
+ async function copyChanges({ from, to, selection, selectMode, opts } = {}, inject = {}) {
1213
+ const fsImpl = inject.fs || fs;
1214
+ const git = inject.git || (() => { try { return require('./worktree-git.js'); } catch { return null; } })();
1215
+ if (!git || typeof git.repoIdentity !== 'function') {
1216
+ return { ok: false, error: 'git helper is unavailable' };
1217
+ }
1218
+ if (typeof from !== 'string' || from.length === 0) return { ok: false, error: 'copy source is empty' };
1219
+ if (typeof to !== 'string' || to.length === 0) return { ok: false, error: 'copy destination is empty' };
1220
+ const runAsync = inject.runAsync || ((bin, args, cwd) => defaultRunAsync(bin, args, cwd, COPY_TIMEOUT_MS));
1221
+ let fromAbs = path.resolve(from);
1222
+ let toAbs = path.resolve(to);
1223
+ // Same-repository proof: linked worktrees each report their own root
1224
+ // from `rev-parse --show-toplevel`, so the shared git common dir is
1225
+ // the identity that proves two checkouts belong to one repository.
1226
+ let fromCommon;
1227
+ let toCommon;
1228
+ try {
1229
+ fromCommon = path.resolve(repoCommonDir(fromAbs, git));
1230
+ toCommon = path.resolve(repoCommonDir(toAbs, git));
1231
+ } catch (err) {
1232
+ return { ok: false, error: `cannot copy changes: ${(err && err.message) || err}` };
1233
+ }
1234
+ if (fromCommon !== toCommon) {
1235
+ return { ok: false, error: `cannot copy changes: ${fromAbs} and ${toAbs} belong to different repositories (nothing was copied)` };
1236
+ }
1237
+ // Resolve both checkouts through their realpaths when available so a
1238
+ // symlinked spelling of the same checkout compares equal; a missing
1239
+ // destination is refused (never created by the copy).
1240
+ try {
1241
+ const st = fsImpl.statSync(toAbs);
1242
+ if (!st.isDirectory()) return { ok: false, error: `cannot copy changes: destination is not a directory: ${toAbs} (nothing was copied)` };
1243
+ } catch (err) {
1244
+ return { ok: false, error: `cannot copy changes: destination is missing: ${toAbs} (nothing was copied)` };
1245
+ }
1246
+ // Normalize the selection: caller passes selectMode/paths or the raw
1247
+ // tool value in `selection`.
1248
+ let mode = selectMode;
1249
+ let paths = [];
1250
+ if (mode === undefined) {
1251
+ const normalized = normalizeChangesSelection(selection);
1252
+ if (!normalized.ok) return { ok: false, error: normalized.error };
1253
+ mode = normalized.mode;
1254
+ paths = normalized.paths;
1255
+ } else if (Array.isArray(selection)) {
1256
+ paths = selection;
1257
+ }
1258
+ if (mode === 'none') return { ok: true, copied: [], files: 0, added: 0, deleted: 0 };
1259
+ const source = await summarizeChanges(fromAbs, { fs: fsImpl, git, runAsync });
1260
+ if (!source.ok) return { ok: false, error: `${source.error} (nothing was copied)` };
1261
+ fromAbs = source.checkout;
1262
+ const matched = matchSelection(source, mode, paths);
1263
+ if (!matched.ok) return { ok: false, error: matched.error };
1264
+ if (matched.entries.length === 0) return { ok: true, copied: [], files: 0, added: 0, deleted: 0 };
1265
+ // Containment on every selected path before any destination read.
1266
+ for (const entry of matched.entries) {
1267
+ if (!isInsideOrEqual(fromAbs, entry.path) || isGitInternal(entry.rel)) {
1268
+ return { ok: false, error: `refusing to copy ${JSON.stringify(entry.rel)}: path escapes the source checkout (nothing was copied)` };
1269
+ }
1270
+ try {
1271
+ const st = fsImpl.lstatSync(entry.path);
1272
+ if (st.isSymbolicLink()) {
1273
+ const real = fsImpl.realpathSync(entry.path);
1274
+ if (!isInsideOrEqual(fromAbs, real)) {
1275
+ return { ok: false, error: `refusing to copy ${JSON.stringify(entry.rel)}: symlink escapes the source checkout (nothing was copied)` };
1276
+ }
1277
+ }
1278
+ } catch (err) {
1279
+ // "Nothing behind the path" is a DELETION, and only the two codes that
1280
+ // mean exactly that are read as one: the patch leg carries the deletion,
1281
+ // so only the lexical check applies. Any other lstat failure (a
1282
+ // permission-denied ancestor, a symlink loop) says nothing about whether
1283
+ // the file is there, so it is refused rather than quietly dropped from
1284
+ // the copy — and it is never reported as a deletion or as unreadable.
1285
+ const code = err && err.code;
1286
+ const absent = code === 'ENOENT' || code === 'ENOTDIR';
1287
+ if (!entry.untracked && absent) continue;
1288
+ if (entry.untracked && absent) {
1289
+ return { ok: false, error: `cannot copy ${JSON.stringify(entry.rel)}: the source file is gone (it was deleted after the selection was made), so there is nothing to copy (nothing was copied)` };
1290
+ }
1291
+ return {
1292
+ ok: false,
1293
+ error: `cannot copy ${JSON.stringify(entry.rel)}: the source file could not be inspected (${(err && err.message) || err}), so it is never treated as a deletion (nothing was copied)`,
1294
+ };
1295
+ }
1296
+ }
1297
+ // Destination overlap preflight: any selected relative path that
1298
+ // already carries destination modifications rejects with zero
1299
+ // mutation. Compared by checkout-relative path (both checkouts share
1300
+ // the repository, so rel paths line up).
1301
+ const dest = await summarizeChanges(toAbs, { fs: fsImpl, git, runAsync });
1302
+ if (!dest.ok) return { ok: false, error: `${dest.error} (nothing was copied)` };
1303
+ toAbs = dest.checkout;
1304
+ const destRels = new Set(dest.entries.map((entry) => entry.rel));
1305
+ const overlapping = matched.entries.filter((entry) => destRels.has(entry.rel)).map((entry) => entry.rel);
1306
+ if (overlapping.length > 0) {
1307
+ return { ok: false, error: `cannot copy changes: destination already modifies ${overlapping.map((r) => JSON.stringify(r)).join(', ')} (nothing was copied, warped or switched)` };
1308
+ }
1309
+ const tracked = matched.entries.filter((entry) => !entry.untracked);
1310
+ const untracked = matched.entries.filter((entry) => entry.untracked);
1311
+ // Untracked leg preflight: the destination must not already hold a
1312
+ // DIFFERENT file at that path — not even a clean tracked one when the
1313
+ // source base differs. Identical bytes skip quietly. A path the destination
1314
+ // holds as anything OTHER than a regular file (a dangling symbolic link
1315
+ // included) is refused: a byte copy would follow it out of the checkout.
1316
+ for (const entry of untracked) {
1317
+ const destPath = path.resolve(toAbs, entry.rel);
1318
+ if (!isInsideOrEqual(toAbs, destPath) || isGitInternal(entry.rel)) {
1319
+ return { ok: false, error: `refusing to copy ${JSON.stringify(entry.rel)}: path escapes the destination checkout (nothing was copied)` };
1320
+ }
1321
+ const obstruction = destObstruction(destPath, fsImpl);
1322
+ if (obstruction !== null) {
1323
+ return { ok: false, error: `cannot copy ${JSON.stringify(entry.rel)}: the destination has ${obstruction} at that path, and a copy never writes through it (nothing was copied)` };
1324
+ }
1325
+ const destState = readWorktreeFile(destPath, fsImpl);
1326
+ if (destState.kind === 'unreadable') {
1327
+ return { ok: false, error: `cannot copy ${JSON.stringify(entry.rel)}: the destination's file there cannot be read, so it cannot be proven identical (nothing was copied)` };
1328
+ }
1329
+ if (destState.exists && !destState.bytes.equals(fsImpl.readFileSync(entry.path))) {
1330
+ return { ok: false, error: `cannot copy ${JSON.stringify(entry.rel)}: the destination already has a file there (nothing was copied)` };
1331
+ }
1332
+ }
1333
+ // Revalidate the snapshot immediately before the write: the source
1334
+ // must still hold exactly the snapshotted selection, and the
1335
+ // destination must still be clear on those paths. Any drift rejects
1336
+ // with zero mutation.
1337
+ const reSource = await summarizeChanges(fromAbs, { fs: fsImpl, git, runAsync });
1338
+ if (!reSource.ok) return { ok: false, error: `${reSource.error} (nothing was copied)` };
1339
+ const reMatched = matchSelection(reSource, mode, paths);
1340
+ if (!reMatched.ok) return { ok: false, error: reMatched.error };
1341
+ const before = new Map(matched.entries.map((entry) => [entry.rel, `${entry.added}/${entry.deleted}/${entry.status}`]));
1342
+ const after = new Map(reMatched.entries.map((entry) => [entry.rel, `${entry.added}/${entry.deleted}/${entry.status}`]));
1343
+ if (before.size !== after.size || [...before.keys()].some((key) => before.get(key) !== after.get(key))) {
1344
+ return { ok: false, error: 'cannot copy changes: the source changed during confirmation (nothing was copied)' };
1345
+ }
1346
+ const reDest = await summarizeChanges(toAbs, { fs: fsImpl, git, runAsync });
1347
+ if (!reDest.ok) return { ok: false, error: `${reDest.error} (nothing was copied)` };
1348
+ const reDestRels = new Set(reDest.entries.map((entry) => entry.rel));
1349
+ const reOverlapping = matched.entries.filter((entry) => reDestRels.has(entry.rel)).map((entry) => entry.rel);
1350
+ if (reOverlapping.length > 0) {
1351
+ return { ok: false, error: `cannot copy changes: destination already modifies ${reOverlapping.map((r) => JSON.stringify(r)).join(', ')} (nothing was copied, warped or switched)` };
1352
+ }
1353
+ // Both legs travel through one planner: every patch dry-run, every
1354
+ // divergent-base merge and every untracked byte comparison happens in a
1355
+ // system temp directory BEFORE the first write, so a refusal mutates nothing;
1356
+ // and the applied result is verified afterwards (destination-only files
1357
+ // unchanged, planned effects landed, nothing unselected touched).
1358
+ const transfer = await transferSelection({
1359
+ tracked,
1360
+ untracked,
1361
+ destOnly: [...reDestRels],
1362
+ selectedRels: matched.entries.map((entry) => entry.rel),
1363
+ fromAbs,
1364
+ toAbs,
1365
+ git,
1366
+ runAsync,
1367
+ fsImpl,
1368
+ });
1369
+ if (!transfer.ok) return transfer;
1370
+ let added = 0;
1371
+ let deleted = 0;
1372
+ for (const entry of matched.entries) {
1373
+ added += entry.added;
1374
+ deleted += entry.deleted;
1375
+ }
1376
+ return {
1377
+ ok: true,
1378
+ copied: matched.entries.map((entry) => entry.rel),
1379
+ files: matched.entries.length,
1380
+ added,
1381
+ deleted,
1382
+ };
1383
+ }
1384
+
1385
+ module.exports = {
1386
+ SUMMARY_TIMEOUT_MS,
1387
+ COPY_TIMEOUT_MS,
1388
+ parseNumstatZ,
1389
+ summarizeChanges,
1390
+ formatChangeSummary,
1391
+ normalizeChangesSelection,
1392
+ matchSelection,
1393
+ copyChanges,
1394
+ isInsideOrEqual,
1395
+ };