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
|
@@ -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
|
+
};
|
|
@@ -130,9 +130,10 @@ function matchBaseToOther(baseLines, otherLines) {
|
|
|
130
130
|
* @param {String[]} baseLines
|
|
131
131
|
* @param {String[]} oursLines
|
|
132
132
|
* @param {String[]} theirsLines
|
|
133
|
+
* @param {{ours?: String, theirs?: String}} [labels]
|
|
133
134
|
* @returns {{merged: String[], conflicts: Array, hasConflicts: Boolean}}
|
|
134
135
|
*/
|
|
135
|
-
function threeWayMerge(baseLines, oursLines, theirsLines) {
|
|
136
|
+
function threeWayMerge(baseLines, oursLines, theirsLines, labels = {}) {
|
|
136
137
|
const oMatch = matchBaseToOther(baseLines, oursLines);
|
|
137
138
|
const tMatch = matchBaseToOther(baseLines, theirsLines);
|
|
138
139
|
|
|
@@ -157,7 +158,7 @@ function threeWayMerge(baseLines, oursLines, theirsLines) {
|
|
|
157
158
|
const oursSeg = oursLines.slice(prevO, a.o);
|
|
158
159
|
const theirsSeg = theirsLines.slice(prevT, a.t);
|
|
159
160
|
|
|
160
|
-
resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts);
|
|
161
|
+
resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts, labels);
|
|
161
162
|
|
|
162
163
|
// Emit the synchronized anchor line (skip the end sentinel).
|
|
163
164
|
if (a.b < baseLines.length) {
|
|
@@ -180,8 +181,9 @@ function threeWayMerge(baseLines, oursLines, theirsLines) {
|
|
|
180
181
|
* @param {String[]} theirsSeg
|
|
181
182
|
* @param {String[]} merged - output accumulator (mutated)
|
|
182
183
|
* @param {Array} conflicts - output accumulator (mutated)
|
|
184
|
+
* @param {{ours?: String, theirs?: String}} [labels]
|
|
183
185
|
*/
|
|
184
|
-
function resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts) {
|
|
186
|
+
function resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts, labels = {}) {
|
|
185
187
|
if (baseSeg.length === 0 && oursSeg.length === 0 && theirsSeg.length === 0) {
|
|
186
188
|
return;
|
|
187
189
|
}
|
|
@@ -213,11 +215,11 @@ function resolveRegion(baseSeg, oursSeg, theirsSeg, merged, conflicts) {
|
|
|
213
215
|
oursContent: oursSeg,
|
|
214
216
|
theirsContent: theirsSeg
|
|
215
217
|
});
|
|
216
|
-
merged.push(
|
|
218
|
+
merged.push(`<<<<<<< ${labels.ours || 'ours'}`);
|
|
217
219
|
merged.push(...oursSeg);
|
|
218
220
|
merged.push('=======');
|
|
219
221
|
merged.push(...theirsSeg);
|
|
220
|
-
merged.push(
|
|
222
|
+
merged.push(`>>>>>>> ${labels.theirs || 'theirs'}`);
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
// ─── Sub-Merge (fine-grained) ───────────────────────────
|
|
@@ -390,9 +392,10 @@ function parseConflictMarkers(content) {
|
|
|
390
392
|
* @param {String} oursContent
|
|
391
393
|
* @param {String} theirsContent
|
|
392
394
|
* @param {String} [fileName] - used to pick a language-aware strategy
|
|
395
|
+
* @param {{ours?: String, theirs?: String}} [labels] - conflict marker labels
|
|
393
396
|
* @returns {{content: String, hasConflicts: Boolean, conflicts: Array}}
|
|
394
397
|
*/
|
|
395
|
-
function mergeFileContent(baseContent, oursContent, theirsContent, fileName) {
|
|
398
|
+
function mergeFileContent(baseContent, oursContent, theirsContent, fileName, labels) {
|
|
396
399
|
if (fileName && /\.json$/i.test(fileName)) {
|
|
397
400
|
const jsonResult = mergeJsonContent(baseContent || '', oursContent || '', theirsContent || '');
|
|
398
401
|
if (jsonResult) return jsonResult;
|
|
@@ -401,7 +404,7 @@ function mergeFileContent(baseContent, oursContent, theirsContent, fileName) {
|
|
|
401
404
|
const base = splitLines(baseContent || '');
|
|
402
405
|
const ours = splitLines(oursContent || '');
|
|
403
406
|
const theirs = splitLines(theirsContent || '');
|
|
404
|
-
const result = threeWayMerge(base, ours, theirs);
|
|
407
|
+
const result = threeWayMerge(base, ours, theirs, labels);
|
|
405
408
|
return { content: result.merged.join('\n'), hasConflicts: result.hasConflicts, conflicts: result.conflicts };
|
|
406
409
|
}
|
|
407
410
|
|