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/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 cacheFailureReason(error, stage) {
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.some((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));
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
- if (unsupportedClone(args))
77
- return undefined;
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 undefined;
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 undefined;
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 undefined;
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 && !processExists(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`), JSON.stringify({ key, remote }) + '\n');
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
- async function git(args, at = cwd, inherit = false) {
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, { cwd: at, env, stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'] });
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 (!inherit) {
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
- if (await isPresent(path))
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 pinnedTips(pool, id) {
441
- return new Set((await checked(['-C', pool, 'for-each-ref', '--format=%(objectname)', `refs/gitx/consumers/${id}`]))
442
- .split('\n')
443
- .filter(Boolean));
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 cache call on the same checkout. Source ref names
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
- const existing = await pinnedTips(pool, id);
453
- const missing = tips.filter((oid) => !existing.has(oid));
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 (!parsed)
483
- return undefined;
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 undefined;
594
+ return fallback(args, 'remote is not a supported network URL');
487
595
  const destination = resolve(effectiveCwd, parsed.destination ?? defaultCloneName(parsed.remote));
488
- if (await isPresent(destination))
489
- return undefined;
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
- const cloned = await withLock(root, async () => {
492
- const poolReused = options.onStorageReport ? await isPresent(poolPath(root)) : false;
493
- let pool;
494
- try {
495
- pool = await fetchRemote(root, parsed.remote, key);
496
- }
497
- catch {
498
- process.stderr.write('git-dedup: object pool unavailable; using Git clone\n');
499
- return undefined;
500
- }
501
- const cloneArgs = ['clone', '--reference', pool, ...parsed.forwarded, parsed.remote, destination];
502
- const result = await git(cloneArgs, effectiveCwd, true);
503
- if (result.code !== 0)
504
- return result.code;
505
- await pinConsumer(pool, destination, await repoCommonGitdir(destination), await consumerTips(destination));
506
- if (options.onStorageReport)
507
- emitStorageReport({ operation: 'clone', repository: destination, poolReused, estimatedSavedBytes: 0 });
508
- return 0;
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 (cloned === 0 && parsed.recurse)
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 cloned;
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 = args.length === 2 ? defaultCloneName(args[1]) : args.length === 3 ? args[2] : undefined;
570
- if (target && !target.startsWith('-')) {
721
+ const target = submoduleAddPath(args);
722
+ if (!target)
723
+ fallback(args, 'submodule path not recognized');
724
+ else {
571
725
  try {
572
- await cache(resolve(at, target));
726
+ await add(resolve(at, target));
573
727
  }
574
728
  catch (error) {
575
- process.stderr.write(`git-dedup: submodule cache unavailable (${String(error)})\n`);
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' || args.slice(1).some((arg) => !['--init', '--recursive', '--quiet', '-q'].includes(arg)))
734
+ if (args[0] !== 'update')
581
735
  return (await git(['submodule', ...args], at, true)).code;
582
- if ((await git(['config', '--get', 'submodule.active'], at)).code === 0)
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
- process.stderr.write('git-dedup: submodule cache unavailable; using Git\n');
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
- async function cache(path = cwd) {
620
- const result = { cached: 0, skipped: 0, failed: 0, repositories: [] };
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
- JSON.stringify({ key, remote: url }) + '\n';
851
+ registration(url, key);
676
852
  const stateMatches = poolReused && alreadyLinked && registered && (await readFile(marker, 'utf8').catch(() => '')) === state;
677
- const currentPins = stateMatches ? await pinnedTips(pool, await consumerId(commonGitdir)) : undefined;
678
- if (currentPins && tips.every((oid) => currentPins.has(oid))) {
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: caching ${repo}: already current\n`);
858
+ process.stderr.write(`git-dedup: adding ${repo}: already current\n`);
681
859
  return;
682
860
  }
683
- process.stderr.write(`git-dedup: caching ${repo}: packing local objects\n`);
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: caching ${repo}: importing refs\n`);
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: caching ${repo}: sharing objects\n`);
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: 'cache',
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, 'cached', refreshError ? 'remote refresh deferred; retry with git-dedup store fetch' : undefined, refreshError ? String(refreshError) : undefined);
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: caching ${repo}: remote refresh deferred; retry with git-dedup store fetch\n`);
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', cacheFailureReason(error, stage), String(error));
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
- async function visitModules(directory) {
734
- for (const entry of await readdir(directory, { withFileTypes: true }).catch(() => [])) {
735
- if (!entry.isDirectory())
736
- continue;
737
- const modulePath = join(directory, entry.name);
738
- if (await isPresent(join(modulePath, 'HEAD'))) {
739
- const workTree = (await git(['--git-dir', modulePath, 'config', '--get', 'core.worktree'])).stdout.trim();
740
- if (workTree)
741
- await adopt(resolve(modulePath, workTree));
742
- }
743
- else
744
- await visitModules(modulePath);
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 adopt(resolve(cwd, path));
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
- let fetched = 0;
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
- await fetchRemote(root, remote, key);
792
- fetched++;
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
- return { fetched };
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
- async function doctor() {
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 checks = [];
812
- const pool = await ensurePool(root);
813
- checks.push({
814
- name: 'pool',
815
- ok: true,
816
- detail: `${pool}: SHA-1 object database`,
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
- return { checks };
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' || hasRepositoryEnvironment || !parsed)
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, cache, storeInfo, listRemotes, fetch, gc, doctor, storePath, gitVersion };
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 {