gent-cli 14.0.0 → 20.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.
@@ -0,0 +1,676 @@
1
+ /**
2
+ * ============================================================================
3
+ * Worktree - status, tree building and recoverable checkout
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * The one layer that changes files on disk. checkout, merge, reset, stash
8
+ * and undo all route through it so they share the same preflight, the same
9
+ * path-safety rules and the same recovery record.
10
+ *
11
+ * PREFLIGHT BEFORE ANY WRITE:
12
+ * staged and unstaged changes that would be lost, untracked files that would
13
+ * be overwritten, unsafe destinations, and unsupported attributes are all
14
+ * detected first. If preflight fails, nothing has been touched.
15
+ *
16
+ * RECOVERY, NOT ATOMICITY:
17
+ * A multi-file checkout is not one filesystem transaction and pretending
18
+ * otherwise would be a lie. Instead the plan is written to
19
+ * <gitdir>/gent/checkout-plan.json before the first write and removed only after
20
+ * index/ref publication. An interrupted operation can be rolled back if
21
+ * no intervening file, index or HEAD changes invalidate its checkpoint.
22
+ *
23
+ * PATH SAFETY:
24
+ * Destinations are rejected when they escape the worktree, name a metadata
25
+ * directory, traverse a symlinked parent, or collide on a case-insensitive
26
+ * filesystem.
27
+ * ============================================================================
28
+ */
29
+
30
+ const fs = require('fs').promises;
31
+ const path = require('path');
32
+
33
+ const { MODE, serializeTree, modeToType } = require('./git-objects');
34
+ const { GitIndex, IndexEntry, modeFromStat } = require('./git-index');
35
+ const { IgnoreMatcher, walkWorktree } = require('./ignore');
36
+ const { AttributesMatcher } = require('./attributes');
37
+ const { writeAtomic, readFileOrNull, withLock } = require('./lockfile');
38
+ const { UnsupportedFeatureError, feature } = require('./feature-support');
39
+
40
+ const PLAN_FILE = 'checkout-plan.json';
41
+
42
+ /** Names that must never appear as a path component in a checkout target. */
43
+ const FORBIDDEN_COMPONENTS = new Set(['.', '..', '.git', '.gent']);
44
+ /** Windows device names; rejected everywhere so repositories stay portable. */
45
+ const RESERVED_WINDOWS_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
46
+
47
+ class WorktreeError extends Error {
48
+ constructor(message, code) {
49
+ super(message);
50
+ this.name = 'WorktreeError';
51
+ this.code = code || 'GENT_WORKTREE';
52
+ }
53
+ }
54
+
55
+ /** Raised by preflight; carries the paths so the CLI can list them. */
56
+ class CheckoutBlocked extends Error {
57
+ /**
58
+ * @param {Array<{path: String, reason: String}>} blockers
59
+ */
60
+ constructor(blockers) {
61
+ super(
62
+ `your local changes would be overwritten:\n` +
63
+ blockers.map(b => ` ${b.path} (${b.reason})`).join('\n') +
64
+ `\nCommit, stash or discard them first.`
65
+ );
66
+ this.name = 'CheckoutBlocked';
67
+ this.code = 'GENT_CHECKOUT_BLOCKED';
68
+ this.blockers = blockers;
69
+ }
70
+ }
71
+
72
+ // ─── Trees ───────────────────────────────────────────────
73
+
74
+ /**
75
+ * Flatten a tree into path -> {mode, oid}, descending into subtrees.
76
+ * @param {Object} repo
77
+ * @param {String|null} treeOid
78
+ * @returns {Promise<Map<String, {mode: Number, oid: String}>>}
79
+ */
80
+ async function readTreeRecursive(repo, treeOid) {
81
+ const result = new Map();
82
+ if (!treeOid) return result;
83
+
84
+ const stack = [[treeOid, '']];
85
+ while (stack.length) {
86
+ const [oid, prefix] = stack.pop();
87
+ for (const entry of await repo.objects.readTree(oid)) {
88
+ const full = prefix ? `${prefix}/${entry.name}` : entry.name;
89
+ if (entry.mode === MODE.TREE) {
90
+ stack.push([entry.oid, full]);
91
+ } else {
92
+ result.set(full, { mode: entry.mode, oid: entry.oid });
93
+ }
94
+ }
95
+ }
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Build nested tree objects from the stage-0 index entries and store them.
101
+ * @param {Object} repo
102
+ * @param {GitIndex} index
103
+ * @returns {Promise<String>} root tree oid
104
+ */
105
+ async function buildTreeFromIndex(repo, index) {
106
+ const staged = index.staged();
107
+ if (index.hasConflicts()) {
108
+ throw new WorktreeError('cannot build a tree while the index has unresolved conflicts', 'GENT_UNMERGED');
109
+ }
110
+
111
+ /** Directory node: name -> node | leaf */
112
+ const root = new Map();
113
+ for (const entry of staged) {
114
+ const parts = entry.path.split('/');
115
+ let node = root;
116
+ for (let i = 0; i < parts.length - 1; i++) {
117
+ if (!node.has(parts[i])) node.set(parts[i], new Map());
118
+ const child = node.get(parts[i]);
119
+ if (!(child instanceof Map)) {
120
+ throw new WorktreeError(`index has both a file and a directory named '${parts.slice(0, i + 1).join('/')}'`);
121
+ }
122
+ node = child;
123
+ }
124
+ node.set(parts[parts.length - 1], { mode: entry.mode, oid: entry.oid });
125
+ }
126
+
127
+ async function store(node) {
128
+ const entries = [];
129
+ for (const [name, child] of node) {
130
+ if (child instanceof Map) {
131
+ entries.push({ mode: MODE.TREE, name, oid: await store(child) });
132
+ } else {
133
+ entries.push({ mode: child.mode, name, oid: child.oid });
134
+ }
135
+ }
136
+ return repo.objects.write('tree', serializeTree(entries));
137
+ }
138
+
139
+ return store(root);
140
+ }
141
+
142
+ // ─── Status ──────────────────────────────────────────────
143
+
144
+ /**
145
+ * Compare HEAD, index and working tree.
146
+ *
147
+ * @param {Object} repo
148
+ * @param {Object} [options]
149
+ * @param {GitIndex} [options.index]
150
+ * @returns {Promise<{staged, unstaged, untracked, conflicted, ignoredCount}>}
151
+ */
152
+ async function status(repo, options = {}) {
153
+ const worktreeRoot = repo.requireWorktree('gent status');
154
+ const index = options.index || await GitIndex.read(repo.indexPath);
155
+ const head = await repo.refs.head();
156
+ const headTree = head.oid ? (await repo.objects.readCommit(head.oid)).tree : null;
157
+ const headEntries = await readTreeRecursive(repo, headTree);
158
+
159
+ const attributes = new AttributesMatcher(repo);
160
+ const matcher = new IgnoreMatcher(repo);
161
+
162
+ const staged = [];
163
+ const unstaged = [];
164
+ const untracked = [];
165
+ const conflicted = [...index.conflicts().keys()].sort();
166
+
167
+ const indexByPath = new Map(index.staged().map(e => [e.path, e]));
168
+
169
+ // HEAD vs index
170
+ for (const [filePath, entry] of indexByPath) {
171
+ const headEntry = headEntries.get(filePath);
172
+ if (!headEntry) staged.push({ path: filePath, status: 'added' });
173
+ else if (headEntry.oid !== entry.oid) staged.push({ path: filePath, status: 'modified' });
174
+ else if (headEntry.mode !== entry.mode) staged.push({ path: filePath, status: 'typechange' });
175
+ }
176
+ for (const filePath of headEntries.keys()) {
177
+ if (!indexByPath.has(filePath) && !index.getAll(filePath).length) {
178
+ staged.push({ path: filePath, status: 'deleted' });
179
+ }
180
+ }
181
+
182
+ // Index vs working tree
183
+ const seen = new Set();
184
+ for await (const found of walkWorktree(repo, matcher, { tracked: new Set(indexByPath.keys()) })) {
185
+ seen.add(found.path);
186
+ const entry = indexByPath.get(found.path);
187
+
188
+ if (!entry) {
189
+ if (!index.getAll(found.path).length) untracked.push(found.path);
190
+ continue;
191
+ }
192
+
193
+ const mode = modeFromStat(found.stat, entry.mode);
194
+ if (mode !== entry.mode) {
195
+ unstaged.push({ path: found.path, status: 'typechange' });
196
+ continue;
197
+ }
198
+
199
+ // Stat equality is only conclusive when the entry is not racy.
200
+ if (entry.matchesStat(found.stat) && !entry.isRacy(index.readMtimeSeconds)) continue;
201
+
202
+ const oid = await hashWorktreeFile(repo, attributes, found.path, found.stat);
203
+ if (oid !== entry.oid) unstaged.push({ path: found.path, status: 'modified' });
204
+ }
205
+
206
+ for (const filePath of indexByPath.keys()) {
207
+ if (!seen.has(filePath)) unstaged.push({ path: filePath, status: 'deleted' });
208
+ }
209
+
210
+ staged.sort((a, b) => a.path.localeCompare(b.path));
211
+ unstaged.sort((a, b) => a.path.localeCompare(b.path));
212
+ untracked.sort();
213
+
214
+ return { staged, unstaged, untracked, conflicted, head, index };
215
+ }
216
+
217
+ /**
218
+ * Object id a worktree file would have if staged now.
219
+ * @param {Object} repo
220
+ * @param {AttributesMatcher} attributes
221
+ * @param {String} relativePath
222
+ * @param {fs.Stats} stat
223
+ * @returns {Promise<String>}
224
+ */
225
+ async function hashWorktreeFile(repo, attributes, relativePath, stat) {
226
+ const { hashObject } = require('./git-objects');
227
+ const absolute = path.join(repo.worktree, ...relativePath.split('/'));
228
+
229
+ if (stat.isSymbolicLink()) {
230
+ return hashObject('blob', Buffer.from(await fs.readlink(absolute), 'utf8'));
231
+ }
232
+ const raw = await fs.readFile(absolute);
233
+ return hashObject('blob', await attributes.toIndex(relativePath, raw));
234
+ }
235
+
236
+ /**
237
+ * Read a worktree file and stage it, storing the blob.
238
+ * @param {Object} repo
239
+ * @param {AttributesMatcher} attributes
240
+ * @param {String} relativePath
241
+ * @returns {Promise<IndexEntry>}
242
+ */
243
+ async function stageWorktreeFile(repo, attributes, relativePath) {
244
+ const absolute = path.join(repo.worktree, ...relativePath.split('/'));
245
+ const stat = await fs.lstat(absolute);
246
+
247
+ let content;
248
+ let mode;
249
+ if (stat.isSymbolicLink()) {
250
+ content = Buffer.from(await fs.readlink(absolute), 'utf8');
251
+ mode = MODE.SYMLINK;
252
+ } else if (stat.isDirectory()) {
253
+ throw new UnsupportedFeatureError(
254
+ [{ ...feature('worktree.gitlink'), detail: `'${relativePath}' is a submodule; Gent does not stage submodule state` }],
255
+ 'staging'
256
+ );
257
+ } else {
258
+ const raw = await fs.readFile(absolute);
259
+ content = await attributes.toIndex(relativePath, raw);
260
+ mode = repo.config.getBoolean('core.fileMode', true) && (stat.mode & 0o111)
261
+ ? MODE.EXECUTABLE
262
+ : MODE.REGULAR;
263
+ }
264
+
265
+ const oid = await repo.objects.write('blob', content);
266
+ return IndexEntry.fromStat(stat, relativePath, oid, mode);
267
+ }
268
+
269
+ // ─── Path safety ─────────────────────────────────────────
270
+
271
+ /**
272
+ * Reject a destination path before anything is written to it.
273
+ * @param {Object} repo
274
+ * @param {String} relativePath - POSIX, relative to the worktree root
275
+ */
276
+ function assertSafeCheckoutPath(repo, relativePath) {
277
+ if (relativePath === '' || relativePath.startsWith('/')) {
278
+ throw new WorktreeError(`refusing to write to '${relativePath}': not a relative path`, 'GENT_UNSAFE_PATH');
279
+ }
280
+ if (path.isAbsolute(relativePath) || /^[A-Za-z]:/.test(relativePath)) {
281
+ throw new WorktreeError(`refusing to write to '${relativePath}': absolute paths are not allowed`, 'GENT_UNSAFE_PATH');
282
+ }
283
+
284
+ for (const component of relativePath.split('/')) {
285
+ if (component === '') {
286
+ throw new WorktreeError(`refusing to write to '${relativePath}': empty path component`, 'GENT_UNSAFE_PATH');
287
+ }
288
+ if (FORBIDDEN_COMPONENTS.has(component.toLowerCase())) {
289
+ throw new WorktreeError(`refusing to write to '${relativePath}': '${component}' is repository metadata`, 'GENT_UNSAFE_PATH');
290
+ }
291
+ if (RESERVED_WINDOWS_NAMES.test(component)) {
292
+ throw new WorktreeError(`refusing to write to '${relativePath}': '${component}' is a reserved device name on Windows`, 'GENT_UNSAFE_PATH');
293
+ }
294
+ if (/[\x00-\x1f\x7f]/.test(component)) {
295
+ throw new WorktreeError(`refusing to write to '${relativePath}': control character in a path component`, 'GENT_UNSAFE_PATH');
296
+ }
297
+ }
298
+
299
+ const resolved = path.resolve(repo.worktree, ...relativePath.split('/'));
300
+ const root = path.resolve(repo.worktree);
301
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
302
+ throw new WorktreeError(`refusing to write to '${relativePath}': it resolves outside the working tree`, 'GENT_UNSAFE_PATH');
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Reject a path whose parent directory is (or goes through) a symlink — the
308
+ * classic way a malicious tree escapes the worktree.
309
+ * @param {Object} repo
310
+ * @param {String} relativePath
311
+ * @returns {Promise<void>}
312
+ */
313
+ async function assertNoSymlinkParent(repo, relativePath) {
314
+ const components = relativePath.split('/');
315
+ let current = repo.worktree;
316
+ for (let i = 0; i < components.length - 1; i++) {
317
+ current = path.join(current, components[i]);
318
+ const stat = await fs.lstat(current).catch(() => null);
319
+ if (stat && stat.isSymbolicLink()) {
320
+ throw new WorktreeError(
321
+ `refusing to write to '${relativePath}': '${components.slice(0, i + 1).join('/')}' is a symbolic link`,
322
+ 'GENT_UNSAFE_PATH'
323
+ );
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Detect two target paths that differ only by case, which collide on macOS
330
+ * and Windows.
331
+ * @param {Iterable<String>} paths
332
+ * @returns {Array<Array<String>>} colliding groups
333
+ */
334
+ function caseCollisions(paths) {
335
+ const byLower = new Map();
336
+ for (const p of paths) {
337
+ const key = p.toLowerCase();
338
+ if (!byLower.has(key)) byLower.set(key, []);
339
+ byLower.get(key).push(p);
340
+ }
341
+ return [...byLower.values()].filter(group => group.length > 1);
342
+ }
343
+
344
+ // ─── Checkout ────────────────────────────────────────────
345
+
346
+ /**
347
+ * Work out what changing from one tree to another would do to the worktree.
348
+ *
349
+ * @param {Object} repo
350
+ * @param {Object} options
351
+ * @param {Map} options.from - current tree entries (usually the index)
352
+ * @param {Map} options.to - target tree entries
353
+ * @param {GitIndex} options.index
354
+ * @param {Boolean} [options.force] - discard local modifications
355
+ * @returns {Promise<{writes: Array, deletes: Array, blockers: Array}>}
356
+ */
357
+ async function planCheckout(repo, options) {
358
+ const { from, to, index, force } = options;
359
+ const attributes = new AttributesMatcher(repo);
360
+ const matcher = new IgnoreMatcher(repo);
361
+ await matcher.load();
362
+
363
+ const writes = [];
364
+ const deletes = [];
365
+ const blockers = [];
366
+
367
+ for (const [filePath, target] of to) {
368
+ assertSafeCheckoutPath(repo, filePath);
369
+ await assertNoSymlinkParent(repo, filePath);
370
+
371
+ const current = from.get(filePath);
372
+ if (current && current.oid === target.oid && current.mode === target.mode && !force) continue;
373
+
374
+ const absolute = path.join(repo.worktree, ...filePath.split('/'));
375
+ const stat = await fs.lstat(absolute).catch(() => null);
376
+
377
+ if (stat && stat.isDirectory() && target.mode !== MODE.GITLINK) {
378
+ blockers.push({ path: filePath, reason: 'directory replacement requires explicit removal first' });
379
+ continue;
380
+ }
381
+ if (target.mode === MODE.GITLINK) {
382
+ blockers.push({ path: filePath, reason: 'submodule checkout is not supported' });
383
+ continue;
384
+ }
385
+ if (stat && !current) {
386
+ // An untracked file sits where a tracked one must go.
387
+ const ignored = await matcher.isIgnored(filePath, stat.isDirectory());
388
+ if (!force && !ignored) {
389
+ blockers.push({ path: filePath, reason: 'untracked file would be overwritten' });
390
+ continue;
391
+ }
392
+ } else if (stat && current && !force) {
393
+ const entry = index.get(filePath);
394
+ if (entry) {
395
+ const dirty = !entry.matchesStat(stat) || entry.isRacy(index.readMtimeSeconds)
396
+ ? (await hashWorktreeFile(repo, attributes, filePath, stat)) !== entry.oid
397
+ : false;
398
+ if (dirty) {
399
+ blockers.push({ path: filePath, reason: 'has unstaged changes' });
400
+ continue;
401
+ }
402
+ }
403
+ }
404
+
405
+ const attrs = await attributes.attributesFor(filePath);
406
+ attributes.assertConvertible(filePath, attrs, 'checking out');
407
+ const bytes = await repo.objects.readBlob(target.oid);
408
+ const content = target.mode === MODE.SYMLINK ? bytes : await attributes.toWorktree(filePath, bytes);
409
+ writes.push({ path: filePath, mode: target.mode, oid: target.oid, content: content.toString('base64') });
410
+ }
411
+
412
+ for (const [filePath, current] of from) {
413
+ if (to.has(filePath)) continue;
414
+ assertSafeCheckoutPath(repo, filePath);
415
+ await assertNoSymlinkParent(repo, filePath);
416
+
417
+ const absolute = path.join(repo.worktree, ...filePath.split('/'));
418
+ const stat = await fs.lstat(absolute).catch(() => null);
419
+ if (!stat) { deletes.push({ path: filePath }); continue; }
420
+ if (stat.isDirectory()) {
421
+ blockers.push({ path: filePath, reason: 'refusing to remove a directory or submodule' });
422
+ continue;
423
+ }
424
+
425
+ if (!force) {
426
+ const entry = index.get(filePath);
427
+ if (entry) {
428
+ const dirty = !entry.matchesStat(stat) || entry.isRacy(index.readMtimeSeconds)
429
+ ? (await hashWorktreeFile(repo, attributes, filePath, stat)) !== entry.oid
430
+ : false;
431
+ if (dirty) {
432
+ blockers.push({ path: filePath, reason: 'has unstaged changes and would be removed' });
433
+ continue;
434
+ }
435
+ }
436
+ }
437
+ deletes.push({ path: filePath, oid: current.oid });
438
+ }
439
+
440
+ const collisions = caseCollisions(writes.map(w => w.path));
441
+ for (const group of collisions) {
442
+ blockers.push({
443
+ path: group.join(', '),
444
+ reason: 'these paths differ only by case and cannot coexist on this filesystem'
445
+ });
446
+ }
447
+
448
+ return { writes, deletes, blockers };
449
+ }
450
+
451
+ /**
452
+ * Apply a plan, recording it first so an interruption is recoverable.
453
+ *
454
+ * @param {Object} repo
455
+ * @param {{writes: Array, deletes: Array, blockers: Array}} plan
456
+ * @param {Object} [options]
457
+ * @param {GitIndex} [options.index] - updated in place when given
458
+ * @returns {Promise<{written: Number, deleted: Number}>}
459
+ */
460
+ async function applyCheckout(repo, plan, options = {}) {
461
+ if (plan.blockers.length) throw new CheckoutBlocked(plan.blockers);
462
+
463
+ const planPath = path.join(repo.gentWorktreeMetaDir, PLAN_FILE);
464
+
465
+ await assertNoPendingCheckout(repo, 'checkout');
466
+ if (options.index) {
467
+ options.index.serialize();
468
+ const current = await readFileOrNull(repo.indexPath);
469
+ const expected = options.index.sourceBytes;
470
+ if (!(current === null && expected === null) && !(current && expected && current.equals(expected))) {
471
+ throw new WorktreeError('index changed before checkout; retry the operation');
472
+ }
473
+ }
474
+ const record = {
475
+ startedAt: new Date().toISOString(),
476
+ head: await repo.refs.head(),
477
+ index: (await readFileOrNull(repo.indexPath))?.toString('base64') ?? null,
478
+ writes: plan.writes,
479
+ deletes: plan.deletes,
480
+ before: {},
481
+ completed: []
482
+ };
483
+ for (const item of [...plan.writes, ...plan.deletes]) {
484
+ record.before[item.path] = await snapshotPath(repo, item.path);
485
+ }
486
+ await fs.mkdir(repo.gentWorktreeMetaDir, { recursive: true });
487
+ await writeAtomic(planPath, JSON.stringify(record));
488
+
489
+ const completed = [];
490
+ let written = 0;
491
+ let deleted = 0;
492
+
493
+ try {
494
+ for (const target of plan.deletes) {
495
+ const absolute = path.join(repo.worktree, ...target.path.split('/'));
496
+ await fs.rm(absolute, { force: true });
497
+ await removeEmptyParents(repo, target.path);
498
+ if (options.index) options.index.remove(target.path);
499
+ completed.push(target.path);
500
+ deleted++;
501
+ }
502
+
503
+ for (const target of plan.writes) {
504
+ await assertNoSymlinkParent(repo, target.path);
505
+ const absolute = path.join(repo.worktree, ...target.path.split('/'));
506
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
507
+
508
+ if (target.mode === MODE.GITLINK) {
509
+ await fs.mkdir(absolute, { recursive: true }); // submodule boundary only
510
+ } else if (target.mode === MODE.SYMLINK) {
511
+ const link = Buffer.from(target.content, 'base64').toString('utf8');
512
+ await fs.rm(absolute, { force: true, recursive: true });
513
+ await fs.symlink(link, absolute);
514
+ } else {
515
+ const content = Buffer.from(target.content, 'base64');
516
+ await fs.rm(absolute, { force: true, recursive: true });
517
+ await fs.writeFile(absolute, content, { mode: target.mode === MODE.EXECUTABLE ? 0o777 & ~processUmask() : 0o666 & ~processUmask() });
518
+ }
519
+
520
+ if (options.index) {
521
+ const stat = await fs.lstat(absolute);
522
+ options.index.add(IndexEntry.fromStat(stat, target.path, target.oid, target.mode));
523
+ }
524
+ completed.push(target.path);
525
+ written++;
526
+ }
527
+ } catch (error) {
528
+ record.completed = completed;
529
+ record.failedWith = error.message;
530
+ await writeAtomic(planPath, JSON.stringify(record));
531
+ throw error;
532
+ }
533
+
534
+ record.completed = completed;
535
+ record.nextIndex = options.index ? options.index.serialize().toString('base64') : record.index;
536
+ await writeAtomic(planPath, JSON.stringify(record));
537
+ return { written, deleted };
538
+ }
539
+
540
+ /**
541
+ * @returns {Number}
542
+ */
543
+ function processUmask() {
544
+ // process.umask() with no argument is deprecated as a *setter* only.
545
+ const current = process.umask();
546
+ return current;
547
+ }
548
+
549
+ /**
550
+ * Remove directories left empty by a deletion, stopping at the worktree root.
551
+ * @param {Object} repo
552
+ * @param {String} relativePath
553
+ */
554
+ async function removeEmptyParents(repo, relativePath) {
555
+ const parts = relativePath.split('/');
556
+ for (let depth = parts.length - 1; depth > 0; depth--) {
557
+ const directory = path.join(repo.worktree, ...parts.slice(0, depth));
558
+ const entries = await fs.readdir(directory).catch(() => null);
559
+ if (!entries || entries.length) return;
560
+ await fs.rmdir(directory).catch(() => {});
561
+ }
562
+ }
563
+
564
+ /**
565
+ * An interrupted checkout, if one is recorded.
566
+ * @param {Object} repo
567
+ * @returns {Promise<Object|null>}
568
+ */
569
+ async function pendingCheckout(repo) {
570
+ const raw = await readFileOrNull(path.join(repo.gentWorktreeMetaDir, PLAN_FILE));
571
+ if (!raw) return null;
572
+ try {
573
+ return JSON.parse(raw.toString('utf-8'));
574
+ } catch {
575
+ return { corrupt: true };
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Refuse to start a new worktree operation while one is half-applied.
581
+ * @param {Object} repo
582
+ * @param {String} what
583
+ */
584
+ async function assertNoPendingCheckout(repo, what) {
585
+ const pending = await pendingCheckout(repo);
586
+ if (!pending) return;
587
+ throw new WorktreeError(
588
+ `${what}: a previous checkout was interrupted after updating ${(pending.completed || []).length} path(s).\n` +
589
+ `Run "gent checkout --abort" to restore the recorded files and index. Recovery refuses intervening changes.`,
590
+ 'GENT_CHECKOUT_PENDING'
591
+ );
592
+ }
593
+
594
+ /** Capture exact working bytes; recovery never follows a symlink. */
595
+ async function snapshotPath(repo, name) {
596
+ assertSafeCheckoutPath(repo, name);
597
+ await assertNoSymlinkParent(repo, name);
598
+ const absolute = path.join(repo.worktree, name);
599
+ let stat;
600
+ try { stat = await fs.lstat(absolute); } catch (error) {
601
+ if (error.code === 'ENOENT') return null;
602
+ throw error;
603
+ }
604
+ if (!stat.isFile() && !stat.isSymbolicLink()) {
605
+ throw new WorktreeError(`cannot replace directory or special file '${name}'`);
606
+ }
607
+ return {
608
+ mode: stat.isSymbolicLink() ? MODE.SYMLINK : (stat.mode & 0o111 ? MODE.EXECUTABLE : MODE.REGULAR),
609
+ content: (stat.isSymbolicLink() ? Buffer.from(await fs.readlink(absolute)) : await fs.readFile(absolute)).toString('base64')
610
+ };
611
+ }
612
+
613
+ /** Called only after index and HEAD/ref publication succeeds. */
614
+ async function completeCheckout(repo) {
615
+ await fs.rm(path.join(repo.gentWorktreeMetaDir, PLAN_FILE), { force: true });
616
+ }
617
+
618
+ async function abortCheckout(repo) {
619
+ const record = await pendingCheckout(repo);
620
+ if (!record) throw new WorktreeError('there is no interrupted checkout');
621
+ if (!record.before || !record.head) throw new WorktreeError('recovery record is incomplete; restore from backup');
622
+ const head = await repo.refs.head();
623
+ if (head.oid !== record.head.oid || head.ref !== record.head.ref) {
624
+ throw new WorktreeError('HEAD changed after checkout began; refusing to overwrite newer history');
625
+ }
626
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
627
+ for (const [name, before] of Object.entries(record.before)) {
628
+ const current = await snapshotPath(repo, name);
629
+ const target = record.writes.find(w => w.path === name);
630
+ const after = target ? { mode: target.mode, content: target.content } : null;
631
+ if (!same(current, before) && !same(current, after)) {
632
+ throw new WorktreeError(`'${name}' changed after checkout began; refusing recovery`);
633
+ }
634
+ }
635
+ await withLock(repo.indexPath, async lock => {
636
+ const raw = await lock.readTarget();
637
+ const value = raw?.toString('base64') ?? null;
638
+ if (value !== record.index && value !== record.nextIndex) {
639
+ throw new WorktreeError('index changed after checkout began; refusing recovery');
640
+ }
641
+ for (const [name, before] of Object.entries(record.before)) {
642
+ const absolute = path.join(repo.worktree, name);
643
+ await fs.rm(absolute, { force: true });
644
+ if (before) {
645
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
646
+ const bytes = Buffer.from(before.content, 'base64');
647
+ if (before.mode === MODE.SYMLINK) await fs.symlink(bytes.toString(), absolute);
648
+ else await fs.writeFile(absolute, bytes, { mode: before.mode === MODE.EXECUTABLE ? 0o755 : 0o644 });
649
+ } else await removeEmptyParents(repo, name);
650
+ }
651
+ await lock.write(record.index === null ? new GitIndex().serialize() : Buffer.from(record.index, 'base64'));
652
+ });
653
+ await completeCheckout(repo);
654
+ }
655
+
656
+ module.exports = {
657
+ WorktreeError,
658
+ CheckoutBlocked,
659
+ readTreeRecursive,
660
+ buildTreeFromIndex,
661
+ status,
662
+ hashWorktreeFile,
663
+ stageWorktreeFile,
664
+ assertSafeCheckoutPath,
665
+ assertNoSymlinkParent,
666
+ caseCollisions,
667
+ planCheckout,
668
+ applyCheckout,
669
+ pendingCheckout,
670
+ assertNoPendingCheckout,
671
+ removeEmptyParents,
672
+ PLAN_FILE,
673
+ snapshotPath,
674
+ completeCheckout,
675
+ abortCheckout
676
+ };