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.
- package/README.md +234 -2
- package/extensions/odd-worktree-tools.ts +7593 -0
- package/lib/checkout-facts.js +598 -0
- package/lib/clipboard.js +226 -0
- package/lib/gh-issues.js +1555 -0
- package/lib/issue-preview.js +273 -0
- package/lib/issues-view.js +2274 -0
- package/lib/odd-docs.js +505 -0
- package/lib/odd-markdown.js +413 -0
- package/lib/odd-view.js +990 -0
- package/lib/open-external.js +106 -0
- package/lib/preview-scroll-keys.js +192 -0
- package/lib/session-auto-name.js +292 -0
- package/lib/session-worktree-register.js +62 -0
- package/lib/tui-paint.js +273 -0
- package/lib/view-layout.js +368 -0
- package/lib/warp-create-panel.js +722 -0
- package/lib/warp-create-picker.js +906 -0
- package/lib/warp-delete-confirm.js +465 -0
- package/lib/warp-selector-view.js +1265 -0
- package/lib/worktree-card.js +1117 -0
- package/lib/worktree-changes.js +1395 -0
- package/lib/worktree-close-herdr.js +73 -0
- package/lib/worktree-git.js +1048 -0
- package/lib/worktree-hooks.js +738 -0
- package/lib/worktree-model-result.js +291 -0
- package/lib/worktree-registry.js +886 -0
- package/lib/worktree-session-guard.js +595 -0
- package/lib/worktree-symlinks.js +336 -0
- package/lib/worktree-warp-sessions.js +566 -0
- package/package.json +36 -4
- package/tools/commands.test.js +176 -0
- package/tools/fixtures/gh-api-user.json +1 -0
- package/tools/fixtures/gh-issue-list.json +1 -0
- package/tools/fixtures/gh-issue-view.json +1 -0
- package/tools/fixtures/gh-label-list.json +1 -0
- package/tools/fixtures/gh-repo-view.json +1 -0
- package/tools/fixtures/load-extension.js +66 -0
- package/tools/gh-issues-feedback.test.js +359 -0
- package/tools/gh-issues.test.js +1502 -0
- package/tools/issue-preview.test.js +216 -0
- package/tools/issues-command.test.js +1217 -0
- package/tools/issues-view.test.js +4401 -0
- package/tools/odd-docs.test.js +803 -0
- package/tools/odd-markdown.test.js +236 -0
- package/tools/odd-view.test.js +2328 -0
- package/tools/package-surface.test.js +138 -0
- package/tools/t5-session-identity.test.js +306 -0
- package/tools/warp-create-panel.test.js +185 -0
- package/tools/warp-create-picker.test.js +1357 -0
- package/tools/warp-delete-confirm.test.js +616 -0
- package/tools/warp-selector-view.test.js +1463 -0
- package/tools/worktree-changes.test.js +951 -0
- package/tools/worktree-close-herdr-async.test.js +204 -0
- package/tools/worktree-odd-migration.test.js +224 -0
- package/tools/worktree-registry-cache.test.js +163 -0
- package/tools/worktree-registry-odd.test.js +231 -0
- package/tools/worktree-registry.test.js +244 -0
- package/tools/worktree-session-guard.test.js +507 -0
- 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
|
+
};
|