@1yefuwang1/dsh-worktrees 0.0.0-stage → 0.2.13

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/dist/git.js ADDED
@@ -0,0 +1,806 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { abortIfRequested, WorktreeError } from './errors.js';
6
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
7
+ const oidPattern = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u;
8
+ const overrides = [
9
+ 'core.hooksPath=/dev/null', 'core.fsmonitor=false', 'core.untrackedCache=false',
10
+ 'core.attributesFile=/dev/null', 'core.autocrlf=false', 'core.safecrlf=false',
11
+ 'core.fileMode=true', 'core.symlinks=true', 'core.quotePath=true', 'core.askPass=',
12
+ 'core.ignoreStat=false', 'core.ignorecase=false', 'core.checkStat=default', 'core.trustctime=true',
13
+ 'diff.external=', 'diff.ignoreSubmodules=none', 'submodule.recurse=false',
14
+ 'fetch.recurseSubmodules=false', 'fetch.writeCommitGraph=false',
15
+ 'maintenance.auto=false', 'gc.auto=0', 'gc.autoDetach=false',
16
+ 'protocol.allow=never', 'protocol.file.allow=always', 'protocol.https.allow=always',
17
+ 'protocol.ssh.allow=always', 'protocol.ext.allow=never', 'credential.interactive=false',
18
+ 'core.pager=cat', 'color.ui=false', 'advice.detachedHead=false',
19
+ ];
20
+ const selectors = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM', 'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM', 'GIT_EXEC_PATH', 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_SSH_VARIANT', 'GIT_ASKPASS', 'GIT_TEMPLATE_DIR', 'GIT_ATTR_SOURCE', 'GIT_ATTR_NOSYSTEM', 'GIT_EXTERNAL_DIFF', 'GIT_DIFF_OPTS', 'GIT_NAMESPACE', 'GIT_REPLACE_REF_BASE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_SHALLOW_FILE', 'GIT_TRACE', 'GIT_TRACE_PACKET', 'GIT_TRACE_CURL', 'GIT_TRACE_SETUP'];
21
+ function checkoutProgress(input, stage) {
22
+ // Observation is not admission. Also absorb rejected promises from JS listeners.
23
+ try {
24
+ void Promise.resolve(input.onProgress?.(stage)).catch(() => undefined);
25
+ }
26
+ catch { /* observer only */ }
27
+ }
28
+ function fail(code, message, details) { throw new WorktreeError(code, message, details); }
29
+ function text(data) {
30
+ try {
31
+ return utf8.decode(data);
32
+ }
33
+ catch {
34
+ return fail('UNSUPPORTED_ENCODING', 'Git paths or output are not valid UTF-8.');
35
+ }
36
+ }
37
+ function nul(data) {
38
+ if (!data.length)
39
+ return [];
40
+ if (data[data.length - 1] !== 0)
41
+ fail('TRUNCATED_OUTPUT', 'Git returned an incomplete NUL-delimited result.');
42
+ return text(data).slice(0, -1).split('\0');
43
+ }
44
+ function oid(value) { if (!oidPattern.test(value))
45
+ fail('INVALID_GIT_OUTPUT', 'Git returned an invalid object identity.'); return value; }
46
+ function inside(root, candidate) { const relative = path.relative(root, candidate); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); }
47
+ function validPath(value) {
48
+ if (!value || value.includes('\0') || (path.sep === '\\' && value.includes('\\')) || path.posix.isAbsolute(value) || value.split('/').some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git'))
49
+ fail('UNSAFE_PATH', 'An unsupported or unsafe repository path was encountered.');
50
+ }
51
+ function hash(data) { return createHash('sha256').update(data).digest('hex'); }
52
+ function line(data) { const value = text(data); if (!value.endsWith('\n'))
53
+ fail('TRUNCATED_OUTPUT', 'Git returned an incomplete result.'); return value.slice(0, -1); }
54
+ /** Strict Git engine. Its executor must enforce byte bounds, deadlines and caller sandbox access. */
55
+ export class GitOperations {
56
+ execute;
57
+ config;
58
+ authorize;
59
+ constructor(execute, config, authorize = () => undefined) {
60
+ this.execute = execute;
61
+ this.config = config;
62
+ this.authorize = authorize;
63
+ }
64
+ env(extra) {
65
+ const result = {};
66
+ for (const name of [...Object.keys(process.env).filter(key => key.startsWith('GIT_')), ...selectors])
67
+ result[name] = undefined;
68
+ result.SSH_ASKPASS = undefined;
69
+ result.SSH_ASKPASS_REQUIRE = 'never';
70
+ result.GIT_TERMINAL_PROMPT = '0';
71
+ result.GCM_INTERACTIVE = 'Never';
72
+ result.GIT_OPTIONAL_LOCKS = '0';
73
+ result.GIT_NO_REPLACE_OBJECTS = '1';
74
+ result.GIT_ATTR_NOSYSTEM = '1';
75
+ result.LC_ALL = 'C.UTF-8';
76
+ return { ...result, ...extra };
77
+ }
78
+ async run(cwd, args, signal, options = {}) {
79
+ abortIfRequested(signal);
80
+ const maxBytes = options.maxBytes ?? this.config.maxRefBytes;
81
+ try {
82
+ const disabledDrivers = [];
83
+ if (args[0] !== 'config') {
84
+ const drivers = await this.run(cwd, ['config', '--null', '--get-regexp', '^(filter\\..*\\.(process|clean|smudge|required)|diff\\..*\\.(command|textconv))$'], signal, { allowed: [0, 1], env: options.env });
85
+ for (const row of nul(drivers.stdout)) {
86
+ const key = row.slice(0, row.indexOf('\n'));
87
+ if (!key || /[\0\r\n]/u.test(key))
88
+ fail('UNSUPPORTED_CONFIG', 'A configured Git driver has an unsupported name.');
89
+ disabledDrivers.push(`${key}=${key.endsWith('.required') ? 'false' : ''}`);
90
+ }
91
+ }
92
+ this.authorize();
93
+ const result = await this.execute({ cwd, args: ['--no-pager', ...[...overrides, ...disabledDrivers].flatMap(value => ['-c', value]), ...args], env: this.env(options.env), signal, stdin: options.stdin, timeoutMs: options.fetch ? this.config.fetchTimeoutMs : this.config.commandTimeoutMs, maxBytes, allowedExitCodes: options.allowed ?? [0] });
94
+ if (result.stdout.length + result.stderr.length > maxBytes)
95
+ fail('OUTPUT_LIMIT', 'Git output exceeded the configured byte limit.');
96
+ if (!(options.allowed ?? [0]).includes(result.exitCode))
97
+ fail('GIT_FAILED', 'Git could not complete the requested operation.', { exitCode: result.exitCode });
98
+ return result;
99
+ }
100
+ catch (error) {
101
+ if (error instanceof WorktreeError)
102
+ throw error;
103
+ abortIfRequested(signal);
104
+ // Executor diagnostics may contain remote URLs, credential-helper output, or local secrets.
105
+ throw new WorktreeError('GIT_FAILED', 'Git could not complete the requested operation within its limits.');
106
+ }
107
+ }
108
+ async configValues(root, key, signal) {
109
+ const result = await this.run(root, ['config', '--null', '--get-all', key], signal, { allowed: [0, 1] });
110
+ return result.exitCode === 1 ? [] : nul(result.stdout);
111
+ }
112
+ async supported(root, signal) {
113
+ for (const key of ['core.sparseCheckout', 'core.sparseCheckoutCone', 'extensions.partialClone', 'extensions.worktreeConfig', 'extensions.refStorage']) {
114
+ const values = await this.configValues(root, key, signal);
115
+ if (values.some(value => !['false', 'no', 'off', '0'].includes(value.toLowerCase())))
116
+ fail('UNSUPPORTED_REPOSITORY', 'Sparse, partial or unsupported repository mappings are not supported.');
117
+ }
118
+ const promisor = await this.run(root, ['config', '--null', '--get-regexp', '^remote\\..*\\.promisor$'], signal, { allowed: [0, 1] });
119
+ if (promisor.exitCode === 0 && nul(promisor.stdout).some(value => !['false', 'no', 'off', '0'].includes(value.slice(value.indexOf('\n') + 1).toLowerCase())))
120
+ fail('UNSUPPORTED_REPOSITORY', 'Partial repositories are not supported.');
121
+ const shallow = line((await this.run(root, ['rev-parse', '--is-shallow-repository'], signal)).stdout);
122
+ if (shallow !== 'false')
123
+ fail('UNSUPPORTED_REPOSITORY', 'Shallow repositories are not supported.');
124
+ const bare = line((await this.run(root, ['rev-parse', '--is-bare-repository'], signal)).stdout);
125
+ if (bare !== 'false')
126
+ fail('UNSUPPORTED_REPOSITORY', 'Bare repositories cannot be used as source checkouts.');
127
+ const coreWorkTree = await this.configValues(root, 'core.worktree', signal);
128
+ if (coreWorkTree.length)
129
+ fail('UNSUPPORTED_REPOSITORY', 'Custom Git worktree mappings are not supported.');
130
+ }
131
+ async discover(projectPath, signal) {
132
+ const project = await realpath(projectPath).catch(() => fail('MISSING_PATH', 'The selected project directory is unavailable.'));
133
+ if (path.resolve(projectPath) !== project)
134
+ fail('UNSAFE_PATH', 'Symlinked project directory mappings are not supported.');
135
+ if (!(await stat(project)).isDirectory())
136
+ fail('MISSING_PATH', 'The selected project must be a directory.');
137
+ const root = await realpath(line((await this.run(project, ['rev-parse', '--path-format=absolute', '--show-toplevel'], signal)).stdout));
138
+ if (!inside(root, project))
139
+ fail('UNSAFE_PATH', 'The project directory is outside the repository.');
140
+ await this.supported(root, signal);
141
+ const commonDir = await realpath(line((await this.run(root, ['rev-parse', '--path-format=absolute', '--git-common-dir'], signal)).stdout));
142
+ const mapping = await lstat(path.join(root, '.git'));
143
+ if (mapping.isSymbolicLink() || (!mapping.isFile() && !mapping.isDirectory()) || (mapping.isDirectory() && commonDir !== path.join(root, '.git')))
144
+ fail('UNSUPPORTED_REPOSITORY', 'Unsupported Git admin directory mappings are not supported.');
145
+ const headResult = await this.run(root, ['rev-parse', '--verify', 'HEAD^{commit}'], signal, { allowed: [0, 128] });
146
+ if (headResult.exitCode !== 0)
147
+ fail('UNBORN_REPOSITORY', 'The repository does not have a committed HEAD.');
148
+ const branchResult = await this.run(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal, { allowed: [0, 1] });
149
+ return { root, commonDir, projectSubdir: path.relative(root, project).split(path.sep).join('/'), head: oid(line(headResult.stdout)), branch: branchResult.exitCode === 0 ? line(branchResult.stdout) : null };
150
+ }
151
+ async ref(root, value, signal) {
152
+ if (value.includes('\0') || value.includes('\n') || value.startsWith('-'))
153
+ fail('INVALID_REF', 'The selected Git reference is invalid.');
154
+ const result = await this.run(root, ['check-ref-format', value], signal, { allowed: [0, 1] });
155
+ if (result.exitCode !== 0)
156
+ fail('INVALID_REF', 'The selected Git reference is invalid.');
157
+ }
158
+ async remote(root, name, signal) {
159
+ if (!name || name.startsWith('-') || /[\0\r\n]/u.test(name))
160
+ fail('INVALID_REF', 'The remote name is invalid.');
161
+ await this.ref(root, `refs/remotes/${name}/__validate__`, signal);
162
+ const raw = await this.configValues(root, `remote.${name}.url`, signal);
163
+ if (raw.length !== 1 || raw[0]?.includes('\n'))
164
+ fail('UNSUPPORTED_REMOTE', 'The remote must have exactly one unambiguous fetch URL.');
165
+ if ((await this.configValues(root, `remote.${name}.vcs`, signal)).length)
166
+ fail('UNSUPPORTED_REMOTE', 'Executable remote helpers are not supported.');
167
+ const effective = line((await this.run(root, ['remote', 'get-url', '--all', name], signal)).stdout);
168
+ if (!effective || /[\0\r\n]/u.test(effective) || effective.startsWith('-'))
169
+ fail('UNSUPPORTED_REMOTE', 'The remote transport is not supported.');
170
+ let canonical;
171
+ if (/^https:\/\//iu.test(effective)) {
172
+ const url = new URL(effective);
173
+ if (!url.hostname || url.hash)
174
+ fail('UNSUPPORTED_REMOTE', 'The remote transport is not supported.');
175
+ canonical = url.href;
176
+ }
177
+ else if (/^ssh:\/\//iu.test(effective)) {
178
+ const url = new URL(effective);
179
+ if (!url.hostname || url.password || url.hash || url.search)
180
+ fail('UNSUPPORTED_REMOTE', 'The remote transport is not supported.');
181
+ canonical = url.href;
182
+ }
183
+ else if (/^file:\/\//iu.test(effective)) {
184
+ const url = new URL(effective);
185
+ if ((url.hostname && url.hostname !== 'localhost') || url.username || url.password || url.hash || url.search)
186
+ fail('UNSUPPORTED_REMOTE', 'Only canonical local file remotes are supported.');
187
+ canonical = await realpath(decodeURIComponent(url.pathname)).catch(() => fail('REMOTE_UNAVAILABLE', 'The local remote is unavailable.'));
188
+ }
189
+ else if (/^(?:[^\s/:@]+@)?[^\s/:]+:[^\0\r\n]+$/u.test(effective) && !effective.includes('::') && !effective.includes('://')) {
190
+ canonical = effective; // Standard SSH scp syntax, never an executable helper scheme.
191
+ }
192
+ else if (!/^[a-z][a-z0-9+.-]*:/iu.test(effective)) {
193
+ canonical = await realpath(path.resolve(root, effective)).catch(() => fail('REMOTE_UNAVAILABLE', 'The local remote is unavailable.'));
194
+ }
195
+ else
196
+ return fail('UNSUPPORTED_REMOTE', 'Only HTTPS, SSH and local file remotes are supported.');
197
+ return { identity: hash(JSON.stringify({ name, configured: raw[0], canonical })) };
198
+ }
199
+ async remotes(repoRoot, signal) {
200
+ const result = await this.run(repoRoot, ['config', '--null', '--get-regexp', '^remote\\..*\\.url$'], signal, { allowed: [0, 1] });
201
+ if (result.exitCode === 1)
202
+ return [];
203
+ const names = new Set();
204
+ for (const record of nul(result.stdout)) {
205
+ const key = record.slice(0, record.indexOf('\n'));
206
+ if (!key.startsWith('remote.') || !key.endsWith('.url'))
207
+ fail('INVALID_GIT_OUTPUT', 'Git returned an invalid remote entry.');
208
+ names.add(key.slice(7, -4));
209
+ }
210
+ const output = [];
211
+ for (const name of [...names].sort())
212
+ output.push({ name, identity: (await this.remote(repoRoot, name, signal)).identity });
213
+ return output;
214
+ }
215
+ async branches(repoRoot, remote, identity, signal) {
216
+ const selected = await this.remote(repoRoot, remote, signal);
217
+ if (identity && selected.identity !== identity)
218
+ fail('REMOTE_CHANGED', 'The remote identity has changed.');
219
+ const result = await this.run(repoRoot, ['ls-remote', '--symref', '--upload-pack=git-upload-pack', '--', remote, 'HEAD', 'refs/heads/*'], signal, { fetch: true });
220
+ const output = text(result.stdout);
221
+ if (output && !output.endsWith('\n'))
222
+ fail('TRUNCATED_OUTPUT', 'The advertised branch generation was truncated.');
223
+ const branches = [];
224
+ let defaultBranch = null;
225
+ const seen = new Set();
226
+ for (const row of output ? output.slice(0, -1).split('\n') : []) {
227
+ const tab = row.indexOf('\t');
228
+ if (tab < 0)
229
+ fail('INVALID_GIT_OUTPUT', 'The remote advertised an invalid reference.');
230
+ const left = row.slice(0, tab);
231
+ const right = row.slice(tab + 1);
232
+ if (left.startsWith('ref: ')) {
233
+ if (right !== 'HEAD' || !left.slice(5).startsWith('refs/heads/'))
234
+ fail('INVALID_GIT_OUTPUT', 'The remote advertised an unsupported symbolic reference.');
235
+ defaultBranch = left.slice(16);
236
+ }
237
+ else if (right.startsWith('refs/heads/')) {
238
+ await this.ref(repoRoot, right, signal);
239
+ const name = right.slice(11);
240
+ if (seen.has(name))
241
+ fail('INVALID_GIT_OUTPUT', 'The remote advertised duplicate references.');
242
+ seen.add(name);
243
+ branches.push({ name, oid: oid(left) });
244
+ if (branches.length > this.config.maxFiles)
245
+ fail('REF_LIMIT', 'The advertised branch generation exceeds the configured count limit.');
246
+ }
247
+ else if (right === 'HEAD')
248
+ oid(left);
249
+ else
250
+ fail('INVALID_GIT_OUTPUT', 'The remote advertised an unexpected reference.');
251
+ }
252
+ if ((await this.remote(repoRoot, remote, signal)).identity !== selected.identity)
253
+ fail('REMOTE_CHANGED', 'The remote identity changed during branch discovery.');
254
+ return { remote, remoteIdentity: selected.identity, defaultBranch, branches: branches.sort((a, b) => a.name.localeCompare(b.name)), observedAt: Date.now() };
255
+ }
256
+ async destination(root, requested) {
257
+ const absolute = path.resolve(requested);
258
+ const parent = await realpath(path.dirname(absolute)).catch(() => fail('MISSING_PATH', 'The managed checkout parent directory is unavailable.'));
259
+ if (parent !== path.dirname(absolute))
260
+ fail('UNSAFE_PATH', 'The managed checkout destination has symlinked ancestors.');
261
+ if (inside(root, absolute) || inside(absolute, root))
262
+ fail('UNSAFE_PATH', 'Source and managed checkout paths must not be nested.');
263
+ const exists = await lstat(absolute).then(() => true, (error) => { if (error.code !== 'ENOENT')
264
+ throw error; return false; });
265
+ if (exists)
266
+ fail('DESTINATION_EXISTS', 'The managed checkout destination already exists.');
267
+ return absolute;
268
+ }
269
+ async store(root, commonDir) {
270
+ const canonical = await realpath(root);
271
+ if (canonical !== path.resolve(root))
272
+ fail('UNSAFE_PATH', 'The snapshot directory has symlinked ancestors.');
273
+ this.authorize();
274
+ const privateRoot = await mkdtemp(path.join(canonical, 'git-private-'));
275
+ this.authorize();
276
+ await mkdir(path.join(privateRoot, 'objects'), { mode: 0o700 });
277
+ return { root: privateRoot, env: { GIT_INDEX_FILE: path.join(privateRoot, 'index'), GIT_OBJECT_DIRECTORY: path.join(privateRoot, 'objects'), GIT_ALTERNATE_OBJECT_DIRECTORIES: JSON.stringify(path.join(commonDir, 'objects')) } };
278
+ }
279
+ async removeStore(store) {
280
+ const absolute = path.resolve(store.root);
281
+ if (absolute !== store.root || !path.basename(absolute).startsWith('git-private-') || (await realpath(absolute)) !== absolute)
282
+ fail('UNSAFE_PATH', 'Private snapshot cleanup could not verify its owned directory.');
283
+ this.authorize();
284
+ await rm(absolute, { recursive: true, force: false });
285
+ }
286
+ async tree(root, tree, signal, env) {
287
+ const rows = nul((await this.run(root, ['ls-tree', '-rz', '--full-tree', tree], signal, { env })).stdout);
288
+ if (rows.length > this.config.maxFiles)
289
+ fail('FILE_LIMIT', 'The repository exceeds the configured file count limit.');
290
+ return rows.map(row => {
291
+ const tab = row.indexOf('\t');
292
+ const metadata = row.slice(0, tab).split(' ');
293
+ const name = row.slice(tab + 1);
294
+ if (tab < 0 || metadata.length !== 3 || metadata[1] !== 'blob' || !['100644', '100755', '120000'].includes(metadata[0] ?? ''))
295
+ fail('UNSUPPORTED_CHECKOUT', 'Submodules or unsupported tree entries are not supported.');
296
+ validPath(name);
297
+ return { path: name, mode: metadata[0], oid: oid(metadata[2]) };
298
+ });
299
+ }
300
+ async attributes(root, files, signal, env, cached = false) {
301
+ if (!files.length)
302
+ return;
303
+ const result = await this.run(root, ['check-attr', ...(cached ? ['--cached'] : []), '-z', '--stdin', 'filter', 'working-tree-encoding'], signal, { env, stdin: `${files.join('\0')}\0` });
304
+ const records = nul(result.stdout);
305
+ if (records.length !== files.length * 6)
306
+ fail('TRUNCATED_OUTPUT', 'Git returned incomplete attribute results.');
307
+ for (let index = 0; index < records.length; index += 3) {
308
+ if (!['unspecified', 'unset'].includes(records[index + 2] ?? ''))
309
+ fail('UNSUPPORTED_ATTRIBUTES', 'Clean/smudge/LFS filters and working-tree encodings are not supported.');
310
+ }
311
+ }
312
+ async create(input) {
313
+ const { signal } = input;
314
+ const repository = await this.discover(path.join(input.repository.root, input.repository.projectSubdir), signal);
315
+ if (repository.root !== input.repository.root || repository.commonDir !== input.repository.commonDir)
316
+ fail('REPOSITORY_CHANGED', 'The source repository identity changed.');
317
+ const destination = await this.destination(repository.root, input.destination);
318
+ if (!input.remoteBranch || input.remoteBranch.startsWith('-'))
319
+ fail('INVALID_REF', 'The selected branch name is invalid.');
320
+ await this.ref(repository.root, `refs/heads/${input.remoteBranch}`, signal);
321
+ await this.ref(repository.root, `refs/remotes/${input.remote}/${input.remoteBranch}`, signal);
322
+ await this.ref(repository.root, input.baseRef, signal);
323
+ if (!input.baseRef.startsWith('refs/dsh-worktrees/'))
324
+ fail('INVALID_REF', 'The persistent base ref must use the managed worktree namespace.');
325
+ const prior = await this.run(repository.root, ['show-ref', '--verify', '--quiet', input.baseRef], signal, { allowed: [0, 1] });
326
+ if (prior.exitCode === 0)
327
+ fail('BASE_REF_EXISTS', 'The persistent base reference is already in use.');
328
+ const remote = await this.remote(repository.root, input.remote, signal);
329
+ if (remote.identity !== input.remoteIdentity)
330
+ fail('REMOTE_CHANGED', 'The selected remote identity has changed.');
331
+ checkoutProgress(input, 'fetching');
332
+ await this.run(repository.root, ['fetch', '--atomic', '--refmap=', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--no-auto-maintenance', '--no-write-commit-graph', '--no-prune', '--upload-pack=git-upload-pack', '--', input.remote, `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remote}/${input.remoteBranch}`, `refs/heads/${input.remoteBranch}:${input.baseRef}`], signal, { fetch: true });
333
+ if ((await this.remote(repository.root, input.remote, signal)).identity !== remote.identity)
334
+ fail('REMOTE_CHANGED', 'The remote changed during fetch; fetched refs may remain.', { refsMayHaveChanged: true });
335
+ const baseOid = oid(line((await this.run(repository.root, ['rev-parse', '--verify', `${input.baseRef}^{commit}`], signal)).stdout));
336
+ const entries = await this.tree(repository.root, baseOid, signal);
337
+ if (repository.projectSubdir && !entries.some(entry => entry.path.startsWith(`${repository.projectSubdir}/`)))
338
+ fail('PROJECT_SUBDIR_MISSING', 'The project subdirectory is absent from the freshly fetched tree.');
339
+ // Attribute validation uses an isolated index and object store, never the source index.
340
+ const privateStore = await this.store(path.dirname(destination), repository.commonDir);
341
+ try {
342
+ await this.run(repository.root, ['read-tree', baseOid], signal, { env: privateStore.env });
343
+ await this.attributes(repository.root, entries.map(entry => entry.path), signal, privateStore.env, true);
344
+ for (const entry of entries.filter(value => value.mode === '120000')) {
345
+ const target = text((await this.run(repository.root, ['cat-file', 'blob', entry.oid], signal)).stdout);
346
+ if (!target || path.isAbsolute(target) || !inside(destination, path.resolve(destination, path.dirname(entry.path), target)))
347
+ fail('UNSAFE_SYMLINK', 'The fetched tree contains an escaping symlink.');
348
+ }
349
+ }
350
+ finally {
351
+ await this.removeStore(privateStore);
352
+ }
353
+ checkoutProgress(input, 'creating');
354
+ try {
355
+ await this.run(repository.root, ['worktree', 'add', '--detach', '--', destination, baseOid], signal);
356
+ const checkoutRoot = await realpath(destination);
357
+ const effectiveCwd = await realpath(path.join(checkoutRoot, repository.projectSubdir)).catch(() => fail('PROJECT_SUBDIR_MISSING', 'The fetched project directory is unavailable.'));
358
+ if (checkoutRoot !== destination || !inside(checkoutRoot, effectiveCwd))
359
+ fail('UNSAFE_PATH', 'The created checkout has an unsafe project mapping.');
360
+ return { checkoutRoot, effectiveCwd, baseOid, baseRef: input.baseRef, fetchedAt: Date.now() };
361
+ }
362
+ catch (error) {
363
+ throw new WorktreeError(error instanceof WorktreeError ? error.code : 'CREATE_FAILED', 'Checkout creation failed; inspect the managed path before recovery. No checkout was deleted.', { checkoutMayExist: true, refsMayHaveChanged: true });
364
+ }
365
+ }
366
+ async identity(root, expectedCommon, signal) {
367
+ const info = await this.discover(root, signal);
368
+ if (info.root !== root || info.commonDir !== expectedCommon)
369
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The checkout repository identity changed.');
370
+ return info;
371
+ }
372
+ async metadata(file) {
373
+ const info = await lstat(file);
374
+ if (!info.isFile() || info.isSymbolicLink() || info.size > this.config.maxRefBytes)
375
+ fail('CHECKOUT_IDENTITY_CHANGED', 'Git admin metadata is not a bounded ordinary file.');
376
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
377
+ try {
378
+ const data = await handle.readFile();
379
+ if (data.length > this.config.maxRefBytes)
380
+ fail('OUTPUT_LIMIT', 'Git admin metadata exceeds the configured byte limit.');
381
+ return text(data);
382
+ }
383
+ finally {
384
+ await handle.close();
385
+ }
386
+ }
387
+ async registered(info, repositoryRoot, signal) {
388
+ const dotGit = path.join(info.root, '.git');
389
+ const mapping = await lstat(dotGit);
390
+ if (mapping.isDirectory()) {
391
+ if (dotGit !== info.commonDir || await realpath(dotGit) !== dotGit)
392
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The main checkout Git admin mapping changed.');
393
+ }
394
+ else {
395
+ const pointer = await this.metadata(dotGit);
396
+ if (!pointer.startsWith('gitdir: ') || !pointer.endsWith('\n') || pointer.slice(8, -1).includes('\n'))
397
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The checkout Git admin pointer is invalid.');
398
+ const admin = await realpath(path.resolve(info.root, pointer.slice(8, -1)));
399
+ if (path.dirname(admin) !== path.join(info.commonDir, 'worktrees'))
400
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The checkout Git admin directory is not a registered worktree.');
401
+ const backlink = (await this.metadata(path.join(admin, 'gitdir'))).replace(/\n$/u, '');
402
+ if (await realpath(path.resolve(admin, backlink)) !== dotGit)
403
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The Git admin backlink does not identify this checkout.');
404
+ const common = (await this.metadata(path.join(admin, 'commondir'))).replace(/\n$/u, '');
405
+ if (await realpath(path.resolve(admin, common)) !== info.commonDir)
406
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The Git admin common directory changed.');
407
+ }
408
+ const registration = nul((await this.run(repositoryRoot, ['worktree', 'list', '--porcelain', '-z'], signal)).stdout);
409
+ const roots = registration.filter(value => value.startsWith('worktree ')).map(value => value.slice(9));
410
+ if (roots.filter(value => value === info.root).length !== 1)
411
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The checkout is not uniquely registered with its source repository.');
412
+ }
413
+ async verify(record, signal) {
414
+ if (path.resolve(record.checkoutRoot) !== record.checkoutRoot || (await realpath(record.checkoutRoot)) !== record.checkoutRoot)
415
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The managed checkout path is not canonical.');
416
+ const info = await this.identity(record.checkoutRoot, record.commonDir, signal);
417
+ const source = await this.identity(record.repoRoot, record.commonDir, signal);
418
+ if (source.root === info.root || inside(source.root, info.root))
419
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The managed checkout has an unsupported source mapping.');
420
+ const effective = await realpath(path.join(info.root, record.projectSubdir));
421
+ if (effective !== record.effectiveCwd || !inside(info.root, effective))
422
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The managed project directory changed.');
423
+ if (!(await lstat(path.join(info.root, '.git'))).isFile())
424
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The managed checkout must have a Git admin pointer file.');
425
+ await this.registered(info, record.repoRoot, signal);
426
+ await this.ref(record.repoRoot, record.baseRef, signal);
427
+ if (!record.baseRef.startsWith('refs/dsh-worktrees/') || oid(line((await this.run(record.repoRoot, ['rev-parse', '--verify', `${record.baseRef}^{commit}`], signal)).stdout)) !== record.baseOid)
428
+ fail('BASE_CHANGED', 'The pinned checkout base reference changed.');
429
+ return this.status(record.checkoutRoot, signal);
430
+ }
431
+ async statusRaw(root, signal) {
432
+ const result = await this.run(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none'], signal);
433
+ const rows = nul(result.stdout);
434
+ const paths = [];
435
+ let changes = 0;
436
+ let untracked = 0;
437
+ for (let index = 0; index < rows.length; index++) {
438
+ const row = rows[index];
439
+ if (row.length < 4 || row[2] !== ' ')
440
+ fail('INVALID_GIT_OUTPUT', 'Git status returned an invalid record.');
441
+ const code = row.slice(0, 2);
442
+ const file = row.slice(3);
443
+ validPath(file);
444
+ paths.push(file);
445
+ if (code.includes('U') || ['AA', 'DD'].includes(code))
446
+ fail('UNMERGED_CHECKOUT', 'Unmerged files are not supported.');
447
+ if (code === '??')
448
+ untracked++;
449
+ else
450
+ changes++;
451
+ if (/[RC]/u.test(code)) {
452
+ const original = rows[++index];
453
+ if (!original)
454
+ fail('TRUNCATED_OUTPUT', 'Git status returned an incomplete rename.');
455
+ validPath(original);
456
+ paths.push(original);
457
+ }
458
+ }
459
+ if (changes + untracked > this.config.maxFiles)
460
+ fail('FILE_LIMIT', 'Git status exceeds the configured file count limit.');
461
+ const head = oid(line((await this.run(root, ['rev-parse', '--verify', 'HEAD^{commit}'], signal)).stdout));
462
+ const branch = await this.run(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal, { allowed: [0, 1] });
463
+ return { state: { head, branch: branch.exitCode === 0 ? line(branch.stdout) : null, dirty: changes + untracked > 0, changes, untracked }, raw: result.stdout, paths };
464
+ }
465
+ async status(checkoutRoot, signal) { return (await this.statusRaw(checkoutRoot, signal)).state; }
466
+ async createBranch(record, name, signal) {
467
+ await this.verify(record, signal);
468
+ await this.files(record.checkoutRoot, signal);
469
+ await this.ref(record.checkoutRoot, `refs/heads/${name}`, signal);
470
+ if (name.startsWith('-') || name === 'HEAD')
471
+ fail('INVALID_REF', 'The branch name is invalid.');
472
+ await this.run(record.checkoutRoot, ['switch', '--no-track', '-c', name], signal);
473
+ return this.status(record.checkoutRoot, signal);
474
+ }
475
+ /** Rename metadata only, never switch HEAD, force a collision or touch the index/files. */
476
+ async renameBranch(record, expected, name, signal) {
477
+ const before = await this.verify(record, signal);
478
+ if (before.branch !== expected || record.branch !== expected)
479
+ fail('BRANCH_CHANGED', 'The initial worktree branch was changed; automatic naming will not override it.');
480
+ for (const branch of [expected, name]) {
481
+ await this.ref(record.checkoutRoot, `refs/heads/${branch}`, signal);
482
+ if (branch.startsWith('-') || branch === 'HEAD')
483
+ fail('INVALID_REF', 'The branch name is invalid.');
484
+ }
485
+ if (name === expected)
486
+ return before;
487
+ const exists = await this.run(record.checkoutRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${name}`], signal, { allowed: [0, 1] });
488
+ if (exists.exitCode === 0)
489
+ fail('BRANCH_EXISTS', 'The generated branch already exists; automatic naming will not overwrite it.');
490
+ // Revalidate both the registered checkout and expected HEAD immediately before mutation.
491
+ if ((await this.verify(record, signal)).branch !== expected)
492
+ fail('BRANCH_CHANGED', 'The initial worktree branch changed before naming.');
493
+ await this.run(record.checkoutRoot, ['branch', '-m', expected, name], signal);
494
+ const after = await this.verify(record, signal);
495
+ if (after.branch !== name || after.head !== before.head)
496
+ fail('BRANCH_RENAME_UNCERTAIN', 'The branch rename result changed; inspect the checkout before recovery.');
497
+ return after;
498
+ }
499
+ async files(root, signal) {
500
+ await this.supported(root, signal);
501
+ const index = (await this.run(root, ['ls-files', '--stage', '-z'], signal)).stdout;
502
+ for (const entry of nul(index)) {
503
+ const tab = entry.indexOf('\t');
504
+ const mode = entry.slice(0, 6);
505
+ if (tab < 0 || !entry.slice(0, tab).endsWith(' 0') || !['100644', '100755', '120000'].includes(mode))
506
+ fail('UNSUPPORTED_CHECKOUT', 'Unmerged entries, submodules and unsupported index entries are not supported.');
507
+ }
508
+ const flags = nul((await this.run(root, ['ls-files', '-v', '-z'], signal)).stdout);
509
+ if (flags.some(value => value[0] !== 'H'))
510
+ fail('UNSUPPORTED_CHECKOUT', 'Sparse, skip-worktree and assume-unchanged index entries are not supported.');
511
+ const paths = [...new Set(nul((await this.run(root, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], signal)).stdout))].sort();
512
+ if (paths.length > this.config.maxFiles)
513
+ fail('FILE_LIMIT', 'The checkout exceeds the configured file count limit.');
514
+ for (const file of paths)
515
+ validPath(file);
516
+ await this.attributes(root, paths, signal);
517
+ return { paths, index, status: await this.statusRaw(root, signal) };
518
+ }
519
+ async fileData(root, name, remaining) {
520
+ const absolute = path.join(root, name);
521
+ let info;
522
+ try {
523
+ info = await lstat(absolute);
524
+ }
525
+ catch (error) {
526
+ if (error.code === 'ENOENT')
527
+ return { data: Buffer.alloc(0), kind: 'missing', executable: false };
528
+ return fail('UNREADABLE_FILE', 'A checkout file cannot be read.');
529
+ }
530
+ // Never traverse a repository-controlled ancestor symlink.
531
+ const parent = await realpath(path.dirname(absolute)).catch(() => fail('UNREADABLE_FILE', 'A checkout path cannot be read.'));
532
+ if (parent !== path.dirname(absolute) || !inside(root, parent))
533
+ fail('UNSAFE_SYMLINK', 'A checkout path traverses a symlink.');
534
+ if (info.isSymbolicLink()) {
535
+ const { readlink } = await import('node:fs/promises');
536
+ const data = await readlink(absolute, { encoding: 'buffer' });
537
+ text(data);
538
+ if (data.length > remaining)
539
+ fail('SNAPSHOT_LIMIT', 'Checkout contents exceed the configured snapshot byte limit.');
540
+ return { data, kind: 'symlink', executable: false };
541
+ }
542
+ if (!info.isFile())
543
+ fail('UNSUPPORTED_CHECKOUT', 'Special files and nested repositories are not supported.');
544
+ if ((info.mode & 0o444) === 0)
545
+ fail('UNREADABLE_FILE', 'A checkout file is unreadable.');
546
+ if (info.size > remaining)
547
+ fail('SNAPSHOT_LIMIT', 'Checkout contents exceed the configured snapshot byte limit.');
548
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => fail('UNREADABLE_FILE', 'A checkout file cannot be opened safely.'));
549
+ try {
550
+ const data = await handle.readFile();
551
+ if (data.length > remaining)
552
+ fail('SNAPSHOT_LIMIT', 'Checkout contents exceed the configured snapshot byte limit.');
553
+ return { data, kind: 'file', executable: (info.mode & 0o111) !== 0 };
554
+ }
555
+ finally {
556
+ await handle.close();
557
+ }
558
+ }
559
+ async fingerprint(checkoutRoot, signal) {
560
+ const root = await realpath(checkoutRoot);
561
+ const before = await this.files(root, signal);
562
+ const digest = createHash('sha256').update(before.status.state.head).update(before.index).update(before.status.raw);
563
+ let bytes = 0;
564
+ for (const name of before.paths) {
565
+ abortIfRequested(signal);
566
+ const content = await this.fileData(root, name, this.config.maxSnapshotBytes - bytes);
567
+ bytes += content.data.length;
568
+ digest.update(JSON.stringify([name, content.kind, content.executable, content.data.length])).update('\0').update(content.data);
569
+ }
570
+ const after = await this.statusRaw(root, signal);
571
+ if (after.state.head !== before.status.state.head || !after.raw.equals(before.status.raw))
572
+ fail('SOURCE_CHANGED', 'The checkout changed while being fingerprinted.');
573
+ return digest.digest('hex');
574
+ }
575
+ async exactContents(root, entries, paths, signal) {
576
+ const final = new Map(entries.map(entry => [entry.path, entry]));
577
+ let bytes = 0;
578
+ for (const name of paths) {
579
+ abortIfRequested(signal);
580
+ const content = await this.fileData(root, name, this.config.maxSnapshotBytes - bytes);
581
+ bytes += content.data.length;
582
+ const entry = final.get(name);
583
+ if (content.kind === 'missing') {
584
+ if (entry)
585
+ fail('SOURCE_CHANGED', 'A snapshot file disappeared unexpectedly.');
586
+ continue;
587
+ }
588
+ const algorithm = entry?.oid.length === 64 ? 'sha256' : 'sha1';
589
+ const identity = createHash(algorithm).update(`blob ${content.data.length}\0`).update(content.data).digest('hex');
590
+ const mode = content.kind === 'symlink' ? '120000' : content.executable ? '100755' : '100644';
591
+ if (!entry || entry.oid !== identity || entry.mode !== mode)
592
+ fail('UNSUPPORTED_CONTENT_TRANSFORM', 'The Git index does not exactly represent working file bytes and modes; content-transforming attributes are unsupported.');
593
+ }
594
+ const known = new Set(paths);
595
+ if (entries.some(entry => !known.has(entry.path)))
596
+ fail('SOURCE_CHANGED', 'The checkout file set changed during snapshot capture.');
597
+ }
598
+ async ensureBase(source, target, baseOid, signal) {
599
+ oid(baseOid);
600
+ if (source.commonDir !== target.commonDir || source.root === target.root)
601
+ fail('CHECKOUT_IDENTITY_CHANGED', 'Source and target must be distinct checkouts of the same repository.');
602
+ if (target.head !== baseOid)
603
+ fail('TARGET_BASE_CHANGED', 'The target must still be at the recorded base commit.');
604
+ if ((await this.status(target.root, signal)).dirty)
605
+ fail('TARGET_DIRTY', 'The target checkout must be clean.');
606
+ const ancestry = await this.run(source.root, ['merge-base', '--is-ancestor', baseOid, source.head], signal, { allowed: [0, 1] });
607
+ if (ancestry.exitCode !== 0)
608
+ fail('SOURCE_BASE_CHANGED', 'The source is no longer descended from its recorded base.');
609
+ }
610
+ async patchFiles(root, base, finalTree, store, signal) {
611
+ const rows = nul((await this.run(root, ['diff', '--no-ext-diff', '--no-textconv', '--name-status', '-z', '--find-renames', base, finalTree, '--'], signal, { env: store.env })).stdout);
612
+ const binary = new Set();
613
+ const stats = nul((await this.run(root, ['diff', '--no-ext-diff', '--no-textconv', '--numstat', '-z', '--no-renames', base, finalTree, '--'], signal, { env: store.env })).stdout);
614
+ for (const row of stats) {
615
+ const first = row.indexOf('\t');
616
+ const second = row.indexOf('\t', first + 1);
617
+ if (first < 0 || second < 0)
618
+ fail('INVALID_GIT_OUTPUT', 'Git returned invalid diff statistics.');
619
+ if (row.slice(0, first) === '-')
620
+ binary.add(row.slice(second + 1));
621
+ }
622
+ const files = [];
623
+ for (let index = 0; index < rows.length; index++) {
624
+ const status = rows[index];
625
+ const original = rows[++index];
626
+ if (!original || !/^(?:[AMDT]|R[0-9]+)$/u.test(status))
627
+ fail('INVALID_GIT_OUTPUT', 'Git returned invalid changed-file metadata.');
628
+ validPath(original);
629
+ if (status.startsWith('R')) {
630
+ const renamed = rows[++index];
631
+ if (!renamed)
632
+ fail('TRUNCATED_OUTPUT', 'Git returned an incomplete rename.');
633
+ validPath(renamed);
634
+ files.push({ path: renamed, status: `${status}:${original}`, binary: binary.has(original) || binary.has(renamed) });
635
+ }
636
+ else
637
+ files.push({ path: original, status, binary: binary.has(original) });
638
+ }
639
+ if (files.length > this.config.maxFiles)
640
+ fail('FILE_LIMIT', 'The patch exceeds the configured file count limit.');
641
+ return files;
642
+ }
643
+ async collisions(target, files, base) {
644
+ const tracked = new Set(base.map(entry => entry.path));
645
+ for (const file of files) {
646
+ validPath(file.path);
647
+ let current = target;
648
+ const parts = file.path.split('/');
649
+ for (let index = 0; index < parts.length; index++) {
650
+ current = path.join(current, parts[index]);
651
+ const info = await lstat(current).catch((error) => { if (error.code === 'ENOENT')
652
+ return null; throw error; });
653
+ if (!info)
654
+ break;
655
+ if (info.isSymbolicLink())
656
+ fail('TARGET_COLLISION', 'A target patch path collides with a symlink.');
657
+ if (index < parts.length - 1 && !info.isDirectory())
658
+ fail('TARGET_COLLISION', 'A target patch parent collides with an existing file.');
659
+ if (index === parts.length - 1 && (!tracked.has(file.path) || !info.isFile()))
660
+ fail('TARGET_COLLISION', 'A target patch path collides with an existing untracked or ignored entry.');
661
+ }
662
+ }
663
+ }
664
+ async snapshot(input) {
665
+ const { signal } = input;
666
+ const source = await this.discover(input.checkoutRoot, signal);
667
+ const repository = await this.discover(input.repositoryRoot, signal);
668
+ const target = await this.discover(input.targetRoot, signal);
669
+ if (source.root !== input.checkoutRoot || target.root !== input.targetRoot || repository.root !== input.repositoryRoot || repository.commonDir !== source.commonDir)
670
+ fail('CHECKOUT_IDENTITY_CHANGED', 'The snapshot checkout identities changed.');
671
+ await this.registered(source, repository.root, signal);
672
+ await this.registered(target, repository.root, signal);
673
+ await this.ensureBase(source, target, input.baseOid, signal);
674
+ const snapshotRoot = await realpath(input.snapshotRoot);
675
+ const managed = await realpath(path.resolve(this.config.root));
676
+ if (!inside(managed, snapshotRoot))
677
+ fail('UNSAFE_PATH', 'Private snapshots must be inside the authorized managed root.');
678
+ if (inside(source.root, snapshotRoot) || inside(target.root, snapshotRoot) || inside(source.commonDir, snapshotRoot))
679
+ fail('UNSAFE_PATH', 'Private snapshots must live outside Git checkouts and source object storage.');
680
+ const sourceFingerprint = await this.fingerprint(source.root, signal);
681
+ const sourceFiles = await this.files(source.root, signal);
682
+ const base = await this.tree(source.root, input.baseOid, signal);
683
+ const headTree = await this.tree(source.root, source.head, signal);
684
+ const symlinks = new Set([...base, ...headTree].filter(entry => entry.mode === '120000').map(entry => entry.path));
685
+ for (const name of sourceFiles.paths) {
686
+ const info = await lstat(path.join(source.root, name)).catch(() => null);
687
+ if (info?.isSymbolicLink())
688
+ symlinks.add(name);
689
+ }
690
+ const privateStore = await this.store(snapshotRoot, source.commonDir);
691
+ let retained = false;
692
+ try {
693
+ // Copy the real index's semantic entries, not its stat cache or extensions.
694
+ // Staged ignored additions remain tracked; zero stat data forces re-reading even
695
+ // same-size files whose mtimes have been restored. The real index is read only.
696
+ await this.run(source.root, ['read-tree', '--empty'], signal, { env: privateStore.env });
697
+ await this.run(source.root, ['update-index', '-z', '--index-info'], signal, { env: privateStore.env, stdin: sourceFiles.index });
698
+ await this.attributes(source.root, sourceFiles.paths, signal, privateStore.env, true);
699
+ await this.run(source.root, ['add', '-A', '--', '.'], signal, { env: privateStore.env });
700
+ const finalTree = oid(line((await this.run(source.root, ['write-tree'], signal, { env: privateStore.env })).stdout));
701
+ const finalEntries = await this.tree(source.root, finalTree, signal, privateStore.env);
702
+ await this.exactContents(source.root, finalEntries, sourceFiles.paths, signal);
703
+ await this.attributes(source.root, finalEntries.map(entry => entry.path), signal, privateStore.env, true);
704
+ const files = await this.patchFiles(source.root, input.baseOid, finalTree, privateStore, signal);
705
+ for (const file of files) {
706
+ const original = file.status.startsWith('R') ? file.status.slice(file.status.indexOf(':') + 1) : file.path;
707
+ if (symlinks.has(file.path) || symlinks.has(original))
708
+ fail('UNSAFE_SYMLINK', 'Changed symlinks are not supported by handoff snapshots.');
709
+ }
710
+ await this.collisions(target.root, files, base);
711
+ const patch = (await this.run(source.root, ['diff', '--binary', '--full-index', '--no-ext-diff', '--no-textconv', '--find-renames', '--src-prefix=a/', '--dst-prefix=b/', input.baseOid, finalTree, '--'], signal, { env: privateStore.env, maxBytes: this.config.maxSnapshotBytes })).stdout;
712
+ if (await this.fingerprint(source.root, signal) !== sourceFingerprint)
713
+ fail('SOURCE_CHANGED', 'The source changed while the snapshot was created.');
714
+ await this.ensureBase(await this.discover(source.root, signal), await this.discover(target.root, signal), input.baseOid, signal);
715
+ const patchPath = path.join(privateStore.root, 'handoff.patch');
716
+ this.authorize();
717
+ await writeFile(patchPath, patch, { flag: 'wx', mode: 0o600 });
718
+ const preview = { id: randomUUID(), worktreeId: input.worktreeId, sourceSessionId: input.sourceSessionId, sourceRoot: source.root, sourceCommonDir: source.commonDir, sourceHead: source.head, sourceFingerprint, targetRoot: target.root, targetCommonDir: target.commonDir, targetHead: target.head, baseOid: input.baseOid, finalTree, snapshotRoot: privateStore.root, patchPath, patchHash: hash(patch), bytes: patch.length, files, createdAt: Date.now() };
719
+ const manifest = JSON.stringify(preview);
720
+ if (Buffer.byteLength(manifest) > this.config.maxRefBytes)
721
+ fail('OUTPUT_LIMIT', 'The preview metadata exceeds the configured byte limit.');
722
+ this.authorize();
723
+ await writeFile(path.join(privateStore.root, 'manifest.json'), manifest, { flag: 'wx', mode: 0o600 });
724
+ retained = true;
725
+ return preview;
726
+ }
727
+ finally {
728
+ if (!retained)
729
+ await this.removeStore(privateStore);
730
+ }
731
+ }
732
+ async ownedPreview(preview) {
733
+ const root = path.resolve(preview.snapshotRoot);
734
+ const managed = await realpath(path.resolve(this.config.root));
735
+ if (!inside(managed, root))
736
+ fail('INVALID_PREVIEW', 'The preview is outside the authorized managed root.');
737
+ if (root !== preview.snapshotRoot || (await realpath(root)) !== root || !path.basename(root).startsWith('git-private-') || preview.patchPath !== path.join(root, 'handoff.patch'))
738
+ fail('INVALID_PREVIEW', 'The preview patch does not identify an owned snapshot.');
739
+ for (const name of ['handoff.patch', 'manifest.json', 'objects']) {
740
+ if ((await lstat(path.join(root, name))).isSymbolicLink())
741
+ fail('INVALID_PREVIEW', 'The snapshot contains an unsafe path.');
742
+ }
743
+ const manifestInfo = await stat(path.join(root, 'manifest.json'));
744
+ if (manifestInfo.size > this.config.maxRefBytes)
745
+ fail('INVALID_PREVIEW', 'The persisted preview metadata exceeds its limit.');
746
+ const manifest = JSON.parse(text(await readFile(path.join(root, 'manifest.json'))));
747
+ if (JSON.stringify(manifest) !== JSON.stringify(preview))
748
+ fail('INVALID_PREVIEW', 'The persisted preview identity does not match.');
749
+ const info = await stat(preview.patchPath);
750
+ if (!info.isFile() || info.size > this.config.maxSnapshotBytes || info.size !== preview.bytes)
751
+ fail('INVALID_PREVIEW', 'The preview patch size is invalid.');
752
+ const patch = await readFile(preview.patchPath);
753
+ if (hash(patch) !== preview.patchHash)
754
+ fail('INVALID_PREVIEW', 'The preview patch hash changed.');
755
+ return { patch, store: { root, env: { GIT_INDEX_FILE: path.join(root, `verify-${randomUUID()}.index`), GIT_OBJECT_DIRECTORY: path.join(root, 'objects'), GIT_ALTERNATE_OBJECT_DIRECTORIES: JSON.stringify(path.join(preview.sourceCommonDir, 'objects')) } } };
756
+ }
757
+ async apply(preview, record, signal) {
758
+ const owned = await this.ownedPreview(preview);
759
+ await this.verify(record, signal);
760
+ if (preview.worktreeId !== record.id || preview.sourceRoot !== record.checkoutRoot || preview.sourceCommonDir !== record.commonDir || preview.targetCommonDir !== record.commonDir || preview.baseOid !== record.baseOid || preview.targetHead !== record.baseOid)
761
+ fail('INVALID_PREVIEW', 'The preview does not belong to this checkout and target.');
762
+ const source = await this.identity(preview.sourceRoot, record.commonDir, signal);
763
+ const target = await this.identity(preview.targetRoot, record.commonDir, signal);
764
+ await this.registered(target, record.repoRoot, signal);
765
+ await this.ensureBase(source, target, record.baseOid, signal);
766
+ if (source.head !== preview.sourceHead || await this.fingerprint(source.root, signal) !== preview.sourceFingerprint)
767
+ fail('STALE_PREVIEW', 'The source changed since this preview was created.');
768
+ const base = await this.tree(target.root, record.baseOid, signal);
769
+ await this.collisions(target.root, preview.files, base);
770
+ await this.attributes(target.root, [...new Set([...base.map(entry => entry.path), ...preview.files.map(file => file.path)])], signal);
771
+ if (!owned.patch.length)
772
+ return { head: target.head, files: 0 };
773
+ await this.run(target.root, ['apply', '--check', '--binary', '--whitespace=nowarn', '-'], signal, { stdin: owned.patch, maxBytes: this.config.maxSnapshotBytes });
774
+ // Parent holds the source/target idle claims. This is intentionally not a global transaction.
775
+ if (await this.fingerprint(source.root, signal) !== preview.sourceFingerprint)
776
+ fail('STALE_PREVIEW', 'The source changed before handoff.');
777
+ await this.ensureBase(await this.discover(source.root, signal), await this.discover(target.root, signal), record.baseOid, signal);
778
+ let effects = false;
779
+ try {
780
+ effects = true;
781
+ await this.run(target.root, ['apply', '--binary', '--whitespace=nowarn', '-'], signal, { stdin: owned.patch, maxBytes: this.config.maxSnapshotBytes });
782
+ await this.run(target.root, ['read-tree', preview.finalTree], signal, { env: owned.store.env });
783
+ await this.run(target.root, ['add', '-A', '--', '.'], signal, { env: owned.store.env });
784
+ const resultTree = oid(line((await this.run(target.root, ['write-tree'], signal, { env: owned.store.env })).stdout));
785
+ const actual = await this.status(target.root, signal);
786
+ if (actual.head !== record.baseOid || resultTree !== preview.finalTree)
787
+ fail('APPLY_RESULT_MISMATCH', 'The applied target does not match the preview.');
788
+ const expected = await this.tree(target.root, preview.finalTree, signal, owned.store.env);
789
+ await this.exactContents(target.root, expected, expected.map(entry => entry.path), signal);
790
+ return { head: actual.head, files: preview.files.length };
791
+ }
792
+ catch (error) {
793
+ throw new WorktreeError(effects ? 'APPLY_RECOVERY_REQUIRED' : 'APPLY_FAILED', 'Handoff failed after target writes may have begun. No destructive rollback was performed.', { targetMayHaveChanged: effects, causeCode: error instanceof WorktreeError ? error.code : 'GIT_FAILED' });
794
+ }
795
+ finally {
796
+ const index = owned.store.env.GIT_INDEX_FILE;
797
+ if (index && path.dirname(index) === owned.store.root && path.basename(index).startsWith('verify-')) {
798
+ const resolved = path.resolve(index);
799
+ if (resolved !== index)
800
+ fail('UNSAFE_PATH', 'Private verification index cleanup was refused.');
801
+ this.authorize();
802
+ await rm(resolved, { force: true });
803
+ }
804
+ }
805
+ }
806
+ }