git-dedup-core 0.1.0 → 2.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 +20 -20
- package/dist/index.d.ts +45 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +537 -109
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { access, mkdir, readdir, readFile, realpath, rm, stat, writeFile } from
|
|
|
5
5
|
import { homedir, constants as osConstants } from 'node:os';
|
|
6
6
|
import { basename, delimiter, dirname, join, resolve, sep } from 'node:path';
|
|
7
7
|
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
-
function
|
|
8
|
+
function addFailureReason(error, stage) {
|
|
9
9
|
const message = String(error);
|
|
10
10
|
if (/cannot lock ref|reference already exists|case.insensitive|refname collision/i.test(message))
|
|
11
11
|
return 'ref collision in the shared pool';
|
|
@@ -70,11 +70,27 @@ async function directorySize(path) {
|
|
|
70
70
|
}
|
|
71
71
|
const remoteId = (key) => createHash('sha256').update(key).digest('hex');
|
|
72
72
|
function unsupportedClone(args) {
|
|
73
|
-
return args.
|
|
73
|
+
return args.find((arg) => /^(--depth|--shallow|--filter|--mirror|--bare|--reference|--dissociate|--no-hardlinks|--shared|--separate-git-dir|--bundle-uri|--sparse|--upload-pack|--server-option|--template|--config|--origin|--no-checkout|--single-branch|--no-single-branch|--revision|--jobs|-j|-o|-u|-c)(=|$)/.test(arg));
|
|
74
74
|
}
|
|
75
|
+
/** Reports a handled command that is forwarded to plain Git, unless Git was asked to be quiet. */
|
|
76
|
+
function fallback(args, reason) {
|
|
77
|
+
if (!args.includes('-q') && !args.includes('--quiet'))
|
|
78
|
+
process.stderr.write(`git-dedup: ${reason}; using plain Git\n`);
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
/** The last Git `fatal:` or `error:` line of a failure, without captured progress output. */
|
|
82
|
+
function gitFailure(error) {
|
|
83
|
+
const line = String(error)
|
|
84
|
+
.match(/\b(?:fatal|error): [^\r\n]*/g)
|
|
85
|
+
?.at(-1)
|
|
86
|
+
?.trim();
|
|
87
|
+
return line ? ` (${line})` : '';
|
|
88
|
+
}
|
|
89
|
+
/** Returns the parsed clone, or the reason git-dedup cannot handle it. */
|
|
75
90
|
function parseClone(args) {
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
const unsupported = unsupportedClone(args);
|
|
92
|
+
if (unsupported)
|
|
93
|
+
return `clone option ${unsupported.split('=')[0]} is not supported`;
|
|
78
94
|
const positional = [];
|
|
79
95
|
let recurse = false;
|
|
80
96
|
let branch;
|
|
@@ -88,7 +104,7 @@ function parseClone(args) {
|
|
|
88
104
|
if (arg === '-b' || arg === '--branch') {
|
|
89
105
|
branch = args[++i];
|
|
90
106
|
if (!branch)
|
|
91
|
-
return
|
|
107
|
+
return `clone option ${arg} requires a value`;
|
|
92
108
|
forwarded.push(arg, branch);
|
|
93
109
|
continue;
|
|
94
110
|
}
|
|
@@ -107,11 +123,11 @@ function parseClone(args) {
|
|
|
107
123
|
continue;
|
|
108
124
|
}
|
|
109
125
|
if (arg.startsWith('-'))
|
|
110
|
-
return
|
|
126
|
+
return `clone option ${arg.split('=')[0]} is not supported`;
|
|
111
127
|
positional.push(arg);
|
|
112
128
|
}
|
|
113
129
|
if (positional.length < 1 || positional.length > 2)
|
|
114
|
-
return
|
|
130
|
+
return 'clone expects a repository and an optional directory';
|
|
115
131
|
return { remote: positional[0], destination: positional[1], recurse, branch, forwarded };
|
|
116
132
|
}
|
|
117
133
|
function defaultCloneName(remote) {
|
|
@@ -121,6 +137,24 @@ function defaultCloneName(remote) {
|
|
|
121
137
|
.at(-1)
|
|
122
138
|
.replace(/\.git$/, '');
|
|
123
139
|
}
|
|
140
|
+
function submoduleAddPath(args) {
|
|
141
|
+
const positional = [];
|
|
142
|
+
for (let i = 1; i < args.length; i++) {
|
|
143
|
+
const arg = args[i];
|
|
144
|
+
if (arg === '--') {
|
|
145
|
+
positional.push(...args.slice(i + 1));
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
// Options of `git submodule add` that take a separate value.
|
|
149
|
+
if (['-b', '--branch', '--reference', '--ref-format', '--name', '--depth'].includes(arg))
|
|
150
|
+
i++;
|
|
151
|
+
else if (!arg.startsWith('-'))
|
|
152
|
+
positional.push(arg);
|
|
153
|
+
}
|
|
154
|
+
if (positional.length === 1)
|
|
155
|
+
return defaultCloneName(positional[0]);
|
|
156
|
+
return positional.length === 2 ? positional[1] : undefined;
|
|
157
|
+
}
|
|
124
158
|
function resolveSubmoduleRemote(parent, child) {
|
|
125
159
|
if (!child.startsWith('./') && !child.startsWith('../'))
|
|
126
160
|
return child;
|
|
@@ -189,6 +223,7 @@ async function canonicalPath(path) {
|
|
|
189
223
|
}
|
|
190
224
|
async function lockDirectory(lock, action) {
|
|
191
225
|
await mkdir(dirname(lock), { recursive: true });
|
|
226
|
+
let ownerless = 0;
|
|
192
227
|
for (let attempts = 0;; attempts++) {
|
|
193
228
|
try {
|
|
194
229
|
await mkdir(lock);
|
|
@@ -198,14 +233,16 @@ async function lockDirectory(lock, action) {
|
|
|
198
233
|
catch (error) {
|
|
199
234
|
if (error.code !== 'EEXIST')
|
|
200
235
|
throw error;
|
|
201
|
-
if (attempts > 1200)
|
|
202
|
-
throw new Error(`Timed out waiting for git-dedup lock: ${lock}`, { cause: error });
|
|
203
236
|
if (attempts === 20)
|
|
204
237
|
process.stderr.write('git-dedup: waiting for the object pool lock\n');
|
|
205
238
|
const owner = Number((await readFile(join(lock, 'owner'), 'utf8').catch(() => '')).trim());
|
|
239
|
+
const alive = owner > 0 && processExists(owner);
|
|
240
|
+
// Wait as long as a live owner holds the lock; only an ownerless lock times out.
|
|
241
|
+
if (!alive && ++ownerless > 1200)
|
|
242
|
+
throw new Error(`Timed out waiting for git-dedup lock: ${lock}`, { cause: error });
|
|
206
243
|
const age = Date.now() - (await stat(lock).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
|
|
207
244
|
// A dead owner may be reaped. A live but slow Git process must never lose its lock.
|
|
208
|
-
if (age > 5000 && owner && !
|
|
245
|
+
if (age > 5000 && owner && !alive) {
|
|
209
246
|
await rm(lock, { recursive: true, force: true });
|
|
210
247
|
continue;
|
|
211
248
|
}
|
|
@@ -255,10 +292,54 @@ async function withLock(root, action) {
|
|
|
255
292
|
function poolPath(root) {
|
|
256
293
|
return join(root, 'pool.git');
|
|
257
294
|
}
|
|
295
|
+
// Keep URL credentials out of the store; Git credential helpers supply them at fetch time.
|
|
296
|
+
// HTTP(S) userinfo is a credential. Other schemes keep the user name, which SSH needs.
|
|
297
|
+
function redactRemote(remote, mask = '') {
|
|
298
|
+
return remote.replace(/^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*)@/i, (authority, scheme, userinfo) => {
|
|
299
|
+
const user = /^https?:/i.test(scheme) ? '' : userinfo.split(':')[0];
|
|
300
|
+
if (user === userinfo)
|
|
301
|
+
return authority;
|
|
302
|
+
const kept = user + (mask && (user ? ':' : '') + mask);
|
|
303
|
+
return scheme + (kept && `${kept}@`);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
function registration(remote, key) {
|
|
307
|
+
return JSON.stringify({ key, remote: redactRemote(remote) }) + '\n';
|
|
308
|
+
}
|
|
258
309
|
async function registerRemote(root, remote, key) {
|
|
259
310
|
const id = remoteId(key);
|
|
260
311
|
await mkdir(join(root, 'remotes'), { recursive: true });
|
|
261
|
-
await writeFile(join(root, 'remotes', `${id}.json`),
|
|
312
|
+
await writeFile(join(root, 'remotes', `${id}.json`), registration(remote, key));
|
|
313
|
+
}
|
|
314
|
+
async function registerConsumer(root, id, gitdir) {
|
|
315
|
+
await mkdir(join(root, 'consumers'), { recursive: true });
|
|
316
|
+
await writeFile(join(root, 'consumers', `${id}.json`), JSON.stringify({ gitdir }) + '\n');
|
|
317
|
+
}
|
|
318
|
+
async function registeredConsumers(root) {
|
|
319
|
+
const consumers = new Map();
|
|
320
|
+
for (const name of await readdir(join(root, 'consumers')).catch(() => [])) {
|
|
321
|
+
const value = JSON.parse(await readFile(join(root, 'consumers', name), 'utf8'));
|
|
322
|
+
const id = name.replace(/\.json$/, '');
|
|
323
|
+
if (!/^[a-f0-9-]{36}$/.test(id) ||
|
|
324
|
+
!value ||
|
|
325
|
+
typeof value !== 'object' ||
|
|
326
|
+
!('gitdir' in value) ||
|
|
327
|
+
typeof value.gitdir !== 'string')
|
|
328
|
+
throw new Error(`Invalid git-dedup consumer registration: ${name}`);
|
|
329
|
+
consumers.set(id, value.gitdir);
|
|
330
|
+
}
|
|
331
|
+
return consumers;
|
|
332
|
+
}
|
|
333
|
+
async function carriesConsumerId(gitdir, id) {
|
|
334
|
+
const current = await readFile(join(gitdir, 'gitx-consumer-id'), 'utf8').catch((error) => {
|
|
335
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
336
|
+
return '';
|
|
337
|
+
throw error;
|
|
338
|
+
});
|
|
339
|
+
return current.trim() === id;
|
|
340
|
+
}
|
|
341
|
+
function checkoutPath(gitdir) {
|
|
342
|
+
return basename(gitdir) === '.git' ? dirname(gitdir) : gitdir;
|
|
262
343
|
}
|
|
263
344
|
async function setAlternate(gitdir, pool) {
|
|
264
345
|
const alternate = join(gitdir, 'objects', 'info', 'alternates');
|
|
@@ -297,6 +378,8 @@ async function consumerId(commonGitdir) {
|
|
|
297
378
|
}
|
|
298
379
|
}
|
|
299
380
|
export function createGitDedup(options = {}) {
|
|
381
|
+
if (process.platform === 'win32')
|
|
382
|
+
throw new Error('git-dedup does not support Windows yet. Run it inside WSL, or use Git directly. See https://github.com/bhouston/git-dedup/issues/74');
|
|
300
383
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
301
384
|
const incomingEnv = { ...process.env, ...options.env };
|
|
302
385
|
const env = { ...incomingEnv, GITX_ACTIVE: '1', GIT_DEDUP_ACTIVE: '1' };
|
|
@@ -346,18 +429,26 @@ export function createGitDedup(options = {}) {
|
|
|
346
429
|
await access(candidate, constants.X_OK);
|
|
347
430
|
return (cachedGit = candidate);
|
|
348
431
|
}
|
|
349
|
-
|
|
432
|
+
// 'tee' captures output like the default and also streams stderr (Git progress) to the user.
|
|
433
|
+
async function git(args, at = cwd, inherit = false, input) {
|
|
350
434
|
const binary = await gitPath();
|
|
351
435
|
return new Promise((resolveResult, reject) => {
|
|
352
|
-
const child = spawn(binary, args, {
|
|
436
|
+
const child = spawn(binary, args, {
|
|
437
|
+
cwd: at,
|
|
438
|
+
env,
|
|
439
|
+
stdio: inherit === true ? 'inherit' : [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
440
|
+
});
|
|
441
|
+
child.stdin?.end(input);
|
|
353
442
|
let stdout = '';
|
|
354
443
|
let stderr = '';
|
|
355
|
-
if (
|
|
444
|
+
if (inherit !== true) {
|
|
356
445
|
child.stdout?.on('data', (b) => {
|
|
357
446
|
stdout += b;
|
|
358
447
|
});
|
|
359
448
|
child.stderr?.on('data', (b) => {
|
|
360
449
|
stderr += b;
|
|
450
|
+
if (inherit === 'tee')
|
|
451
|
+
process.stderr.write(b);
|
|
361
452
|
});
|
|
362
453
|
}
|
|
363
454
|
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
@@ -365,10 +456,10 @@ export function createGitDedup(options = {}) {
|
|
|
365
456
|
child.kill(signal);
|
|
366
457
|
});
|
|
367
458
|
const cleanup = () => {
|
|
368
|
-
if (inherit)
|
|
459
|
+
if (inherit === true)
|
|
369
460
|
signals.forEach((signal, i) => process.removeListener(signal, handlers[i]));
|
|
370
461
|
};
|
|
371
|
-
if (inherit)
|
|
462
|
+
if (inherit === true)
|
|
372
463
|
signals.forEach((signal, i) => process.on(signal, handlers[i]));
|
|
373
464
|
child.on('error', (error) => {
|
|
374
465
|
cleanup();
|
|
@@ -380,10 +471,10 @@ export function createGitDedup(options = {}) {
|
|
|
380
471
|
});
|
|
381
472
|
});
|
|
382
473
|
}
|
|
383
|
-
async function checked(args, at = cwd) {
|
|
474
|
+
async function checked(args, at = cwd, progress = false, input) {
|
|
384
475
|
if (hasRepositoryEnvironment)
|
|
385
476
|
throw new Error('Unset Git repository override environment variables before running git-dedup storage operations');
|
|
386
|
-
const result = await git(args, at);
|
|
477
|
+
const result = await git(args, at, progress && 'tee', input);
|
|
387
478
|
if (result.code !== 0)
|
|
388
479
|
throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`);
|
|
389
480
|
return result.stdout.trim();
|
|
@@ -394,7 +485,8 @@ export function createGitDedup(options = {}) {
|
|
|
394
485
|
return canonicalPath(resolve(cwd, expandHome(configured)));
|
|
395
486
|
for (const legacy of ['~/.cache/gitx', '~/.gitx']) {
|
|
396
487
|
const path = expandHome(legacy);
|
|
397
|
-
|
|
488
|
+
const marker = await readFile(join(path, '.gitx-store'), 'utf8').catch(() => '');
|
|
489
|
+
if (marker === 'gitx-store-v2\n')
|
|
398
490
|
return canonicalPath(path);
|
|
399
491
|
}
|
|
400
492
|
return canonicalPath(expandHome('~/.git-dedup'));
|
|
@@ -409,17 +501,23 @@ export function createGitDedup(options = {}) {
|
|
|
409
501
|
// containing unrelated histories. The graph is derived data, so omit it.
|
|
410
502
|
await checked(['-C', pool, 'config', 'gc.writeCommitGraph', 'false']);
|
|
411
503
|
await checked(['-C', pool, 'config', 'maintenance.commit-graph.enabled', 'false']);
|
|
504
|
+
// Consumers borrow objects that a force push can leave unreachable in the
|
|
505
|
+
// pool. Automatic gc would prune them, so only store prune deletes objects,
|
|
506
|
+
// after re-pinning every live consumer.
|
|
507
|
+
await checked(['-C', pool, 'config', 'gc.auto', '0']);
|
|
508
|
+
await checked(['-C', pool, 'config', 'maintenance.auto', 'false']);
|
|
509
|
+
await checked(['-C', pool, 'config', 'gc.pruneExpire', 'never']);
|
|
412
510
|
await rm(join(pool, 'objects', 'info', 'commit-graph'), { force: true });
|
|
413
511
|
await rm(join(pool, 'objects', 'info', 'commit-graphs'), { recursive: true, force: true });
|
|
414
512
|
return pool;
|
|
415
513
|
}
|
|
416
|
-
async function fetchRemote(root, remote, key) {
|
|
514
|
+
async function fetchRemote(root, remote, key, progress = false) {
|
|
417
515
|
const pool = await ensurePool(root);
|
|
418
|
-
await fetchRemoteObjects(pool, remote, key);
|
|
516
|
+
await fetchRemoteObjects(pool, remote, key, progress);
|
|
419
517
|
await registerRemote(root, remote, key);
|
|
420
518
|
return pool;
|
|
421
519
|
}
|
|
422
|
-
async function fetchRemoteObjects(pool, remote, key) {
|
|
520
|
+
async function fetchRemoteObjects(pool, remote, key, progress = false) {
|
|
423
521
|
const id = remoteId(key);
|
|
424
522
|
process.stderr.write(`git-dedup: updating object pool for ${key}\n`);
|
|
425
523
|
await checked([
|
|
@@ -427,30 +525,40 @@ export function createGitDedup(options = {}) {
|
|
|
427
525
|
pool,
|
|
428
526
|
'fetch',
|
|
429
527
|
'--no-tags',
|
|
528
|
+
...(progress ? ['--progress'] : []),
|
|
430
529
|
remote,
|
|
431
530
|
`+refs/heads/*:refs/gitx/remotes/${id}/heads/*`,
|
|
432
531
|
`+refs/tags/*:refs/gitx/remotes/${id}/tags/*`,
|
|
433
|
-
]);
|
|
532
|
+
], cwd, progress);
|
|
434
533
|
}
|
|
435
534
|
async function consumerTips(repo) {
|
|
436
535
|
const refs = await checked(['for-each-ref', '--format=%(objectname)', 'refs'], repo);
|
|
437
536
|
const head = await checked(['rev-parse', 'HEAD'], repo);
|
|
438
537
|
return [...new Set([...refs.split('\n').filter(Boolean), head])].toSorted();
|
|
439
538
|
}
|
|
440
|
-
async function
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
539
|
+
async function unpinnedTips(pool, id, tips) {
|
|
540
|
+
// A tip that a remote ref holds needs no pin until the remote moves. Only
|
|
541
|
+
// store prune deletes objects, and it re-pins every live consumer first.
|
|
542
|
+
const held = new Set((await checked([
|
|
543
|
+
'-C',
|
|
544
|
+
pool,
|
|
545
|
+
'for-each-ref',
|
|
546
|
+
'--format=%(objectname)',
|
|
547
|
+
`refs/gitx/consumers/${id}`,
|
|
548
|
+
'refs/gitx/remotes',
|
|
549
|
+
])).split('\n'));
|
|
550
|
+
return tips.filter((oid) => !held.has(oid));
|
|
444
551
|
}
|
|
445
552
|
async function pinConsumer(pool, repo, commonGitdir, tips) {
|
|
446
553
|
// Immutable snapshots keep old tips reachable after force pushes, branch
|
|
447
|
-
// deletion, or a later
|
|
554
|
+
// deletion, or a later store add call on the same checkout. Source ref names
|
|
448
555
|
// cannot be copied into a files-backed pool: refs differing only by case
|
|
449
556
|
// collide on case-insensitive filesystems. Object IDs are safe ref names,
|
|
450
557
|
// and one pin per distinct tip preserves the same reachability.
|
|
451
558
|
const id = await consumerId(commonGitdir);
|
|
452
|
-
|
|
453
|
-
|
|
559
|
+
// Register before pinning: prune refuses to run while pins lack a registration.
|
|
560
|
+
await registerConsumer(dirname(pool), id, commonGitdir);
|
|
561
|
+
const missing = await unpinnedTips(pool, id, tips);
|
|
454
562
|
// Keep fetch argument lists bounded for repositories with many refs.
|
|
455
563
|
for (let offset = 0; offset < missing.length; offset += 128)
|
|
456
564
|
await checked([
|
|
@@ -479,37 +587,66 @@ export function createGitDedup(options = {}) {
|
|
|
479
587
|
}
|
|
480
588
|
async function clone(args, effectiveCwd) {
|
|
481
589
|
const parsed = parseClone(args);
|
|
482
|
-
if (
|
|
483
|
-
return
|
|
590
|
+
if (typeof parsed === 'string')
|
|
591
|
+
return fallback(args, parsed);
|
|
484
592
|
const key = await remoteKey(parsed.remote, effectiveCwd);
|
|
485
593
|
if (!key)
|
|
486
|
-
return
|
|
594
|
+
return fallback(args, 'remote is not a supported network URL');
|
|
487
595
|
const destination = resolve(effectiveCwd, parsed.destination ?? defaultCloneName(parsed.remote));
|
|
488
|
-
|
|
489
|
-
|
|
596
|
+
// Like Git, clone into a missing or empty directory.
|
|
597
|
+
const entries = await readdir(destination).catch((error) => error.code === 'ENOENT' ? [] : undefined);
|
|
598
|
+
if (!entries || entries.length)
|
|
599
|
+
return fallback(args, `${destination} is not an empty directory`);
|
|
490
600
|
const root = await storePath();
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
601
|
+
// Lock only the pool writes. Only store prune deletes pool objects, so git clone can
|
|
602
|
+
// read the pool while other processes fetch or repack, and the new tips are pinned right after.
|
|
603
|
+
let poolReused = false;
|
|
604
|
+
let fetched;
|
|
605
|
+
let fetchError;
|
|
606
|
+
// Like git clone: progress on a terminal or with --progress, never with -q/--quiet.
|
|
607
|
+
const quiet = parsed.forwarded.some((arg) => arg === '-q' || arg === '--quiet');
|
|
608
|
+
const progress = !quiet && (process.stderr.isTTY || parsed.forwarded.includes('--progress'));
|
|
609
|
+
// Until the pin lands, this clone borrows pool objects that nothing pins, so
|
|
610
|
+
// store prune refuses while the marker exists. A failed pin leaves it behind.
|
|
611
|
+
const marker = join(root, 'clones', `${randomUUID()}.json`);
|
|
612
|
+
try {
|
|
613
|
+
fetched = await withLock(root, async () => {
|
|
614
|
+
poolReused = options.onStorageReport ? await isPresent(poolPath(root)) : false;
|
|
615
|
+
const pool = await fetchRemote(root, parsed.remote, key, progress).catch((error) => {
|
|
616
|
+
fetchError = error;
|
|
617
|
+
return undefined;
|
|
618
|
+
});
|
|
619
|
+
if (pool) {
|
|
620
|
+
await mkdir(dirname(marker), { recursive: true });
|
|
621
|
+
await writeFile(marker, JSON.stringify({ pid: process.pid, destination }) + '\n');
|
|
622
|
+
}
|
|
623
|
+
return pool;
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
return fallback(args, 'object pool lock unavailable');
|
|
628
|
+
}
|
|
629
|
+
if (!fetched)
|
|
630
|
+
return fallback(args, `object pool unavailable${gitFailure(fetchError)}`);
|
|
631
|
+
const pool = fetched;
|
|
632
|
+
const cloneArgs = ['clone', '--reference', pool, ...parsed.forwarded, parsed.remote, destination];
|
|
633
|
+
const result = await git(cloneArgs, effectiveCwd, true);
|
|
634
|
+
if (result.code !== 0) {
|
|
635
|
+
if (!(await isPresent(join(destination, '.git'))))
|
|
636
|
+
await rm(marker, { force: true });
|
|
637
|
+
return result.code;
|
|
638
|
+
}
|
|
639
|
+
const commonGitdir = await repoCommonGitdir(destination);
|
|
640
|
+
const tips = await consumerTips(destination);
|
|
641
|
+
await withLock(root, async () => {
|
|
642
|
+
await pinConsumer(pool, destination, commonGitdir, tips);
|
|
643
|
+
await rm(marker, { force: true });
|
|
509
644
|
});
|
|
510
|
-
if (
|
|
645
|
+
if (options.onStorageReport)
|
|
646
|
+
emitStorageReport({ operation: 'clone', repository: destination, poolReused, estimatedSavedBytes: 0 });
|
|
647
|
+
if (parsed.recurse)
|
|
511
648
|
return updateSubmodules(['update', '--init', '--recursive'], destination);
|
|
512
|
-
return
|
|
649
|
+
return 0;
|
|
513
650
|
}
|
|
514
651
|
async function seedSubmodules(repo) {
|
|
515
652
|
const modulesFile = join(repo, '.gitmodules');
|
|
@@ -555,6 +692,21 @@ export function createGitDedup(options = {}) {
|
|
|
555
692
|
const pool = await fetchRemote(root, remote, key);
|
|
556
693
|
await mkdir(dirname(gitdir), { recursive: true });
|
|
557
694
|
await checked(['clone', '--bare', '--reference', pool, remote, gitdir]);
|
|
695
|
+
// Match a native submodule clone: remote-tracking refs, origin/HEAD, and only the default local branch.
|
|
696
|
+
await checked(['--git-dir', gitdir, 'config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*']);
|
|
697
|
+
await checked(['--git-dir', gitdir, 'fetch', '--quiet', 'origin']);
|
|
698
|
+
const head = await git(['--git-dir', gitdir, 'symbolic-ref', '--short', '-q', 'HEAD']);
|
|
699
|
+
const branch = head.code === 0 ? head.stdout.trim() : undefined;
|
|
700
|
+
if (branch) {
|
|
701
|
+
await checked(['--git-dir', gitdir, 'remote', 'set-head', 'origin', branch]);
|
|
702
|
+
await checked(['--git-dir', gitdir, 'config', `branch.${branch}.remote`, 'origin']);
|
|
703
|
+
await checked(['--git-dir', gitdir, 'config', `branch.${branch}.merge`, `refs/heads/${branch}`]);
|
|
704
|
+
}
|
|
705
|
+
const others = (await checked(['--git-dir', gitdir, 'for-each-ref', '--format=%(refname:short)', 'refs/heads']))
|
|
706
|
+
.split('\n')
|
|
707
|
+
.filter((ref) => ref && ref !== branch);
|
|
708
|
+
if (others.length > 0)
|
|
709
|
+
await checked(['--git-dir', gitdir, 'branch', '-D', ...others]);
|
|
558
710
|
await checked(['--git-dir', gitdir, 'config', 'core.bare', 'false']);
|
|
559
711
|
await checked(['--git-dir', gitdir, 'config', 'core.worktree', target]);
|
|
560
712
|
await pinConsumer(pool, gitdir, gitdir, await consumerTips(gitdir));
|
|
@@ -566,21 +718,30 @@ export function createGitDedup(options = {}) {
|
|
|
566
718
|
const code = (await git(['submodule', ...args], at, true)).code;
|
|
567
719
|
if (code !== 0)
|
|
568
720
|
return code;
|
|
569
|
-
const target =
|
|
570
|
-
if (
|
|
721
|
+
const target = submoduleAddPath(args);
|
|
722
|
+
if (!target)
|
|
723
|
+
fallback(args, 'submodule path not recognized');
|
|
724
|
+
else {
|
|
571
725
|
try {
|
|
572
|
-
await
|
|
726
|
+
await add(resolve(at, target));
|
|
573
727
|
}
|
|
574
728
|
catch (error) {
|
|
575
|
-
process.stderr.write(`git-dedup: submodule
|
|
729
|
+
process.stderr.write(`git-dedup: submodule adoption unavailable (${String(error)})\n`);
|
|
576
730
|
}
|
|
577
731
|
}
|
|
578
732
|
return 0;
|
|
579
733
|
}
|
|
580
|
-
if (args[0] !== 'update'
|
|
734
|
+
if (args[0] !== 'update')
|
|
581
735
|
return (await git(['submodule', ...args], at, true)).code;
|
|
582
|
-
|
|
736
|
+
const unsupported = args.slice(1).find((arg) => !['--init', '--recursive', '--quiet', '-q'].includes(arg));
|
|
737
|
+
if (unsupported) {
|
|
738
|
+
fallback(args, `submodule update ${unsupported} is not supported`);
|
|
583
739
|
return (await git(['submodule', ...args], at, true)).code;
|
|
740
|
+
}
|
|
741
|
+
if ((await git(['config', '--get', 'submodule.active'], at)).code === 0) {
|
|
742
|
+
fallback(args, 'submodule.active is configured');
|
|
743
|
+
return (await git(['submodule', ...args], at, true)).code;
|
|
744
|
+
}
|
|
584
745
|
if (args.includes('--init')) {
|
|
585
746
|
const init = (await git(['submodule', 'init'], at, true)).code;
|
|
586
747
|
if (init !== 0)
|
|
@@ -589,8 +750,8 @@ export function createGitDedup(options = {}) {
|
|
|
589
750
|
try {
|
|
590
751
|
await seedSubmodules(at);
|
|
591
752
|
}
|
|
592
|
-
catch {
|
|
593
|
-
|
|
753
|
+
catch (error) {
|
|
754
|
+
fallback(args, `submodule adoption unavailable${gitFailure(error)}`);
|
|
594
755
|
}
|
|
595
756
|
const recursive = args.includes('--recursive') &&
|
|
596
757
|
args.includes('--init') &&
|
|
@@ -616,8 +777,23 @@ export function createGitDedup(options = {}) {
|
|
|
616
777
|
}
|
|
617
778
|
return 0;
|
|
618
779
|
}
|
|
619
|
-
|
|
620
|
-
|
|
780
|
+
/** Visit the checkout of every initialized submodule gitdir under a modules directory. */
|
|
781
|
+
async function visitModules(directory, visit) {
|
|
782
|
+
for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
|
|
783
|
+
if (!entry.isDirectory())
|
|
784
|
+
continue;
|
|
785
|
+
const modulePath = join(directory, entry.name);
|
|
786
|
+
if (await isPresent(join(modulePath, 'HEAD'))) {
|
|
787
|
+
const workTree = (await git(['--git-dir', modulePath, 'config', '--get', 'core.worktree'])).stdout.trim();
|
|
788
|
+
if (workTree)
|
|
789
|
+
await visit(resolve(modulePath, workTree));
|
|
790
|
+
}
|
|
791
|
+
else
|
|
792
|
+
await visitModules(modulePath, visit);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function add(path = cwd) {
|
|
796
|
+
const result = { added: 0, skipped: 0, failed: 0, repositories: [] };
|
|
621
797
|
const visited = new Set();
|
|
622
798
|
function record(repoPath, status, reason, detail) {
|
|
623
799
|
result[status]++;
|
|
@@ -672,25 +848,27 @@ export function createGitDedup(options = {}) {
|
|
|
672
848
|
.split('\n')
|
|
673
849
|
.includes(join(pool, 'objects'));
|
|
674
850
|
const registered = (await readFile(join(root, 'remotes', `${remoteId(key)}.json`), 'utf8').catch(() => '')) ===
|
|
675
|
-
|
|
851
|
+
registration(url, key);
|
|
676
852
|
const stateMatches = poolReused && alreadyLinked && registered && (await readFile(marker, 'utf8').catch(() => '')) === state;
|
|
677
|
-
const
|
|
678
|
-
if (
|
|
853
|
+
const id = await consumerId(commonGitdir);
|
|
854
|
+
if (stateMatches && !(await unpinnedTips(pool, id, tips)).length) {
|
|
855
|
+
// Checkouts adopted before the consumer registry existed register here.
|
|
856
|
+
await registerConsumer(root, id, commonGitdir);
|
|
679
857
|
record(repo, 'skipped', 'already current');
|
|
680
|
-
process.stderr.write(`git-dedup:
|
|
858
|
+
process.stderr.write(`git-dedup: adding ${repo}: already current\n`);
|
|
681
859
|
return;
|
|
682
860
|
}
|
|
683
|
-
process.stderr.write(`git-dedup:
|
|
861
|
+
process.stderr.write(`git-dedup: adding ${repo}: packing local objects\n`);
|
|
684
862
|
// Gather loose local objects before adding an alternate; afterwards Git may
|
|
685
863
|
// consider a local object redundant and omit it from a new pack.
|
|
686
864
|
if (!alreadyLinked)
|
|
687
865
|
await checked(['-c', 'repack.writeBitmaps=false', 'repack', '-a', '-d', '-l'], repo);
|
|
688
866
|
const beforeUniqueBytes = await uniquePackBytes(commonGitdir);
|
|
689
|
-
process.stderr.write(`git-dedup:
|
|
867
|
+
process.stderr.write(`git-dedup: adding ${repo}: importing refs\n`);
|
|
690
868
|
stage = 'importing refs';
|
|
691
869
|
await pinConsumer(pool, repo, commonGitdir, tips);
|
|
692
870
|
stage = 'sharing objects';
|
|
693
|
-
process.stderr.write(`git-dedup:
|
|
871
|
+
process.stderr.write(`git-dedup: adding ${repo}: sharing objects\n`);
|
|
694
872
|
await setAlternate(commonGitdir, pool);
|
|
695
873
|
await removeLegacyKeeps(commonGitdir);
|
|
696
874
|
await checked(['-c', 'repack.writeBitmaps=false', 'repack', '-a', '-d', '-l'], repo);
|
|
@@ -711,7 +889,7 @@ export function createGitDedup(options = {}) {
|
|
|
711
889
|
if (options.onStorageReport) {
|
|
712
890
|
const afterUniqueBytes = await uniquePackBytes(commonGitdir);
|
|
713
891
|
emitStorageReport({
|
|
714
|
-
operation: '
|
|
892
|
+
operation: 'add',
|
|
715
893
|
repository: repo,
|
|
716
894
|
poolReused,
|
|
717
895
|
beforeUniqueBytes,
|
|
@@ -719,33 +897,112 @@ export function createGitDedup(options = {}) {
|
|
|
719
897
|
estimatedSavedBytes: Math.max(0, beforeUniqueBytes - afterUniqueBytes),
|
|
720
898
|
});
|
|
721
899
|
}
|
|
722
|
-
record(repo, '
|
|
900
|
+
record(repo, 'added', refreshError ? 'remote refresh deferred; retry with git-dedup store fetch' : undefined, refreshError ? String(refreshError) : undefined);
|
|
723
901
|
if (refreshError)
|
|
724
|
-
process.stderr.write(`git-dedup:
|
|
902
|
+
process.stderr.write(`git-dedup: adding ${repo}: remote refresh deferred; retry with git-dedup store fetch\n`);
|
|
725
903
|
});
|
|
726
904
|
}
|
|
727
905
|
catch (error) {
|
|
728
|
-
record(repo, 'failed',
|
|
906
|
+
record(repo, 'failed', addFailureReason(error, stage), String(error));
|
|
729
907
|
}
|
|
730
908
|
finally {
|
|
731
|
-
await visitModules(join(gitdir, 'modules'));
|
|
909
|
+
await visitModules(join(gitdir, 'modules'), adopt);
|
|
732
910
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
911
|
+
}
|
|
912
|
+
await adopt(resolve(cwd, path));
|
|
913
|
+
return result;
|
|
914
|
+
}
|
|
915
|
+
async function remove(path = cwd) {
|
|
916
|
+
const result = { removed: 0, skipped: 0, failed: 0, repositories: [] };
|
|
917
|
+
const visited = new Set();
|
|
918
|
+
const root = await storePath();
|
|
919
|
+
const pool = poolPath(root);
|
|
920
|
+
const poolObjects = join(pool, 'objects');
|
|
921
|
+
function record(entry) {
|
|
922
|
+
result[entry.status]++;
|
|
923
|
+
result.repositories.push(entry);
|
|
924
|
+
}
|
|
925
|
+
async function detach(repo) {
|
|
926
|
+
let commonGitdir;
|
|
927
|
+
try {
|
|
928
|
+
commonGitdir = await repoCommonGitdir(repo);
|
|
929
|
+
}
|
|
930
|
+
catch (error) {
|
|
931
|
+
const missing = !(await isPresent(repo));
|
|
932
|
+
record({
|
|
933
|
+
path: repo,
|
|
934
|
+
status: missing ? 'failed' : 'skipped',
|
|
935
|
+
reason: missing
|
|
936
|
+
? `path not found; if this checkout was deleted, run git-dedup store remove --forget ${repo}`
|
|
937
|
+
: 'not a Git repository or path is unavailable',
|
|
938
|
+
detail: String(error),
|
|
939
|
+
});
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
// Linked worktrees share one object database, so detach it once.
|
|
943
|
+
if (visited.has(commonGitdir))
|
|
944
|
+
return;
|
|
945
|
+
visited.add(commonGitdir);
|
|
946
|
+
const gitdirs = [
|
|
947
|
+
commonGitdir,
|
|
948
|
+
...(await readdir(join(commonGitdir, 'worktrees')).catch(() => [])).map((name) => join(commonGitdir, 'worktrees', name)),
|
|
949
|
+
];
|
|
950
|
+
const alternate = join(commonGitdir, 'objects', 'info', 'alternates');
|
|
951
|
+
try {
|
|
952
|
+
const original = await readFile(alternate, 'utf8').catch(() => '');
|
|
953
|
+
const lines = original.split('\n');
|
|
954
|
+
if (!lines.includes(poolObjects)) {
|
|
955
|
+
record({ path: repo, status: 'skipped', reason: 'not linked to the store' });
|
|
956
|
+
return;
|
|
745
957
|
}
|
|
958
|
+
if (!(await isPresent(pool)))
|
|
959
|
+
throw new Error(`Object pool is missing: ${pool}`);
|
|
960
|
+
// Lock without initializing: removal must never create a store.
|
|
961
|
+
await lockDirectory(storeLock(root), async () => {
|
|
962
|
+
process.stderr.write(`git-dedup: removing ${repo}: copying shared objects\n`);
|
|
963
|
+
// Without -l, repack copies every object reachable from refs, reflogs, and
|
|
964
|
+
// the indexes of all worktrees, including objects read through the pool.
|
|
965
|
+
await checked(['-c', 'repack.writeBitmaps=false', 'repack', '-a', '-d', '--pack-kept-objects'], repo);
|
|
966
|
+
const remaining = lines.filter((line) => line && line !== poolObjects);
|
|
967
|
+
if (remaining.length)
|
|
968
|
+
await writeFile(alternate, remaining.join('\n') + '\n');
|
|
969
|
+
else
|
|
970
|
+
await rm(alternate);
|
|
971
|
+
try {
|
|
972
|
+
await checked(['fsck', '--connectivity-only'], repo);
|
|
973
|
+
}
|
|
974
|
+
catch (error) {
|
|
975
|
+
await writeFile(alternate, original);
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
const id = (await readFile(join(commonGitdir, 'gitx-consumer-id'), 'utf8').catch(() => '')).trim();
|
|
979
|
+
if (/^[a-f0-9-]{36}$/.test(id)) {
|
|
980
|
+
const pins = await checked([
|
|
981
|
+
'-C',
|
|
982
|
+
pool,
|
|
983
|
+
'for-each-ref',
|
|
984
|
+
'--format=delete %(refname)',
|
|
985
|
+
`refs/gitx/consumers/${id}`,
|
|
986
|
+
]);
|
|
987
|
+
if (pins)
|
|
988
|
+
await checked(['-C', pool, 'update-ref', '--stdin'], cwd, false, pins + '\n');
|
|
989
|
+
await rm(join(root, 'consumers', `${id}.json`), { force: true });
|
|
990
|
+
}
|
|
991
|
+
await rm(join(commonGitdir, 'gitx-consumer-id'), { force: true });
|
|
992
|
+
for (const gitdir of gitdirs)
|
|
993
|
+
await rm(join(gitdir, 'gitx-cache.json'), { force: true });
|
|
994
|
+
});
|
|
995
|
+
record({ path: repo, status: 'removed', objectBytes: await directorySize(join(commonGitdir, 'objects')) });
|
|
996
|
+
}
|
|
997
|
+
catch (error) {
|
|
998
|
+
record({ path: repo, status: 'failed', reason: 'could not detach from the store', detail: String(error) });
|
|
999
|
+
}
|
|
1000
|
+
finally {
|
|
1001
|
+
for (const gitdir of gitdirs)
|
|
1002
|
+
await visitModules(join(gitdir, 'modules'), detach);
|
|
746
1003
|
}
|
|
747
1004
|
}
|
|
748
|
-
await
|
|
1005
|
+
await detach(resolve(cwd, path));
|
|
749
1006
|
return result;
|
|
750
1007
|
}
|
|
751
1008
|
async function storeInfo() {
|
|
@@ -776,25 +1033,33 @@ export function createGitDedup(options = {}) {
|
|
|
776
1033
|
typeof value.remote !== 'string' ||
|
|
777
1034
|
`${remoteId(value.key)}.json` !== name)
|
|
778
1035
|
throw new Error(`Invalid git-dedup remote registration: ${name}`);
|
|
779
|
-
return { key: value.key, remote: value.remote };
|
|
1036
|
+
return { key: value.key, remote: redactRemote(value.remote, '***') };
|
|
780
1037
|
}));
|
|
781
1038
|
return remotes.toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
782
1039
|
}
|
|
783
1040
|
async function fetch() {
|
|
784
1041
|
const root = await storePath();
|
|
785
|
-
|
|
1042
|
+
const remotes = [];
|
|
786
1043
|
await withLock(root, async () => {
|
|
787
1044
|
for (const name of await readdir(join(root, 'remotes')).catch(() => [])) {
|
|
788
1045
|
if (!name.endsWith('.json'))
|
|
789
1046
|
continue;
|
|
790
1047
|
const { key, remote } = JSON.parse(await readFile(join(root, 'remotes', name), 'utf8'));
|
|
791
|
-
|
|
792
|
-
|
|
1048
|
+
try {
|
|
1049
|
+
await fetchRemote(root, remote, key);
|
|
1050
|
+
remotes.push({ key, status: 'fetched' });
|
|
1051
|
+
}
|
|
1052
|
+
catch (error) {
|
|
1053
|
+
const detail = String(error);
|
|
1054
|
+
const reason = /fatal: (.+)/.exec(detail)?.[1] ?? 'remote fetch failed';
|
|
1055
|
+
remotes.push({ key, status: 'failed', reason, detail });
|
|
1056
|
+
}
|
|
793
1057
|
}
|
|
794
|
-
if (fetched)
|
|
1058
|
+
if (remotes.some((result) => result.status === 'fetched'))
|
|
795
1059
|
await checked(['-C', poolPath(root), 'repack', '-d', '--geometric=2']);
|
|
796
1060
|
});
|
|
797
|
-
|
|
1061
|
+
const fetched = remotes.filter((result) => result.status === 'fetched').length;
|
|
1062
|
+
return { fetched, failed: remotes.length - fetched, remotes };
|
|
798
1063
|
}
|
|
799
1064
|
async function gc() {
|
|
800
1065
|
const root = await storePath();
|
|
@@ -805,20 +1070,171 @@ export function createGitDedup(options = {}) {
|
|
|
805
1070
|
});
|
|
806
1071
|
return { compacted: true };
|
|
807
1072
|
}
|
|
808
|
-
|
|
1073
|
+
/** Every object a consumer may borrow from the pool without a pin. */
|
|
1074
|
+
async function liveTips(commonGitdir) {
|
|
1075
|
+
if ((await git(['--git-dir', commonGitdir, 'config', '--get', 'extensions.refStorage'], commonGitdir)).stdout.trim())
|
|
1076
|
+
throw new Error(`Refusing to prune: ${commonGitdir} uses a ref storage format that git-dedup cannot inspect`);
|
|
1077
|
+
const tips = new Set();
|
|
1078
|
+
const extra = new Set();
|
|
1079
|
+
const worktrees = (await readdir(join(commonGitdir, 'worktrees')).catch(() => [])).map((name) => join(commonGitdir, 'worktrees', name));
|
|
1080
|
+
for (const gitdir of [commonGitdir, ...worktrees]) {
|
|
1081
|
+
// Per-worktree refs and HEAD, then the index: git add skips blobs the pool already has.
|
|
1082
|
+
for (const oid of (await checked(['--git-dir', gitdir, 'for-each-ref', '--format=%(objectname)'], gitdir)).split('\n'))
|
|
1083
|
+
if (oid)
|
|
1084
|
+
tips.add(oid);
|
|
1085
|
+
const head = await git(['--git-dir', gitdir, 'rev-parse', '--verify', '-q', 'HEAD'], gitdir);
|
|
1086
|
+
if (head.code === 0)
|
|
1087
|
+
tips.add(head.stdout.trim());
|
|
1088
|
+
if (await isPresent(join(gitdir, 'index'))) {
|
|
1089
|
+
const tree = await git(['--git-dir', gitdir, 'write-tree'], gitdir);
|
|
1090
|
+
if (tree.code !== 0)
|
|
1091
|
+
throw new Error(`Refusing to prune: cannot record the index of ${gitdir}: ${tree.stderr.trim()}`);
|
|
1092
|
+
// Git always has the empty tree, so it needs no pin.
|
|
1093
|
+
if (tree.stdout.trim() !== '4b825dc642cb6eb9a060e54bf8d69288fbee4904')
|
|
1094
|
+
tips.add(tree.stdout.trim());
|
|
1095
|
+
}
|
|
1096
|
+
// Pseudorefs and in-progress rebase or cherry-pick state can name fetched commits no ref holds.
|
|
1097
|
+
const files = ['FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'REBASE_HEAD'].map((name) => join(gitdir, name));
|
|
1098
|
+
for (const directory of ['rebase-merge', 'rebase-apply', 'sequencer'])
|
|
1099
|
+
for (const entry of await readdir(join(gitdir, directory), { withFileTypes: true }).catch(() => []))
|
|
1100
|
+
if (entry.isFile())
|
|
1101
|
+
files.push(join(gitdir, directory, entry.name));
|
|
1102
|
+
for (const file of files)
|
|
1103
|
+
for (const oid of (await readFile(file, 'utf8').catch(() => '')).match(/\b[0-9a-f]{40}\b/g) ?? [])
|
|
1104
|
+
if (!tips.has(oid))
|
|
1105
|
+
extra.add(oid);
|
|
1106
|
+
}
|
|
1107
|
+
// Commits held only by reflogs or state files; pin the tips of each abandoned history.
|
|
1108
|
+
const abandoned = await checked(['--git-dir', commonGitdir, 'rev-list', '--parents', '--ignore-missing', '--reflog', ...extra, '--not', '--all'], commonGitdir);
|
|
1109
|
+
const commits = new Set();
|
|
1110
|
+
const parents = new Set();
|
|
1111
|
+
for (const line of abandoned.split('\n').filter(Boolean)) {
|
|
1112
|
+
const [commit, ...rest] = line.split(' ');
|
|
1113
|
+
commits.add(commit);
|
|
1114
|
+
for (const parent of rest)
|
|
1115
|
+
parents.add(parent);
|
|
1116
|
+
}
|
|
1117
|
+
for (const commit of commits)
|
|
1118
|
+
if (!parents.has(commit))
|
|
1119
|
+
tips.add(commit);
|
|
1120
|
+
return [...tips].toSorted();
|
|
1121
|
+
}
|
|
1122
|
+
async function prune() {
|
|
809
1123
|
const root = await storePath();
|
|
1124
|
+
const pool = poolPath(root);
|
|
1125
|
+
if (!(await isPresent(pool)))
|
|
1126
|
+
return { reclaimedBytes: 0 };
|
|
810
1127
|
return withLock(root, async () => {
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
name
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1128
|
+
const registry = await registeredConsumers(root);
|
|
1129
|
+
// A clone between its pool fetch and its pin borrows objects that nothing pins yet.
|
|
1130
|
+
for (const name of await readdir(join(root, 'clones')).catch(() => [])) {
|
|
1131
|
+
const { pid, destination } = JSON.parse(await readFile(join(root, 'clones', name), 'utf8'));
|
|
1132
|
+
if (processExists(pid))
|
|
1133
|
+
throw new Error(`Refusing to prune: a clone into ${destination} is in progress; retry when it finishes.`);
|
|
1134
|
+
const id = (await readFile(join(destination, '.git', 'gitx-consumer-id'), 'utf8').catch(() => '')).trim();
|
|
1135
|
+
if ((await isPresent(join(destination, '.git'))) && !registry.has(id))
|
|
1136
|
+
throw new Error(`Refusing to prune: the clone into ${destination} was never registered. ` +
|
|
1137
|
+
`Run git-dedup store add ${destination}, or delete that checkout, then retry.`);
|
|
1138
|
+
await rm(join(root, 'clones', name));
|
|
1139
|
+
}
|
|
1140
|
+
const pinned = new Set((await checked(['-C', pool, 'for-each-ref', '--format=%(refname)', 'refs/gitx/consumers']))
|
|
1141
|
+
.split('\n')
|
|
1142
|
+
.filter(Boolean)
|
|
1143
|
+
.map((ref) => ref.split('/')[3]));
|
|
1144
|
+
const unregistered = [...pinned].filter((id) => !registry.has(id));
|
|
1145
|
+
if (unregistered.length)
|
|
1146
|
+
throw new Error(`Refusing to prune: the pool holds objects for ${unregistered.length} unregistered checkout(s). ` +
|
|
1147
|
+
'Run git-dedup store add for every checkout that uses this store (for example ' +
|
|
1148
|
+
'git-dedup store add --all <directory>), then retry.');
|
|
1149
|
+
// A moved checkout still borrows through its absolute alternate path, including
|
|
1150
|
+
// objects fetched after its last pin. Only the user can say it was deleted.
|
|
1151
|
+
const missing = [];
|
|
1152
|
+
for (const [id, gitdir] of registry)
|
|
1153
|
+
if (!(await carriesConsumerId(gitdir, id)))
|
|
1154
|
+
missing.push(gitdir);
|
|
1155
|
+
if (missing.length)
|
|
1156
|
+
throw new Error(`Refusing to prune: ${missing.length} registered checkout(s) cannot be found:\n` +
|
|
1157
|
+
missing.map((gitdir) => ` ${checkoutPath(gitdir)}\n`).join('') +
|
|
1158
|
+
'If this checkout was moved, run git-dedup store add <new path>. ' +
|
|
1159
|
+
'If it was deleted, run git-dedup store remove --forget <old path>.');
|
|
1160
|
+
for (const gitdir of registry.values())
|
|
1161
|
+
await pinConsumer(pool, gitdir, gitdir, await liveTips(gitdir));
|
|
1162
|
+
const before = await directorySize(pool);
|
|
1163
|
+
// Explicit expiry: the pool never prunes automatically.
|
|
1164
|
+
await checked(['-C', pool, 'gc', '--prune=now']);
|
|
1165
|
+
return { reclaimedBytes: Math.max(0, before - (await directorySize(pool))) };
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
/** Release a registered checkout that no longer exists. Returns the Git directories forgotten. */
|
|
1169
|
+
async function forget(path) {
|
|
1170
|
+
const root = await storePath();
|
|
1171
|
+
const target = resolve(cwd, path);
|
|
1172
|
+
const candidates = new Set();
|
|
1173
|
+
for (const base of [target, await canonicalPath(target)])
|
|
1174
|
+
candidates.add(base).add(join(base, '.git'));
|
|
1175
|
+
if (!(await isPresent(root)))
|
|
1176
|
+
return [];
|
|
1177
|
+
return lockDirectory(storeLock(root), async () => {
|
|
1178
|
+
const forgotten = [];
|
|
1179
|
+
let present = false;
|
|
1180
|
+
for (const [id, gitdir] of await registeredConsumers(root)) {
|
|
1181
|
+
if (!candidates.has(gitdir))
|
|
1182
|
+
continue;
|
|
1183
|
+
// A new checkout at a deleted checkout's path is live; forget only the old registration.
|
|
1184
|
+
if (await carriesConsumerId(gitdir, id)) {
|
|
1185
|
+
present = true;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (await isPresent(poolPath(root))) {
|
|
1189
|
+
const pins = await checked([
|
|
1190
|
+
'-C',
|
|
1191
|
+
poolPath(root),
|
|
1192
|
+
'for-each-ref',
|
|
1193
|
+
'--format=delete %(refname)',
|
|
1194
|
+
`refs/gitx/consumers/${id}`,
|
|
1195
|
+
]);
|
|
1196
|
+
if (pins)
|
|
1197
|
+
await checked(['-C', poolPath(root), 'update-ref', '--stdin'], cwd, false, pins + '\n');
|
|
1198
|
+
}
|
|
1199
|
+
await rm(join(root, 'consumers', `${id}.json`));
|
|
1200
|
+
forgotten.push(gitdir);
|
|
1201
|
+
}
|
|
1202
|
+
if (!forgotten.length && present)
|
|
1203
|
+
throw new Error(`${path} still exists; run git-dedup store remove without --forget to detach it`);
|
|
1204
|
+
return forgotten;
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
async function doctor() {
|
|
1208
|
+
const root = await storePath();
|
|
1209
|
+
const checks = [];
|
|
1210
|
+
const pool = poolPath(root);
|
|
1211
|
+
const empty = !(await isPresent(pool));
|
|
1212
|
+
if (!empty) {
|
|
1213
|
+
try {
|
|
1214
|
+
const format = await checked(['-C', pool, 'rev-parse', '--show-object-format']);
|
|
1215
|
+
const bare = await checked(['-C', pool, 'rev-parse', '--is-bare-repository']);
|
|
1216
|
+
checks.push({
|
|
1217
|
+
name: 'pool',
|
|
1218
|
+
ok: format === 'sha1' && bare === 'true',
|
|
1219
|
+
detail: `${pool}: ${format === 'sha1' ? 'SHA-1' : format.toUpperCase()} ${bare === 'true' ? 'bare' : 'non-bare'} object database`,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
catch (error) {
|
|
1223
|
+
checks.push({
|
|
1224
|
+
name: 'pool',
|
|
1225
|
+
ok: false,
|
|
1226
|
+
detail: `${pool}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
try {
|
|
818
1231
|
const binary = await gitPath();
|
|
819
1232
|
checks.push({ name: 'git', ok: true, detail: `${binary}: ${await checked(['--version'])}` });
|
|
820
|
-
|
|
821
|
-
|
|
1233
|
+
}
|
|
1234
|
+
catch (error) {
|
|
1235
|
+
checks.push({ name: 'git', ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
1236
|
+
}
|
|
1237
|
+
return { empty, checks };
|
|
822
1238
|
}
|
|
823
1239
|
async function addWorktree(args, at) {
|
|
824
1240
|
if (args[0] !== 'add')
|
|
@@ -855,8 +1271,10 @@ export function createGitDedup(options = {}) {
|
|
|
855
1271
|
].includes(arg) ||
|
|
856
1272
|
/^(--reason|--track)=/.test(arg))
|
|
857
1273
|
continue;
|
|
858
|
-
if (arg.startsWith('-'))
|
|
1274
|
+
if (arg.startsWith('-')) {
|
|
1275
|
+
fallback(args, `worktree add ${arg.split('=')[0]} is not supported`);
|
|
859
1276
|
return (await git(['worktree', ...args], at, true)).code;
|
|
1277
|
+
}
|
|
860
1278
|
path = arg;
|
|
861
1279
|
break;
|
|
862
1280
|
}
|
|
@@ -872,8 +1290,16 @@ export function createGitDedup(options = {}) {
|
|
|
872
1290
|
}
|
|
873
1291
|
async function run(args) {
|
|
874
1292
|
const parsed = parseGlobal(args, cwd);
|
|
875
|
-
if (incomingEnv.GITX_ACTIVE === '1' || incomingEnv.GIT_DEDUP_ACTIVE === '1' ||
|
|
1293
|
+
if (incomingEnv.GITX_ACTIVE === '1' || incomingEnv.GIT_DEDUP_ACTIVE === '1' || !parsed)
|
|
876
1294
|
return (await git(args, cwd, true)).code;
|
|
1295
|
+
const managed = parsed.command === 'clone' ||
|
|
1296
|
+
(parsed.command === 'submodule' && ['add', 'update'].includes(parsed.rest[0])) ||
|
|
1297
|
+
(parsed.command === 'worktree' && parsed.rest[0] === 'add');
|
|
1298
|
+
if (hasRepositoryEnvironment) {
|
|
1299
|
+
if (managed)
|
|
1300
|
+
fallback(parsed.rest, 'Git repository environment variables are set');
|
|
1301
|
+
return (await git(args, cwd, true)).code;
|
|
1302
|
+
}
|
|
877
1303
|
// -C is resolved explicitly. Other global options can change Git semantics, so forward intact.
|
|
878
1304
|
for (let i = 0; i < parsed.prefix.length; i++) {
|
|
879
1305
|
const arg = parsed.prefix[i];
|
|
@@ -883,6 +1309,8 @@ export function createGitDedup(options = {}) {
|
|
|
883
1309
|
}
|
|
884
1310
|
if (arg.startsWith('-C'))
|
|
885
1311
|
continue;
|
|
1312
|
+
if (managed)
|
|
1313
|
+
fallback(parsed.rest, `global option ${arg.split('=')[0]} is not supported`);
|
|
886
1314
|
return (await git(args, cwd, true)).code;
|
|
887
1315
|
}
|
|
888
1316
|
if (parsed.cwd !== cwd) {
|
|
@@ -906,7 +1334,7 @@ export function createGitDedup(options = {}) {
|
|
|
906
1334
|
throw new Error(`git --version failed: ${result.stderr.trim()}`);
|
|
907
1335
|
return result.stdout.trim();
|
|
908
1336
|
}
|
|
909
|
-
return { run,
|
|
1337
|
+
return { run, add, remove, storeInfo, listRemotes, fetch, gc, prune, forget, doctor, storePath, gitVersion, gitPath };
|
|
910
1338
|
}
|
|
911
1339
|
function processExists(pid) {
|
|
912
1340
|
try {
|