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,836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Gent Ops - repository operations on the canonical engine
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* One implementation of add, commit, checkout, branch, tag, reset and
|
|
8
|
+
* history walking, shared by every command. Commands own presentation; this
|
|
9
|
+
* module owns behaviour.
|
|
10
|
+
*
|
|
11
|
+
* INVARIANTS:
|
|
12
|
+
* - Commit ids are derived from content. Nothing here generates one.
|
|
13
|
+
* - Every ref move is a compare-and-set against the value the operation
|
|
14
|
+
* read, so an external Git writing concurrently is detected, not lost.
|
|
15
|
+
* - Every worktree change goes through worktree.js, which preflights first.
|
|
16
|
+
* - assertNoExternalOperation() runs before anything that writes.
|
|
17
|
+
* ============================================================================
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs').promises;
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const repository = require('./repository');
|
|
24
|
+
const { MODE, serializeCommit, serializeTag, isObjectId } = require('./git-objects');
|
|
25
|
+
const { GitIndex, IndexEntry } = require('./git-index');
|
|
26
|
+
const { AttributesMatcher } = require('./attributes');
|
|
27
|
+
const { IgnoreMatcher, walkWorktree } = require('./ignore');
|
|
28
|
+
const worktree = require('./worktree');
|
|
29
|
+
const { assertRefName } = require('./refs');
|
|
30
|
+
const { writeAtomic, readFileOrNull } = require('./lockfile');
|
|
31
|
+
|
|
32
|
+
class OperationError extends Error {
|
|
33
|
+
constructor(message, code) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'OperationError';
|
|
36
|
+
this.code = code || 'GENT_OPERATION';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Open the repository containing the current directory.
|
|
42
|
+
* @param {String} [startDir]
|
|
43
|
+
* @returns {Promise<repository.Repository>}
|
|
44
|
+
*/
|
|
45
|
+
async function openRepository(startDir) {
|
|
46
|
+
return repository.open(startDir || process.cwd());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Revision resolution ─────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a revision expression to an object id.
|
|
53
|
+
* Supports: a full oid, an unambiguous oid prefix, any ref shorthand, HEAD,
|
|
54
|
+
* and the `~N` / `^N` suffixes.
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} repo
|
|
57
|
+
* @param {String} revision
|
|
58
|
+
* @returns {Promise<String>} oid
|
|
59
|
+
*/
|
|
60
|
+
async function resolveRevision(repo, revision) {
|
|
61
|
+
if (!revision) throw new OperationError('no revision given');
|
|
62
|
+
|
|
63
|
+
let expression = revision;
|
|
64
|
+
const suffixes = [];
|
|
65
|
+
const suffixPattern = /(\^\d*|~\d*)$/;
|
|
66
|
+
let match;
|
|
67
|
+
while ((match = suffixPattern.exec(expression))) {
|
|
68
|
+
suffixes.unshift(match[1]);
|
|
69
|
+
expression = expression.slice(0, -match[1].length);
|
|
70
|
+
}
|
|
71
|
+
if (!expression) expression = 'HEAD';
|
|
72
|
+
|
|
73
|
+
let oid = await resolveBase(repo, expression);
|
|
74
|
+
|
|
75
|
+
for (const suffix of suffixes) {
|
|
76
|
+
const count = suffix.length > 1 ? Number.parseInt(suffix.slice(1), 10) : 1;
|
|
77
|
+
if (suffix.startsWith('~')) {
|
|
78
|
+
for (let i = 0; i < count; i++) {
|
|
79
|
+
const commit = await repo.objects.readCommit(await peelToCommit(repo, oid));
|
|
80
|
+
if (!commit.parents.length) throw new OperationError(`'${revision}': ${oid.slice(0, 12)} has no parent`);
|
|
81
|
+
oid = commit.parents[0];
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
const commit = await repo.objects.readCommit(await peelToCommit(repo, oid));
|
|
85
|
+
const parent = commit.parents[count - 1];
|
|
86
|
+
if (!parent) throw new OperationError(`'${revision}': ${oid.slice(0, 12)} has no parent number ${count}`);
|
|
87
|
+
oid = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return oid;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {Object} repo
|
|
95
|
+
* @param {String} expression
|
|
96
|
+
* @returns {Promise<String>}
|
|
97
|
+
*/
|
|
98
|
+
async function resolveBase(repo, expression) {
|
|
99
|
+
const viaRef = await repo.refs.expand(expression);
|
|
100
|
+
if (viaRef) return viaRef.oid;
|
|
101
|
+
|
|
102
|
+
if (isObjectId(expression)) {
|
|
103
|
+
if (await repo.objects.has(expression)) return expression;
|
|
104
|
+
throw new OperationError(`object ${expression} is not in this repository`, 'GENT_UNKNOWN_REVISION');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (/^[0-9a-f]{4,63}$/.test(expression)) {
|
|
108
|
+
const matches = [];
|
|
109
|
+
for (const oid of await repo.objects.listAll()) {
|
|
110
|
+
if (oid.startsWith(expression)) matches.push(oid);
|
|
111
|
+
if (matches.length > 1) break;
|
|
112
|
+
}
|
|
113
|
+
if (matches.length === 1) return matches[0];
|
|
114
|
+
if (matches.length > 1) throw new OperationError(`'${expression}' is ambiguous — it matches more than one object`, 'GENT_AMBIGUOUS');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw new OperationError(`'${expression}' is not a known revision`, 'GENT_UNKNOWN_REVISION');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The commit a revision ultimately names. Accepts an object id or any
|
|
122
|
+
* revision expression, so callers never have to resolve first.
|
|
123
|
+
* @param {Object} repo
|
|
124
|
+
* @param {String} revision
|
|
125
|
+
* @returns {Promise<String>}
|
|
126
|
+
*/
|
|
127
|
+
async function peelToCommit(repo, revision) {
|
|
128
|
+
const oid = isObjectId(revision) ? revision : await resolveRevision(repo, revision);
|
|
129
|
+
const peeled = await repo.objects.peel(oid);
|
|
130
|
+
if (peeled.type !== 'commit') {
|
|
131
|
+
throw new OperationError(`${oid.slice(0, 12)} is a ${peeled.type}, not a commit`);
|
|
132
|
+
}
|
|
133
|
+
return peeled.oid;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── History ─────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Walk the commit DAG from one or more starting points.
|
|
140
|
+
* Ordering is by committer time descending, with parents always emitted after
|
|
141
|
+
* their children, so merges read correctly.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} repo
|
|
144
|
+
* @param {Object} [options]
|
|
145
|
+
* @param {Array<String>} [options.from] - defaults to HEAD
|
|
146
|
+
* @param {Number} [options.max]
|
|
147
|
+
* @param {Boolean} [options.firstParentOnly]
|
|
148
|
+
* @returns {Promise<Array<Object>>} parsed commits, newest first
|
|
149
|
+
*/
|
|
150
|
+
async function walkHistory(repo, options = {}) {
|
|
151
|
+
let starts = options.from;
|
|
152
|
+
if (!starts || !starts.length) {
|
|
153
|
+
const head = await repo.refs.head();
|
|
154
|
+
starts = head.oid ? [head.oid] : [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const queue = [];
|
|
159
|
+
const results = [];
|
|
160
|
+
|
|
161
|
+
for (const start of starts) {
|
|
162
|
+
const oid = await peelToCommit(repo, start);
|
|
163
|
+
if (seen.has(oid)) continue;
|
|
164
|
+
seen.add(oid);
|
|
165
|
+
queue.push(await repo.objects.readCommit(oid));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
while (queue.length) {
|
|
169
|
+
queue.sort((a, b) => (b.committer?.timestamp || 0) - (a.committer?.timestamp || 0));
|
|
170
|
+
const commit = queue.shift();
|
|
171
|
+
results.push(commit);
|
|
172
|
+
if (options.max && results.length >= options.max) break;
|
|
173
|
+
|
|
174
|
+
const parents = options.firstParentOnly ? commit.parents.slice(0, 1) : commit.parents;
|
|
175
|
+
for (const parent of parents) {
|
|
176
|
+
if (seen.has(parent)) continue;
|
|
177
|
+
seen.add(parent);
|
|
178
|
+
queue.push(await repo.objects.readCommit(parent));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return results;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Best common ancestor of two commits.
|
|
186
|
+
* @param {Object} repo
|
|
187
|
+
* @param {String} a
|
|
188
|
+
* @param {String} b
|
|
189
|
+
* @returns {Promise<String|null>}
|
|
190
|
+
*/
|
|
191
|
+
async function findMergeBase(repo, a, b) {
|
|
192
|
+
const ancestorsOfA = new Set();
|
|
193
|
+
const stack = [await peelToCommit(repo, a)];
|
|
194
|
+
while (stack.length) {
|
|
195
|
+
const oid = stack.pop();
|
|
196
|
+
if (ancestorsOfA.has(oid)) continue;
|
|
197
|
+
ancestorsOfA.add(oid);
|
|
198
|
+
stack.push(...(await repo.objects.readCommit(oid)).parents);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Breadth-first from b so the *closest* common ancestor wins.
|
|
202
|
+
const visited = new Set();
|
|
203
|
+
const queue = [await peelToCommit(repo, b)];
|
|
204
|
+
while (queue.length) {
|
|
205
|
+
const oid = queue.shift();
|
|
206
|
+
if (visited.has(oid)) continue;
|
|
207
|
+
visited.add(oid);
|
|
208
|
+
if (ancestorsOfA.has(oid)) return oid;
|
|
209
|
+
queue.push(...(await repo.objects.readCommit(oid)).parents);
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {Object} repo
|
|
216
|
+
* @param {String} ancestor
|
|
217
|
+
* @param {String} descendant
|
|
218
|
+
* @returns {Promise<Boolean>}
|
|
219
|
+
*/
|
|
220
|
+
async function isAncestor(repo, ancestor, descendant) {
|
|
221
|
+
const target = await peelToCommit(repo, ancestor);
|
|
222
|
+
const visited = new Set();
|
|
223
|
+
const stack = [await peelToCommit(repo, descendant)];
|
|
224
|
+
while (stack.length) {
|
|
225
|
+
const oid = stack.pop();
|
|
226
|
+
if (oid === target) return true;
|
|
227
|
+
if (visited.has(oid)) continue;
|
|
228
|
+
visited.add(oid);
|
|
229
|
+
stack.push(...(await repo.objects.readCommit(oid)).parents);
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Staging ─────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Expand user-supplied path arguments into repository-relative file paths.
|
|
238
|
+
* @param {Object} repo
|
|
239
|
+
* @param {Array<String>} paths
|
|
240
|
+
* @param {Object} [options]
|
|
241
|
+
* @param {Boolean} [options.all]
|
|
242
|
+
* @returns {Promise<Array<String>>}
|
|
243
|
+
*/
|
|
244
|
+
async function expandPaths(repo, paths, options = {}) {
|
|
245
|
+
const root = repo.requireWorktree('staging');
|
|
246
|
+
const matcher = new IgnoreMatcher(repo);
|
|
247
|
+
const index = options.index || await GitIndex.read(repo.indexPath);
|
|
248
|
+
const tracked = new Set(index.staged().map(e => e.path));
|
|
249
|
+
|
|
250
|
+
const wantsEverything = options.all || !paths || !paths.length;
|
|
251
|
+
|
|
252
|
+
if (wantsEverything) {
|
|
253
|
+
const found = [];
|
|
254
|
+
for await (const entry of walkWorktree(repo, matcher, { tracked })) {
|
|
255
|
+
if (!entry.submodule) found.push(entry.path);
|
|
256
|
+
}
|
|
257
|
+
return [...new Set([...found, ...tracked])].sort();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result = new Set();
|
|
261
|
+
for (const given of paths) {
|
|
262
|
+
const absolute = path.resolve(process.cwd(), given);
|
|
263
|
+
const relative = repo.relativePath(absolute);
|
|
264
|
+
const stat = await fs.lstat(absolute).catch(() => null);
|
|
265
|
+
|
|
266
|
+
if (stat && stat.isDirectory()) {
|
|
267
|
+
for await (const entry of walkWorktree(repo, matcher, { tracked })) {
|
|
268
|
+
if (!relative || entry.path === relative || entry.path.startsWith(relative + '/')) {
|
|
269
|
+
if (!entry.submodule) result.add(entry.path);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const trackedPath of tracked) {
|
|
273
|
+
if (!relative || trackedPath === relative || trackedPath.startsWith(relative + '/')) result.add(trackedPath);
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
result.add(relative);
|
|
279
|
+
}
|
|
280
|
+
return [...result].sort();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Stage the given paths, recording deletions for tracked files that are gone.
|
|
285
|
+
*
|
|
286
|
+
* @param {Object} repo
|
|
287
|
+
* @param {Array<String>} paths
|
|
288
|
+
* @param {Object} [options]
|
|
289
|
+
* @returns {Promise<{staged: Array, removed: Array, unchanged: Number, index: GitIndex}>}
|
|
290
|
+
*/
|
|
291
|
+
async function addPaths(repo, paths, options = {}) {
|
|
292
|
+
await repo.assertNoExternalOperation('gent add');
|
|
293
|
+
repo.requireWorktree('gent add');
|
|
294
|
+
|
|
295
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
296
|
+
const attributes = new AttributesMatcher(repo);
|
|
297
|
+
const candidates = await expandPaths(repo, paths, { ...options, index });
|
|
298
|
+
|
|
299
|
+
const staged = [];
|
|
300
|
+
const removed = [];
|
|
301
|
+
let unchanged = 0;
|
|
302
|
+
|
|
303
|
+
for (const relativePath of candidates) {
|
|
304
|
+
worktree.assertSafeCheckoutPath(repo, relativePath);
|
|
305
|
+
const absolute = path.join(repo.worktree, ...relativePath.split('/'));
|
|
306
|
+
const stat = await fs.lstat(absolute).catch(() => null);
|
|
307
|
+
|
|
308
|
+
if (!stat) {
|
|
309
|
+
if (index.getAll(relativePath).length) {
|
|
310
|
+
index.remove(relativePath);
|
|
311
|
+
removed.push(relativePath);
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (stat.isDirectory()) continue; // submodule; skipped by expandPaths
|
|
316
|
+
|
|
317
|
+
const before = index.get(relativePath);
|
|
318
|
+
const entry = await worktree.stageWorktreeFile(repo, attributes, relativePath);
|
|
319
|
+
|
|
320
|
+
if (before && before.oid === entry.oid && before.mode === entry.mode && before.stage === 0) {
|
|
321
|
+
index.add(entry); // refresh stat data only
|
|
322
|
+
unchanged++;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
index.add(entry);
|
|
326
|
+
staged.push({ path: relativePath, oid: entry.oid, mode: entry.mode, previous: before ? before.oid : null });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await index.write(repo.indexPath);
|
|
330
|
+
return { staged, removed, unchanged, index };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Unstage or untrack paths.
|
|
335
|
+
* @param {Object} repo
|
|
336
|
+
* @param {Array<String>} paths
|
|
337
|
+
* @param {Object} [options]
|
|
338
|
+
* @param {Boolean} [options.cached] - keep the file on disk
|
|
339
|
+
* @returns {Promise<Array<String>>}
|
|
340
|
+
*/
|
|
341
|
+
async function removePaths(repo, paths, options = {}) {
|
|
342
|
+
await repo.assertNoExternalOperation('gent rm');
|
|
343
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
344
|
+
const removed = new Set();
|
|
345
|
+
const head = await repo.refs.head();
|
|
346
|
+
const headTree = head.oid ? await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(head.oid)).tree) : new Map();
|
|
347
|
+
const attributes = new AttributesMatcher(repo);
|
|
348
|
+
const from = new Map();
|
|
349
|
+
for (const given of paths) {
|
|
350
|
+
const relative = repo.relativePath(path.resolve(process.cwd(), given));
|
|
351
|
+
for (const entry of index.entries.filter(e => e.path === relative || e.path.startsWith(relative + '/'))) {
|
|
352
|
+
worktree.assertSafeCheckoutPath(repo, entry.path);
|
|
353
|
+
await worktree.assertNoSymlinkParent(repo, entry.path);
|
|
354
|
+
if (!options.cached) {
|
|
355
|
+
const original = headTree.get(entry.path);
|
|
356
|
+
const stat = await fs.lstat(path.join(repo.worktree, entry.path)).catch(error => {
|
|
357
|
+
if (error.code === 'ENOENT') return null;
|
|
358
|
+
throw error;
|
|
359
|
+
});
|
|
360
|
+
if (entry.stage || !original || original.oid !== entry.oid || original.mode !== entry.mode ||
|
|
361
|
+
(stat && (await worktree.hashWorktreeFile(repo, attributes, entry.path, stat)) !== entry.oid)) {
|
|
362
|
+
throw new OperationError(`'${entry.path}' has local changes; refusing removal`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
from.set(entry.path, entry);
|
|
366
|
+
removed.add(entry.path);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (options.cached) {
|
|
370
|
+
for (const name of removed) index.remove(name);
|
|
371
|
+
} else {
|
|
372
|
+
const plan = await worktree.planCheckout(repo, { from, to: new Map(), index });
|
|
373
|
+
await worktree.applyCheckout(repo, plan, { index });
|
|
374
|
+
}
|
|
375
|
+
await index.write(repo.indexPath);
|
|
376
|
+
if (!options.cached) await worktree.completeCheckout(repo);
|
|
377
|
+
return [...removed];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Commit ──────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Record a commit from the current index.
|
|
384
|
+
*
|
|
385
|
+
* @param {Object} repo
|
|
386
|
+
* @param {Object} options
|
|
387
|
+
* @param {String} options.message
|
|
388
|
+
* @param {Boolean} [options.amend]
|
|
389
|
+
* @param {Boolean} [options.allowEmpty]
|
|
390
|
+
* @param {Array<String>} [options.extraParents] - merge parents
|
|
391
|
+
* @returns {Promise<{oid: String, tree: String, branch: String|null, parents: Array<String>}>}
|
|
392
|
+
*/
|
|
393
|
+
async function createCommit(repo, options) {
|
|
394
|
+
await repo.assertNoExternalOperation('gent commit');
|
|
395
|
+
|
|
396
|
+
const message = String(options.message || '').replace(/\s+$/, '') + '\n';
|
|
397
|
+
if (message.trim() === '') throw new OperationError('a commit needs a message');
|
|
398
|
+
|
|
399
|
+
const author = await repo.identity('author');
|
|
400
|
+
const committer = await repo.identity('committer');
|
|
401
|
+
if (!author || !committer) {
|
|
402
|
+
throw new OperationError(
|
|
403
|
+
'author identity unknown.\nSet it with:\n gent config user.name "Your Name"\n gent config user.email you@example.com',
|
|
404
|
+
'GENT_NO_IDENTITY'
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
409
|
+
if (index.hasConflicts()) {
|
|
410
|
+
throw new OperationError(
|
|
411
|
+
`cannot commit with unresolved conflicts:\n${[...index.conflicts().keys()].map(p => ' ' + p).join('\n')}`,
|
|
412
|
+
'GENT_UNMERGED'
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const head = await repo.refs.head();
|
|
417
|
+
let parents = [];
|
|
418
|
+
|
|
419
|
+
if (options.amend) {
|
|
420
|
+
if (!head.oid) throw new OperationError('there is no commit to amend');
|
|
421
|
+
parents = (await repo.objects.readCommit(head.oid)).parents;
|
|
422
|
+
} else if (head.oid) {
|
|
423
|
+
parents = [head.oid];
|
|
424
|
+
}
|
|
425
|
+
const mergeState = await readMergeState(repo);
|
|
426
|
+
if (options.amend && mergeState) throw new OperationError('cannot amend during a merge');
|
|
427
|
+
parents = [...new Set([...parents, ...(options.extraParents || mergeState?.heads || [])])];
|
|
428
|
+
|
|
429
|
+
const tree = await worktree.buildTreeFromIndex(repo, index);
|
|
430
|
+
|
|
431
|
+
if (!options.allowEmpty && !options.amend && parents.length === 1) {
|
|
432
|
+
const parentTree = (await repo.objects.readCommit(parents[0])).tree;
|
|
433
|
+
if (parentTree === tree) {
|
|
434
|
+
throw new OperationError('nothing to commit — the index matches HEAD', 'GENT_EMPTY_COMMIT');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Amending keeps the original author, as Git does.
|
|
439
|
+
let effectiveAuthor = author;
|
|
440
|
+
if (options.amend) {
|
|
441
|
+
const previous = await repo.objects.readCommit(head.oid);
|
|
442
|
+
if (previous.author) effectiveAuthor = previous.author;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const oid = await repo.objects.write('commit', serializeCommit({
|
|
446
|
+
tree,
|
|
447
|
+
parents,
|
|
448
|
+
author: effectiveAuthor,
|
|
449
|
+
committer,
|
|
450
|
+
message: Buffer.from(message, 'utf-8')
|
|
451
|
+
}));
|
|
452
|
+
|
|
453
|
+
const summary = message.split('\n')[0];
|
|
454
|
+
const reason = parents.length === 0
|
|
455
|
+
? `commit (initial): ${summary}`
|
|
456
|
+
: options.amend ? `commit (amend): ${summary}`
|
|
457
|
+
: parents.length > 1 ? `commit (merge): ${summary}`
|
|
458
|
+
: `commit: ${summary}`;
|
|
459
|
+
|
|
460
|
+
if (head.detached) {
|
|
461
|
+
await repo.refs.setHeadDetached(oid, reason, head);
|
|
462
|
+
} else if (head.ref) {
|
|
463
|
+
await repo.refs.update(head.ref, oid, { expectedOldOid: head.oid, reason });
|
|
464
|
+
} else {
|
|
465
|
+
throw new OperationError('HEAD is not usable — it names neither a branch nor a commit');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
await clearMergeState(repo);
|
|
469
|
+
return { oid, tree, branch: head.branch, parents };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ─── Merge state ─────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* @param {Object} repo
|
|
476
|
+
* @returns {Promise<{heads: Array<String>, message: String}|null>}
|
|
477
|
+
*/
|
|
478
|
+
async function readMergeState(repo) {
|
|
479
|
+
const raw = await readFileOrNull(repo.gitPath('MERGE_HEAD'));
|
|
480
|
+
if (!raw) return null;
|
|
481
|
+
const heads = raw.toString('utf-8').split('\n').map(l => l.trim()).filter(Boolean);
|
|
482
|
+
const message = (await readFileOrNull(repo.gitPath('MERGE_MSG')))?.toString('utf-8') || '';
|
|
483
|
+
return { heads, message };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* @param {Object} repo
|
|
488
|
+
* @param {Array<String>} heads
|
|
489
|
+
* @param {String} message
|
|
490
|
+
*/
|
|
491
|
+
async function writeMergeState(repo, heads, message) {
|
|
492
|
+
await writeAtomic(repo.gitPath('MERGE_HEAD'), heads.join('\n') + '\n');
|
|
493
|
+
await writeAtomic(repo.gitPath('MERGE_MSG'), message.endsWith('\n') ? message : message + '\n');
|
|
494
|
+
const head = await repo.refs.head();
|
|
495
|
+
if (head.oid) await writeAtomic(repo.gitPath('ORIG_HEAD'), head.oid + '\n');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* @param {Object} repo
|
|
500
|
+
*/
|
|
501
|
+
async function clearMergeState(repo) {
|
|
502
|
+
for (const name of ['MERGE_HEAD', 'MERGE_MSG', 'MERGE_MODE', 'AUTO_MERGE']) {
|
|
503
|
+
await fs.rm(repo.gitPath(name), { force: true });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ─── Branches ────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* @param {Object} repo
|
|
511
|
+
* @returns {Promise<Array<{name: String, oid: String, current: Boolean}>>}
|
|
512
|
+
*/
|
|
513
|
+
async function listBranches(repo) {
|
|
514
|
+
const head = await repo.refs.head();
|
|
515
|
+
const refs = await repo.refs.list('refs/heads/');
|
|
516
|
+
return [...refs.entries()]
|
|
517
|
+
.map(([name, oid]) => ({ name: name.slice('refs/heads/'.length), oid, current: name === head.ref }))
|
|
518
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* @param {Object} repo
|
|
523
|
+
* @param {String} name
|
|
524
|
+
* @param {String} [startPoint]
|
|
525
|
+
* @returns {Promise<{name: String, oid: String}>}
|
|
526
|
+
*/
|
|
527
|
+
async function createBranch(repo, name, startPoint) {
|
|
528
|
+
await repo.assertNoExternalOperation('gent branch');
|
|
529
|
+
const ref = `refs/heads/${name}`;
|
|
530
|
+
assertRefName(ref);
|
|
531
|
+
|
|
532
|
+
const start = startPoint ? await peelToCommit(repo, await resolveRevision(repo, startPoint)) : (await repo.refs.head()).oid;
|
|
533
|
+
if (!start) throw new OperationError('cannot create a branch before the first commit');
|
|
534
|
+
|
|
535
|
+
await repo.refs.update(ref, start, {
|
|
536
|
+
expectedOldOid: null,
|
|
537
|
+
reason: `branch: Created from ${startPoint || 'HEAD'}`
|
|
538
|
+
});
|
|
539
|
+
return { name, oid: start };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* @param {Object} repo
|
|
544
|
+
* @param {String} name
|
|
545
|
+
* @param {Object} [options]
|
|
546
|
+
* @param {Boolean} [options.force] - delete even if unmerged into HEAD
|
|
547
|
+
* @returns {Promise<String>} the deleted oid
|
|
548
|
+
*/
|
|
549
|
+
async function deleteBranch(repo, name, options = {}) {
|
|
550
|
+
await repo.assertNoExternalOperation('gent branch -d');
|
|
551
|
+
const ref = `refs/heads/${name}`;
|
|
552
|
+
const head = await repo.refs.head();
|
|
553
|
+
|
|
554
|
+
if (head.ref === ref) throw new OperationError(`cannot delete '${name}': it is the current branch`);
|
|
555
|
+
|
|
556
|
+
const oid = await repo.refs.resolveToOid(ref);
|
|
557
|
+
if (!oid) throw new OperationError(`branch '${name}' does not exist`);
|
|
558
|
+
|
|
559
|
+
if (!options.force && head.oid && !(await isAncestor(repo, oid, head.oid))) {
|
|
560
|
+
throw new OperationError(
|
|
561
|
+
`branch '${name}' is not fully merged into ${head.branch || 'HEAD'}; its commits would become unreachable.\n` +
|
|
562
|
+
`Use --force to delete it anyway.`,
|
|
563
|
+
'GENT_UNMERGED_BRANCH'
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
await repo.refs.delete(ref, { expectedOldOid: oid, reason: `branch: deleted ${name}` });
|
|
568
|
+
return oid;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ─── Tags ────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* @param {Object} repo
|
|
575
|
+
* @returns {Promise<Array<{name, oid, annotated, target, message}>>}
|
|
576
|
+
*/
|
|
577
|
+
async function listTags(repo) {
|
|
578
|
+
const refs = await repo.refs.list('refs/tags/');
|
|
579
|
+
const tags = [];
|
|
580
|
+
for (const [ref, oid] of refs) {
|
|
581
|
+
const object = await repo.objects.read(oid);
|
|
582
|
+
if (object.type === 'tag') {
|
|
583
|
+
const tag = await repo.objects.readTag(oid);
|
|
584
|
+
tags.push({
|
|
585
|
+
name: ref.slice('refs/tags/'.length),
|
|
586
|
+
oid,
|
|
587
|
+
annotated: true,
|
|
588
|
+
target: tag.object,
|
|
589
|
+
tagger: tag.tagger,
|
|
590
|
+
message: tag.message.toString('utf-8')
|
|
591
|
+
});
|
|
592
|
+
} else {
|
|
593
|
+
tags.push({ name: ref.slice('refs/tags/'.length), oid, annotated: false, target: oid, message: '' });
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return tags.sort((a, b) => a.name.localeCompare(b.name));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* @param {Object} repo
|
|
601
|
+
* @param {String} name
|
|
602
|
+
* @param {Object} [options]
|
|
603
|
+
* @param {String} [options.message] - creates an annotated tag when present
|
|
604
|
+
* @param {String} [options.target]
|
|
605
|
+
* @returns {Promise<{name: String, oid: String, annotated: Boolean}>}
|
|
606
|
+
*/
|
|
607
|
+
async function createTag(repo, name, options = {}) {
|
|
608
|
+
await repo.assertNoExternalOperation('gent tag');
|
|
609
|
+
const ref = `refs/tags/${name}`;
|
|
610
|
+
assertRefName(ref);
|
|
611
|
+
|
|
612
|
+
const targetOid = options.target
|
|
613
|
+
? await resolveRevision(repo, options.target)
|
|
614
|
+
: (await repo.refs.head()).oid;
|
|
615
|
+
if (!targetOid) throw new OperationError('cannot tag before the first commit');
|
|
616
|
+
|
|
617
|
+
if (!options.message) {
|
|
618
|
+
await repo.refs.update(ref, targetOid, { expectedOldOid: null, reason: `tag: ${name}` });
|
|
619
|
+
return { name, oid: targetOid, annotated: false };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const tagger = await repo.identity('committer');
|
|
623
|
+
if (!tagger) throw new OperationError('an annotated tag needs user.name and user.email', 'GENT_NO_IDENTITY');
|
|
624
|
+
|
|
625
|
+
const targetType = (await repo.objects.read(targetOid)).type;
|
|
626
|
+
const oid = await repo.objects.write('tag', serializeTag({
|
|
627
|
+
object: targetOid,
|
|
628
|
+
targetType,
|
|
629
|
+
tag: name,
|
|
630
|
+
tagger,
|
|
631
|
+
message: Buffer.from(String(options.message).replace(/\s+$/, '') + '\n', 'utf-8')
|
|
632
|
+
}));
|
|
633
|
+
|
|
634
|
+
await repo.refs.update(ref, oid, { expectedOldOid: null, reason: `tag: ${name}` });
|
|
635
|
+
return { name, oid, annotated: true };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* @param {Object} repo
|
|
640
|
+
* @param {String} name
|
|
641
|
+
* @returns {Promise<String>}
|
|
642
|
+
*/
|
|
643
|
+
async function deleteTag(repo, name) {
|
|
644
|
+
await repo.assertNoExternalOperation('gent tag -d');
|
|
645
|
+
const oid = await repo.refs.resolveToOid(`refs/tags/${name}`);
|
|
646
|
+
if (!oid) throw new OperationError(`tag '${name}' does not exist`);
|
|
647
|
+
await repo.refs.delete(`refs/tags/${name}`, { expectedOldOid: oid, reason: `tag: deleted ${name}` });
|
|
648
|
+
return oid;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ─── Checkout ────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Move HEAD, the index and the working tree together.
|
|
655
|
+
*
|
|
656
|
+
* @param {Object} repo
|
|
657
|
+
* @param {String} target - branch name, tag or revision
|
|
658
|
+
* @param {Object} [options]
|
|
659
|
+
* @param {Boolean} [options.create] - create the branch first
|
|
660
|
+
* @param {Boolean} [options.force] - discard local changes
|
|
661
|
+
* @param {Boolean} [options.detach]
|
|
662
|
+
* @returns {Promise<{branch: String|null, oid: String, written: Number, deleted: Number}>}
|
|
663
|
+
*/
|
|
664
|
+
async function checkout(repo, target, options = {}) {
|
|
665
|
+
await repo.assertNoExternalOperation('gent checkout');
|
|
666
|
+
await worktree.assertNoPendingCheckout(repo, 'gent checkout');
|
|
667
|
+
repo.requireWorktree('gent checkout');
|
|
668
|
+
|
|
669
|
+
const previousHead = await repo.refs.head();
|
|
670
|
+
if (options.create) await createBranch(repo, target);
|
|
671
|
+
|
|
672
|
+
const branchRef = `refs/heads/${target}`;
|
|
673
|
+
const branchOid = await repo.refs.resolveToOid(branchRef).catch(() => null);
|
|
674
|
+
const detach = options.detach || !branchOid;
|
|
675
|
+
|
|
676
|
+
const commitOid = await peelToCommit(repo, branchOid || await resolveRevision(repo, target));
|
|
677
|
+
const targetTree = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(commitOid)).tree);
|
|
678
|
+
|
|
679
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
680
|
+
if (!options.force) {
|
|
681
|
+
const state = await worktree.status(repo, { index });
|
|
682
|
+
if (state.staged.length || state.conflicted.length) {
|
|
683
|
+
throw new OperationError('commit or stash staged changes before switching branches', 'GENT_CHECKOUT_BLOCKED');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const current = new Map(index.staged().map(e => [e.path, { mode: e.mode, oid: e.oid }]));
|
|
687
|
+
|
|
688
|
+
const plan = await worktree.planCheckout(repo, { from: current, to: targetTree, index, force: options.force });
|
|
689
|
+
const applied = await worktree.applyCheckout(repo, plan, { index });
|
|
690
|
+
await index.write(repo.indexPath);
|
|
691
|
+
|
|
692
|
+
if (detach) {
|
|
693
|
+
await repo.refs.setHeadDetached(commitOid, `checkout: moving to ${target}`, previousHead);
|
|
694
|
+
} else {
|
|
695
|
+
await repo.refs.setHeadSymbolic(branchRef, `checkout: moving to ${target}`, previousHead);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
await clearMergeState(repo);
|
|
699
|
+
await worktree.completeCheckout(repo);
|
|
700
|
+
return { branch: detach ? null : target, oid: commitOid, ...applied };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Replace the working-tree copy of specific paths with their index content.
|
|
705
|
+
* @param {Object} repo
|
|
706
|
+
* @param {Array<String>} paths
|
|
707
|
+
* @returns {Promise<Number>} files restored
|
|
708
|
+
*/
|
|
709
|
+
async function checkoutPaths(repo, paths) {
|
|
710
|
+
await repo.assertNoExternalOperation('gent checkout -- <paths>');
|
|
711
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
712
|
+
const wanted = await expandPaths(repo, paths, { index });
|
|
713
|
+
|
|
714
|
+
const to = new Map();
|
|
715
|
+
for (const relativePath of wanted) {
|
|
716
|
+
const entry = index.get(relativePath);
|
|
717
|
+
if (entry) to.set(relativePath, { mode: entry.mode, oid: entry.oid });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const plan = await worktree.planCheckout(repo, { from: new Map(), to, index, force: true });
|
|
721
|
+
const applied = await worktree.applyCheckout(repo, plan);
|
|
722
|
+
await worktree.completeCheckout(repo);
|
|
723
|
+
return applied.written;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ─── Reset ───────────────────────────────────────────────
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* @param {Object} repo
|
|
730
|
+
* @param {String} mode - 'soft' | 'mixed' | 'hard'
|
|
731
|
+
* @param {String} [target] - defaults to HEAD
|
|
732
|
+
* @returns {Promise<{oid: String, mode: String, written: Number, deleted: Number}>}
|
|
733
|
+
*/
|
|
734
|
+
async function reset(repo, mode, target) {
|
|
735
|
+
await repo.assertNoExternalOperation('gent reset');
|
|
736
|
+
await worktree.assertNoPendingCheckout(repo, 'gent reset');
|
|
737
|
+
if (!['soft', 'mixed', 'hard'].includes(mode)) throw new OperationError(`unknown reset mode '${mode}'`);
|
|
738
|
+
|
|
739
|
+
const head = await repo.refs.head();
|
|
740
|
+
const commitOid = await peelToCommit(repo, target ? await resolveRevision(repo, target) : head.oid);
|
|
741
|
+
const targetTree = await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(commitOid)).tree);
|
|
742
|
+
|
|
743
|
+
if (head.oid) await writeAtomic(repo.gitPath('ORIG_HEAD'), head.oid + '\n');
|
|
744
|
+
|
|
745
|
+
let applied = { written: 0, deleted: 0 };
|
|
746
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
747
|
+
|
|
748
|
+
if (mode === 'hard') {
|
|
749
|
+
repo.requireWorktree('gent reset --hard');
|
|
750
|
+
const current = new Map(index.staged().map(e => [e.path, { mode: e.mode, oid: e.oid }]));
|
|
751
|
+
const plan = await worktree.planCheckout(repo, { from: current, to: targetTree, index, force: true });
|
|
752
|
+
applied = await worktree.applyCheckout(repo, plan, { index });
|
|
753
|
+
await index.write(repo.indexPath);
|
|
754
|
+
} else if (mode === 'mixed') {
|
|
755
|
+
const rebuilt = new GitIndex();
|
|
756
|
+
rebuilt.sourceBytes = index.sourceBytes;
|
|
757
|
+
for (const [filePath, entry] of targetTree) {
|
|
758
|
+
rebuilt.add(new IndexEntry({ path: filePath, oid: entry.oid, mode: entry.mode }));
|
|
759
|
+
}
|
|
760
|
+
await rebuilt.write(repo.indexPath);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (head.detached) {
|
|
764
|
+
await repo.refs.setHeadDetached(commitOid, `reset: moving to ${target || 'HEAD'}`, head);
|
|
765
|
+
} else if (head.ref) {
|
|
766
|
+
await repo.refs.update(head.ref, commitOid, { expectedOldOid: head.oid, reason: `reset: moving to ${target || 'HEAD'}` });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
await clearMergeState(repo);
|
|
770
|
+
await worktree.completeCheckout(repo);
|
|
771
|
+
return { oid: commitOid, mode, ...applied };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Unstage paths: rewrite their index entries from HEAD, leaving files alone.
|
|
776
|
+
* @param {Object} repo
|
|
777
|
+
* @param {Array<String>} paths
|
|
778
|
+
* @returns {Promise<Array<String>>}
|
|
779
|
+
*/
|
|
780
|
+
async function unstagePaths(repo, paths) {
|
|
781
|
+
await repo.assertNoExternalOperation('gent reset <paths>');
|
|
782
|
+
const head = await repo.refs.head();
|
|
783
|
+
const headTree = head.oid
|
|
784
|
+
? await worktree.readTreeRecursive(repo, (await repo.objects.readCommit(head.oid)).tree)
|
|
785
|
+
: new Map();
|
|
786
|
+
|
|
787
|
+
const index = await GitIndex.read(repo.indexPath);
|
|
788
|
+
const wanted = await expandPaths(repo, paths, { index });
|
|
789
|
+
const changed = [];
|
|
790
|
+
|
|
791
|
+
for (const relativePath of wanted) {
|
|
792
|
+
const target = headTree.get(relativePath);
|
|
793
|
+
if (!target) {
|
|
794
|
+
if (index.remove(relativePath)) changed.push(relativePath);
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
const existing = index.get(relativePath);
|
|
798
|
+
if (existing && existing.oid === target.oid && existing.mode === target.mode) continue;
|
|
799
|
+
|
|
800
|
+
index.add(new IndexEntry({ path: relativePath, oid: target.oid, mode: target.mode }));
|
|
801
|
+
changed.push(relativePath);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
await index.write(repo.indexPath);
|
|
805
|
+
return changed;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
module.exports = {
|
|
809
|
+
OperationError,
|
|
810
|
+
openRepository,
|
|
811
|
+
resolveRevision,
|
|
812
|
+
peelToCommit,
|
|
813
|
+
walkHistory,
|
|
814
|
+
findMergeBase,
|
|
815
|
+
isAncestor,
|
|
816
|
+
expandPaths,
|
|
817
|
+
addPaths,
|
|
818
|
+
removePaths,
|
|
819
|
+
createCommit,
|
|
820
|
+
readMergeState,
|
|
821
|
+
writeMergeState,
|
|
822
|
+
clearMergeState,
|
|
823
|
+
listBranches,
|
|
824
|
+
createBranch,
|
|
825
|
+
deleteBranch,
|
|
826
|
+
listTags,
|
|
827
|
+
createTag,
|
|
828
|
+
deleteTag,
|
|
829
|
+
checkout,
|
|
830
|
+
checkoutPaths,
|
|
831
|
+
reset,
|
|
832
|
+
unstagePaths,
|
|
833
|
+
status: worktree.status,
|
|
834
|
+
readTreeRecursive: worktree.readTreeRecursive,
|
|
835
|
+
buildTreeFromIndex: worktree.buildTreeFromIndex
|
|
836
|
+
};
|