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.
- package/README.md +1 -1
- package/package.json +7 -4
- package/src/commands/canonical.js +242 -0
- package/src/commands/clone.js +2 -13
- package/src/commands/pet.js +122 -98
- package/src/commands/pull.js +3 -4
- package/src/commands/push.js +4 -5
- 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-ops.js +282 -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,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Refs - loose refs, packed-refs, symbolic HEAD and reflogs
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* The only place branch, tag and HEAD pointers are read or written. Every
|
|
8
|
+
* update is a compare-and-set under <ref>.lock, so an external Git writing
|
|
9
|
+
* the same ref concurrently cannot be silently overwritten.
|
|
10
|
+
*
|
|
11
|
+
* STORAGE:
|
|
12
|
+
* loose: <commondir>/refs/heads/main "<oid>\n"
|
|
13
|
+
* symbolic: <gitdir>/HEAD "ref: refs/heads/main\n"
|
|
14
|
+
* packed: <commondir>/packed-refs "<oid> <ref>" + "^<peeled>"
|
|
15
|
+
* reflog: <commondir>/logs/<ref> append-only
|
|
16
|
+
*
|
|
17
|
+
* PRECEDENCE:
|
|
18
|
+
* A loose ref shadows the packed value. Deleting must therefore remove the
|
|
19
|
+
* loose file *and* rewrite packed-refs, or the ref comes back from the dead.
|
|
20
|
+
*
|
|
21
|
+
* PER-WORKTREE REFS:
|
|
22
|
+
* HEAD, ORIG_HEAD, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, BISECT_*,
|
|
23
|
+
* refs/bisect/*, refs/worktree/* and refs/rewritten/* belong to the calling
|
|
24
|
+
* worktree's gitdir. Everything else lives in the common directory.
|
|
25
|
+
*
|
|
26
|
+
* See docs/git-compat/format-contract.md sections 6 and 7.
|
|
27
|
+
* ============================================================================
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs').promises;
|
|
31
|
+
const path = require('path');
|
|
32
|
+
|
|
33
|
+
const { isObjectId, assertObjectId, NULL_OID } = require('./git-objects');
|
|
34
|
+
const { withLock, writeAtomic, readFileOrNull, Lock } = require('./lockfile');
|
|
35
|
+
|
|
36
|
+
const PACKED_REFS_HEADER = '# pack-refs with: peeled fully-peeled sorted ';
|
|
37
|
+
const MAX_SYMREF_DEPTH = 5;
|
|
38
|
+
|
|
39
|
+
/** Refs that are private to one worktree rather than shared. */
|
|
40
|
+
const PER_WORKTREE_EXACT = new Set([
|
|
41
|
+
'HEAD', 'ORIG_HEAD', 'FETCH_HEAD', 'MERGE_HEAD', 'CHERRY_PICK_HEAD',
|
|
42
|
+
'REVERT_HEAD', 'REBASE_HEAD', 'AUTO_MERGE', 'BISECT_EXPECTED_REV'
|
|
43
|
+
]);
|
|
44
|
+
const PER_WORKTREE_PREFIXES = ['refs/bisect/', 'refs/worktree/', 'refs/rewritten/'];
|
|
45
|
+
|
|
46
|
+
class RefError extends Error {
|
|
47
|
+
constructor(message, code) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'RefError';
|
|
50
|
+
this.code = code || 'GENT_BAD_REF';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* git check-ref-format, applied to a full ref name such as refs/heads/main.
|
|
56
|
+
* @param {String} name
|
|
57
|
+
* @param {Object} [options]
|
|
58
|
+
* @param {Boolean} [options.allowOneLevel] - HEAD and friends
|
|
59
|
+
* @returns {Boolean}
|
|
60
|
+
*/
|
|
61
|
+
function isValidRefName(name, options = {}) {
|
|
62
|
+
if (typeof name !== 'string' || name === '') return false;
|
|
63
|
+
if (name.endsWith('/') || name.endsWith('.') || name.endsWith('.lock')) return false;
|
|
64
|
+
if (name.startsWith('/')) return false;
|
|
65
|
+
if (name.includes('//') || name.includes('..') || name.includes('@{')) return false;
|
|
66
|
+
if (name === '@') return false;
|
|
67
|
+
|
|
68
|
+
for (const ch of name) {
|
|
69
|
+
const code = ch.codePointAt(0);
|
|
70
|
+
if (code < 0x20 || code === 0x7f) return false;
|
|
71
|
+
if (' ~^:?*[\\'.includes(ch)) return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const components = name.split('/');
|
|
75
|
+
if (!options.allowOneLevel && components.length < 2) return false;
|
|
76
|
+
|
|
77
|
+
for (const component of components) {
|
|
78
|
+
if (component === '') return false;
|
|
79
|
+
if (component.startsWith('.')) return false;
|
|
80
|
+
if (component.endsWith('.lock')) return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {String} name
|
|
87
|
+
* @param {Object} [options]
|
|
88
|
+
* @returns {String} the same name
|
|
89
|
+
*/
|
|
90
|
+
function assertRefName(name, options) {
|
|
91
|
+
if (!isValidRefName(name, options)) {
|
|
92
|
+
throw new RefError(`'${name}' is not a valid ref name`);
|
|
93
|
+
}
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {String} name
|
|
99
|
+
* @returns {Boolean}
|
|
100
|
+
*/
|
|
101
|
+
function isPerWorktreeRef(name) {
|
|
102
|
+
return PER_WORKTREE_EXACT.has(name) || PER_WORKTREE_PREFIXES.some(prefix => name.startsWith(prefix));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class RefStore {
|
|
106
|
+
/**
|
|
107
|
+
* @param {Object} repo - { gitdir, commondir, identity? }
|
|
108
|
+
*/
|
|
109
|
+
constructor(repo) {
|
|
110
|
+
this.gitdir = repo.gitdir;
|
|
111
|
+
this.commondir = repo.commondir || repo.gitdir;
|
|
112
|
+
this.repo = repo;
|
|
113
|
+
this._packed = null; // lazily loaded cache
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Filesystem location of a ref, honouring per-worktree placement.
|
|
118
|
+
* @param {String} name
|
|
119
|
+
* @returns {String}
|
|
120
|
+
*/
|
|
121
|
+
refPath(name) {
|
|
122
|
+
assertRefName(name, { allowOneLevel: true });
|
|
123
|
+
return path.join(isPerWorktreeRef(name) ? this.gitdir : this.commondir, ...name.split('/'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {String} name
|
|
128
|
+
* @returns {String}
|
|
129
|
+
*/
|
|
130
|
+
reflogPath(name) {
|
|
131
|
+
return path.join(isPerWorktreeRef(name) ? this.gitdir : this.commondir, 'logs', ...name.split('/'));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Drop the packed-refs cache after anything that could change it. */
|
|
135
|
+
invalidate() {
|
|
136
|
+
this._packed = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── packed-refs ─────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @returns {Promise<Map<String, {oid: String, peeled: String|null}>>}
|
|
143
|
+
*/
|
|
144
|
+
async packedRefs() {
|
|
145
|
+
if (this._packed) return this._packed;
|
|
146
|
+
|
|
147
|
+
const packed = new Map();
|
|
148
|
+
const raw = await readFileOrNull(path.join(this.commondir, 'packed-refs'));
|
|
149
|
+
if (!raw) {
|
|
150
|
+
this._packed = packed;
|
|
151
|
+
return packed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let last = null;
|
|
155
|
+
for (const line of raw.toString('utf-8').split('\n')) {
|
|
156
|
+
if (!line || line.startsWith('#')) continue;
|
|
157
|
+
|
|
158
|
+
if (line.startsWith('^')) {
|
|
159
|
+
const peeled = line.slice(1).trim();
|
|
160
|
+
if (!last) throw new RefError('packed-refs has a peel line with no preceding ref');
|
|
161
|
+
packed.get(last).peeled = assertObjectId(peeled, 'peeled ref');
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const space = line.indexOf(' ');
|
|
166
|
+
if (space < 0) throw new RefError(`malformed packed-refs line: ${JSON.stringify(line)}`);
|
|
167
|
+
const oid = line.slice(0, space);
|
|
168
|
+
const name = line.slice(space + 1).trim();
|
|
169
|
+
if (!isObjectId(oid) || !isValidRefName(name, { allowOneLevel: true })) {
|
|
170
|
+
throw new RefError(`malformed packed-refs line: ${JSON.stringify(line)}`);
|
|
171
|
+
}
|
|
172
|
+
packed.set(name, { oid, peeled: null });
|
|
173
|
+
last = name;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this._packed = packed;
|
|
177
|
+
return packed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Rewrite packed-refs from a full map. Callers hold the packed-refs lock.
|
|
182
|
+
* @param {Lock} lock
|
|
183
|
+
* @param {Map<String, {oid: String, peeled: String|null}>} packed
|
|
184
|
+
*/
|
|
185
|
+
async _writePackedRefs(lock, packed) {
|
|
186
|
+
const names = [...packed.keys()].sort();
|
|
187
|
+
const lines = [PACKED_REFS_HEADER];
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
const entry = packed.get(name);
|
|
190
|
+
lines.push(`${entry.oid} ${name}`);
|
|
191
|
+
if (entry.peeled) lines.push(`^${entry.peeled}`);
|
|
192
|
+
}
|
|
193
|
+
await lock.write(lines.join('\n') + '\n');
|
|
194
|
+
this.invalidate();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── reading ─────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Raw content of a ref: an oid, or a symbolic target.
|
|
201
|
+
* @param {String} name
|
|
202
|
+
* @returns {Promise<{kind: 'oid'|'symbolic', value: String, loose: Boolean}|null>}
|
|
203
|
+
*/
|
|
204
|
+
async readRef(name) {
|
|
205
|
+
const raw = await readFileOrNull(this.refPath(name));
|
|
206
|
+
if (raw !== null) {
|
|
207
|
+
const text = raw.toString('utf-8').trim();
|
|
208
|
+
if (text.startsWith('ref:')) {
|
|
209
|
+
const target = text.slice(4).trim();
|
|
210
|
+
assertRefName(target, { allowOneLevel: true });
|
|
211
|
+
return { kind: 'symbolic', value: target, loose: true };
|
|
212
|
+
}
|
|
213
|
+
if (!isObjectId(text)) {
|
|
214
|
+
throw new RefError(`ref '${name}' does not contain an object id: ${JSON.stringify(text.slice(0, 80))}`);
|
|
215
|
+
}
|
|
216
|
+
return { kind: 'oid', value: text, loose: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const packed = (await this.packedRefs()).get(name);
|
|
220
|
+
return packed ? { kind: 'oid', value: packed.oid, loose: false } : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Follow symbolic refs to an object id.
|
|
225
|
+
* @param {String} name
|
|
226
|
+
* @returns {Promise<{oid: String|null, ref: String, symbolic: Array<String>}>}
|
|
227
|
+
* oid is null for an unborn ref (HEAD on a branch with no commits)
|
|
228
|
+
*/
|
|
229
|
+
async resolve(name) {
|
|
230
|
+
const chain = [];
|
|
231
|
+
let current = name;
|
|
232
|
+
|
|
233
|
+
for (let depth = 0; depth < MAX_SYMREF_DEPTH; depth++) {
|
|
234
|
+
const entry = await this.readRef(current);
|
|
235
|
+
if (!entry) return { oid: null, ref: current, symbolic: chain };
|
|
236
|
+
if (entry.kind === 'oid') return { oid: entry.value, ref: current, symbolic: chain };
|
|
237
|
+
chain.push(current);
|
|
238
|
+
current = entry.value;
|
|
239
|
+
}
|
|
240
|
+
throw new RefError(`symbolic ref '${name}' is nested more than ${MAX_SYMREF_DEPTH} levels deep`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {String} name
|
|
245
|
+
* @returns {Promise<String|null>}
|
|
246
|
+
*/
|
|
247
|
+
async resolveToOid(name) {
|
|
248
|
+
return (await this.resolve(name)).oid;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every ref under a prefix, loose entries shadowing packed ones.
|
|
253
|
+
* @param {String} [prefix] - e.g. 'refs/heads/'
|
|
254
|
+
* @returns {Promise<Map<String, String>>} name -> oid
|
|
255
|
+
*/
|
|
256
|
+
async list(prefix = 'refs/') {
|
|
257
|
+
const result = new Map();
|
|
258
|
+
|
|
259
|
+
for (const [name, entry] of await this.packedRefs()) {
|
|
260
|
+
if (name.startsWith(prefix)) result.set(name, entry.oid);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const roots = new Set([path.join(this.commondir, 'refs'), path.join(this.gitdir, 'refs')]);
|
|
264
|
+
for (const root of roots) {
|
|
265
|
+
await this._walkLoose(root, 'refs', prefix, result);
|
|
266
|
+
}
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async _walkLoose(dir, refPrefix, wanted, out) {
|
|
271
|
+
let entries;
|
|
272
|
+
try {
|
|
273
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return;
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
if (entry.name.endsWith('.lock')) continue;
|
|
281
|
+
const name = `${refPrefix}/${entry.name}`;
|
|
282
|
+
|
|
283
|
+
if (entry.isDirectory()) {
|
|
284
|
+
// Descend when the prefix could still match either way.
|
|
285
|
+
if (wanted.startsWith(name + '/') || name.startsWith(wanted) || wanted.startsWith(name)) {
|
|
286
|
+
await this._walkLoose(path.join(dir, entry.name), name, wanted, out);
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (!name.startsWith(wanted)) continue;
|
|
291
|
+
|
|
292
|
+
const ref = await this.readRef(name);
|
|
293
|
+
if (ref && ref.kind === 'oid') out.set(name, ref.value);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Git's ref lookup order for a user-supplied shorthand.
|
|
299
|
+
* @param {String} shorthand
|
|
300
|
+
* @returns {Promise<{name: String, oid: String}|null>}
|
|
301
|
+
*/
|
|
302
|
+
async expand(shorthand) {
|
|
303
|
+
const candidates = shorthand === 'HEAD'
|
|
304
|
+
? ['HEAD']
|
|
305
|
+
: [
|
|
306
|
+
shorthand,
|
|
307
|
+
`refs/${shorthand}`,
|
|
308
|
+
`refs/tags/${shorthand}`,
|
|
309
|
+
`refs/heads/${shorthand}`,
|
|
310
|
+
`refs/remotes/${shorthand}`,
|
|
311
|
+
`refs/remotes/${shorthand}/HEAD`
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
for (const candidate of candidates) {
|
|
315
|
+
if (!isValidRefName(candidate, { allowOneLevel: true })) continue;
|
|
316
|
+
const resolved = await this.resolve(candidate);
|
|
317
|
+
if (resolved.oid) return { name: resolved.ref, oid: resolved.oid };
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── HEAD ────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @returns {Promise<{detached: Boolean, unborn: Boolean, ref: String|null, oid: String|null, branch: String|null}>}
|
|
326
|
+
*/
|
|
327
|
+
async head() {
|
|
328
|
+
const entry = await this.readRef('HEAD');
|
|
329
|
+
if (!entry) return { detached: false, unborn: true, ref: null, oid: null, branch: null };
|
|
330
|
+
|
|
331
|
+
if (entry.kind === 'oid') {
|
|
332
|
+
return { detached: true, unborn: false, ref: null, oid: entry.value, branch: null };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const target = entry.value;
|
|
336
|
+
const oid = await this.resolveToOid(target);
|
|
337
|
+
return {
|
|
338
|
+
detached: false,
|
|
339
|
+
unborn: oid === null,
|
|
340
|
+
ref: target,
|
|
341
|
+
oid,
|
|
342
|
+
branch: target.startsWith('refs/heads/') ? target.slice('refs/heads/'.length) : null
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Point HEAD at a branch without touching the branch itself.
|
|
348
|
+
* @param {String} target
|
|
349
|
+
* @param {String} [reason]
|
|
350
|
+
*/
|
|
351
|
+
async setHeadSymbolic(target, reason, expectedHead) {
|
|
352
|
+
assertRefName(target);
|
|
353
|
+
await this._setHead(`ref: ${target}\n`, reason || `checkout: moving to ${target}`, expectedHead);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async setHeadDetached(oid, reason, expectedHead) {
|
|
357
|
+
assertObjectId(oid);
|
|
358
|
+
await this._setHead(`${oid}\n`, reason || `checkout: moving to ${oid}`, expectedHead);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async _setHead(content, reason, expectedHead) {
|
|
362
|
+
const before = await withLock(path.join(this.gitdir, 'HEAD'), async lock => {
|
|
363
|
+
const current = await this.head();
|
|
364
|
+
if (expectedHead && (current.ref !== expectedHead.ref || current.oid !== expectedHead.oid)) {
|
|
365
|
+
throw new RefError('HEAD changed during the operation; refusing to overwrite it');
|
|
366
|
+
}
|
|
367
|
+
await lock.write(content);
|
|
368
|
+
return current.oid;
|
|
369
|
+
});
|
|
370
|
+
this.invalidate();
|
|
371
|
+
await this._appendReflog('HEAD', before, await this.resolveToOid('HEAD'), reason);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── writing ─────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Compare-and-set a ref.
|
|
378
|
+
*
|
|
379
|
+
* @param {String} name
|
|
380
|
+
* @param {String} newOid
|
|
381
|
+
* @param {Object} [options]
|
|
382
|
+
* @param {String|null|undefined} [options.expectedOldOid]
|
|
383
|
+
* `undefined` = no check, `null` = must not exist,
|
|
384
|
+
* an oid = must currently be exactly that.
|
|
385
|
+
* @param {String} [options.reason] - reflog message
|
|
386
|
+
* @returns {Promise<{oldOid: String|null, newOid: String}>}
|
|
387
|
+
*/
|
|
388
|
+
async update(name, newOid, options = {}) {
|
|
389
|
+
assertRefName(name, { allowOneLevel: true });
|
|
390
|
+
assertObjectId(newOid, `new value for ${name}`);
|
|
391
|
+
|
|
392
|
+
const result = await withLock(this.refPath(name), async (lock) => {
|
|
393
|
+
const current = await this._currentUnderLock(name, lock);
|
|
394
|
+
this._checkExpected(name, current, options.expectedOldOid);
|
|
395
|
+
await lock.write(`${newOid}\n`);
|
|
396
|
+
return current;
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
this.invalidate();
|
|
400
|
+
await this._appendReflog(name, result, newOid, options.reason || 'update');
|
|
401
|
+
await this._mirrorHeadReflog(name, result, newOid, options.reason);
|
|
402
|
+
return { oldOid: result, newOid };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Delete a ref from both the loose file and packed-refs.
|
|
407
|
+
* @param {String} name
|
|
408
|
+
* @param {Object} [options]
|
|
409
|
+
* @param {String|undefined} [options.expectedOldOid]
|
|
410
|
+
* @returns {Promise<String|null>} the value that was removed
|
|
411
|
+
*/
|
|
412
|
+
async delete(name, options = {}) {
|
|
413
|
+
assertRefName(name, { allowOneLevel: true });
|
|
414
|
+
|
|
415
|
+
const lock = await Lock.acquire(this.refPath(name));
|
|
416
|
+
let current;
|
|
417
|
+
try {
|
|
418
|
+
current = await this._currentUnderLock(name, lock);
|
|
419
|
+
this._checkExpected(name, current, options.expectedOldOid);
|
|
420
|
+
await fs.rm(this.refPath(name), { force: true });
|
|
421
|
+
} finally {
|
|
422
|
+
await lock.release();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const packed = await this.packedRefs();
|
|
426
|
+
if (packed.has(name)) {
|
|
427
|
+
await withLock(path.join(this.commondir, 'packed-refs'), async (packedLock) => {
|
|
428
|
+
const fresh = new Map(await this.packedRefs());
|
|
429
|
+
fresh.delete(name);
|
|
430
|
+
await this._writePackedRefs(packedLock, fresh);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this.invalidate();
|
|
435
|
+
if (current) await this._appendReflog(name, current, NULL_OID, options.reason || 'delete');
|
|
436
|
+
return current;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Apply several ref updates, stopping before the first that fails its
|
|
441
|
+
* precondition. Not a filesystem transaction: earlier updates that already
|
|
442
|
+
* committed stay committed and are reported, so a caller can undo them.
|
|
443
|
+
*
|
|
444
|
+
* @param {Array<{name, newOid?, delete?, expectedOldOid?}>} updates
|
|
445
|
+
* @param {String} [reason]
|
|
446
|
+
* @returns {Promise<Array<{name, oldOid, newOid}>>} applied, in order
|
|
447
|
+
*/
|
|
448
|
+
async updateMany(updates, reason) {
|
|
449
|
+
// Check every precondition first so the common case fails atomically.
|
|
450
|
+
for (const update of updates) {
|
|
451
|
+
const current = await this.readRef(update.name);
|
|
452
|
+
const currentOid = current && current.kind === 'oid' ? current.value : null;
|
|
453
|
+
this._checkExpected(update.name, currentOid, update.expectedOldOid);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const applied = [];
|
|
457
|
+
for (const update of updates) {
|
|
458
|
+
if (update.delete) {
|
|
459
|
+
const oldOid = await this.delete(update.name, { expectedOldOid: update.expectedOldOid, reason });
|
|
460
|
+
applied.push({ name: update.name, oldOid, newOid: null });
|
|
461
|
+
} else {
|
|
462
|
+
const result = await this.update(update.name, update.newOid, { expectedOldOid: update.expectedOldOid, reason });
|
|
463
|
+
applied.push({ name: update.name, ...result });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return applied;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Read the ref while its lock is held — the loose file may have been
|
|
471
|
+
* created between our earlier read and the lock.
|
|
472
|
+
* @param {String} name
|
|
473
|
+
* @param {Lock} lock
|
|
474
|
+
* @returns {Promise<String|null>}
|
|
475
|
+
*/
|
|
476
|
+
async _currentUnderLock(name, lock) {
|
|
477
|
+
const raw = await lock.readTarget();
|
|
478
|
+
if (raw !== null) {
|
|
479
|
+
const text = raw.toString('utf-8').trim();
|
|
480
|
+
if (text.startsWith('ref:')) {
|
|
481
|
+
throw new RefError(`'${name}' is a symbolic ref; use setHeadSymbolic to change it`);
|
|
482
|
+
}
|
|
483
|
+
return assertObjectId(text, `current value of ${name}`);
|
|
484
|
+
}
|
|
485
|
+
this.invalidate();
|
|
486
|
+
const packed = (await this.packedRefs()).get(name);
|
|
487
|
+
return packed ? packed.oid : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* @param {String} name
|
|
492
|
+
* @param {String|null} current
|
|
493
|
+
* @param {String|null|undefined} expected
|
|
494
|
+
*/
|
|
495
|
+
_checkExpected(name, current, expected) {
|
|
496
|
+
if (expected === undefined) return;
|
|
497
|
+
|
|
498
|
+
if (expected === null || expected === NULL_OID) {
|
|
499
|
+
if (current !== null) {
|
|
500
|
+
throw new RefError(
|
|
501
|
+
`'${name}' already exists (${current.slice(0, 12)}) but was expected not to`,
|
|
502
|
+
'GENT_REF_RACE'
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
assertObjectId(expected, `expected old value of ${name}`);
|
|
509
|
+
if (current !== expected) {
|
|
510
|
+
throw new RefError(
|
|
511
|
+
`'${name}' is ${current ? current.slice(0, 12) : 'missing'}, not the expected ${expected.slice(0, 12)} — ` +
|
|
512
|
+
`it changed underneath this operation`,
|
|
513
|
+
'GENT_REF_RACE'
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─── reflog ──────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {String} name
|
|
522
|
+
* @param {String|null} oldOid
|
|
523
|
+
* @param {String|null} newOid
|
|
524
|
+
* @param {String} message
|
|
525
|
+
*/
|
|
526
|
+
async _appendReflog(name, oldOid, newOid, message) {
|
|
527
|
+
const identity = this.repo.reflogIdentity ? await this.repo.reflogIdentity() : null;
|
|
528
|
+
if (!identity) return; // no identity configured yet: skip rather than invent one
|
|
529
|
+
|
|
530
|
+
const line =
|
|
531
|
+
`${oldOid || NULL_OID} ${newOid || NULL_OID} ` +
|
|
532
|
+
`${identity.name} <${identity.email}> ${identity.timestamp} ${identity.timezone}\t` +
|
|
533
|
+
`${String(message).replace(/[\n\r]+/g, ' ')}\n`;
|
|
534
|
+
|
|
535
|
+
const target = this.reflogPath(name);
|
|
536
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
537
|
+
await fs.appendFile(target, line, 'utf-8');
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Git records branch movements in HEAD's reflog too, when HEAD points at
|
|
542
|
+
* the branch being moved.
|
|
543
|
+
*/
|
|
544
|
+
async _mirrorHeadReflog(name, oldOid, newOid, reason) {
|
|
545
|
+
if (name === 'HEAD') return;
|
|
546
|
+
const entry = await this.readRef('HEAD');
|
|
547
|
+
if (entry && entry.kind === 'symbolic' && entry.value === name) {
|
|
548
|
+
await this._appendReflog('HEAD', oldOid, newOid, reason || 'update');
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* @param {String} name
|
|
554
|
+
* @returns {Promise<Array<{oldOid, newOid, name, email, timestamp, timezone, message}>>}
|
|
555
|
+
* newest last, matching the file order
|
|
556
|
+
*/
|
|
557
|
+
async readReflog(name) {
|
|
558
|
+
const raw = await readFileOrNull(this.reflogPath(name));
|
|
559
|
+
if (!raw) return [];
|
|
560
|
+
|
|
561
|
+
const entries = [];
|
|
562
|
+
for (const line of raw.toString('utf-8').split('\n')) {
|
|
563
|
+
if (!line.trim()) continue;
|
|
564
|
+
const tab = line.indexOf('\t');
|
|
565
|
+
const head = tab < 0 ? line : line.slice(0, tab);
|
|
566
|
+
const message = tab < 0 ? '' : line.slice(tab + 1);
|
|
567
|
+
|
|
568
|
+
const open = head.indexOf('<');
|
|
569
|
+
const close = head.indexOf('>', open + 1);
|
|
570
|
+
if (open < 0 || close < 0) continue;
|
|
571
|
+
|
|
572
|
+
const [oldOid, newOid] = head.slice(0, open).trim().split(/\s+/);
|
|
573
|
+
const rest = head.slice(close + 1).trim().split(/\s+/);
|
|
574
|
+
entries.push({
|
|
575
|
+
oldOid,
|
|
576
|
+
newOid,
|
|
577
|
+
name: head.slice(head.indexOf(' ', head.indexOf(' ') + 1) + 1, open).trim(),
|
|
578
|
+
email: head.slice(open + 1, close),
|
|
579
|
+
timestamp: Number.parseInt(rest[0], 10) || 0,
|
|
580
|
+
timezone: rest[1] || '+0000',
|
|
581
|
+
message
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return entries;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
module.exports = {
|
|
589
|
+
RefStore,
|
|
590
|
+
RefError,
|
|
591
|
+
isValidRefName,
|
|
592
|
+
assertRefName,
|
|
593
|
+
isPerWorktreeRef,
|
|
594
|
+
PACKED_REFS_HEADER
|
|
595
|
+
};
|