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/README.md +1 -1
- package/package.json +7 -4
- package/src/commands/canonical.js +246 -0
- package/src/commands/clone.js +2 -13
- package/src/commands/pet.js +23 -45
- package/src/commands/push.js +1 -1
- 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-engine.js +10 -7
- package/src/utils/merge-ops.js +283 -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
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
|
|
156
|
-
.option('--soft
|
|
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
|
|
377
|
-
.option('--branch <name>', '
|
|
378
|
-
.option('--commit <hash>', '
|
|
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
|
|
385
|
-
.option('--branch <name>', '
|
|
386
|
-
.option('--commit <hash>', '
|
|
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
|
package/src/utils/api-client.js
CHANGED
|
@@ -80,8 +80,13 @@ apiClient.interceptors.response.use(
|
|
|
80
80
|
async (error) => {
|
|
81
81
|
const originalRequest = error.config;
|
|
82
82
|
|
|
83
|
-
//
|
|
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 };
|