gent-cli 15.0.0 → 21.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/src/index.js CHANGED
@@ -30,33 +30,34 @@ const { program } = require('commander');
30
30
  const chalk = require('chalk');
31
31
  const packageJson = require('../package.json');
32
32
  const interactive = require('./utils/interactive');
33
+ const { route } = require('./commands/canonical');
33
34
 
34
35
  // Import core commands
35
- const autoCommand = require('./commands/auto');
36
- const initCommand = require('./commands/init');
36
+ const autoCommand = route('auto', require('./commands/auto'));
37
+ const initCommand = route('init', require('./commands/init'));
37
38
  const cloneCommand = require('./commands/clone');
38
- const statusCommand = require('./commands/status');
39
- const addCommand = require('./commands/add');
40
- const rmCommand = require('./commands/rm');
41
- const resetCommand = require('./commands/reset');
42
- const diffCommand = require('./commands/diff');
43
- const commitCommand = require('./commands/commit');
44
- const logCommand = require('./commands/log');
45
- const showCommand = require('./commands/show');
46
- const tagCommand = require('./commands/tag');
47
- const branchCommand = require('./commands/branch');
48
- const checkoutCommand = require('./commands/checkout');
49
- const mergeCommand = require('./commands/merge');
50
- const stashCommand = require('./commands/stash');
51
- const remoteCommand = require('./commands/remote');
52
- const pushCommand = require('./commands/push');
53
- const pullCommand = require('./commands/pull');
39
+ const statusCommand = route('status', require('./commands/status'));
40
+ const addCommand = route('add', require('./commands/add'));
41
+ const rmCommand = route('rm', require('./commands/rm'));
42
+ const resetCommand = route('reset', require('./commands/reset'));
43
+ const diffCommand = route('diff', require('./commands/diff'));
44
+ const commitCommand = route('commit', require('./commands/commit'));
45
+ const logCommand = route('log', require('./commands/log'));
46
+ const showCommand = route('show', require('./commands/show'));
47
+ const tagCommand = route('tag', require('./commands/tag'));
48
+ const branchCommand = route('branch', require('./commands/branch'));
49
+ const checkoutCommand = route('checkout', require('./commands/checkout'));
50
+ const mergeCommand = route('merge', require('./commands/merge'));
51
+ const stashCommand = route('stash', require('./commands/stash'));
52
+ const remoteCommand = route('remote', require('./commands/remote'));
53
+ const pushCommand = route('push', require('./commands/push'));
54
+ const pullCommand = route('pull', require('./commands/pull'));
54
55
  const reposCommand = require('./commands/repos');
55
56
  const membersCommand = require('./commands/members');
56
- const undoCommand = require('./commands/undo');
57
- const resolveCommand = require('./commands/resolve');
58
- const summaryCommand = require('./commands/summary');
59
- const explainCommand = require('./commands/explain');
57
+ const undoCommand = route('undo', require('./commands/undo'));
58
+ const resolveCommand = route('resolve', require('./commands/resolve'));
59
+ const summaryCommand = route('summary', require('./commands/summary'));
60
+ const explainCommand = route('explain', require('./commands/explain'));
60
61
 
61
62
  // Import auth commands
62
63
  const registerCommand = require('./commands/register');
@@ -66,16 +67,16 @@ const whoamiCommand = require('./commands/whoami');
66
67
  const passwordCommand = require('./commands/password');
67
68
 
68
69
  // Import new gent-platform commands
69
- const configCommand = require('./commands/config');
70
+ const configCommand = route('config', require('./commands/config'));
70
71
  const doctorCommand = require('./commands/doctor');
71
72
  const setupCommand = require('./commands/setup');
72
73
  const aiCommand = require('./commands/ai');
73
- const askCommand = require('./commands/ask');
74
- const reviewCommand = require('./commands/review');
75
- const docsCommand = require('./commands/docs');
76
- const changelogCommand = require('./commands/changelog');
74
+ const askCommand = route('ask', require('./commands/ask'));
75
+ const reviewCommand = route('review', require('./commands/review'));
76
+ const docsCommand = route('docs', require('./commands/docs'));
77
+ const changelogCommand = route('changelog', require('./commands/changelog'));
77
78
  const webCommand = require('./commands/web');
78
- const shareCommand = require('./commands/share');
79
+ const shareCommand = route('share', require('./commands/share'));
79
80
  const searchCommand = require('./commands/search');
80
81
  const templateCommand = require('./commands/template');
81
82
  const petCommand = require('./commands/pet');
@@ -97,9 +98,21 @@ program
97
98
  .command('init')
98
99
  .description('Initialize a new gent repository')
99
100
  .option('-y, --yes', 'Skip prompts and use defaults')
101
+ .option('--object-format <format>', 'Opt into the canonical SHA-256 engine')
100
102
  .option('--remote [name]', 'Create a remote repository on the backend')
101
103
  .action(initCommand);
102
104
 
105
+ program
106
+ .command('migrate')
107
+ .description('Migrate an offline legacy repository with a verified backup')
108
+ .option('--dry-run', 'Inspect and validate without changing repository state')
109
+ .option('--continue', 'Complete an interrupted migration')
110
+ .option('--abort', 'Roll back an interrupted migration if no newer state exists')
111
+ .action(async options => {
112
+ try { console.log(JSON.stringify(await require('./utils/migrate').migrate(process.cwd(), options), null, 2)); }
113
+ catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 1; }
114
+ });
115
+
103
116
  program
104
117
  .command('clone [url] [directory]')
105
118
  .description('Clone a remote repository')
@@ -111,6 +124,11 @@ program
111
124
  console.error(chalk.red('error: missing url — usage: gent clone <url> [directory]'));
112
125
  process.exit(1);
113
126
  }
127
+ if (/^https?:/.test(url) && /\.git\/?$/.test(url)) {
128
+ try { console.log(`Cloned to ${await require('./utils/smart-http').clone(url, directory)}`); }
129
+ catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 1; }
130
+ return;
131
+ }
114
132
  return cloneCommand(url, directory, options);
115
133
  });
116
134
 
@@ -152,8 +170,8 @@ program
152
170
  program
153
171
  .command('reset [files...]')
154
172
  .description('Unstage files or reset HEAD to a commit')
155
- .option('--hard <hash>', 'Reset HEAD and working tree to commit')
156
- .option('--soft <hash>', 'Reset HEAD but keep staging')
173
+ .option('--hard [hash]', 'Reset HEAD and working tree to commit')
174
+ .option('--soft [hash]', 'Reset HEAD but keep staging')
157
175
  .action(resetCommand);
158
176
 
159
177
  program
@@ -221,13 +239,15 @@ program
221
239
  .command('checkout [branch]')
222
240
  .description('Switch branches or restore working tree files')
223
241
  .option('-b, --create', 'Create a new branch')
242
+ .option('--abort', 'Recover an interrupted canonical checkout')
243
+ .option('-f, --force', 'Discard local changes when switching')
224
244
  .action(async (branch, options) => {
225
- if (!branch && interactive.isInteractive()) {
245
+ if (!branch && !options.abort && interactive.isInteractive()) {
226
246
  const picked = await interactive.promptCheckout();
227
247
  branch = picked.branch;
228
248
  options.create = picked.create; // picker decides: existing branch ⇒ switch, not create
229
249
  }
230
- if (!branch) {
250
+ if (!branch && !options.abort) {
231
251
  console.error(chalk.red('error: missing branch — usage: gent checkout <branch>'));
232
252
  process.exit(1);
233
253
  }
@@ -238,8 +258,10 @@ program
238
258
  .command('merge [branch]')
239
259
  .description('Merge a branch into the current branch (3-way smart merge)')
240
260
  .option('-m, --message <message>', 'Merge commit message')
261
+ .option('--continue', 'Finish a resolved canonical merge')
262
+ .option('--abort', 'Abort a canonical merge')
241
263
  .action(async (branch, options) => {
242
- if (!branch && interactive.isInteractive()) {
264
+ if (!branch && !options.continue && !options.abort && interactive.isInteractive()) {
243
265
  const picked = await interactive.promptMerge();
244
266
  if (!picked.branch) {
245
267
  // In a repo with only one branch → nothing to merge; otherwise
@@ -252,7 +274,7 @@ program
252
274
  branch = picked.branch;
253
275
  }
254
276
  }
255
- if (!branch && !interactive.isInteractive()) {
277
+ if (!branch && !options.continue && !options.abort && !interactive.isInteractive()) {
256
278
  console.error(chalk.red('error: missing branch — usage: gent merge <branch>'));
257
279
  process.exit(1);
258
280
  }
@@ -268,6 +290,9 @@ program
268
290
  .command('stash [subcommand]')
269
291
  .description('Stash working tree changes (pop|list|drop|apply)')
270
292
  .option('-m, --message <message>', 'Stash message')
293
+ .option('--restore-index', 'Restore canonical stash staged state')
294
+ .option('--keep-index', 'Keep staged changes when stashing')
295
+ .option('-u, --include-untracked', 'Include untracked files')
271
296
  .option('-i, --index <index>', 'Stash index for pop/apply/drop')
272
297
  .action(stashCommand);
273
298
 
@@ -311,6 +336,11 @@ program
311
336
  .option('-f, --force', 'Force push (overwrite remote)')
312
337
  .action(pushCommand);
313
338
 
339
+ program
340
+ .command('fetch [remote]')
341
+ .description('Fetch canonical remote branches and tags')
342
+ .action(route('fetch', async () => { throw new Error('fetch requires a canonical repository'); }));
343
+
314
344
  program
315
345
  .command('pull [remote] [branch]')
316
346
  .description('Pull and merge remote commits')
@@ -373,17 +403,17 @@ program
373
403
 
374
404
  program
375
405
  .command('web')
376
- .description('Open the current repo (or a branch/commit) on the gent web app')
377
- .option('--branch <name>', 'Open a specific branch')
378
- .option('--commit <hash>', 'Open a specific commit')
406
+ .description('Open the current repo on the gent web app')
407
+ .option('--branch <name>', 'Branch context (opens the repo page — no branch page yet)')
408
+ .option('--commit <hash>', 'Commit context (opens the repo page — no commit page yet)')
379
409
  .option('--print', 'Print the URL instead of launching a browser')
380
410
  .action(webCommand);
381
411
 
382
412
  program
383
413
  .command('share')
384
- .description('Print a shareable link to current branch tip (or --branch/--commit)')
385
- .option('--branch <name>', 'Link to a specific branch')
386
- .option('--commit <hash>', 'Link to a specific commit')
414
+ .description('Print a shareable link to the current repository')
415
+ .option('--branch <name>', 'Branch context (link resolves to the repo page)')
416
+ .option('--commit <hash>', 'Commit context (link resolves to the repo page)')
387
417
  .action(shareCommand);
388
418
 
389
419
  program
@@ -80,8 +80,13 @@ apiClient.interceptors.response.use(
80
80
  async (error) => {
81
81
  const originalRequest = error.config;
82
82
 
83
- // If error is 401 and we haven't tried to refresh yet
83
+ // A public request made without credentials should keep its original
84
+ // 401 response. Commands such as `gent clone` use that response to
85
+ // explain that a private repository needs sign-in.
84
86
  if (error.response?.status === 401 && !originalRequest._retry) {
87
+ const refreshToken = await authStorage.getRefreshToken();
88
+ if (!refreshToken) return Promise.reject(error);
89
+
85
90
  if (isRefreshing) {
86
91
  // If already refreshing, queue this request
87
92
  return new Promise((resolve, reject) => {
@@ -100,14 +105,6 @@ apiClient.interceptors.response.use(
100
105
  isRefreshing = true;
101
106
 
102
107
  try {
103
- const refreshToken = await authStorage.getRefreshToken();
104
-
105
- if (!refreshToken) {
106
- // No refresh token, user needs to login again
107
- await authStorage.clearAuth();
108
- throw new Error('Session expired. Please login again.');
109
- }
110
-
111
108
  // Call refresh endpoint (raw axios — bypasses our interceptor
112
109
  // intentionally so a 401 here doesn't loop back into refresh).
113
110
  const baseUrl = await resolveBaseUrl();
@@ -0,0 +1,280 @@
1
+ /**
2
+ * ============================================================================
3
+ * Attributes - gitattributes lookup and text/EOL conversion
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Decide how a path's bytes differ between the object store and the working
8
+ * tree, and refuse — before writing anything — when the answer depends on a
9
+ * transformation Gent does not implement.
10
+ *
11
+ * SUPPORTED:
12
+ * text, -text, text=auto, eol=lf, eol=crlf, the `binary` macro,
13
+ * core.autocrlf and core.eol.
14
+ *
15
+ * REFUSED (never guessed):
16
+ * filter=* (including Git LFS) and working-tree-encoding. A path carrying
17
+ * either raises UnsupportedFeatureError, so an operation stops before it
18
+ * corrupts content by storing the wrong bytes.
19
+ *
20
+ * DIRECTION:
21
+ * toIndex() working tree -> object store ("clean")
22
+ * toWorktree() object store -> working tree ("smudge")
23
+ * Blobs are stored with LF line endings; CRLF is a working-tree property.
24
+ * ============================================================================
25
+ */
26
+
27
+ const path = require('path');
28
+
29
+ const { readFileOrNull } = require('./lockfile');
30
+ const { IgnorePattern } = require('./ignore');
31
+ const { UnsupportedFeatureError, feature } = require('./feature-support');
32
+
33
+ /** Attributes that decide content transformation. */
34
+ const BINARY_MACRO = { text: false, diff: false, merge: false };
35
+
36
+ /**
37
+ * @param {Buffer} buffer
38
+ * @returns {Boolean} Git's heuristic: a NUL byte in the first 8000 bytes
39
+ */
40
+ function looksBinary(buffer) {
41
+ const limit = Math.min(buffer.length, 8000);
42
+ return buffer.indexOf(0, 0) >= 0 && buffer.indexOf(0, 0) < limit;
43
+ }
44
+
45
+ /**
46
+ * One `<pattern> <attr>...` line.
47
+ */
48
+ class AttributeRule {
49
+ /**
50
+ * @param {String} line
51
+ * @param {String} base - POSIX directory the file sits in
52
+ */
53
+ constructor(line, base) {
54
+ const parts = line.trim().split(/\s+/);
55
+ const pattern = parts.shift();
56
+ this.matcher = new IgnorePattern(pattern.endsWith('/') ? pattern : pattern, base);
57
+ this.attributes = {};
58
+
59
+ for (const token of parts) {
60
+ if (token.startsWith('-')) { this.attributes[token.slice(1)] = false; continue; }
61
+ if (token.startsWith('!')) { this.attributes[token.slice(1)] = undefined; continue; }
62
+ const eq = token.indexOf('=');
63
+ if (eq < 0) { this.attributes[token] = true; continue; }
64
+ this.attributes[token.slice(0, eq)] = token.slice(eq + 1);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * @param {String} relativePath
70
+ * @param {Boolean} isDirectory
71
+ * @returns {Boolean}
72
+ */
73
+ matches(relativePath, isDirectory) {
74
+ return this.matcher.matches(relativePath, isDirectory);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * @param {String} text
80
+ * @param {String} base
81
+ * @returns {Array<AttributeRule>}
82
+ */
83
+ function parseAttributes(text, base = '') {
84
+ const rules = [];
85
+ for (const raw of text.split('\n')) {
86
+ const line = raw.replace(/\r$/, '').trim();
87
+ if (!line || line.startsWith('#')) continue;
88
+ rules.push(new AttributeRule(line, base));
89
+ }
90
+ return rules;
91
+ }
92
+
93
+ class AttributesMatcher {
94
+ /**
95
+ * @param {Object} repo
96
+ */
97
+ constructor(repo) {
98
+ this.repo = repo;
99
+ this.baseRules = [];
100
+ this.perDirectory = new Map();
101
+ this.loaded = false;
102
+
103
+ this.autocrlf = repo.config.get('core.autocrlf', 'false').toLowerCase();
104
+ this.eol = repo.config.get('core.eol', 'native').toLowerCase();
105
+ this.safecrlf = repo.config.get('core.safecrlf', 'false').toLowerCase();
106
+ }
107
+
108
+ async load() {
109
+ if (this.loaded) return;
110
+ this.loaded = true;
111
+
112
+ const infoAttributes = await readFileOrNull(path.join(this.repo.commondir, 'info', 'attributes'));
113
+ if (infoAttributes) this.baseRules.push(...parseAttributes(infoAttributes.toString('utf-8')));
114
+ }
115
+
116
+ /**
117
+ * @param {String} directory
118
+ * @returns {Promise<Array<AttributeRule>>}
119
+ */
120
+ async rulesFor(directory) {
121
+ if (this.perDirectory.has(directory)) return this.perDirectory.get(directory);
122
+
123
+ const filePath = path.join(this.repo.worktree, ...(directory ? directory.split('/') : []), '.gitattributes');
124
+ const text = await readFileOrNull(filePath);
125
+ const rules = text ? parseAttributes(text.toString('utf-8'), directory) : [];
126
+ this.perDirectory.set(directory, rules);
127
+ return rules;
128
+ }
129
+
130
+ /**
131
+ * @param {String} relativePath
132
+ * @returns {Promise<Object>} resolved attribute values
133
+ */
134
+ async attributesFor(relativePath) {
135
+ await this.load();
136
+
137
+ const resolved = {};
138
+ const apply = (rule) => {
139
+ if (!rule.matches(relativePath, false)) return;
140
+ const source = rule.attributes.binary === true ? { ...BINARY_MACRO, ...rule.attributes } : rule.attributes;
141
+ for (const [key, value] of Object.entries(source)) {
142
+ if (key === 'binary') continue;
143
+ resolved[key] = value;
144
+ }
145
+ };
146
+
147
+ for (const rule of this.baseRules) apply(rule);
148
+
149
+ const components = relativePath.split('/');
150
+ for (let depth = 0; depth < components.length; depth++) {
151
+ for (const rule of await this.rulesFor(components.slice(0, depth).join('/'))) apply(rule);
152
+ }
153
+ return resolved;
154
+ }
155
+
156
+ /**
157
+ * Refuse paths whose bytes depend on an unimplemented transformation.
158
+ * @param {String} relativePath
159
+ * @param {Object} attributes
160
+ * @param {String} what
161
+ */
162
+ assertConvertible(relativePath, attributes, what) {
163
+ const problems = [];
164
+ if (attributes.filter !== undefined && attributes.filter !== false) {
165
+ problems.push({
166
+ ...feature('attributes.filter'),
167
+ detail: `'${relativePath}' has filter=${attributes.filter}; its stored bytes are produced by an external program.`
168
+ });
169
+ }
170
+ if (attributes['working-tree-encoding'] !== undefined && attributes['working-tree-encoding'] !== false) {
171
+ problems.push({
172
+ id: 'attributes.encoding',
173
+ status: 'unsupported',
174
+ title: 'working-tree-encoding attribute',
175
+ detail: `'${relativePath}' declares working-tree-encoding=${attributes['working-tree-encoding']}.`,
176
+ remedy: 'Check this path out with Git, or remove the attribute.'
177
+ });
178
+ }
179
+ if (problems.length) throw new UnsupportedFeatureError(problems, what);
180
+ }
181
+
182
+ /**
183
+ * Decide whether CRLF conversion applies to a path.
184
+ * @param {String} relativePath
185
+ * @param {Buffer} sample - content used for the text=auto heuristic
186
+ * @returns {Promise<{convert: Boolean, worktreeEol: 'lf'|'crlf'}>}
187
+ */
188
+ async conversionFor(relativePath, sample) {
189
+ const attributes = await this.attributesFor(relativePath);
190
+ this.assertConvertible(relativePath, attributes, 'converting line endings');
191
+
192
+ let isText;
193
+ if (attributes.text === false) isText = false;
194
+ else if (attributes.text === true) isText = true;
195
+ else if (attributes.text === 'auto') isText = !looksBinary(sample);
196
+ else if (this.autocrlf === 'true' || this.autocrlf === 'input') isText = !looksBinary(sample);
197
+ else isText = false;
198
+
199
+ if (!isText) return { convert: false, worktreeEol: 'lf' };
200
+
201
+ let worktreeEol;
202
+ if (attributes.eol === 'crlf') worktreeEol = 'crlf';
203
+ else if (attributes.eol === 'lf') worktreeEol = 'lf';
204
+ else if (this.autocrlf === 'true') worktreeEol = 'crlf';
205
+ else if (this.autocrlf === 'input') worktreeEol = 'lf';
206
+ else if (this.eol === 'crlf') worktreeEol = 'crlf';
207
+ else if (this.eol === 'native') worktreeEol = process.platform === 'win32' ? 'crlf' : 'lf';
208
+ else worktreeEol = 'lf';
209
+
210
+ return { convert: true, worktreeEol };
211
+ }
212
+
213
+ /**
214
+ * Working tree bytes -> stored blob bytes.
215
+ * @param {String} relativePath
216
+ * @param {Buffer} content
217
+ * @returns {Promise<Buffer>}
218
+ */
219
+ async toIndex(relativePath, content) {
220
+ const { convert } = await this.conversionFor(relativePath, content);
221
+ if (!convert) return content;
222
+ return normalizeToLf(content);
223
+ }
224
+
225
+ /**
226
+ * Stored blob bytes -> working tree bytes.
227
+ * @param {String} relativePath
228
+ * @param {Buffer} content
229
+ * @returns {Promise<Buffer>}
230
+ */
231
+ async toWorktree(relativePath, content) {
232
+ const { convert, worktreeEol } = await this.conversionFor(relativePath, content);
233
+ if (!convert || worktreeEol !== 'crlf') return content;
234
+ return normalizeToCrlf(content);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * @param {Buffer} content
240
+ * @returns {Buffer}
241
+ */
242
+ function normalizeToLf(content) {
243
+ if (!content.includes(0x0d)) return content;
244
+
245
+ const out = Buffer.allocUnsafe(content.length);
246
+ let written = 0;
247
+ for (let i = 0; i < content.length; i++) {
248
+ if (content[i] === 0x0d && content[i + 1] === 0x0a) continue; // drop the CR of a CRLF
249
+ out[written++] = content[i];
250
+ }
251
+ return out.subarray(0, written);
252
+ }
253
+
254
+ /**
255
+ * @param {Buffer} content
256
+ * @returns {Buffer}
257
+ */
258
+ function normalizeToCrlf(content) {
259
+ const lf = normalizeToLf(content);
260
+ let count = 0;
261
+ for (let i = 0; i < lf.length; i++) if (lf[i] === 0x0a) count++;
262
+ if (count === 0) return lf;
263
+
264
+ const out = Buffer.allocUnsafe(lf.length + count);
265
+ let written = 0;
266
+ for (let i = 0; i < lf.length; i++) {
267
+ if (lf[i] === 0x0a) out[written++] = 0x0d;
268
+ out[written++] = lf[i];
269
+ }
270
+ return out;
271
+ }
272
+
273
+ module.exports = {
274
+ AttributesMatcher,
275
+ AttributeRule,
276
+ parseAttributes,
277
+ looksBinary,
278
+ normalizeToLf,
279
+ normalizeToCrlf
280
+ };
@@ -0,0 +1,122 @@
1
+ /** Exact local checkpoints for canonical undo/redo. Private refs retain every
2
+ * object needed by a checkpoint when external Git runs garbage collection.
3
+ */
4
+ const fs = require('fs').promises;
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const { GitIndex, IndexEntry } = require('./git-index');
8
+ const { IgnoreMatcher, walkWorktree } = require('./ignore');
9
+ const { serializeCommit, serializeTree, MODE } = require('./git-objects');
10
+ const { writeAtomic, readFileOrNull } = require('./lockfile');
11
+ const worktree = require('./worktree');
12
+
13
+ const journalPath = repo => path.join(repo.gentWorktreeMetaDir, 'journal.json');
14
+ async function read(repo) {
15
+ const bytes = await readFileOrNull(journalPath(repo));
16
+ return bytes ? JSON.parse(bytes.toString()) : { undo: [], redo: [] };
17
+ }
18
+ async function save(repo, journal) { await writeAtomic(journalPath(repo), JSON.stringify(journal)); }
19
+
20
+ async function capture(repo) {
21
+ const index = await GitIndex.read(repo.indexPath);
22
+ const files = {};
23
+ const matcher = new IgnoreMatcher(repo);
24
+ const names = new Set(index.entries.map(e => e.path));
25
+ for await (const entry of walkWorktree(repo, matcher, { tracked: names })) names.add(entry.path);
26
+ for (const name of [...names].sort()) {
27
+ const file = await worktree.snapshotPath(repo, name);
28
+ if (file) files[name] = { mode: file.mode, oid: await repo.objects.write('blob', Buffer.from(file.content, 'base64')) };
29
+ }
30
+ const refs = Object.fromEntries([...(await repo.refs.list())].filter(([name]) => !name.startsWith('refs/gent/')).sort(([a], [b]) => a.localeCompare(b)));
31
+ const merge = {};
32
+ for (const name of ['MERGE_HEAD', 'MERGE_MSG', 'MERGE_MODE', 'ORIG_HEAD']) merge[name] = (await readFileOrNull(repo.gitPath(name)))?.toString('base64') ?? null;
33
+ return { head: await repo.refs.head(), refs, index: index.sourceBytes?.toString('base64') ?? null, files, merge };
34
+ }
35
+
36
+ async function retain(repo, state, prefix) {
37
+ const entries = [];
38
+ // These are retention objects, not worktree trees. Numeric names allow
39
+ // conflicted index stages and staged/unstaged versions to coexist.
40
+ for (const item of Object.values(state.files)) entries.push({ name: String(entries.length), mode: MODE.REGULAR, oid: item.oid });
41
+ if (state.index) {
42
+ for (const item of GitIndex.parse(Buffer.from(state.index, 'base64')).entries) {
43
+ if (item.mode === MODE.GITLINK) throw new Error('journal checkpoints do not support submodules');
44
+ entries.push({ name: String(entries.length), mode: MODE.REGULAR, oid: item.oid });
45
+ }
46
+ }
47
+ const tree = await repo.objects.write('tree', serializeTree(entries));
48
+ const identity = { name: 'Gent checkpoint', email: 'checkpoint@gent.local', timestamp: Math.floor(Date.now() / 1000), timezone: '+0000' };
49
+ const oid = await repo.objects.write('commit', serializeCommit({ tree, parents: state.head.oid ? [state.head.oid] : [], author: identity, committer: identity, message: Buffer.from('Gent undo checkpoint\n') }));
50
+ await repo.refs.update(`${prefix}/snapshot`, oid, { expectedOldOid: null, reason: 'retain undo checkpoint' });
51
+ let n = 0;
52
+ for (const target of new Set(Object.values(state.refs))) await repo.refs.update(`${prefix}/ref-${n++}`, target, { expectedOldOid: null, reason: 'retain undo ref' });
53
+ }
54
+
55
+ async function begin(repo, name) {
56
+ const before = await capture(repo);
57
+ const id = crypto.randomUUID();
58
+ await retain(repo, before, `refs/gent/journal/${id}/before`);
59
+ return { id, name, before };
60
+ }
61
+
62
+ async function finish(repo, entry) {
63
+ entry.after = await capture(repo);
64
+ await retain(repo, entry.after, `refs/gent/journal/${entry.id}/after`);
65
+ const journal = await read(repo);
66
+ journal.undo.push(entry);
67
+ journal.redo = [];
68
+ await save(repo, journal);
69
+ }
70
+
71
+ function sameState(a, b) {
72
+ // Index stat refreshes by other tools are harmless. Compare staged content
73
+ // and flags, not filesystem cache timestamps.
74
+ const semantic = state => {
75
+ const entries = state.index ? GitIndex.parse(Buffer.from(state.index, 'base64')).entries : [];
76
+ return { ...state, index: entries.map(e => ({ path: e.path, oid: e.oid, mode: e.mode, stage: e.stage, assumeValid: e.assumeValid, skipWorktree: e.skipWorktree, intentToAdd: e.intentToAdd })) };
77
+ };
78
+ return JSON.stringify(semantic(a)) === JSON.stringify(semantic(b));
79
+ }
80
+
81
+ async function restore(repo, redo = false) {
82
+ await worktree.assertNoPendingCheckout(repo, 'undo/redo');
83
+ const journal = await read(repo);
84
+ const source = redo ? journal.redo : journal.undo;
85
+ const entry = source[source.length - 1];
86
+ if (!entry) throw new Error(`nothing to ${redo ? 'redo' : 'undo'}`);
87
+ const expected = redo ? entry.before : entry.after;
88
+ const target = redo ? entry.after : entry.before;
89
+ const current = await capture(repo);
90
+ if (!sameState(current, expected)) throw new Error('repository changed since the recorded operation; refusing to overwrite intervening work');
91
+ const index = await GitIndex.read(repo.indexPath);
92
+ const targetIndex = target.index ? GitIndex.parse(Buffer.from(target.index, 'base64')) : new GitIndex();
93
+ const from = new Map(Object.entries(current.files));
94
+ const to = new Map(Object.entries(target.files));
95
+ const plan = await worktree.planCheckout(repo, { from, to, index, force: true });
96
+ // Snapshots store exact working bytes, so EOL conversion must not run twice.
97
+ for (const write of plan.writes) write.content = (await repo.objects.readBlob(write.oid)).toString('base64');
98
+ await worktree.applyCheckout(repo, plan, { index });
99
+ index.entries = targetIndex.entries;
100
+ index.extensions.clear();
101
+ await index.write(repo.indexPath);
102
+ for (const name of new Set([...Object.keys(current.refs), ...Object.keys(target.refs)])) {
103
+ if (current.refs[name] === target.refs[name]) continue;
104
+ if (target.refs[name]) await repo.refs.update(name, target.refs[name], { expectedOldOid: current.refs[name] || null, reason: redo ? 'redo' : 'undo' });
105
+ else await repo.refs.delete(name, { expectedOldOid: current.refs[name], reason: redo ? 'redo' : 'undo' });
106
+ }
107
+ // A branch ref may already have moved above; HEAD's expected oid follows it.
108
+ const expectedHead = { ...current.head, oid: current.head.ref ? (target.refs[current.head.ref] || null) : current.head.oid };
109
+ if (target.head.ref) await repo.refs.setHeadSymbolic(target.head.ref, redo ? 'redo' : 'undo', expectedHead);
110
+ else await repo.refs.setHeadDetached(target.head.oid, redo ? 'redo' : 'undo', expectedHead);
111
+ for (const [name, bytes] of Object.entries(target.merge)) {
112
+ if (bytes === null) await fs.rm(repo.gitPath(name), { force: true });
113
+ else await writeAtomic(repo.gitPath(name), Buffer.from(bytes, 'base64'));
114
+ }
115
+ source.pop();
116
+ (redo ? journal.undo : journal.redo).push(entry);
117
+ await save(repo, journal);
118
+ await worktree.completeCheckout(repo);
119
+ return entry.name;
120
+ }
121
+
122
+ module.exports = { begin, finish, restore, read };