gent-cli 15.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,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
+ };