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.
- package/README.md +1 -1
- package/package.json +7 -4
- package/src/commands/canonical.js +242 -0
- package/src/commands/clone.js +2 -13
- package/src/commands/pet.js +122 -98
- package/src/commands/pull.js +3 -4
- package/src/commands/push.js +4 -5
- package/src/commands/share.js +40 -15
- package/src/commands/web.js +30 -15
- package/src/index.js +70 -40
- package/src/utils/api-client.js +6 -9
- package/src/utils/attributes.js +280 -0
- package/src/utils/canonical-journal.js +122 -0
- package/src/utils/feature-support.js +92 -0
- package/src/utils/feature-support.json +210 -0
- package/src/utils/gent-ops.js +836 -0
- package/src/utils/git-config.js +606 -0
- package/src/utils/git-index.js +581 -0
- package/src/utils/git-objects.js +537 -0
- package/src/utils/ignore.js +365 -0
- package/src/utils/lockfile.js +219 -0
- package/src/utils/merge-ops.js +282 -0
- package/src/utils/migrate.js +257 -0
- package/src/utils/object-store.js +332 -36
- package/src/utils/packfile.js +812 -0
- package/src/utils/refs.js +595 -0
- package/src/utils/repository.js +533 -0
- package/src/utils/smart-http.js +195 -0
- package/src/utils/stash-ops.js +355 -0
- package/src/utils/user-config.js +10 -0
- package/src/utils/web-urls.js +125 -0
- package/src/utils/worktree.js +676 -0
|
@@ -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
|
+
};
|