@karmaniverous/jeeves-watcher 0.19.0 → 0.19.1

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
@@ -403,6 +403,7 @@ function computeDelayMs(attempt, baseDelayMs, maxDelayMs, jitter = 0) {
403
403
  */
404
404
  async function retry(fn, options) {
405
405
  const attempts = Math.max(1, options.attempts);
406
+ const shouldRetry = options.shouldRetry ?? (() => true);
406
407
  let lastError;
407
408
  for (let attempt = 1; attempt <= attempts; attempt++) {
408
409
  try {
@@ -411,7 +412,7 @@ async function retry(fn, options) {
411
412
  catch (error) {
412
413
  lastError = error;
413
414
  const isLast = attempt >= attempts;
414
- if (isLast)
415
+ if (isLast || !shouldRetry(error))
415
416
  break;
416
417
  const delayMs = computeDelayMs(attempt, options.baseDelayMs, options.maxDelayMs, options.jitter);
417
418
  options.onRetry?.({ attempt, attempts, delayMs, error });
@@ -423,10 +424,28 @@ async function retry(fn, options) {
423
424
 
424
425
  /**
425
426
  * @module vcs/gitExec
426
- * Shared git execution utilities.
427
+ * Shared git execution utilities: the pinned argv prefix, timeouts, error
428
+ * field extraction and classification, and path comparison helpers.
427
429
  */
428
430
  /** Promisified execFile for git commands. */
429
431
  const execFileAsync = promisify(execFile);
432
+ /**
433
+ * Leading git arguments applied to every VCS-owned git invocation.
434
+ *
435
+ * `-c core.longpaths=true` lets Windows checkouts handle paths beyond the
436
+ * legacy MAX_PATH limit (260 chars) without requiring a machine-wide
437
+ * `core.longpaths` git config or registry change. See #249.
438
+ */
439
+ const GIT_BASE_ARGS = ['-c', 'core.longpaths=true'];
440
+ /**
441
+ * Build a git argv array with {@link GIT_BASE_ARGS} prefixed.
442
+ *
443
+ * @param args - The subcommand and its arguments (e.g. `'commit', '-m', msg`).
444
+ * @returns The full argv array, base args first.
445
+ */
446
+ function gitArgs(...args) {
447
+ return [...GIT_BASE_ARGS, ...args];
448
+ }
430
449
  /** Standard timeout (ms) for most git operations. */
431
450
  const GIT_TIMEOUT_STANDARD = 30_000;
432
451
  /** Extended timeout (ms) for cherry-pick operations. */
@@ -448,49 +467,37 @@ function getExecErrorFields(error) {
448
467
  return { message: error.message || '', stderr, stdout };
449
468
  }
450
469
  /**
451
- * Stage files via stdin to avoid ENAMETOOLONG on Windows.
470
+ * Read the `code` property of an unknown rejection value, if present.
471
+ * execFile rejects with the child's numeric exit status in `code`; fs calls
472
+ * reject with a string errno code (e.g. `'ENOENT'`).
452
473
  *
453
- * Uses `git add --pathspec-from-file=- --pathspec-file-nul` so the file list is piped
454
- * through stdin (NUL-delimited) instead of passed as command-line
455
- * arguments. This sidesteps the Windows CreateProcessW 32 767-char
456
- * limit that triggers ENAMETOOLONG when batches contain many files
457
- * with long absolute paths.
474
+ * @param error - The caught value.
475
+ * @returns The code, or undefined if the value has none.
476
+ */
477
+ function getErrorCode(error) {
478
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
479
+ return undefined;
480
+ }
481
+ const { code } = error;
482
+ return typeof code === 'string' || typeof code === 'number'
483
+ ? code
484
+ : undefined;
485
+ }
486
+ /**
487
+ * Check whether an error is caused by `index.lock` contention.
458
488
  *
459
- * @param files - Absolute paths of files to stage.
460
- * @param cwd - Repository root (working directory for git).
461
- * @param timeoutMs - Kill the child process after this many milliseconds. Default: 30000.
489
+ * Requires the literal `index.lock` in the message or stderr: a bare
490
+ * `EEXIST` (any file that already exists) is not lock contention and must
491
+ * not be retried as if it were. Only Error instances qualify.
492
+ *
493
+ * @param error - The caught value.
494
+ * @returns true if the error names `index.lock`.
462
495
  */
463
- function gitAddViaStdin(files, cwd, timeoutMs = 30_000) {
464
- return new Promise((resolve, reject) => {
465
- let timedOut = false;
466
- const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
467
- clearTimeout(timer);
468
- if (timedOut)
469
- return;
470
- if (error)
471
- reject(error);
472
- else
473
- resolve();
474
- });
475
- const timer = setTimeout(() => {
476
- timedOut = true;
477
- child.kill();
478
- reject(new Error(`git add timed out after ${String(timeoutMs)}ms`));
479
- }, timeoutMs);
480
- if (!child.stdin) {
481
- clearTimeout(timer);
482
- reject(new Error('Failed to open stdin for git add'));
483
- return;
484
- }
485
- // Suppress EPIPE — expected if git exits before we finish writing
486
- child.stdin.on('error', (err) => {
487
- if (err.code !== 'EPIPE') {
488
- clearTimeout(timer);
489
- reject(err);
490
- }
491
- });
492
- child.stdin.end(files.join('\0'));
493
- });
496
+ function isIndexLockError(error) {
497
+ if (!(error instanceof Error))
498
+ return false;
499
+ const { message, stderr } = getExecErrorFields(error);
500
+ return message.includes('index.lock') || stderr.includes('index.lock');
494
501
  }
495
502
  /**
496
503
  * Normalize a path for case-insensitive comparison on Windows.
@@ -503,6 +510,17 @@ function gitAddViaStdin(files, cwd, timeoutMs = 30_000) {
503
510
  function normalizePathCase(p, platform = process.platform) {
504
511
  return platform === 'win32' ? p.toLowerCase() : p;
505
512
  }
513
+ /**
514
+ * Build a comparison key for an absolute path: resolved, forward slashes,
515
+ * and lowercased on Windows.
516
+ *
517
+ * @param p - The path.
518
+ * @param platform - The platform to check against (default: `process.platform`).
519
+ * @returns The comparison key.
520
+ */
521
+ function pathKey(p, platform = process.platform) {
522
+ return normalizePathCase(normalizeSlashes(resolve(p)), platform);
523
+ }
506
524
  /**
507
525
  * Find the longest-prefix-match root for a normalized path.
508
526
  * Roots must be sorted longest-first for correct nested matching.
@@ -3661,6 +3679,28 @@ function resolveWatchRootsForGlob(coordinator, glob) {
3661
3679
  return results;
3662
3680
  }
3663
3681
 
3682
+ /**
3683
+ * @module vcs/runGit
3684
+ * Run a git subcommand in a repository with the pinned argv prefix and a
3685
+ * timeout. Lives in its own module (rather than gitExec) so callers resolve
3686
+ * `execFileAsync` through the gitExec module binding.
3687
+ */
3688
+ /**
3689
+ * Run `git <args>` in `cwd` with {@link gitArgs} applied.
3690
+ *
3691
+ * @param cwd - Repository root (working directory for git).
3692
+ * @param args - Subcommand and arguments.
3693
+ * @param options - Timeout and buffer limits.
3694
+ * @returns The child's stdout and stderr.
3695
+ */
3696
+ function runGit(cwd, args, { timeoutMs = GIT_TIMEOUT_STANDARD, maxBuffer } = {}) {
3697
+ return execFileAsync('git', gitArgs(...args), {
3698
+ cwd,
3699
+ timeout: timeoutMs,
3700
+ ...(maxBuffer === undefined ? {} : { maxBuffer }),
3701
+ });
3702
+ }
3703
+
3664
3704
  /**
3665
3705
  * @module api/handlers/vcs/vcsCheckExclusion
3666
3706
  * Fastify route handler for GET /vcs/check-exclusion. Checks gitignore status of a path.
@@ -3685,7 +3725,11 @@ function createVcsCheckExclusionHandler(deps) {
3685
3725
  return;
3686
3726
  }
3687
3727
  try {
3688
- const { stdout } = await execFileAsync('git', ['check-ignore', '-v', resolved.relativePath], { cwd: resolved.root });
3728
+ const { stdout } = await runGit(resolved.root, [
3729
+ 'check-ignore',
3730
+ '-v',
3731
+ resolved.relativePath,
3732
+ ]);
3689
3733
  // Output format: <source>:<linenum>:<pattern>\t<pathname>
3690
3734
  const trimmed = stdout.trim();
3691
3735
  const match = /^(.+):(\d+):(.+)\t/.exec(trimmed);
@@ -3733,7 +3777,7 @@ function createVcsDiffHandler(deps) {
3733
3777
  const range = commitEnd ? `${commit}..${commitEnd}` : `${commit}..HEAD`;
3734
3778
  const args = ['diff', range, '--', relativePath || '.'];
3735
3779
  try {
3736
- const { stdout } = await execFileAsync('git', args, { cwd: root });
3780
+ const { stdout } = await runGit(root, args);
3737
3781
  if (stdout.trim())
3738
3782
  diffs.push(stdout);
3739
3783
  }
@@ -3947,7 +3991,7 @@ function createVcsHistoryHandler(deps) {
3947
3991
  args.push(`--until=${until}`);
3948
3992
  args.push('--', relativePath || '.');
3949
3993
  try {
3950
- const { stdout } = await execFileAsync('git', args, { cwd: root });
3994
+ const { stdout } = await runGit(root, args);
3951
3995
  allEntries.push(...parseGitLog(stdout));
3952
3996
  }
3953
3997
  catch {
@@ -3994,7 +4038,7 @@ function createVcsRevertHandler(deps) {
3994
4038
  const args = relativePath === '' || relativePath === '.'
3995
4039
  ? ['ls-tree', '-r', '--name-only', commit]
3996
4040
  : ['ls-tree', '-r', '--name-only', commit, '--', relativePath];
3997
- const { stdout } = await execFileAsync('git', args, { cwd: root });
4041
+ const { stdout } = await runGit(root, args);
3998
4042
  fileList = stdout
3999
4043
  .trim()
4000
4044
  .split('\n')
@@ -4018,7 +4062,10 @@ function createVcsRevertHandler(deps) {
4018
4062
  // Get file content at the target commit
4019
4063
  let content;
4020
4064
  try {
4021
- const { stdout } = await execFileAsync('git', ['show', `${commit}:${relFile}`], { cwd: root });
4065
+ const { stdout } = await runGit(root, [
4066
+ 'show',
4067
+ `${commit}:${relFile}`,
4068
+ ]);
4022
4069
  content = stdout;
4023
4070
  }
4024
4071
  catch {
@@ -4083,7 +4130,10 @@ function createVcsShowHandler(deps) {
4083
4130
  return;
4084
4131
  }
4085
4132
  try {
4086
- const { stdout } = await execFileAsync('git', ['show', `${commit}:${resolved.relativePath}`], { cwd: resolved.root });
4133
+ const { stdout } = await runGit(resolved.root, [
4134
+ 'show',
4135
+ `${commit}:${resolved.relativePath}`,
4136
+ ]);
4087
4137
  const ext = extname(path).toLowerCase();
4088
4138
  const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
4089
4139
  void reply.header('content-type', contentType).send(stdout);
@@ -4100,37 +4150,34 @@ function createVcsShowHandler(deps) {
4100
4150
  * @module api/handlers/vcs/vcsStatus
4101
4151
  * Fastify route handler for GET /vcs/status. Returns VCS state for all roots.
4102
4152
  */
4103
- async function getLastCommit(cwd) {
4153
+ /**
4154
+ * Run a read-only git query; a failure (no commits, no remote, not a repo)
4155
+ * or empty output yields null.
4156
+ */
4157
+ async function queryGit(cwd, args) {
4104
4158
  try {
4105
- const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%H|%s|%aI'], { cwd });
4106
- const trimmed = stdout.trim();
4107
- if (!trimmed)
4108
- return null;
4109
- const [hash, message, timestamp] = trimmed.split('|');
4110
- return { hash, message, timestamp };
4159
+ const out = (await runGit(cwd, args)).stdout.trim();
4160
+ return out || null;
4111
4161
  }
4112
4162
  catch {
4113
4163
  return null;
4114
4164
  }
4115
4165
  }
4116
- async function getTrackedCount(cwd) {
4117
- try {
4118
- const { stdout } = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], { cwd });
4119
- return parseInt(stdout.trim(), 10);
4120
- }
4121
- catch {
4122
- return 0;
4123
- }
4124
- }
4125
- async function getRemoteUrl(cwd) {
4126
- try {
4127
- const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd });
4128
- const url = stdout.trim();
4129
- return url || null;
4130
- }
4131
- catch {
4166
+ async function getLastCommit(cwd) {
4167
+ const out = await queryGit(cwd, ['log', '-1', '--format=%H|%s|%aI']);
4168
+ if (out === null)
4132
4169
  return null;
4133
- }
4170
+ // Hash and timestamp never contain '|'; the subject may.
4171
+ const parts = out.split('|');
4172
+ return {
4173
+ hash: parts[0],
4174
+ message: parts.slice(1, -1).join('|'),
4175
+ timestamp: parts[parts.length - 1],
4176
+ };
4177
+ }
4178
+ async function getCommitCount(cwd) {
4179
+ const out = await queryGit(cwd, ['rev-list', '--count', 'HEAD']);
4180
+ return out === null ? 0 : parseInt(out, 10);
4134
4181
  }
4135
4182
  /**
4136
4183
  * Create handler for GET /vcs/status.
@@ -4142,9 +4189,9 @@ function createVcsStatusHandler(deps) {
4142
4189
  const rootStatuses = await Promise.all(roots.map(async (root) => {
4143
4190
  const manager = deps.coordinator.getManager(root);
4144
4191
  const [tracked, lastCommit, remoteUrl] = await Promise.all([
4145
- getTrackedCount(root),
4192
+ getCommitCount(root),
4146
4193
  getLastCommit(root),
4147
- getRemoteUrl(root),
4194
+ queryGit(root, ['remote', 'get-url', 'origin']),
4148
4195
  ]);
4149
4196
  return {
4150
4197
  path: root,
@@ -4153,6 +4200,7 @@ function createVcsStatusHandler(deps) {
4153
4200
  remoteUrl: manager?.remoteUrl ?? remoteUrl,
4154
4201
  lastPush: manager?.lastPushTime ?? null,
4155
4202
  pushErrors: manager?.pushErrors ?? [],
4203
+ breaker: manager?.breakerState ?? null,
4156
4204
  };
4157
4205
  }));
4158
4206
  return { enabled, roots: rootStatuses };
@@ -4442,6 +4490,57 @@ class CommitMessageGenerator {
4442
4490
  }
4443
4491
  }
4444
4492
 
4493
+ /**
4494
+ * @module vcs/cronMatch
4495
+ * Minimal 5-field cron matcher used by the squash retention scheduler.
4496
+ */
4497
+ function partMatches(part, value, min, max) {
4498
+ // Handle step: */N or range/N
4499
+ const [rangePart, stepStr] = part.split('/');
4500
+ const step = stepStr ? parseInt(stepStr, 10) : 1;
4501
+ if (rangePart === '*') {
4502
+ return (value - min) % step === 0;
4503
+ }
4504
+ // Handle range: N-M
4505
+ if (rangePart.includes('-')) {
4506
+ const [startStr, endStr] = rangePart.split('-');
4507
+ const start = parseInt(startStr, 10);
4508
+ const end = parseInt(endStr, 10);
4509
+ if (value < start || value > end)
4510
+ return false;
4511
+ return (value - start) % step === 0;
4512
+ }
4513
+ // Plain number. For day-of-week, 7 also means Sunday (0).
4514
+ const num = parseInt(rangePart, 10);
4515
+ if (step === 1 && max === 7 && num === 7)
4516
+ return value === 0;
4517
+ return value === num;
4518
+ }
4519
+ function fieldMatches(field, value, min, max) {
4520
+ return field
4521
+ .split(',')
4522
+ .some((part) => partMatches(part.trim(), value, min, max));
4523
+ }
4524
+ /**
4525
+ * Check whether a standard 5-field cron expression matches a time.
4526
+ * Fields: minute hour day-of-month month day-of-week.
4527
+ * Supports numbers, wildcards, lists (1,3,5), ranges (1-5) and steps.
4528
+ *
4529
+ * @param cronExpr - The cron expression.
4530
+ * @param now - The time to test (default: now).
4531
+ * @returns true if every field matches.
4532
+ */
4533
+ function cronMatchesNow(cronExpr, now = new Date()) {
4534
+ const fields = cronExpr.trim().split(/\s+/);
4535
+ if (fields.length !== 5)
4536
+ return false;
4537
+ return (fieldMatches(fields[0], now.getMinutes(), 0, 59) &&
4538
+ fieldMatches(fields[1], now.getHours(), 0, 23) &&
4539
+ fieldMatches(fields[2], now.getDate(), 1, 31) &&
4540
+ fieldMatches(fields[3], now.getMonth() + 1, 1, 12) &&
4541
+ fieldMatches(fields[4], now.getDay(), 0, 7));
4542
+ }
4543
+
4445
4544
  /**
4446
4545
  * @module vcs/gitNetwork
4447
4546
  * Non-interactive, credential-safe execution of git commands that touch the
@@ -4606,7 +4705,7 @@ function sanitizeGitError(error, secret) {
4606
4705
  */
4607
4706
  async function execGitNetwork(args, options) {
4608
4707
  try {
4609
- return await execFileAsync('git', [...buildGitNetworkArgs(options.clearCredentialHelpers), ...args], {
4708
+ return await execFileAsync('git', gitArgs(...buildGitNetworkArgs(options.clearCredentialHelpers), ...args), {
4610
4709
  cwd: options.cwd,
4611
4710
  env: buildGitNetworkEnv(process.env, options.config),
4612
4711
  timeout: options.timeout,
@@ -4643,64 +4742,122 @@ async function gitPushNonInteractive(options) {
4643
4742
  }
4644
4743
 
4645
4744
  /**
4646
- * @module vcs/SquashManager
4647
- * Handles scheduled squash retention for git-backed VCS roots.
4745
+ * @module vcs/gitRepoState
4746
+ * Read-mostly checks of a repository's state that gate VCS operations:
4747
+ * abandoned sequencer/rebase/merge state, uncommitted tracked changes, and
4748
+ * `index.lock` presence and staleness. See #249.
4648
4749
  */
4649
4750
  /**
4650
- * Parse a standard 5-field cron expression and check if the current time matches.
4651
- * Fields: minute hour dom month dow
4652
- * Supports: numbers, wildcards, lists (1,3,5), ranges (1-5), steps (star/5)
4751
+ * Paths under `.git/` that indicate an in-progress rebase, cherry-pick, or
4752
+ * merge. Rewriting history (or committing) on top of one is unsafe.
4653
4753
  */
4654
- function cronMatchesNow(cronExpr, now = new Date()) {
4655
- const fields = cronExpr.trim().split(/\s+/);
4656
- if (fields.length !== 5)
4657
- return false;
4658
- const minute = now.getMinutes();
4659
- const hour = now.getHours();
4660
- const dom = now.getDate();
4661
- const month = now.getMonth() + 1; // 1-based
4662
- const dow = now.getDay(); // 0=Sunday
4663
- return (fieldMatches(fields[0], minute, 0, 59) &&
4664
- fieldMatches(fields[1], hour, 0, 23) &&
4665
- fieldMatches(fields[2], dom, 1, 31) &&
4666
- fieldMatches(fields[3], month, 1, 12) &&
4667
- fieldMatches(fields[4], dow, 0, 7) // 7 also means Sunday
4668
- );
4754
+ const STALE_OPERATION_MARKERS = [
4755
+ 'sequencer',
4756
+ 'rebase-merge',
4757
+ 'rebase-apply',
4758
+ 'CHERRY_PICK_HEAD',
4759
+ 'MERGE_HEAD',
4760
+ ];
4761
+ /** Manual resolution hint logged alongside stale-operation reports. */
4762
+ const STALE_OPERATION_HINT = 'resolve it manually (e.g. git cherry-pick --quit / git rebase --abort / git merge --abort)';
4763
+ function indexLockPath(rootPath) {
4764
+ return join(rootPath, '.git', 'index.lock');
4669
4765
  }
4670
- function fieldMatches(field, value, min, max) {
4671
- for (const part of field.split(',')) {
4672
- if (partMatches(part.trim(), value, min, max))
4673
- return true;
4766
+ /**
4767
+ * Detect an abandoned cherry-pick, rebase, or merge left in `.git/`.
4768
+ * Report-only: never clears the state.
4769
+ *
4770
+ * @param rootPath - Repository root.
4771
+ * @returns The first marker found, or undefined if none.
4772
+ */
4773
+ async function detectStaleGitOperation(rootPath) {
4774
+ for (const marker of STALE_OPERATION_MARKERS) {
4775
+ try {
4776
+ await access(join(rootPath, '.git', marker));
4777
+ return marker;
4778
+ }
4779
+ catch {
4780
+ // Not present: check the next marker.
4781
+ }
4674
4782
  }
4675
- return false;
4783
+ return undefined;
4676
4784
  }
4677
- function partMatches(part, value, min, max) {
4678
- // Handle step: */N or range/N
4679
- const [rangePart, stepStr] = part.split('/');
4680
- const step = stepStr ? parseInt(stepStr, 10) : 1;
4681
- if (rangePart === '*') {
4682
- return (value - min) % step === 0;
4785
+ /**
4786
+ * Check for uncommitted changes to tracked files (staged or unstaged).
4787
+ * Untracked files are ignored.
4788
+ *
4789
+ * @param rootPath - Repository root.
4790
+ * @returns true if any tracked file differs from HEAD.
4791
+ */
4792
+ async function hasDirtyTrackedFiles(rootPath) {
4793
+ const { stdout } = await runGit(rootPath, [
4794
+ 'status',
4795
+ '--porcelain',
4796
+ '-z',
4797
+ '--untracked-files=no',
4798
+ ]);
4799
+ return stdout.length > 0;
4800
+ }
4801
+ /**
4802
+ * Check whether `.git/index.lock` exists.
4803
+ *
4804
+ * @param rootPath - Repository root.
4805
+ * @returns true if the lock file is present.
4806
+ */
4807
+ async function indexLockExists(rootPath) {
4808
+ try {
4809
+ await access(indexLockPath(rootPath));
4810
+ return true;
4683
4811
  }
4684
- // Handle range: N-M
4685
- if (rangePart.includes('-')) {
4686
- const [startStr, endStr] = rangePart.split('-');
4687
- const start = parseInt(startStr, 10);
4688
- const end = parseInt(endStr, 10);
4689
- if (value < start || value > end)
4690
- return false;
4691
- return (value - start) % step === 0;
4812
+ catch {
4813
+ return false;
4692
4814
  }
4693
- // Plain number
4694
- const num = parseInt(rangePart, 10);
4695
- if (step === 1) {
4696
- // For dow, treat 7 as 0 (Sunday)
4697
- if (max === 7 && num === 7)
4698
- return value === 0;
4699
- return value === num;
4815
+ }
4816
+ /**
4817
+ * Remove `.git/index.lock` unconditionally (missing is fine).
4818
+ *
4819
+ * @param rootPath - Repository root.
4820
+ */
4821
+ async function removeIndexLock(rootPath) {
4822
+ await rm(indexLockPath(rootPath), { force: true });
4823
+ }
4824
+ /**
4825
+ * Remove `.git/index.lock` if it is older than `thresholdMs`. A fresh lock
4826
+ * (held by a live git process) is left alone.
4827
+ *
4828
+ * @param rootPath - Repository root.
4829
+ * @param thresholdMs - Minimum age for a lock to count as stale.
4830
+ * @param logger - Logger for removal and failure reports.
4831
+ */
4832
+ async function removeStaleIndexLock(rootPath, thresholdMs, logger) {
4833
+ try {
4834
+ const ageMs = Date.now() - (await stat(indexLockPath(rootPath))).mtimeMs;
4835
+ if (ageMs > thresholdMs) {
4836
+ await removeIndexLock(rootPath);
4837
+ logger.warn({ root: rootPath, ageMs }, 'Removed stale index.lock');
4838
+ }
4839
+ }
4840
+ catch (error) {
4841
+ if (getErrorCode(error) !== 'ENOENT') {
4842
+ logger.warn({ root: rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
4843
+ }
4700
4844
  }
4701
- // Number with step doesn't make sense for single values, treat as exact
4702
- return value === num;
4703
4845
  }
4846
+
4847
+ /**
4848
+ * @module vcs/SquashManager
4849
+ * Handles scheduled squash retention for git-backed VCS roots.
4850
+ */
4851
+ const DAY_MS = 24 * 60 * 60 * 1000;
4852
+ /**
4853
+ * Manages scheduled squash retention for a single VCS root.
4854
+ *
4855
+ * Runs on a cron schedule (checked every 60s). When triggered, squashes
4856
+ * commits older than the retention boundary (the tighter of maxAgeDays
4857
+ * and maxVersions) into a single "historical baseline" orphan commit,
4858
+ * then cherry-picks retained commits on top. Force-pushes to remote
4859
+ * if configured (D13).
4860
+ */
4704
4861
  class SquashManager {
4705
4862
  rootPath;
4706
4863
  retention;
@@ -4755,17 +4912,9 @@ class SquashManager {
4755
4912
  * to drain and suspend the commit pipeline during the squash.
4756
4913
  */
4757
4914
  async runSquash() {
4758
- // Check for index.lock before starting multi-step operation
4759
- const lockPath = join(this.rootPath, '.git', 'index.lock');
4760
- try {
4761
- await access(lockPath);
4762
- // Lock exists — abort and retry next cycle
4763
- this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
4764
- return { squashed: false, error: 'index.lock exists' };
4765
- }
4766
- catch {
4767
- // No lock — proceed
4768
- }
4915
+ const blocked = await this.checkRepoPreconditions();
4916
+ if (blocked !== undefined)
4917
+ return { squashed: false, error: blocked };
4769
4918
  // Bug 3: Pause the commit pipeline before squash
4770
4919
  try {
4771
4920
  await this.pauseCommits?.();
@@ -4777,40 +4926,65 @@ class SquashManager {
4777
4926
  return { squashed: false, error: normalizeError(error).message };
4778
4927
  }
4779
4928
  try {
4780
- const commits = await this.getCommitLog();
4781
- if (commits.length <= 1) {
4782
- return { squashed: false };
4929
+ // #249 guard: the squash uses `reset --hard` / `checkout -f`, which
4930
+ // would destroy uncommitted changes to tracked files. After draining
4931
+ // the commit pipeline the tracked tree should be clean; if it isn't,
4932
+ // refuse rather than lose content. Untracked files don't block.
4933
+ if (await hasDirtyTrackedFiles(this.rootPath)) {
4934
+ const error = 'Squash refused: working tree has uncommitted changes to tracked files';
4935
+ this.logger.warn({ root: this.rootPath }, error);
4936
+ return { squashed: false, error };
4783
4937
  }
4784
- const boundaryIndex = this.calculateRetentionBoundary(commits);
4785
- if (boundaryIndex <= 0) {
4786
- // All commits are within retention or only one commit before boundary
4938
+ const commits = await this.getCommitLog();
4939
+ const boundaryIndex = commits.length <= 1 ? 0 : this.calculateRetentionBoundary(commits);
4940
+ if (boundaryIndex <= 0)
4787
4941
  return { squashed: false };
4788
- }
4789
- // Perform the squash
4790
4942
  await this.performSquash(commits, boundaryIndex);
4791
4943
  const commitsRemoved = boundaryIndex;
4792
4944
  const commitsRetained = commits.length - boundaryIndex;
4793
4945
  this.logger.info({ root: this.rootPath, commitsRemoved, commitsRetained }, 'Squash retention completed');
4794
- // Force push if remote configured
4795
4946
  await this.forcePushIfConfigured();
4796
4947
  return { squashed: true, commitsRemoved, commitsRetained };
4797
4948
  }
4798
4949
  catch (error) {
4799
- const message = normalizeError(error).message;
4800
4950
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
4801
- return { squashed: false, error: message };
4951
+ return { squashed: false, error: normalizeError(error).message };
4802
4952
  }
4803
4953
  finally {
4804
4954
  // Bug 3: Always resume the commit pipeline
4805
4955
  this.resumeCommits?.();
4806
4956
  }
4807
4957
  }
4958
+ /**
4959
+ * Refuse to start a multi-step history rewrite while another git
4960
+ * operation holds the index or an abandoned cherry-pick, rebase, or merge
4961
+ * is present (#249: report only, never auto-clear).
4962
+ *
4963
+ * @returns The refusal reason, or undefined if the squash may proceed.
4964
+ */
4965
+ async checkRepoPreconditions() {
4966
+ if (await indexLockExists(this.rootPath)) {
4967
+ this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
4968
+ return 'index.lock exists';
4969
+ }
4970
+ const marker = await detectStaleGitOperation(this.rootPath);
4971
+ if (marker !== undefined) {
4972
+ const error = `Squash refused: in-progress git operation detected (.git/${marker})`;
4973
+ this.logger.error({ root: this.rootPath, marker }, `${error} — ${STALE_OPERATION_HINT}`);
4974
+ return error;
4975
+ }
4976
+ return undefined;
4977
+ }
4808
4978
  /**
4809
4979
  * Get all commits in chronological order (oldest first).
4810
4980
  */
4811
4981
  async getCommitLog() {
4812
4982
  try {
4813
- const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4983
+ const { stdout } = await runGit(this.rootPath, [
4984
+ 'log',
4985
+ '--format=%H %aI',
4986
+ '--reverse',
4987
+ ]);
4814
4988
  if (!stdout.trim())
4815
4989
  return [];
4816
4990
  return stdout
@@ -4839,17 +5013,10 @@ class SquashManager {
4839
5013
  * The boundary is the one that retains FEWER old commits (tighter).
4840
5014
  */
4841
5015
  calculateRetentionBoundary(commits) {
4842
- const now = new Date();
4843
- const maxAgeMs = this.retention.maxAgeDays * 24 * 60 * 60 * 1000;
4844
- const cutoffDate = new Date(now.getTime() - maxAgeMs);
4845
- // Find the first commit that is within the age window (to keep)
4846
- let ageBoundary = commits.length; // Default: all too old
4847
- for (let i = 0; i < commits.length; i++) {
4848
- if (commits[i].date >= cutoffDate) {
4849
- ageBoundary = i;
4850
- break;
4851
- }
4852
- }
5016
+ const cutoffDate = new Date(Date.now() - this.retention.maxAgeDays * DAY_MS);
5017
+ // First commit within the age window (to keep); default: all too old.
5018
+ const firstKept = commits.findIndex((c) => c.date >= cutoffDate);
5019
+ const ageBoundary = firstKept === -1 ? commits.length : firstKept;
4853
5020
  // Count boundary: keep the last maxVersions commits
4854
5021
  const countBoundary = Math.max(0, commits.length - this.retention.maxVersions);
4855
5022
  // The tighter constraint wins (keeps fewer old commits = higher boundary index)
@@ -4860,80 +5027,59 @@ class SquashManager {
4860
5027
  * Uses the configured branch name instead of dynamic detection (Bug 1).
4861
5028
  */
4862
5029
  async performSquash(commits, boundaryIndex) {
5030
+ const git = (...args) => runGit(this.rootPath, args);
4863
5031
  // The commit just before boundary is the last one to squash
4864
5032
  const baselineHash = commits[boundaryIndex - 1].hash;
4865
- // Bug 1: Use configured branch name, not dynamic detection
4866
5033
  const targetBranch = this.branch;
4867
5034
  const orphanBranch = `__squash_orphan_${String(Date.now())}`;
4868
5035
  try {
4869
- // 1. Create orphan branch with the tree state at the baseline commit
4870
- await execFileAsync('git', ['checkout', '--orphan', orphanBranch], {
4871
- cwd: this.rootPath,
4872
- timeout: GIT_TIMEOUT_STANDARD,
4873
- });
4874
- // 2. Reset index to the baseline tree (the last squashed commit's tree)
4875
- await execFileAsync('git', ['reset', '--hard', baselineHash], {
4876
- cwd: this.rootPath,
4877
- timeout: GIT_TIMEOUT_STANDARD,
4878
- });
4879
- // 3. Create the baseline commit with the same tree
4880
- // Use commit-tree for a clean orphan commit
4881
- const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4882
- const treeHash = treeOut.trim();
4883
- const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4884
- const baselineCommitHash = commitOut.trim();
4885
- // 4. Reset orphan branch to this new baseline commit
4886
- await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
4887
- cwd: this.rootPath,
4888
- timeout: GIT_TIMEOUT_STANDARD,
4889
- });
4890
- // 5. Cherry-pick boundary..HEAD (the retained commits)
4891
- // Get the list of commits to cherry-pick (from boundary to the end)
5036
+ // 1-2. Orphan branch at the baseline commit's tree
5037
+ await git('checkout', '--orphan', orphanBranch);
5038
+ await git('reset', '--hard', baselineHash);
5039
+ // 3-4. Single parentless baseline commit with that tree
5040
+ const { stdout: treeOut } = await git('rev-parse', `${baselineHash}^{tree}`);
5041
+ const { stdout: commitOut } = await git('commit-tree', treeOut.trim(), '-m', 'historical baseline');
5042
+ await git('reset', '--hard', commitOut.trim());
5043
+ // 5. Cherry-pick the retained commits on top
4892
5044
  const commitsToCherry = commits.slice(boundaryIndex).map((c) => c.hash);
4893
5045
  if (commitsToCherry.length > 0) {
4894
- await execFileAsync('git', ['cherry-pick', ...commitsToCherry], {
4895
- cwd: this.rootPath,
4896
- timeout: GIT_TIMEOUT_CHERRY_PICK,
5046
+ await runGit(this.rootPath, ['cherry-pick', ...commitsToCherry], {
5047
+ timeoutMs: GIT_TIMEOUT_CHERRY_PICK,
4897
5048
  });
4898
5049
  }
4899
- // 6. Force-update the configured branch to the orphan
4900
- const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4901
- await execFileAsync('git', ['branch', '-f', targetBranch, newHead.trim()], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4902
- // 7. Switch back to configured branch
4903
- await execFileAsync('git', ['checkout', targetBranch], {
4904
- cwd: this.rootPath,
4905
- timeout: GIT_TIMEOUT_STANDARD,
4906
- });
4907
- // 8. Delete orphan branch
4908
- await execFileAsync('git', ['branch', '-D', orphanBranch], {
4909
- cwd: this.rootPath,
4910
- timeout: GIT_TIMEOUT_STANDARD,
4911
- });
5050
+ // 6-8. Move the configured branch to the new history and clean up
5051
+ const { stdout: newHead } = await git('rev-parse', 'HEAD');
5052
+ await git('branch', '-f', targetBranch, newHead.trim());
5053
+ await git('checkout', targetBranch);
5054
+ await git('branch', '-D', orphanBranch);
4912
5055
  }
4913
5056
  catch (error) {
4914
- // Attempt cleanup: try to get back to configured branch
4915
- try {
4916
- await execFileAsync('git', ['checkout', '-f', targetBranch], {
4917
- cwd: this.rootPath,
4918
- timeout: GIT_TIMEOUT_STANDARD,
4919
- });
4920
- await execFileAsync('git', ['branch', '-D', orphanBranch], {
4921
- cwd: this.rootPath,
4922
- timeout: GIT_TIMEOUT_STANDARD,
4923
- });
4924
- }
4925
- catch {
4926
- // Cleanup failed — log but re-throw original error
4927
- }
4928
- // Remove index.lock if left behind
5057
+ await this.cleanupFailedSquash(targetBranch, orphanBranch);
5058
+ throw error;
5059
+ }
5060
+ }
5061
+ /**
5062
+ * Best-effort cleanup after a failed squash. Aborts any half-finished
5063
+ * cherry-pick first, so a failure never leaves a sequencer behind (#249),
5064
+ * then returns to the configured branch and removes a leftover lock.
5065
+ */
5066
+ async cleanupFailedSquash(targetBranch, orphanBranch) {
5067
+ const attempt = async (...args) => {
4929
5068
  try {
4930
- const lockFile = join(this.rootPath, '.git', 'index.lock');
4931
- await rm(lockFile, { force: true });
5069
+ await runGit(this.rootPath, args);
4932
5070
  }
4933
5071
  catch {
4934
- // ignore
5072
+ // Best effort: the state may not need this step.
4935
5073
  }
4936
- throw error;
5074
+ };
5075
+ await attempt('cherry-pick', '--abort');
5076
+ await attempt('checkout', '-f', targetBranch);
5077
+ await attempt('branch', '-D', orphanBranch);
5078
+ try {
5079
+ await removeIndexLock(this.rootPath);
5080
+ }
5081
+ catch {
5082
+ // Best effort: the original squash error is what gets reported.
4937
5083
  }
4938
5084
  }
4939
5085
  /**
@@ -5010,7 +5156,7 @@ const ALWAYS_GITIGNORE_ENTRIES = [
5010
5156
  */
5011
5157
  async function checkGitAvailable() {
5012
5158
  try {
5013
- await execFileAsync('git', ['--version']);
5159
+ await execFileAsync('git', gitArgs('--version'));
5014
5160
  return true;
5015
5161
  }
5016
5162
  catch {
@@ -5030,7 +5176,7 @@ async function initRepo(rootPath) {
5030
5176
  // .git exists, nothing to do
5031
5177
  }
5032
5178
  catch {
5033
- await execFileAsync('git', ['init'], { cwd: rootPath });
5179
+ await runGit(rootPath, ['init']);
5034
5180
  }
5035
5181
  }
5036
5182
  /**
@@ -5042,12 +5188,8 @@ async function initRepo(rootPath) {
5042
5188
  * @param email - Author email for commits.
5043
5189
  */
5044
5190
  async function configureRepoIdentity(rootPath, name, email) {
5045
- await execFileAsync('git', ['config', '--local', 'user.name', name], {
5046
- cwd: rootPath,
5047
- });
5048
- await execFileAsync('git', ['config', '--local', 'user.email', email], {
5049
- cwd: rootPath,
5050
- });
5191
+ await runGit(rootPath, ['config', '--local', 'user.name', name]);
5192
+ await runGit(rootPath, ['config', '--local', 'user.email', email]);
5051
5193
  }
5052
5194
  /**
5053
5195
  * Detect and recover from orphan branch state on startup.
@@ -5069,24 +5211,22 @@ async function detectAndRecoverOrphanBranch(rootPath, expectedBranch, logger) {
5069
5211
  // Not a git repo — nothing to detect
5070
5212
  return;
5071
5213
  }
5072
- const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
5214
+ const { stdout: branchOut } = await runGit(rootPath, [
5215
+ 'rev-parse',
5216
+ '--abbrev-ref',
5217
+ 'HEAD',
5218
+ ]);
5073
5219
  const currentBranch = branchOut.trim();
5074
5220
  if (currentBranch === expectedBranch)
5075
5221
  return;
5076
5222
  logger.warn({ root: rootPath, currentBranch, expectedBranch }, 'Repo is on unexpected branch — recovering to configured branch');
5077
5223
  // Get current HEAD
5078
- const { stdout: headOut } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
5224
+ const { stdout: headOut } = await runGit(rootPath, ['rev-parse', 'HEAD']);
5079
5225
  const headHash = headOut.trim();
5080
5226
  // Force-update the expected branch to current HEAD
5081
- await execFileAsync('git', ['branch', '-f', expectedBranch, headHash], {
5082
- cwd: rootPath,
5083
- timeout: GIT_TIMEOUT_STANDARD,
5084
- });
5227
+ await runGit(rootPath, ['branch', '-f', expectedBranch, headHash]);
5085
5228
  // Checkout the expected branch
5086
- await execFileAsync('git', ['checkout', expectedBranch], {
5087
- cwd: rootPath,
5088
- timeout: GIT_TIMEOUT_STANDARD,
5089
- });
5229
+ await runGit(rootPath, ['checkout', expectedBranch]);
5090
5230
  logger.info({ root: rootPath, recoveredFrom: currentBranch, expectedBranch, headHash }, 'Orphan branch recovery complete — now on configured branch');
5091
5231
  }
5092
5232
  /**
@@ -5266,6 +5406,295 @@ function resolveCommitMessageApiKey(provider, configApiKey, logger) {
5266
5406
  return undefined;
5267
5407
  }
5268
5408
 
5409
+ /**
5410
+ * @module vcs/gitIndex
5411
+ * Git index operations used by the commit pipeline: staging additions and
5412
+ * removals through stdin, listing tracked-but-deleted paths, detecting a
5413
+ * staged diff, and creating a commit. Every call carries
5414
+ * {@link GIT_BASE_ARGS} via {@link gitArgs}.
5415
+ */
5416
+ /** Max stdout buffered from `git ls-files` (large corpora can list many paths). */
5417
+ const LS_FILES_MAX_BUFFER = 256 * 1024 * 1024;
5418
+ /**
5419
+ * Run a git subcommand with a NUL-delimited file list piped through stdin.
5420
+ *
5421
+ * This sidesteps the Windows CreateProcessW 32 767-char limit that
5422
+ * triggers ENAMETOOLONG when batches contain many files with long
5423
+ * absolute paths.
5424
+ *
5425
+ * @param argv - Full git argv (base args, subcommand, and flags).
5426
+ * @param files - Absolute paths to pipe through stdin (NUL-delimited).
5427
+ * @param cwd - Repository root (working directory for git).
5428
+ * @param timeoutMs - Kill the child process after this many milliseconds.
5429
+ */
5430
+ function execGitStdin(argv, files, cwd, timeoutMs) {
5431
+ return new Promise((resolvePromise, reject) => {
5432
+ let timedOut = false;
5433
+ const child = execFile('git', argv, { cwd }, (error) => {
5434
+ clearTimeout(timer);
5435
+ if (timedOut)
5436
+ return;
5437
+ if (error)
5438
+ reject(error);
5439
+ else
5440
+ resolvePromise();
5441
+ });
5442
+ const timer = setTimeout(() => {
5443
+ timedOut = true;
5444
+ child.kill();
5445
+ reject(new Error(`git ${argv.join(' ')} timed out after ${String(timeoutMs)}ms`));
5446
+ }, timeoutMs);
5447
+ if (!child.stdin) {
5448
+ clearTimeout(timer);
5449
+ reject(new Error('Failed to open stdin for git command'));
5450
+ return;
5451
+ }
5452
+ // Suppress EPIPE: expected if git exits before we finish writing.
5453
+ child.stdin.on('error', (err) => {
5454
+ if (err.code !== 'EPIPE') {
5455
+ clearTimeout(timer);
5456
+ reject(err);
5457
+ }
5458
+ });
5459
+ child.stdin.end(files.join('\0'));
5460
+ });
5461
+ }
5462
+ /**
5463
+ * Stage files with `git add --pathspec-from-file=- --pathspec-file-nul`.
5464
+ *
5465
+ * @param files - Absolute paths of files to stage.
5466
+ * @param cwd - Repository root (working directory for git).
5467
+ * @param timeoutMs - Kill the child process after this many milliseconds.
5468
+ */
5469
+ async function gitAddViaStdin(files, cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
5470
+ if (files.length === 0)
5471
+ return;
5472
+ await execGitStdin(gitArgs('add', '--pathspec-from-file=-', '--pathspec-file-nul'), files, cwd, timeoutMs);
5473
+ }
5474
+ /**
5475
+ * Stage the removal (index only) of tracked files that are missing from
5476
+ * disk. `--ignore-unmatch` means a path that isn't actually tracked is
5477
+ * skipped instead of failing the whole command. See #249.
5478
+ *
5479
+ * @param files - Absolute paths of tracked files to remove from the index.
5480
+ * @param cwd - Repository root (working directory for git).
5481
+ * @param timeoutMs - Kill the child process after this many milliseconds.
5482
+ */
5483
+ async function gitRmCachedViaStdin(files, cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
5484
+ if (files.length === 0)
5485
+ return;
5486
+ await execGitStdin(gitArgs('rm', '--cached', '--ignore-unmatch', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'), files, cwd, timeoutMs);
5487
+ }
5488
+ /**
5489
+ * List tracked paths that are missing from the working tree, via a single
5490
+ * `git ls-files --deleted -z` call (never one process per file).
5491
+ *
5492
+ * `git ls-files` has no `--pathspec-from-file`, so rather than passing the
5493
+ * candidate paths (which could exceed the Windows command-line limit) this
5494
+ * asks for every tracked-but-deleted path; callers intersect with their own
5495
+ * candidates. Output is bounded by the number of deleted tracked files.
5496
+ *
5497
+ * @param cwd - Repository root (working directory for git).
5498
+ * @param timeoutMs - Kill the child process after this many milliseconds.
5499
+ * @returns Absolute paths (forward slashes) of tracked files missing on disk.
5500
+ */
5501
+ async function listDeletedTrackedPaths(cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
5502
+ const { stdout } = await runGit(cwd, ['ls-files', '--deleted', '-z'], {
5503
+ timeoutMs,
5504
+ maxBuffer: LS_FILES_MAX_BUFFER,
5505
+ });
5506
+ return stdout
5507
+ .split('\0')
5508
+ .filter((rel) => rel.length > 0)
5509
+ .map((rel) => normalizeSlashes(resolve(cwd, rel)));
5510
+ }
5511
+ /**
5512
+ * Check whether anything is staged (`git diff --cached --quiet`).
5513
+ * Exit code 0 means the index matches HEAD; exit code 1 means there is a
5514
+ * staged diff. Any other outcome is a real error and is rethrown. See #249.
5515
+ *
5516
+ * @param cwd - Repository root (working directory for git).
5517
+ * @param timeoutMs - Kill the child process after this many milliseconds.
5518
+ * @returns true if there is a staged diff to commit.
5519
+ */
5520
+ async function hasStagedChanges(cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
5521
+ try {
5522
+ await runGit(cwd, ['diff', '--cached', '--quiet'], { timeoutMs });
5523
+ return false;
5524
+ }
5525
+ catch (error) {
5526
+ if (getErrorCode(error) === 1)
5527
+ return true;
5528
+ throw error;
5529
+ }
5530
+ }
5531
+ /**
5532
+ * Commit the staged index and return the new commit's short hash.
5533
+ *
5534
+ * @param cwd - Repository root (working directory for git).
5535
+ * @param message - Commit message.
5536
+ * @param timeoutMs - Kill each child process after this many milliseconds.
5537
+ * @returns The short hash of the new HEAD.
5538
+ */
5539
+ async function gitCommit(cwd, message, timeoutMs = GIT_TIMEOUT_STANDARD) {
5540
+ await runGit(cwd, ['commit', '-m', message], { timeoutMs });
5541
+ const { stdout } = await runGit(cwd, ['rev-parse', '--short', 'HEAD'], {
5542
+ timeoutMs,
5543
+ });
5544
+ return stdout.trim();
5545
+ }
5546
+
5547
+ /**
5548
+ * @module vcs/batchStaging
5549
+ * Stage a commit batch so that a missing path never fails it: existing
5550
+ * files are added, tracked files that vanished have their deletion staged,
5551
+ * and never-tracked files that vanished are dropped. See #249.
5552
+ */
5553
+ /** Errno codes that mean "this path does not exist". */
5554
+ const MISSING_CODES = new Set([
5555
+ 'ENOENT',
5556
+ 'ENOTDIR',
5557
+ ]);
5558
+ /**
5559
+ * Check whether a path exists. Only "does not exist" errors count as
5560
+ * missing; any other failure (permissions, I/O) propagates so the batch is
5561
+ * retained and retried rather than silently dropping a real change.
5562
+ *
5563
+ * @param path - Absolute path to check.
5564
+ * @param statFn - Stat implementation (injectable for tests).
5565
+ * @returns true if the path exists.
5566
+ */
5567
+ async function pathExists(path, statFn) {
5568
+ try {
5569
+ await statFn(path);
5570
+ return true;
5571
+ }
5572
+ catch (error) {
5573
+ if (MISSING_CODES.has(getErrorCode(error) ?? ''))
5574
+ return false;
5575
+ throw error;
5576
+ }
5577
+ }
5578
+ /**
5579
+ * Split a batch into existing, missing-tracked, and missing-untracked
5580
+ * paths. Uses one `git ls-files --deleted` call, and only when something is
5581
+ * missing.
5582
+ *
5583
+ * @param files - Absolute paths in the batch.
5584
+ * @param rootPath - Repository root.
5585
+ * @param statFn - Stat implementation (injectable for tests).
5586
+ * @returns The classified batch.
5587
+ */
5588
+ async function classifyBatchPaths(files, rootPath, statFn = stat) {
5589
+ const exists = await Promise.all(files.map((f) => pathExists(f, statFn)));
5590
+ const existing = files.filter((_, i) => exists[i]);
5591
+ const missing = files.filter((_, i) => !exists[i]);
5592
+ if (missing.length === 0) {
5593
+ return { existing, missingTracked: [], missingUntracked: [] };
5594
+ }
5595
+ const deletedTracked = new Set((await listDeletedTrackedPaths(rootPath)).map((p) => pathKey(p)));
5596
+ const isTracked = (f) => deletedTracked.has(pathKey(f));
5597
+ return {
5598
+ existing,
5599
+ missingTracked: missing.filter(isTracked),
5600
+ missingUntracked: missing.filter((f) => !isTracked(f)),
5601
+ };
5602
+ }
5603
+ /**
5604
+ * Stage a batch: add existing files, stage removal of tracked files that
5605
+ * are gone, and drop never-tracked files that are gone.
5606
+ *
5607
+ * @param files - Absolute paths in the batch.
5608
+ * @param rootPath - Repository root.
5609
+ * @param logger - Logger (dropped paths are reported at debug).
5610
+ * @param timeoutMs - Timeout for each git call.
5611
+ */
5612
+ async function stageBatch(files, rootPath, logger, timeoutMs = GIT_TIMEOUT_STANDARD) {
5613
+ const { existing, missingTracked, missingUntracked } = await classifyBatchPaths(files, rootPath);
5614
+ if (missingUntracked.length > 0) {
5615
+ logger.debug({ root: rootPath, count: missingUntracked.length }, 'Dropping missing, untracked paths from batch');
5616
+ }
5617
+ await gitAddViaStdin(existing, rootPath, timeoutMs);
5618
+ await gitRmCachedViaStdin(missingTracked, rootPath, timeoutMs);
5619
+ }
5620
+
5621
+ /**
5622
+ * @module vcs/CommitCircuitBreaker
5623
+ * Per-root circuit breaker for the VCS commit pipeline. Counts consecutive
5624
+ * failures, trips at a threshold, and allows one half-open attempt per
5625
+ * cooldown. It holds no files: callers retain pending work while it is
5626
+ * tripped. See #249.
5627
+ */
5628
+ /** Circuit breaker with a time-based half-open recovery. */
5629
+ class CommitCircuitBreaker {
5630
+ maxConsecutiveFailures;
5631
+ cooldownMs;
5632
+ now;
5633
+ failures = 0;
5634
+ trippedAtMs;
5635
+ lastError;
5636
+ /**
5637
+ * @param maxConsecutiveFailures - Failures that trip the breaker.
5638
+ * @param cooldownMs - Time after a trip before one attempt is allowed.
5639
+ * @param now - Clock (injectable for tests).
5640
+ */
5641
+ constructor(maxConsecutiveFailures, cooldownMs, now = Date.now) {
5642
+ this.maxConsecutiveFailures = maxConsecutiveFailures;
5643
+ this.cooldownMs = cooldownMs;
5644
+ this.now = now;
5645
+ }
5646
+ /** Whether the breaker is tripped. */
5647
+ get isTripped() {
5648
+ return this.trippedAtMs !== undefined;
5649
+ }
5650
+ /**
5651
+ * Milliseconds until an attempt is allowed. 0 when not tripped or when
5652
+ * the cooldown has elapsed (half-open).
5653
+ */
5654
+ remainingCooldownMs() {
5655
+ if (this.trippedAtMs === undefined)
5656
+ return 0;
5657
+ return Math.max(0, this.cooldownMs - (this.now() - this.trippedAtMs));
5658
+ }
5659
+ /**
5660
+ * Record a failed attempt.
5661
+ *
5662
+ * @param message - The failure message.
5663
+ * @returns true if this failure tripped (or re-armed) the breaker.
5664
+ */
5665
+ recordFailure(message) {
5666
+ this.failures++;
5667
+ this.lastError = message;
5668
+ if (this.failures < this.maxConsecutiveFailures)
5669
+ return false;
5670
+ this.trippedAtMs = this.now();
5671
+ return true;
5672
+ }
5673
+ /**
5674
+ * Record a successful attempt (a commit or a no-op) and reset.
5675
+ *
5676
+ * @returns true if the breaker was tripped before this success.
5677
+ */
5678
+ recordSuccess() {
5679
+ const wasTripped = this.isTripped;
5680
+ this.failures = 0;
5681
+ this.trippedAtMs = undefined;
5682
+ this.lastError = undefined;
5683
+ return wasTripped;
5684
+ }
5685
+ /** Current state for status reporting. */
5686
+ snapshot() {
5687
+ return {
5688
+ consecutiveFailures: this.failures,
5689
+ tripped: this.isTripped,
5690
+ trippedAt: this.trippedAtMs === undefined
5691
+ ? null
5692
+ : new Date(this.trippedAtMs).toISOString(),
5693
+ lastError: this.lastError ?? null,
5694
+ };
5695
+ }
5696
+ }
5697
+
5269
5698
  /**
5270
5699
  * @module vcs/CommitMessageBuilder
5271
5700
  * Builds commit messages — both template-based and AI-generated.
@@ -5341,8 +5770,15 @@ class CommitMessageBuilder {
5341
5770
  */
5342
5771
  async getStagedDiff() {
5343
5772
  try {
5344
- const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
5345
- const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
5773
+ const { stdout: stat } = await runGit(this.rootPath, [
5774
+ 'diff',
5775
+ '--cached',
5776
+ '--stat',
5777
+ ]);
5778
+ const { stdout: patch } = await runGit(this.rootPath, [
5779
+ 'diff',
5780
+ '--cached',
5781
+ ]);
5346
5782
  return `${stat}\n${patch}`;
5347
5783
  }
5348
5784
  catch {
@@ -5405,7 +5841,9 @@ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger
5405
5841
  * (with optional AI-generated messages), and optionally pushed to a remote.
5406
5842
  *
5407
5843
  * Concurrency: only one commit is in-flight at a time per root. Index.lock
5408
- * contention is handled with exponential backoff retries (D10).
5844
+ * contention is handled with exponential backoff retries (D10). Failed
5845
+ * batches are always retained; a circuit breaker spaces out retries of a
5846
+ * persistently failing root (#249).
5409
5847
  */
5410
5848
  class VcsManager {
5411
5849
  config;
@@ -5414,13 +5852,15 @@ class VcsManager {
5414
5852
  accessToken;
5415
5853
  logger;
5416
5854
  commitMessageBuilder;
5855
+ breaker;
5417
5856
  pending = new Set();
5418
5857
  pendingReversions = [];
5419
5858
  _pushErrors = [];
5420
5859
  squashManager;
5421
5860
  throttleTimer;
5861
+ breakerRetryTimer;
5422
5862
  commitInFlight = Promise.resolve();
5423
- consecutiveCommitFailures = 0;
5863
+ inFlightFileCount = 0;
5424
5864
  started = false;
5425
5865
  paused = false;
5426
5866
  isBaseline = true;
@@ -5432,12 +5872,11 @@ class VcsManager {
5432
5872
  this.remoteUrl = remoteUrl;
5433
5873
  this.accessToken = accessToken;
5434
5874
  this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
5875
+ this.breaker = new CommitCircuitBreaker(config.maxConsecutiveFailures, config.circuitBreakerCooldownMs);
5435
5876
  if (config.retention) {
5436
5877
  this.squashManager = new SquashManager(rootPath, config.retention, logger, {
5437
5878
  branch: config.branch,
5438
- pauseCommits: () => {
5439
- return this.pause();
5440
- },
5879
+ pauseCommits: () => this.pause(),
5441
5880
  resumeCommits: () => {
5442
5881
  this.resume();
5443
5882
  },
@@ -5452,25 +5891,36 @@ class VcsManager {
5452
5891
  get pushErrors() {
5453
5892
  return this._pushErrors;
5454
5893
  }
5894
+ /** Current circuit breaker state and backlog, for /vcs/status. */
5895
+ get breakerState() {
5896
+ return {
5897
+ ...this.breaker.snapshot(),
5898
+ pendingCount: this.pending.size + this.inFlightFileCount,
5899
+ };
5900
+ }
5455
5901
  /**
5456
5902
  * Begin accepting file changes. Sets the manager to started state and
5457
5903
  * starts the squash manager if configured.
5458
5904
  *
5459
5905
  * Performs startup orphan detection/recovery before starting the squash
5460
- * manager, ensuring the repo is on the configured branch.
5906
+ * manager, ensuring the repo is on the configured branch, and reports
5907
+ * (never clears) an abandoned cherry-pick, rebase, or merge.
5461
5908
  *
5462
5909
  * Baseline mode (commit prefix `"baseline:"`) remains active until
5463
5910
  * {@link endBaseline} is called by the coordinator after the initial scan.
5464
5911
  */
5465
5912
  async start() {
5466
5913
  // Bug 6: Detect and recover from orphan branches before normal operations
5467
- const branch = this.config.branch;
5468
5914
  try {
5469
- await detectAndRecoverOrphanBranch(this.rootPath, branch, this.logger);
5915
+ await detectAndRecoverOrphanBranch(this.rootPath, this.config.branch, this.logger);
5470
5916
  }
5471
5917
  catch (error) {
5472
5918
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Orphan branch recovery failed — continuing with current state');
5473
5919
  }
5920
+ const marker = await detectStaleGitOperation(this.rootPath);
5921
+ if (marker !== undefined) {
5922
+ this.logger.error({ root: this.rootPath, marker }, `Stale in-progress git operation detected (.git/${marker}) — squash retention will refuse to run until you ${STALE_OPERATION_HINT}`);
5923
+ }
5474
5924
  this.started = true;
5475
5925
  this.squashManager?.start();
5476
5926
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
@@ -5484,26 +5934,22 @@ class VcsManager {
5484
5934
  }
5485
5935
  /**
5486
5936
  * Add a file to the pending set and start the throttle timer.
5487
- * If the pending set exceeds maxBatchSize, flush immediately.
5937
+ * If the pending set reaches maxBatchSize, commit a batch immediately.
5938
+ *
5939
+ * The circuit breaker is intentionally not reset here: recovery is
5940
+ * time-based, so a busy root cannot hammer a deterministic failure.
5488
5941
  *
5489
5942
  * @param filePath - Absolute path of the changed file.
5490
5943
  */
5491
5944
  fileChanged(filePath) {
5492
5945
  if (!this.started)
5493
5946
  return;
5494
- // Reset circuit breaker on new file change so the system can recover
5495
- if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
5496
- this.logger.info({ root: this.rootPath }, 'VCS circuit breaker reset — new file change received, retrying commits');
5497
- this.consecutiveCommitFailures = 0;
5498
- }
5499
5947
  this.pending.add(filePath);
5500
5948
  if (this.pending.size >= this.config.maxBatchSize) {
5501
5949
  this.clearThrottle();
5502
- const batch = this.takeBatch(this.config.maxBatchSize);
5503
- this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
5504
- if (this.pending.size > 0) {
5950
+ this.enqueueBatch(this.takeBatch());
5951
+ if (this.pending.size > 0)
5505
5952
  this.resetThrottle();
5506
- }
5507
5953
  return;
5508
5954
  }
5509
5955
  this.resetThrottle();
@@ -5517,14 +5963,11 @@ class VcsManager {
5517
5963
  this.pendingReversions.push(reversion);
5518
5964
  }
5519
5965
  /**
5520
- * Stage a file deletion and add to pending set.
5521
- * git add handles deleted files when the file is gone from disk.
5966
+ * Queue a deleted file. Staging records the deletion if git tracks it.
5522
5967
  *
5523
5968
  * @param filePath - Absolute path of the deleted file.
5524
5969
  */
5525
5970
  handleUnlink(filePath) {
5526
- if (!this.started)
5527
- return;
5528
5971
  this.fileChanged(filePath);
5529
5972
  }
5530
5973
  /**
@@ -5545,19 +5988,16 @@ class VcsManager {
5545
5988
  resume() {
5546
5989
  this.paused = false;
5547
5990
  this.logger.debug({ root: this.rootPath }, 'VcsManager resumed');
5548
- if (this.pending.size > 0) {
5991
+ if (this.pending.size > 0)
5549
5992
  this.resetThrottle();
5550
- }
5551
5993
  }
5552
5994
  /**
5553
- * Flush all pending files immediately.
5995
+ * Commit all pending files now, in batches of at most maxBatchSize.
5554
5996
  */
5555
5997
  async flush() {
5556
5998
  this.clearThrottle();
5557
- if (this.pending.size > 0) {
5558
- const batch = [...this.pending];
5559
- this.pending.clear();
5560
- this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
5999
+ while (this.pending.size > 0) {
6000
+ this.enqueueBatch(this.takeBatch());
5561
6001
  }
5562
6002
  await this.commitInFlight;
5563
6003
  }
@@ -5567,32 +6007,61 @@ class VcsManager {
5567
6007
  async stop() {
5568
6008
  this.started = false;
5569
6009
  this.squashManager?.stop();
6010
+ this.clearBreakerRetry();
5570
6011
  await this.flush();
5571
6012
  this.logger.info({ root: this.rootPath }, 'VcsManager stopped');
5572
6013
  }
5573
6014
  /**
5574
- * Take up to N items from the pending set.
6015
+ * Add tracked-but-deleted paths (`git ls-files --deleted`) in this
6016
+ * manager's watch scope to pending, so the next commit (typically the
6017
+ * baseline) records deletions that happened while nothing was watching.
6018
+ * Called by the coordinator before flush/endBaseline on startup. See #249.
6019
+ *
6020
+ * @param isInScope - Returns true if the absolute path routes to this
6021
+ * manager (the coordinator's routing and watch-scope logic).
5575
6022
  */
5576
- takeBatch(n) {
5577
- const batch = [];
5578
- for (const item of this.pending) {
5579
- if (batch.length >= n)
5580
- break;
5581
- batch.push(item);
6023
+ async reconcileDeletions(isInScope) {
6024
+ try {
6025
+ const deleted = (await listDeletedTrackedPaths(this.rootPath)).filter(isInScope);
6026
+ for (const path of deleted)
6027
+ this.pending.add(path);
6028
+ if (deleted.length > 0) {
6029
+ this.logger.info({ root: this.rootPath, count: deleted.length }, 'VCS startup reconciliation — queued tracked deletions');
6030
+ }
5582
6031
  }
5583
- for (const item of batch) {
5584
- this.pending.delete(item);
6032
+ catch (error) {
6033
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'VCS startup deletion reconciliation failed');
5585
6034
  }
6035
+ }
6036
+ /** Take up to maxBatchSize files from the pending set. */
6037
+ takeBatch() {
6038
+ const batch = [...this.pending].slice(0, this.config.maxBatchSize);
6039
+ for (const item of batch)
6040
+ this.pending.delete(item);
5586
6041
  return batch;
5587
6042
  }
5588
- /**
5589
- * Clear the throttle timer.
5590
- */
6043
+ /** Chain a batch behind the in-flight commit, tracking it as backlog. */
6044
+ enqueueBatch(batch) {
6045
+ this.inFlightFileCount += batch.length;
6046
+ this.commitInFlight = this.commitInFlight.then(async () => {
6047
+ try {
6048
+ await this.commitBatch(batch);
6049
+ }
6050
+ finally {
6051
+ this.inFlightFileCount -= batch.length;
6052
+ }
6053
+ });
6054
+ }
6055
+ /** Put files back in the pending set. Pending is a set, so this cannot grow it past the distinct paths seen. */
6056
+ retain(files) {
6057
+ for (const f of files)
6058
+ this.pending.add(f);
6059
+ }
5591
6060
  clearThrottle() {
5592
- if (this.throttleTimer !== undefined) {
5593
- clearTimeout(this.throttleTimer);
5594
- this.throttleTimer = undefined;
5595
- }
6061
+ if (this.throttleTimer === undefined)
6062
+ return;
6063
+ clearTimeout(this.throttleTimer);
6064
+ this.throttleTimer = undefined;
5596
6065
  }
5597
6066
  /**
5598
6067
  * Start the throttle timer to fire flush() after commitThrottleMs.
@@ -5606,117 +6075,148 @@ class VcsManager {
5606
6075
  void this.flush();
5607
6076
  }, this.config.commitThrottleMs);
5608
6077
  }
5609
- /**
5610
- * Check for and remove stale index.lock files before retrying.
5611
- */
5612
- async removeStaleLock() {
5613
- const lockPath = join(this.rootPath, '.git', 'index.lock');
5614
- try {
5615
- const lockStat = await stat(lockPath);
5616
- const ageMs = Date.now() - lockStat.mtimeMs;
5617
- if (ageMs > this.config.staleLockThresholdMs) {
5618
- await rm(lockPath, { force: true });
5619
- this.logger.warn({ root: this.rootPath, ageMs }, 'Removed stale index.lock');
5620
- }
5621
- }
5622
- catch (error) {
5623
- const code = error.code;
5624
- if (code !== 'ENOENT') {
5625
- this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
5626
- }
5627
- }
6078
+ clearBreakerRetry() {
6079
+ if (this.breakerRetryTimer === undefined)
6080
+ return;
6081
+ clearTimeout(this.breakerRetryTimer);
6082
+ this.breakerRetryTimer = undefined;
5628
6083
  }
5629
6084
  /**
5630
- * Check whether a git error represents a "nothing to commit" state.
5631
- * Git outputs this message to stdout (not stderr) on exit code 1.
6085
+ * Ensure a timer will flush pending files when the breaker's cooldown
6086
+ * elapses, without needing a new file-change event.
5632
6087
  */
5633
- isNothingToCommitError(error) {
5634
- const { message, stderr, stdout } = getExecErrorFields(error);
5635
- return (message.includes('nothing to commit') ||
5636
- stderr.includes('nothing to commit') ||
5637
- stdout.includes('nothing to commit'));
6088
+ scheduleBreakerRetry() {
6089
+ if (!this.started || this.breakerRetryTimer !== undefined)
6090
+ return;
6091
+ this.breakerRetryTimer = setTimeout(() => {
6092
+ this.breakerRetryTimer = undefined;
6093
+ void this.flush();
6094
+ }, this.breaker.remainingCooldownMs());
5638
6095
  }
5639
6096
  /**
5640
- * Commit a batch of files to the git repo with index.lock retry,
5641
- * stale lock detection, circuit breaker, pause support, and re-queue cap.
6097
+ * Commit a batch with index.lock retry, stale lock detection, pause
6098
+ * support, and the circuit breaker. A failed batch is always retained.
5642
6099
  */
5643
6100
  async commitBatch(files) {
5644
6101
  if (files.length === 0)
5645
6102
  return;
5646
- // Pause support: re-queue files without counting as failure
5647
6103
  if (this.paused) {
5648
- for (const f of files) {
5649
- this.pending.add(f);
5650
- }
6104
+ this.retain(files);
5651
6105
  this.logger.debug({ root: this.rootPath, fileCount: files.length }, 'VcsManager paused — files re-queued');
5652
6106
  return;
5653
6107
  }
5654
- // Circuit breaker: skip if too many consecutive failures
5655
- if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
5656
- this.logger.error({
5657
- root: this.rootPath,
5658
- consecutiveFailures: this.consecutiveCommitFailures,
5659
- discardedFiles: files.length,
5660
- }, 'VCS circuit breaker tripped — pending files discarded until next successful commit');
5661
- return;
6108
+ if (this.breaker.isTripped) {
6109
+ if (this.breaker.remainingCooldownMs() > 0) {
6110
+ this.retain(files);
6111
+ this.scheduleBreakerRetry();
6112
+ return;
6113
+ }
6114
+ this.logger.info({ root: this.rootPath }, 'VCS circuit breaker half-open — attempting recovery commit');
5662
6115
  }
5663
6116
  try {
5664
- // Check for stale lock before first attempt
5665
- await this.removeStaleLock();
5666
- await retry(async (attempt) => {
5667
- await gitAddViaStdin(files, this.rootPath, GIT_TIMEOUT_STANDARD);
5668
- // Build message after staging so getStagedDiff can see the changes.
5669
- // Skip AI for baselines and retries — use template directly.
5670
- const message = this.isBaseline || attempt > 1
5671
- ? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
5672
- : await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
5673
- this.pendingReversions.length = 0;
5674
- await execFileAsync('git', ['commit', '-m', message], {
5675
- cwd: this.rootPath,
5676
- timeout: GIT_TIMEOUT_STANDARD,
5677
- });
5678
- const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
5679
- const hash = hashOut.trim();
5680
- this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
5681
- const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
5682
- if (pushTime)
5683
- this._lastPushTime = pushTime;
5684
- }, {
6117
+ await removeStaleIndexLock(this.rootPath, this.config.staleLockThresholdMs, this.logger);
6118
+ const committed = await retry((attempt) => this.tryCommit(files, attempt), {
5685
6119
  attempts: 4,
5686
6120
  baseDelayMs: 500,
5687
6121
  maxDelayMs: 2000,
6122
+ // Only index.lock contention is transient. Anything else fails on
6123
+ // the first attempt with its real message. See #249.
6124
+ shouldRetry: isIndexLockError,
5688
6125
  onRetry: ({ attempt, delayMs }) => {
5689
6126
  this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
5690
6127
  },
5691
6128
  });
5692
- // Success — reset circuit breaker
5693
- this.consecutiveCommitFailures = 0;
6129
+ if (!committed) {
6130
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'VCS commit skipped — nothing staged');
6131
+ }
6132
+ this.onCommitSuccess();
5694
6133
  }
5695
6134
  catch (error) {
5696
- // Bug 5: "nothing to commit" is a no-op, not a failure
5697
- if (this.isNothingToCommitError(error)) {
5698
- this.logger.info({ root: this.rootPath, fileCount: files.length }, 'VCS commit skipped — nothing to commit');
5699
- return;
5700
- }
5701
- this.consecutiveCommitFailures++;
5702
- // Re-queue cap: only re-queue up to maxBatchSize files
5703
- const cap = this.config.maxBatchSize;
5704
- const toRequeue = files.slice(0, cap);
5705
- const overflow = files.length - toRequeue.length;
5706
- for (const f of toRequeue) {
5707
- this.pending.add(f);
5708
- }
5709
- if (overflow > 0) {
5710
- this.logger.warn({ root: this.rootPath, overflow, cap }, 'Re-queue cap exceeded — discarded overflow files');
5711
- }
5712
- this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
5713
- this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
6135
+ this.onCommitFailure(files, error);
6136
+ }
6137
+ }
6138
+ /**
6139
+ * One commit attempt: stage, skip if nothing is staged, commit, push.
6140
+ *
6141
+ * @returns true if a commit was created; false for a no-op.
6142
+ */
6143
+ async tryCommit(files, attempt) {
6144
+ await stageBatch(files, this.rootPath, this.logger);
6145
+ // "Nothing staged" is a no-op, detected without parsing git's text.
6146
+ if (!(await hasStagedChanges(this.rootPath)))
6147
+ return false;
6148
+ // Build message after staging so getStagedDiff can see the changes.
6149
+ // Skip AI for baselines and retries — use template directly.
6150
+ const message = this.isBaseline || attempt > 1
6151
+ ? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
6152
+ : await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
6153
+ this.pendingReversions.length = 0;
6154
+ const hash = await gitCommit(this.rootPath, message);
6155
+ this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
6156
+ const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
6157
+ if (pushTime)
6158
+ this._lastPushTime = pushTime;
6159
+ return true;
6160
+ }
6161
+ onCommitSuccess() {
6162
+ if (!this.breaker.recordSuccess())
6163
+ return;
6164
+ this.clearBreakerRetry();
6165
+ this.logger.info({ root: this.rootPath }, 'VCS circuit breaker recovered — resuming normal operation');
6166
+ }
6167
+ onCommitFailure(files, error) {
6168
+ const err = normalizeError(error);
6169
+ this.retain(files);
6170
+ this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
6171
+ this.logger.error({ root: this.rootPath, err }, 'VCS commit failed');
6172
+ if (!this.breaker.recordFailure(err.message)) {
5714
6173
  // Bug 2: Restart throttle timer so re-queued files get retried
5715
6174
  this.resetThrottle();
6175
+ return;
5716
6176
  }
6177
+ this.logger.error({
6178
+ root: this.rootPath,
6179
+ consecutiveFailures: this.breaker.snapshot().consecutiveFailures,
6180
+ pendingCount: this.pending.size,
6181
+ cooldownMs: this.config.circuitBreakerCooldownMs,
6182
+ }, 'VCS circuit breaker tripped — pending files retained, will retry after cooldown');
6183
+ this.scheduleBreakerRetry();
5717
6184
  }
5718
6185
  }
5719
6186
 
6187
+ /**
6188
+ * @module vcs/watchScope
6189
+ * Build a predicate for "would the filesystem watcher observe this path?"
6190
+ * from `watch.paths` and `watch.ignored`, using the watcher's own glob
6191
+ * resolution. Used to scope VCS startup deletion reconciliation. See #249.
6192
+ */
6193
+ /**
6194
+ * Create a watch-scope predicate. A path is in scope if it matches a watch
6195
+ * glob and neither it nor any ancestor directory matches a `watch.ignored`
6196
+ * pattern (chokidar applies `ignored` to directories as well as files).
6197
+ *
6198
+ * @param watch - The `watch` section of the config.
6199
+ * @returns The scope predicate.
6200
+ */
6201
+ function createWatchScope(watch) {
6202
+ const matchesGlobs = resolveWatchPaths(extractWatchPathStrings(watch.paths)).matches;
6203
+ // Config `ignored` entries are strings, which resolveIgnored always turns
6204
+ // into picomatch functions; the filter only narrows the union type.
6205
+ const ignored = resolveIgnored(watch.ignored).filter((entry) => typeof entry === 'function');
6206
+ return (normalizedPath) => {
6207
+ if (!matchesGlobs(normalizedPath))
6208
+ return false;
6209
+ for (let current = normalizedPath;;) {
6210
+ if (ignored.some((isIgnored) => isIgnored(current)))
6211
+ return false;
6212
+ const parent = normalizeSlashes(dirname(current));
6213
+ if (parent === current)
6214
+ return true;
6215
+ current = parent;
6216
+ }
6217
+ };
6218
+ }
6219
+
5720
6220
  /**
5721
6221
  * @module vcs/VcsCoordinator
5722
6222
  * Orchestrates VCS across all watch roots. Routes file events to the correct
@@ -5729,10 +6229,14 @@ class VcsCoordinator {
5729
6229
  managers = new Map();
5730
6230
  roots = [];
5731
6231
  logger;
6232
+ isInWatchScope = () => false;
5732
6233
  constructor(config, logger) {
5733
6234
  this.logger = logger;
5734
6235
  if (!config.vcs?.enabled)
5735
6236
  return;
6237
+ // Same watch-scope logic as the filesystem watcher (globs + ignored),
6238
+ // used to filter startup deletion reconciliation. See #249.
6239
+ this.isInWatchScope = createWatchScope(config.watch);
5736
6240
  const normalized = normalizeWatchPaths(config.watch.paths);
5737
6241
  for (const entry of normalized) {
5738
6242
  const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
@@ -5811,6 +6315,11 @@ class VcsCoordinator {
5811
6315
  */
5812
6316
  async onInitialScanComplete() {
5813
6317
  for (const manager of this.managers.values()) {
6318
+ // Reconcile tracked-but-deleted paths within this manager's watch
6319
+ // scope before flushing, so the baseline commit records deletions
6320
+ // that happened while the process wasn't watching. See #249.
6321
+ await manager.reconcileDeletions((absPath) => this.findManagerForPath(absPath) === manager &&
6322
+ this.isInWatchScope(absPath));
5814
6323
  await manager.flush();
5815
6324
  manager.endBaseline();
5816
6325
  }