gent-cli 14.0.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * ============================================================================
3
+ * Feature Support Manifest
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * One declaration of what Gent v13 supports, consumed by repository opening,
8
+ * command preflight, the generated documentation and the test suites. The
9
+ * data lives in feature-support.json so the Django API can load the exact
10
+ * same bytes when server integration is implemented.
11
+ *
12
+ * RULE:
13
+ * Nothing may claim support for a feature this manifest does not mark
14
+ * 'supported'. Detection happens *before* any state is modified, so an
15
+ * unsupported repository is refused rather than half-converted.
16
+ *
17
+ * ============================================================================
18
+ */
19
+
20
+ const manifest = require('./feature-support.json');
21
+
22
+ const BY_ID = new Map(manifest.features.map(f => [f.id, f]));
23
+
24
+ /**
25
+ * Raised when a repository or request needs something Gent does not implement.
26
+ * Carries the manifest entries so callers can render a consistent message.
27
+ */
28
+ class UnsupportedFeatureError extends Error {
29
+ /**
30
+ * @param {Array<{id: String, title: String, detail: String, remedy?: String}>} features
31
+ * @param {String} [context] - what was being attempted
32
+ */
33
+ constructor(features, context) {
34
+ const list = features.map(f => ` - ${f.title}: ${f.detail}${f.remedy ? `\n Fix: ${f.remedy}` : ''}`).join('\n');
35
+ super(`${context ? `${context}: ` : ''}unsupported repository feature\n${list}`);
36
+ this.name = 'UnsupportedFeatureError';
37
+ this.code = 'GENT_UNSUPPORTED';
38
+ this.features = features;
39
+ this.context = context || null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Look up a manifest entry.
45
+ * @param {String} id
46
+ * @returns {Object}
47
+ */
48
+ function feature(id) {
49
+ const f = BY_ID.get(id);
50
+ if (!f) throw new Error(`Unknown feature id '${id}' — add it to feature-support.json`);
51
+ return f;
52
+ }
53
+
54
+ /**
55
+ * @param {String} id
56
+ * @returns {Boolean}
57
+ */
58
+ function isSupported(id) {
59
+ const status = feature(id).status;
60
+ return status === 'supported' || status === 'partial';
61
+ }
62
+
63
+ /**
64
+ * Throw if any of the given feature ids is not implemented.
65
+ * @param {Array<String>} ids
66
+ * @param {String} [context]
67
+ */
68
+ function assertSupported(ids, context) {
69
+ const bad = ids.filter(id => feature(id).status === 'unsupported').map(feature);
70
+ if (bad.length) throw new UnsupportedFeatureError(bad, context);
71
+ }
72
+
73
+ /**
74
+ * All features in a given status, for documentation and status output.
75
+ * @param {String} status
76
+ * @returns {Array<Object>}
77
+ */
78
+ function byStatus(status) {
79
+ return manifest.features.filter(f => f.status === status);
80
+ }
81
+
82
+ module.exports = {
83
+ manifest,
84
+ FORMAT_MARKER: manifest.formatMarker,
85
+ OBJECT_FORMAT: manifest.objectFormat,
86
+ REPOSITORY_FORMAT_VERSION: manifest.repositoryFormatVersion,
87
+ UnsupportedFeatureError,
88
+ feature,
89
+ isSupported,
90
+ assertSupported,
91
+ byStatus
92
+ };