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,533 @@
1
+ /**
2
+ * ============================================================================
3
+ * Repository - discovery, validation and the handle everything else uses
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Find the repository from any directory, decide whether Gent can safely
8
+ * operate on it, and hand back one object carrying the object store, refs,
9
+ * config and paths.
10
+ *
11
+ * LAYOUTS UNDERSTOOD:
12
+ * <root>/.gent/ Gent-created; a .git *file* points at it
13
+ * <root>/.git/ ordinary Git clone
14
+ * <root>/.git a gitfile: "gitdir: <path>"
15
+ * bare repositories (core.bare = true)
16
+ * linked worktrees (gitdir + commondir files)
17
+ *
18
+ * VALIDATION HAPPENS BEFORE ANY WRITE:
19
+ * repositoryformatversion, unknown required extensions, the object format,
20
+ * the ref backend and any in-progress external Git operation are all checked
21
+ * at open time. An unsupported repository is refused intact.
22
+ *
23
+ * BRANDING IS NOT VALIDITY:
24
+ * The directory being called `.gent` never makes a repository valid, and its
25
+ * being called `.git` never makes one invalid.
26
+ * ============================================================================
27
+ */
28
+
29
+ const fs = require('fs').promises;
30
+ const path = require('path');
31
+ const os = require('os');
32
+
33
+ const { ObjectStore } = require('./object-store');
34
+ const { PackStore } = require('./packfile');
35
+ const { RefStore } = require('./refs');
36
+ const { ConfigFile, ConfigSet, expandIncludes } = require('./git-config');
37
+ const { UnsupportedFeatureError, feature, FORMAT_MARKER, OBJECT_FORMAT, REPOSITORY_FORMAT_VERSION } = require('./feature-support');
38
+ const { readFileOrNull, writeAtomic } = require('./lockfile');
39
+ const { timezoneOffset } = require('./git-objects');
40
+
41
+ const GENT_DIR = '.gent';
42
+ const GIT_DIR = '.git';
43
+
44
+ /** Extensions Gent understands. Anything else refuses the repository. */
45
+ const KNOWN_EXTENSIONS = new Set(['objectformat', 'worktreeconfig', 'preciousobjects', 'compatobjectformat']);
46
+
47
+ /** Marker files that mean another tool is mid-operation. */
48
+ const IN_PROGRESS_MARKERS = [
49
+ ['rebase-merge', 'an interactive rebase'],
50
+ ['rebase-apply', 'a rebase or "git am"'],
51
+ ['CHERRY_PICK_HEAD', 'a cherry-pick'],
52
+ ['REVERT_HEAD', 'a revert'],
53
+ ['BISECT_LOG', 'a bisect'],
54
+ ['sequencer', 'a sequencer operation']
55
+ ];
56
+
57
+ class RepositoryError extends Error {
58
+ constructor(message, code) {
59
+ super(message);
60
+ this.name = 'RepositoryError';
61
+ this.code = code || 'GENT_NOT_A_REPOSITORY';
62
+ }
63
+ }
64
+
65
+ /** A pre-v13 repository: JSON history, no canonical config. */
66
+ class LegacyRepositoryError extends Error {
67
+ constructor(gentPath) {
68
+ super(
69
+ `'${gentPath}' is a Gent v12 repository.\n` +
70
+ `Its history is stored in commits.json with randomly generated commit ids, which v13 cannot read.\n` +
71
+ `Run "gent migrate --dry-run" to see exactly what conversion would do; nothing is modified until you run "gent migrate".`
72
+ );
73
+ this.name = 'LegacyRepositoryError';
74
+ this.code = 'GENT_LEGACY_REPOSITORY';
75
+ this.gentPath = gentPath;
76
+ }
77
+ }
78
+
79
+ class Repository {
80
+ /**
81
+ * @param {Object} parts
82
+ */
83
+ constructor(parts) {
84
+ /** @type {String} the per-worktree git directory */
85
+ this.gitdir = parts.gitdir;
86
+ /** @type {String} shared objects/refs directory (differs in linked worktrees) */
87
+ this.commondir = parts.commondir;
88
+ /** @type {String|null} null for a bare repository */
89
+ this.worktree = parts.worktree;
90
+ /** @type {Boolean} */
91
+ this.bare = parts.bare;
92
+ /** @type {ConfigSet} */
93
+ this.config = parts.config;
94
+ /** @type {ConfigFile} the file `gent config` writes to */
95
+ this.localConfig = parts.localConfig;
96
+ /** @type {Boolean} true when the gitdir is named .gent */
97
+ this.gentBranded = path.basename(this.commondir) === GENT_DIR;
98
+
99
+ const objectsDir = path.join(this.commondir, 'objects');
100
+ this.objects = new ObjectStore(objectsDir, { packBackend: new PackStore(objectsDir) });
101
+ this.refs = new RefStore(this);
102
+ }
103
+
104
+ /** Gent's own metadata namespace inside the resolved gitdir. */
105
+ get gentMetaDir() {
106
+ return path.join(this.commondir, 'gent');
107
+ }
108
+
109
+ /** Per-worktree Gent metadata (operation state that must not be shared). */
110
+ get gentWorktreeMetaDir() {
111
+ return path.join(this.gitdir, 'gent');
112
+ }
113
+
114
+ get indexPath() {
115
+ return path.join(this.gitdir, 'index');
116
+ }
117
+
118
+ /**
119
+ * @param {String} name
120
+ * @returns {String}
121
+ */
122
+ gitPath(name) {
123
+ return path.join(this.gitdir, name);
124
+ }
125
+
126
+ /**
127
+ * @param {String} name
128
+ * @returns {String}
129
+ */
130
+ commonPath(name) {
131
+ return path.join(this.commondir, name);
132
+ }
133
+
134
+ /**
135
+ * Committer/author identity from config, or null when unset.
136
+ * @param {String} [kind] - 'author' | 'committer'
137
+ * @returns {Promise<{name, email, timestamp, timezone}|null>}
138
+ */
139
+ async identity(kind = 'committer') {
140
+ const now = new Date();
141
+ const name = process.env[`GIT_${kind.toUpperCase()}_NAME`] || this.config.get('user.name');
142
+ const email = process.env[`GIT_${kind.toUpperCase()}_EMAIL`] || this.config.get('user.email');
143
+ if (!name || !email) return null;
144
+ return {
145
+ name,
146
+ email,
147
+ timestamp: Math.floor(now.getTime() / 1000),
148
+ timezone: timezoneOffset(now)
149
+ };
150
+ }
151
+
152
+ /** Used by RefStore for reflog lines; absent identity means no reflog. */
153
+ async reflogIdentity() {
154
+ return this.identity('committer');
155
+ }
156
+
157
+ /**
158
+ * Refuse to write while another tool holds the repository mid-operation.
159
+ * @param {String} what - the Gent operation being attempted
160
+ */
161
+ async assertNoExternalOperation(what) {
162
+ for (const [marker, description] of IN_PROGRESS_MARKERS) {
163
+ for (const candidate of [this.gitPath(marker), this.commonPath(marker)]) {
164
+ const present = await fs.access(candidate).then(() => true, () => false);
165
+ if (!present) continue;
166
+ throw new UnsupportedFeatureError(
167
+ [{ ...feature('sequencer.external'), detail: `${description} is in progress (${path.basename(candidate)} exists)` }],
168
+ what
169
+ );
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * @returns {Promise<String>} absolute path
176
+ * @throws when the repository is bare
177
+ */
178
+ requireWorktree(what) {
179
+ if (!this.worktree) {
180
+ throw new RepositoryError(`${what} requires a working tree, and this is a bare repository`, 'GENT_BARE_REPOSITORY');
181
+ }
182
+ return this.worktree;
183
+ }
184
+
185
+ /**
186
+ * Path of a worktree file relative to the worktree root, in POSIX form.
187
+ * @param {String} absolutePath
188
+ * @returns {String}
189
+ */
190
+ relativePath(absolutePath) {
191
+ const root = this.requireWorktree('resolving a path');
192
+ const relative = path.relative(root, path.resolve(absolutePath));
193
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
194
+ throw new RepositoryError(`'${absolutePath}' is outside the repository at ${root}`, 'GENT_OUTSIDE_WORKTREE');
195
+ }
196
+ return relative.split(path.sep).join('/');
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Read a gitfile ("gitdir: <path>") and resolve it.
202
+ * @param {String} filePath
203
+ * @returns {Promise<String|null>}
204
+ */
205
+ async function readGitfile(filePath) {
206
+ const raw = await readFileOrNull(filePath);
207
+ if (!raw) return null;
208
+
209
+ const text = raw.toString('utf-8').trim();
210
+ if (!text.startsWith('gitdir:')) return null;
211
+
212
+ const target = text.slice('gitdir:'.length).trim();
213
+ if (!target) throw new RepositoryError(`gitfile '${filePath}' names no directory`);
214
+ return path.resolve(path.dirname(filePath), target);
215
+ }
216
+
217
+ /**
218
+ * Follow a linked worktree's `commondir` file.
219
+ * @param {String} gitdir
220
+ * @returns {Promise<String>}
221
+ */
222
+ async function resolveCommonDir(gitdir) {
223
+ const raw = await readFileOrNull(path.join(gitdir, 'commondir'));
224
+ if (!raw) return gitdir;
225
+ const target = raw.toString('utf-8').trim();
226
+ return path.resolve(gitdir, target);
227
+ }
228
+
229
+ /**
230
+ * Locate the git directory by walking up from a starting directory.
231
+ * @param {String} [startDir]
232
+ * @returns {Promise<{gitdir: String, commondir: String, worktree: String|null}>}
233
+ */
234
+ async function findGitdir(startDir = process.cwd()) {
235
+ if (process.env.GIT_DIR) {
236
+ const gitdir = path.resolve(process.env.GIT_DIR);
237
+ return {
238
+ gitdir,
239
+ commondir: await resolveCommonDir(gitdir),
240
+ worktree: process.env.GIT_WORK_TREE ? path.resolve(process.env.GIT_WORK_TREE) : null
241
+ };
242
+ }
243
+
244
+ let current = await fs.realpath(startDir);
245
+ const root = path.parse(current).root;
246
+
247
+ for (;;) {
248
+ for (const name of [GENT_DIR, GIT_DIR]) {
249
+ const candidate = path.join(current, name);
250
+ let stat;
251
+ try {
252
+ stat = await fs.stat(candidate);
253
+ } catch {
254
+ continue;
255
+ }
256
+
257
+ if (stat.isDirectory()) {
258
+ if (name === GENT_DIR && !(await looksLikeGitdir(candidate)) && !(await looksLikeLegacyGentdir(candidate))) {
259
+ continue;
260
+ }
261
+ const commondir = await resolveCommonDir(candidate);
262
+ return { gitdir: candidate, commondir, worktree: current };
263
+ }
264
+ if (stat.isFile()) {
265
+ const target = await readGitfile(candidate);
266
+ if (!target) continue;
267
+ const commondir = await resolveCommonDir(target);
268
+ return { gitdir: target, commondir, worktree: current };
269
+ }
270
+ }
271
+
272
+ // A bare repository: the directory we are standing in *is* the gitdir.
273
+ if (await looksLikeGitdir(current)) {
274
+ return { gitdir: current, commondir: await resolveCommonDir(current), worktree: null };
275
+ }
276
+
277
+ if (current === root) {
278
+ throw new RepositoryError(
279
+ `not a Gent repository (or any parent up to ${root}): no .gent or .git found`
280
+ );
281
+ }
282
+ current = path.dirname(current);
283
+ }
284
+ }
285
+
286
+ /**
287
+ * @param {String} dir
288
+ * @returns {Promise<Boolean>}
289
+ */
290
+ async function looksLikeGitdir(dir) {
291
+ try {
292
+ const [head, objects, refs] = await Promise.all([
293
+ fs.stat(path.join(dir, 'HEAD')).then(() => true, () => false),
294
+ fs.stat(path.join(dir, 'objects')).then(s => s.isDirectory(), () => false),
295
+ fs.stat(path.join(dir, 'refs')).then(s => s.isDirectory(), () => false)
296
+ ]);
297
+ return head && objects && refs;
298
+ } catch {
299
+ return false;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * @param {String} dir
305
+ * @returns {Promise<Boolean>}
306
+ */
307
+ async function looksLikeLegacyGentdir(dir) {
308
+ return fs.access(path.join(dir, 'commits.json')).then(() => true, () => false);
309
+ }
310
+
311
+ /**
312
+ * Build the layered config: system < global < local < worktree.
313
+ * @param {String} commondir
314
+ * @param {String} gitdir
315
+ * @returns {Promise<{set: ConfigSet, local: ConfigFile}>}
316
+ */
317
+ async function loadConfig(commondir, gitdir) {
318
+ const localPath = path.join(commondir, 'config');
319
+ const local = (await ConfigFile.load(localPath)) || new ConfigFile('', localPath);
320
+
321
+ const context = { gitdir: commondir, worktree: null, branch: null };
322
+ const files = [];
323
+
324
+ if (!process.env.GIT_CONFIG_NOSYSTEM) {
325
+ for (const candidate of ['/etc/gitconfig', '/usr/local/etc/gitconfig']) {
326
+ files.push(...await expandIncludes(await ConfigFile.load(candidate), context));
327
+ }
328
+ }
329
+
330
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
331
+ for (const candidate of [path.join(xdg, 'git', 'config'), path.join(os.homedir(), '.gitconfig')]) {
332
+ files.push(...await expandIncludes(await ConfigFile.load(candidate), context));
333
+ }
334
+
335
+ files.push(...await expandIncludes(local, context));
336
+
337
+ const set = new ConfigSet(files);
338
+ if (set.getBoolean('extensions.worktreeConfig', false)) {
339
+ const worktreeConfig = await ConfigFile.load(path.join(gitdir, 'config.worktree'));
340
+ if (worktreeConfig) files.push(...await expandIncludes(worktreeConfig, context));
341
+ }
342
+
343
+ return { set: new ConfigSet(files), local };
344
+ }
345
+
346
+ /**
347
+ * Refuse repositories whose format Gent does not implement.
348
+ * @param {ConfigSet} config
349
+ * @param {String} commondir
350
+ */
351
+ function assertSupportedFormat(config, commondir) {
352
+ const version = config.getInt('core.repositoryformatversion', 0);
353
+ const violations = [];
354
+
355
+ if (version > REPOSITORY_FORMAT_VERSION) {
356
+ throw new RepositoryError(
357
+ `repository format version ${version} at ${commondir} is newer than this Gent understands (${REPOSITORY_FORMAT_VERSION})`,
358
+ 'GENT_UNKNOWN_FORMAT'
359
+ );
360
+ }
361
+
362
+ const objectFormat = version >= 1 ? config.get('extensions.objectFormat') : undefined;
363
+ if (objectFormat === undefined) {
364
+ violations.push({
365
+ ...feature('object-format.sha1'),
366
+ detail: `${commondir} has no extensions.objectFormat, so it is a SHA-1 repository`
367
+ });
368
+ } else if (objectFormat.toLowerCase() !== OBJECT_FORMAT) {
369
+ violations.push({ ...feature('object-format.sha1'), detail: `extensions.objectFormat is '${objectFormat}'` });
370
+ }
371
+
372
+ if (version >= 1) {
373
+ for (const [name] of config.list()) {
374
+ if (!name.startsWith('extensions.')) continue;
375
+ const extension = name.slice('extensions.'.length).toLowerCase();
376
+ if (extension === 'refstorage') {
377
+ const backend = config.get(name);
378
+ if (backend && backend.toLowerCase() !== 'files') {
379
+ violations.push({ ...feature('refs.reftable'), detail: `extensions.refStorage is '${backend}'` });
380
+ }
381
+ continue;
382
+ }
383
+ if (extension === 'partialclone') {
384
+ violations.push({ ...feature('transport.shallow'), detail: `extensions.partialClone is set (promisor remote '${config.get(name)}')` });
385
+ continue;
386
+ }
387
+ if (!KNOWN_EXTENSIONS.has(extension)) {
388
+ violations.push({
389
+ id: `extensions.${extension}`,
390
+ status: 'unsupported',
391
+ title: `Repository extension '${extension}'`,
392
+ detail: `${commondir} requires an extension Gent does not implement`,
393
+ remedy: 'Use Git for this repository, or remove the extension if it is no longer needed.'
394
+ });
395
+ }
396
+ }
397
+ }
398
+
399
+ if (violations.length) throw new UnsupportedFeatureError(violations, `opening ${commondir}`);
400
+ }
401
+
402
+ /**
403
+ * Detect a v12 repository so the caller can explain migration instead of
404
+ * failing with a confusing format error.
405
+ * @param {String} gitdir
406
+ * @returns {Promise<Boolean>}
407
+ */
408
+ async function isLegacyRepository(gitdir) {
409
+ const hasCommitsJson = await fs.access(path.join(gitdir, 'commits.json')).then(() => true, () => false);
410
+ if (!hasCommitsJson) return false;
411
+ const hasCanonicalConfig = await fs.access(path.join(gitdir, 'config')).then(() => true, () => false);
412
+ return !hasCanonicalConfig;
413
+ }
414
+
415
+ /**
416
+ * Open the repository containing `startDir`.
417
+ * @param {String} [startDir]
418
+ * @returns {Promise<Repository>}
419
+ */
420
+ async function open(startDir = process.cwd()) {
421
+ const located = await findGitdir(startDir);
422
+
423
+ if (await isLegacyRepository(located.commondir)) {
424
+ throw new LegacyRepositoryError(located.commondir);
425
+ }
426
+
427
+ const { set, local } = await loadConfig(located.commondir, located.gitdir);
428
+ assertSupportedFormat(set, located.commondir);
429
+
430
+ const bare = set.getBoolean('core.bare', located.worktree === null);
431
+ let worktree = bare ? null : located.worktree;
432
+
433
+ const configuredWorktree = set.get('core.worktree');
434
+ if (!bare && configuredWorktree) {
435
+ worktree = path.resolve(located.commondir, configuredWorktree);
436
+ }
437
+ if (!bare && !worktree) {
438
+ const raw = await readFileOrNull(path.join(located.gitdir, 'gitdir'));
439
+ if (raw) worktree = path.dirname(raw.toString('utf-8').trim());
440
+ }
441
+
442
+ return new Repository({
443
+ gitdir: located.gitdir,
444
+ commondir: located.commondir,
445
+ worktree,
446
+ bare,
447
+ config: set,
448
+ localConfig: local
449
+ });
450
+ }
451
+
452
+ /**
453
+ * Create a new repository.
454
+ *
455
+ * Gent-created repositories keep the `.gent` directory name and add a `.git`
456
+ * gitfile so tools with hard-coded discovery still find them. `.gent` itself
457
+ * is excluded through info/exclude, not through a hidden built-in rule.
458
+ *
459
+ * @param {String} directory
460
+ * @param {Object} [options]
461
+ * @param {Boolean} [options.bare]
462
+ * @param {String} [options.defaultBranch]
463
+ * @param {Boolean} [options.useGitDirName] - name the directory .git instead
464
+ * @returns {Promise<{repo: Repository, created: Boolean, gitdir: String}>}
465
+ */
466
+ async function init(directory, options = {}) {
467
+ await fs.mkdir(path.resolve(directory), { recursive: true });
468
+ const root = await fs.realpath(directory);
469
+ // Reinitialization must never turn legacy/SHA-1 metadata into SHA-256
470
+ // metadata or overwrite the pointer of another repository.
471
+ for (const name of (options.bare ? ['HEAD'] : [GIT_DIR, GENT_DIR])) {
472
+ if (await fs.lstat(path.join(root, name)).then(() => true, error => {
473
+ if (error.code === 'ENOENT') return false;
474
+ throw error;
475
+ })) {
476
+ const repo = await open(root);
477
+ return { repo, created: false, gitdir: repo.gitdir };
478
+ }
479
+ }
480
+ const dirName = options.bare ? '' : (options.useGitDirName ? GIT_DIR : GENT_DIR);
481
+ const gitdir = options.bare ? root : path.join(root, dirName);
482
+
483
+ const existed = await fs.access(path.join(gitdir, 'HEAD')).then(() => true, () => false);
484
+
485
+ await fs.mkdir(path.join(gitdir, 'objects', 'pack'), { recursive: true });
486
+ await fs.mkdir(path.join(gitdir, 'objects', 'info'), { recursive: true });
487
+ await fs.mkdir(path.join(gitdir, 'refs', 'heads'), { recursive: true });
488
+ await fs.mkdir(path.join(gitdir, 'refs', 'tags'), { recursive: true });
489
+ await fs.mkdir(path.join(gitdir, 'info'), { recursive: true });
490
+ await fs.mkdir(path.join(gitdir, 'gent'), { recursive: true });
491
+
492
+ const branch = options.defaultBranch || 'main';
493
+ if (!existed) {
494
+ await writeAtomic(path.join(gitdir, 'HEAD'), `ref: refs/heads/${branch}\n`);
495
+ }
496
+
497
+ const configPath = path.join(gitdir, 'config');
498
+ const config = (await ConfigFile.load(configPath)) || new ConfigFile('', configPath);
499
+ config.set('core.repositoryformatversion', String(REPOSITORY_FORMAT_VERSION));
500
+ config.set('core.filemode', 'true');
501
+ config.set('core.bare', options.bare ? 'true' : 'false');
502
+ if (!options.bare) config.set('core.logallrefupdates', 'true');
503
+ config.set('extensions.objectFormat', OBJECT_FORMAT);
504
+ config.set('gent.format', FORMAT_MARKER);
505
+ await config.save();
506
+
507
+ // Exclude our own metadata directory the way Git excludes anything else.
508
+ if (!options.bare && dirName === GENT_DIR) {
509
+ const excludePath = path.join(gitdir, 'info', 'exclude');
510
+ const existing = (await readFileOrNull(excludePath))?.toString('utf-8') || '';
511
+ if (!existing.split('\n').some(line => line.trim() === '/.gent/')) {
512
+ await writeAtomic(excludePath, `${existing}${existing && !existing.endsWith('\n') ? '\n' : ''}/.gent/\n`);
513
+ }
514
+ await writeAtomic(path.join(root, GIT_DIR), `gitdir: ${GENT_DIR}\n`);
515
+ }
516
+
517
+ return { repo: await open(options.bare ? gitdir : root), created: !existed, gitdir };
518
+ }
519
+
520
+ module.exports = {
521
+ Repository,
522
+ RepositoryError,
523
+ LegacyRepositoryError,
524
+ open,
525
+ init,
526
+ findGitdir,
527
+ isLegacyRepository,
528
+ readGitfile,
529
+ resolveCommonDir,
530
+ loadConfig,
531
+ GENT_DIR,
532
+ GIT_DIR
533
+ };