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,365 @@
1
+ /**
2
+ * ============================================================================
3
+ * Ignore - gitignore pattern matching
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Decide which untracked paths Gent hides, using exactly Git's rules so the
8
+ * two agree. Replaces the v12 hidden built-in list, which excluded things
9
+ * like .gitignore itself and could never be overridden.
10
+ *
11
+ * SOURCES, lowest precedence first:
12
+ * core.excludesFile → <gitdir>/info/exclude → .gitignore files from the
13
+ * worktree root down to the file's own directory (deeper wins).
14
+ * Within one file, the last matching pattern wins.
15
+ *
16
+ * SYNTAX:
17
+ * blank lines and '#' comments; '!' negation; trailing '/' for directories
18
+ * only; a '/' anywhere but the end anchors the pattern to the file's
19
+ * directory; '*' and '?' stop at '/'; '**' spans separators; '[a-z]'
20
+ * character classes; backslash escapes.
21
+ *
22
+ * THE ONE RULE PEOPLE FORGET:
23
+ * A file inside an excluded directory cannot be re-included. isIgnored()
24
+ * therefore tests every ancestor directory before the path itself.
25
+ *
26
+ * SCOPE:
27
+ * Ignore rules govern *untracked* discovery only. A tracked file's changes
28
+ * are always reported, whatever the patterns say.
29
+ * ============================================================================
30
+ */
31
+
32
+ const fs = require('fs').promises;
33
+ const path = require('path');
34
+
35
+ const { readFileOrNull, } = require('./lockfile');
36
+ const { expandTilde } = require('./git-config');
37
+
38
+ /**
39
+ * One parsed pattern line.
40
+ */
41
+ class IgnorePattern {
42
+ /**
43
+ * @param {String} line - already stripped of its trailing newline
44
+ * @param {String} base - POSIX directory the pattern is relative to ('' = root)
45
+ */
46
+ constructor(line, base) {
47
+ this.source = line;
48
+ this.base = base;
49
+ this.negated = false;
50
+ this.directoryOnly = false;
51
+
52
+ let pattern = line;
53
+ if (pattern.startsWith('!')) {
54
+ this.negated = true;
55
+ pattern = pattern.slice(1);
56
+ } else if (pattern.startsWith('\\!')) {
57
+ pattern = pattern.slice(1);
58
+ }
59
+
60
+ if (pattern.endsWith('/')) {
61
+ this.directoryOnly = true;
62
+ pattern = pattern.slice(0, -1);
63
+ }
64
+
65
+ // A '/' anywhere except the very end anchors the pattern to `base`.
66
+ const withoutLeading = pattern.startsWith('/') ? pattern.slice(1) : pattern;
67
+ this.anchored = pattern.includes('/');
68
+ this.pattern = withoutLeading;
69
+ this.regex = compile(withoutLeading, this.anchored);
70
+ }
71
+
72
+ /**
73
+ * @param {String} relativePath - POSIX, relative to the worktree root
74
+ * @param {Boolean} isDirectory
75
+ * @returns {Boolean}
76
+ */
77
+ matches(relativePath, isDirectory) {
78
+ if (this.directoryOnly && !isDirectory) return false;
79
+
80
+ let subject = relativePath;
81
+ if (this.base) {
82
+ if (!relativePath.startsWith(this.base + '/')) return false;
83
+ subject = relativePath.slice(this.base.length + 1);
84
+ }
85
+
86
+ if (this.anchored) return this.regex.test(subject);
87
+
88
+ // Unanchored patterns match at any depth, i.e. against any suffix
89
+ // that starts at a path component boundary.
90
+ if (this.regex.test(subject)) return true;
91
+ let from = subject.indexOf('/');
92
+ while (from >= 0) {
93
+ if (this.regex.test(subject.slice(from + 1))) return true;
94
+ from = subject.indexOf('/', from + 1);
95
+ }
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Translate a gitignore glob into an anchored regular expression.
102
+ * @param {String} pattern
103
+ * @param {Boolean} anchored
104
+ * @returns {RegExp}
105
+ */
106
+ function compile(pattern) {
107
+ let source = '';
108
+
109
+ for (let i = 0; i < pattern.length; i++) {
110
+ const ch = pattern[i];
111
+
112
+ if (ch === '\\') {
113
+ const next = pattern[i + 1];
114
+ if (next === undefined) { source += '\\\\'; break; }
115
+ source += escapeLiteral(next);
116
+ i += 1;
117
+ continue;
118
+ }
119
+
120
+ if (ch === '*') {
121
+ const doubled = pattern[i + 1] === '*';
122
+ if (doubled) {
123
+ const before = i === 0 || pattern[i - 1] === '/';
124
+ const after = pattern[i + 2] === '/' || pattern[i + 2] === undefined;
125
+ i += 1;
126
+ if (before && after && pattern[i + 1] === '/') {
127
+ source += '(?:[^/]+/)*'; // 'a/**/b' -> zero or more dirs
128
+ i += 1;
129
+ } else if (before && after) {
130
+ source += '.*'; // trailing '/**'
131
+ } else {
132
+ source += '[^/]*'; // '**' inside a component
133
+ }
134
+ } else {
135
+ source += '[^/]*';
136
+ }
137
+ continue;
138
+ }
139
+
140
+ if (ch === '?') { source += '[^/]'; continue; }
141
+
142
+ if (ch === '[') {
143
+ const close = findClassEnd(pattern, i);
144
+ if (close < 0) { source += '\\['; continue; }
145
+ let body = pattern.slice(i + 1, close);
146
+ if (body.startsWith('!')) body = '^' + body.slice(1);
147
+ source += '[' + body.replace(/\\/g, '\\\\') + ']';
148
+ i = close;
149
+ continue;
150
+ }
151
+
152
+ source += escapeLiteral(ch);
153
+ }
154
+
155
+ return new RegExp(`^${source}$`);
156
+ }
157
+
158
+ /**
159
+ * @param {String} pattern
160
+ * @param {Number} start - index of '['
161
+ * @returns {Number} index of the closing ']', or -1
162
+ */
163
+ function findClassEnd(pattern, start) {
164
+ let i = start + 1;
165
+ if (pattern[i] === '!' || pattern[i] === '^') i += 1;
166
+ if (pattern[i] === ']') i += 1;
167
+ for (; i < pattern.length; i++) {
168
+ if (pattern[i] === '\\') { i += 1; continue; }
169
+ if (pattern[i] === ']') return i;
170
+ }
171
+ return -1;
172
+ }
173
+
174
+ /**
175
+ * @param {String} ch
176
+ * @returns {String}
177
+ */
178
+ function escapeLiteral(ch) {
179
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? '\\' + ch : ch;
180
+ }
181
+
182
+ /**
183
+ * Parse one ignore file's text into patterns.
184
+ * @param {String} text
185
+ * @param {String} base - POSIX directory the file sits in ('' for the root)
186
+ * @returns {Array<IgnorePattern>}
187
+ */
188
+ function parsePatterns(text, base = '') {
189
+ const patterns = [];
190
+ for (const raw of text.split('\n')) {
191
+ let line = raw.replace(/\r$/, '');
192
+ if (!line.trim()) continue;
193
+ if (line.startsWith('#')) continue;
194
+
195
+ // Trailing whitespace is stripped unless the last space is escaped.
196
+ line = line.replace(/(?<!\\)\s+$/, '');
197
+ if (!line) continue;
198
+
199
+ patterns.push(new IgnorePattern(line, base));
200
+ }
201
+ return patterns;
202
+ }
203
+
204
+ /**
205
+ * Evaluates a whole precedence stack. Per-directory .gitignore files are read
206
+ * on demand and cached, so a status walk pays for each file once.
207
+ */
208
+ class IgnoreMatcher {
209
+ /**
210
+ * @param {Object} repo - a Repository
211
+ */
212
+ constructor(repo) {
213
+ this.repo = repo;
214
+ this.worktree = repo.worktree;
215
+ /** Lowest-precedence layers: core.excludesFile then info/exclude. */
216
+ this.basePatterns = [];
217
+ /** dir (POSIX, '' = root) -> patterns from that directory's .gitignore */
218
+ this.perDirectory = new Map();
219
+ this.loaded = false;
220
+ }
221
+
222
+ /**
223
+ * @returns {Promise<void>}
224
+ */
225
+ async load() {
226
+ if (this.loaded) return;
227
+ this.loaded = true;
228
+
229
+ const excludesFile = this.repo.config.get('core.excludesFile');
230
+ if (excludesFile) {
231
+ const text = await readFileOrNull(expandTilde(excludesFile));
232
+ if (text) this.basePatterns.push(...parsePatterns(text.toString('utf-8')));
233
+ }
234
+
235
+ const infoExclude = await readFileOrNull(path.join(this.repo.commondir, 'info', 'exclude'));
236
+ if (infoExclude) this.basePatterns.push(...parsePatterns(infoExclude.toString('utf-8')));
237
+ }
238
+
239
+ /**
240
+ * @param {String} directory - POSIX path relative to the worktree root
241
+ * @returns {Promise<Array<IgnorePattern>>}
242
+ */
243
+ async patternsFor(directory) {
244
+ if (this.perDirectory.has(directory)) return this.perDirectory.get(directory);
245
+
246
+ const filePath = path.join(this.worktree, ...(directory ? directory.split('/') : []), '.gitignore');
247
+ const text = await readFileOrNull(filePath);
248
+ const patterns = text ? parsePatterns(text.toString('utf-8'), directory) : [];
249
+ this.perDirectory.set(directory, patterns);
250
+ return patterns;
251
+ }
252
+
253
+ /**
254
+ * Decision for one path, ignoring its ancestors.
255
+ * @param {String} relativePath
256
+ * @param {Boolean} isDirectory
257
+ * @returns {Promise<Boolean|null>} null when no pattern matched
258
+ */
259
+ async decide(relativePath, isDirectory) {
260
+ await this.load();
261
+
262
+ let decision = null;
263
+ for (const pattern of this.basePatterns) {
264
+ if (pattern.matches(relativePath, isDirectory)) decision = !pattern.negated;
265
+ }
266
+
267
+ // Root .gitignore first, then each deeper directory: later wins.
268
+ const components = relativePath.split('/');
269
+ for (let depth = 0; depth < components.length; depth++) {
270
+ const directory = components.slice(0, depth).join('/');
271
+ for (const pattern of await this.patternsFor(directory)) {
272
+ if (pattern.matches(relativePath, isDirectory)) decision = !pattern.negated;
273
+ }
274
+ }
275
+ return decision;
276
+ }
277
+
278
+ /**
279
+ * Full decision including ancestors: a path inside an excluded directory
280
+ * is excluded no matter what a later negation says.
281
+ * @param {String} relativePath - POSIX, relative to the worktree root
282
+ * @param {Boolean} isDirectory
283
+ * @returns {Promise<Boolean>}
284
+ */
285
+ async isIgnored(relativePath, isDirectory) {
286
+ const components = relativePath.split('/');
287
+ for (let depth = 1; depth < components.length; depth++) {
288
+ const ancestor = components.slice(0, depth).join('/');
289
+ if ((await this.decide(ancestor, true)) === true) return true;
290
+ }
291
+ return (await this.decide(relativePath, isDirectory)) === true;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Walk the worktree, yielding files that are neither ignored nor inside the
297
+ * git directory. Directories that are ignored are not descended into.
298
+ *
299
+ * @param {Object} repo
300
+ * @param {IgnoreMatcher} matcher
301
+ * @param {Object} [options]
302
+ * @param {Set<String>} [options.tracked] - paths that must be visited even if ignored
303
+ * @returns {AsyncGenerator<{path: String, stat: fs.Stats}>}
304
+ */
305
+ async function* walkWorktree(repo, matcher, options = {}) {
306
+ const root = repo.requireWorktree('scanning the working tree');
307
+ const tracked = options.tracked || new Set();
308
+ const trackedDirectories = new Set();
309
+ for (const entry of tracked) {
310
+ const parts = entry.split('/');
311
+ for (let i = 1; i < parts.length; i++) trackedDirectories.add(parts.slice(0, i).join('/'));
312
+ }
313
+
314
+ const gitdirReal = path.resolve(repo.gitdir);
315
+ const commondirReal = path.resolve(repo.commondir);
316
+
317
+ async function* visit(relativeDir) {
318
+ const absoluteDir = relativeDir ? path.join(root, ...relativeDir.split('/')) : root;
319
+
320
+ let entries;
321
+ try {
322
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
323
+ } catch (error) {
324
+ if (error.code === 'ENOENT' || error.code === 'EACCES') return;
325
+ throw error;
326
+ }
327
+
328
+ for (const entry of entries) {
329
+ const relative = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
330
+ const absolute = path.join(absoluteDir, entry.name);
331
+
332
+ // '.git' is never tracked at any level, whether it is this
333
+ // repository's gitfile or a nested submodule's directory.
334
+ if (entry.name === '.git') continue;
335
+ if (absolute === gitdirReal || absolute === commondirReal) continue;
336
+
337
+ const stat = await fs.lstat(absolute).catch(() => null);
338
+ if (!stat) continue;
339
+
340
+ if (stat.isDirectory()) {
341
+ // A directory holding its own .git is a submodule boundary.
342
+ const nested = await fs.stat(path.join(absolute, '.git')).then(() => true, () => false);
343
+ if (nested) {
344
+ yield { path: relative, stat, submodule: true };
345
+ continue;
346
+ }
347
+ if (!trackedDirectories.has(relative) && await matcher.isIgnored(relative, true)) continue;
348
+ yield* visit(relative);
349
+ continue;
350
+ }
351
+
352
+ if (!tracked.has(relative) && await matcher.isIgnored(relative, false)) continue;
353
+ yield { path: relative, stat, submodule: false };
354
+ }
355
+ }
356
+
357
+ yield* visit('');
358
+ }
359
+
360
+ module.exports = {
361
+ IgnorePattern,
362
+ IgnoreMatcher,
363
+ parsePatterns,
364
+ walkWorktree
365
+ };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * ============================================================================
3
+ * Lockfile - Git-compatible <target>.lock protocol
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Serialise updates to files that an external Git may also be writing, and
8
+ * make every update atomic. Gent takes the same lock Git takes, so the two
9
+ * cannot lose each other's changes.
10
+ *
11
+ * PROTOCOL:
12
+ * 1. open("<target>.lock", O_CREAT | O_EXCL) — fails if anyone holds it
13
+ * 2. write the *new* content into the lock file, then fsync
14
+ * 3. rename("<target>.lock", "<target>") — atomic publish
15
+ * Release without commit simply unlinks the lock.
16
+ *
17
+ * NEVER STEAL A LOCK:
18
+ * A held lock means another process is mid-update. Gent reports the lock
19
+ * path and its age and stops. Deciding a lock is abandoned is the operator's
20
+ * call, never a heuristic in library code.
21
+ *
22
+ * See docs/git-compat/format-contract.md section 7.
23
+ * ============================================================================
24
+ */
25
+
26
+ const fs = require('fs').promises;
27
+ const path = require('path');
28
+
29
+ const DEFAULT_RETRIES = 5;
30
+ const DEFAULT_RETRY_DELAY_MS = 40;
31
+
32
+ class LockError extends Error {
33
+ /**
34
+ * @param {String} targetPath
35
+ * @param {String} lockPath
36
+ * @param {Number|null} ageMs
37
+ */
38
+ constructor(targetPath, lockPath, ageMs) {
39
+ const age = ageMs === null ? 'unknown age' : `held for ${Math.round(ageMs / 1000)}s`;
40
+ super(
41
+ `cannot lock '${path.basename(targetPath)}': another process holds ${lockPath} (${age}).\n` +
42
+ `If you are certain no Gent or Git process is running, remove that file by hand.`
43
+ );
44
+ this.name = 'LockError';
45
+ this.code = 'GENT_LOCKED';
46
+ this.targetPath = targetPath;
47
+ this.lockPath = lockPath;
48
+ this.ageMs = ageMs;
49
+ }
50
+ }
51
+
52
+ class Lock {
53
+ /**
54
+ * @param {String} targetPath
55
+ * @param {fs.FileHandle} handle
56
+ */
57
+ constructor(targetPath, handle) {
58
+ this.targetPath = targetPath;
59
+ this.lockPath = `${targetPath}.lock`;
60
+ this.handle = handle;
61
+ this.committed = false;
62
+ this.released = false;
63
+ }
64
+
65
+ /**
66
+ * @param {String} targetPath
67
+ * @param {Object} [options]
68
+ * @param {Number} [options.retries]
69
+ * @param {Number} [options.mode] - permissions for the published file
70
+ * @returns {Promise<Lock>}
71
+ */
72
+ static async acquire(targetPath, options = {}) {
73
+ const retries = options.retries ?? DEFAULT_RETRIES;
74
+ const lockPath = `${targetPath}.lock`;
75
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
76
+
77
+ for (let attempt = 0; ; attempt++) {
78
+ try {
79
+ const handle = await fs.open(lockPath, 'wx', options.mode ?? 0o666);
80
+ return new Lock(targetPath, handle);
81
+ } catch (error) {
82
+ if (error.code !== 'EEXIST') throw error;
83
+ if (attempt >= retries) {
84
+ let ageMs = null;
85
+ try {
86
+ ageMs = Date.now() - (await fs.stat(lockPath)).mtimeMs;
87
+ } catch { /* it vanished — still report a failure, not a silent success */ }
88
+ throw new LockError(targetPath, lockPath, ageMs);
89
+ }
90
+ await new Promise(resolve => setTimeout(resolve, DEFAULT_RETRY_DELAY_MS * (attempt + 1)));
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Current content of the locked target, or null when it does not exist.
97
+ * Reading *after* acquiring is what makes compare-and-set safe.
98
+ * @returns {Promise<Buffer|null>}
99
+ */
100
+ async readTarget() {
101
+ try {
102
+ return await fs.readFile(this.targetPath);
103
+ } catch (error) {
104
+ if (error.code === 'ENOENT') return null;
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * @param {Buffer|String} content
111
+ */
112
+ async write(content) {
113
+ if (this.committed || this.released) throw new Error('lock is no longer open');
114
+ await this.handle.writeFile(Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'));
115
+ }
116
+
117
+ /**
118
+ * fsync and rename into place.
119
+ * @returns {Promise<void>}
120
+ */
121
+ async commit() {
122
+ if (this.committed) return;
123
+ if (this.released) throw new Error('lock was already released');
124
+
125
+ await this.handle.sync();
126
+ await this.handle.close();
127
+ this.handle = null;
128
+ await fs.rename(this.lockPath, this.targetPath);
129
+ this.committed = true;
130
+ }
131
+
132
+ /**
133
+ * Discard the pending update.
134
+ * @returns {Promise<void>}
135
+ */
136
+ async release() {
137
+ if (this.committed || this.released) return;
138
+ this.released = true;
139
+ if (this.handle) {
140
+ await this.handle.close().catch(() => {});
141
+ this.handle = null;
142
+ }
143
+ await fs.rm(this.lockPath, { force: true });
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Acquire, run, commit — releasing on any failure.
149
+ * The callback commits by writing; returning without writing still commits an
150
+ * empty file, so callers that may decide not to update should call
151
+ * `lock.release()` themselves and return a sentinel.
152
+ *
153
+ * @param {String} targetPath
154
+ * @param {(lock: Lock) => Promise<any>} fn
155
+ * @param {Object} [options]
156
+ * @returns {Promise<any>} whatever fn returned
157
+ */
158
+ async function withLock(targetPath, fn, options) {
159
+ const lock = await Lock.acquire(targetPath, options);
160
+ try {
161
+ const result = await fn(lock);
162
+ await lock.commit();
163
+ return result;
164
+ } finally {
165
+ await lock.release();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Replace a file atomically without taking the .lock name — used for files
171
+ * Git does not lock (per-worktree operation state), where a temp-and-rename is
172
+ * correct but a .lock would confuse an external Git.
173
+ *
174
+ * @param {String} targetPath
175
+ * @param {Buffer|String} content
176
+ * @returns {Promise<void>}
177
+ */
178
+ async function writeAtomic(targetPath, content) {
179
+ const dir = path.dirname(targetPath);
180
+ await fs.mkdir(dir, { recursive: true });
181
+ const tmp = path.join(dir, `.gent_tmp_${process.pid}_${Date.now().toString(36)}`);
182
+
183
+ let handle;
184
+ try {
185
+ handle = await fs.open(tmp, 'wx', 0o666);
186
+ await handle.writeFile(Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'));
187
+ await handle.sync();
188
+ } finally {
189
+ if (handle) await handle.close();
190
+ }
191
+
192
+ try {
193
+ await fs.rename(tmp, targetPath);
194
+ } catch (error) {
195
+ await fs.rm(tmp, { force: true });
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * @param {String} filePath
202
+ * @returns {Promise<Buffer|null>}
203
+ */
204
+ async function readFileOrNull(filePath) {
205
+ try {
206
+ return await fs.readFile(filePath);
207
+ } catch (error) {
208
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
209
+ throw error;
210
+ }
211
+ }
212
+
213
+ module.exports = {
214
+ Lock,
215
+ LockError,
216
+ withLock,
217
+ writeAtomic,
218
+ readFileOrNull
219
+ };