@karmaniverous/jeeves-watcher 0.18.10 → 0.18.11

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
@@ -427,6 +427,39 @@ async function retry(fn, options) {
427
427
  */
428
428
  /** Promisified execFile for git commands. */
429
429
  const execFileAsync = promisify(execFile);
430
+ /** Standard timeout (ms) for most git operations. */
431
+ const GIT_TIMEOUT_STANDARD = 30_000;
432
+ /** Extended timeout (ms) for cherry-pick operations. */
433
+ const GIT_TIMEOUT_CHERRY_PICK = 120_000;
434
+ /** Timeout (ms) for push operations. */
435
+ const GIT_TIMEOUT_PUSH = 60_000;
436
+ /**
437
+ * Extract string fields from a child-process error.
438
+ * Node's execFile wraps failures in an Error with `stderr`, `stdout`, and
439
+ * `code` properties that aren't part of the Error type.
440
+ */
441
+ function getExecErrorFields(error) {
442
+ if (!(error instanceof Error)) {
443
+ return { message: String(error), stderr: '', stdout: '' };
444
+ }
445
+ const rec = error;
446
+ const stderr = typeof rec['stderr'] === 'string' ? rec['stderr'] : '';
447
+ const stdout = typeof rec['stdout'] === 'string' ? rec['stdout'] : '';
448
+ return { message: error.message || '', stderr, stdout };
449
+ }
450
+ /**
451
+ * Build an authenticated push URL by injecting a token into an HTTPS remote.
452
+ * Non-HTTPS URLs are returned as-is.
453
+ *
454
+ * @param remoteUrl - The remote repository URL.
455
+ * @param accessToken - Optional access token to inject.
456
+ * @returns The URL with the token injected if applicable.
457
+ */
458
+ function buildAuthenticatedPushUrl(remoteUrl, accessToken) {
459
+ if (!accessToken)
460
+ return remoteUrl;
461
+ return remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`);
462
+ }
430
463
  /**
431
464
  * Stage files via stdin to avoid ENAMETOOLONG on Windows.
432
465
  *
@@ -438,22 +471,34 @@ const execFileAsync = promisify(execFile);
438
471
  *
439
472
  * @param files - Absolute paths of files to stage.
440
473
  * @param cwd - Repository root (working directory for git).
474
+ * @param timeoutMs - Kill the child process after this many milliseconds. Default: 30000.
441
475
  */
442
- function gitAddViaStdin(files, cwd) {
476
+ function gitAddViaStdin(files, cwd, timeoutMs = 30_000) {
443
477
  return new Promise((resolve, reject) => {
478
+ let timedOut = false;
444
479
  const child = execFile('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], { cwd }, (error) => {
480
+ clearTimeout(timer);
481
+ if (timedOut)
482
+ return;
445
483
  if (error)
446
484
  reject(error);
447
485
  else
448
486
  resolve();
449
487
  });
488
+ const timer = setTimeout(() => {
489
+ timedOut = true;
490
+ child.kill();
491
+ reject(new Error(`git add timed out after ${String(timeoutMs)}ms`));
492
+ }, timeoutMs);
450
493
  if (!child.stdin) {
494
+ clearTimeout(timer);
451
495
  reject(new Error('Failed to open stdin for git add'));
452
496
  return;
453
497
  }
454
498
  // Suppress EPIPE — expected if git exits before we finish writing
455
499
  child.stdin.on('error', (err) => {
456
500
  if (err.code !== 'EPIPE') {
501
+ clearTimeout(timer);
457
502
  reject(err);
458
503
  }
459
504
  });
@@ -4320,8 +4365,8 @@ class CommitMessageBuilder {
4320
4365
  */
4321
4366
  async getStagedDiff() {
4322
4367
  try {
4323
- const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
4324
- const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
4368
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4369
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4325
4370
  return `${stat}\n${patch}`;
4326
4371
  }
4327
4372
  catch {
@@ -4502,29 +4547,26 @@ function partMatches(part, value, min, max) {
4502
4547
  // Number with step doesn't make sense for single values, treat as exact
4503
4548
  return value === num;
4504
4549
  }
4505
- /**
4506
- * Manages scheduled squash retention for a single VCS root.
4507
- *
4508
- * Runs on a cron schedule (checked every 60s). When triggered, squashes
4509
- * commits older than the retention boundary (the tighter of maxAgeDays
4510
- * and maxVersions) into a single "historical baseline" orphan commit,
4511
- * then cherry-picks retained commits on top. Force-pushes to remote
4512
- * if configured (D13).
4513
- */
4514
4550
  class SquashManager {
4515
4551
  rootPath;
4516
4552
  retention;
4553
+ branch;
4554
+ pauseCommits;
4555
+ resumeCommits;
4517
4556
  remoteUrl;
4518
4557
  accessToken;
4519
4558
  logger;
4520
4559
  intervalTimer;
4521
4560
  running = false;
4522
- constructor(rootPath, retention, logger, remoteUrl, accessToken) {
4561
+ constructor(rootPath, retention, logger, options = {}) {
4523
4562
  this.rootPath = rootPath;
4524
4563
  this.retention = retention;
4525
4564
  this.logger = logger;
4526
- this.remoteUrl = remoteUrl;
4527
- this.accessToken = accessToken;
4565
+ this.branch = options.branch ?? 'master';
4566
+ this.pauseCommits = options.pauseCommits;
4567
+ this.resumeCommits = options.resumeCommits;
4568
+ this.remoteUrl = options.remoteUrl;
4569
+ this.accessToken = options.accessToken;
4528
4570
  }
4529
4571
  /**
4530
4572
  * Start the cron-based squash scheduler.
@@ -4554,6 +4596,9 @@ class SquashManager {
4554
4596
  }
4555
4597
  /**
4556
4598
  * Run the squash operation. Exposed publicly for testing.
4599
+ *
4600
+ * Coordinates with VcsManager by calling pause/resume callbacks
4601
+ * to drain and suspend the commit pipeline during the squash.
4557
4602
  */
4558
4603
  async runSquash() {
4559
4604
  // Check for index.lock before starting multi-step operation
@@ -4567,6 +4612,16 @@ class SquashManager {
4567
4612
  catch {
4568
4613
  // No lock — proceed
4569
4614
  }
4615
+ // Bug 3: Pause the commit pipeline before squash
4616
+ try {
4617
+ await this.pauseCommits?.();
4618
+ }
4619
+ catch (error) {
4620
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Failed to pause commit pipeline before squash');
4621
+ // Resume in case pause partially executed (flush succeeded but flag not set)
4622
+ this.resumeCommits?.();
4623
+ return { squashed: false, error: normalizeError(error).message };
4624
+ }
4570
4625
  try {
4571
4626
  const commits = await this.getCommitLog();
4572
4627
  if (commits.length <= 1) {
@@ -4591,13 +4646,17 @@ class SquashManager {
4591
4646
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
4592
4647
  return { squashed: false, error: message };
4593
4648
  }
4649
+ finally {
4650
+ // Bug 3: Always resume the commit pipeline
4651
+ this.resumeCommits?.();
4652
+ }
4594
4653
  }
4595
4654
  /**
4596
4655
  * Get all commits in chronological order (oldest first).
4597
4656
  */
4598
4657
  async getCommitLog() {
4599
4658
  try {
4600
- const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath });
4659
+ const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4601
4660
  if (!stdout.trim())
4602
4661
  return [];
4603
4662
  return stdout
@@ -4644,32 +4703,35 @@ class SquashManager {
4644
4703
  }
4645
4704
  /**
4646
4705
  * Perform the squash: create orphan with baseline + cherry-pick retained commits.
4706
+ * Uses the configured branch name instead of dynamic detection (Bug 1).
4647
4707
  */
4648
4708
  async performSquash(commits, boundaryIndex) {
4649
4709
  // The commit just before boundary is the last one to squash
4650
4710
  const baselineHash = commits[boundaryIndex - 1].hash;
4651
- // Get the current branch name
4652
- const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: this.rootPath });
4653
- const currentBranch = branchOut.trim();
4711
+ // Bug 1: Use configured branch name, not dynamic detection
4712
+ const targetBranch = this.branch;
4654
4713
  const orphanBranch = `__squash_orphan_${String(Date.now())}`;
4655
4714
  try {
4656
4715
  // 1. Create orphan branch with the tree state at the baseline commit
4657
4716
  await execFileAsync('git', ['checkout', '--orphan', orphanBranch], {
4658
4717
  cwd: this.rootPath,
4718
+ timeout: GIT_TIMEOUT_STANDARD,
4659
4719
  });
4660
4720
  // 2. Reset index to the baseline tree (the last squashed commit's tree)
4661
4721
  await execFileAsync('git', ['reset', '--hard', baselineHash], {
4662
4722
  cwd: this.rootPath,
4723
+ timeout: GIT_TIMEOUT_STANDARD,
4663
4724
  });
4664
4725
  // 3. Create the baseline commit with the same tree
4665
4726
  // Use commit-tree for a clean orphan commit
4666
- const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath });
4727
+ const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4667
4728
  const treeHash = treeOut.trim();
4668
- const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath });
4729
+ const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4669
4730
  const baselineCommitHash = commitOut.trim();
4670
4731
  // 4. Reset orphan branch to this new baseline commit
4671
4732
  await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
4672
4733
  cwd: this.rootPath,
4734
+ timeout: GIT_TIMEOUT_STANDARD,
4673
4735
  });
4674
4736
  // 5. Cherry-pick boundary..HEAD (the retained commits)
4675
4737
  // Get the list of commits to cherry-pick (from boundary to the end)
@@ -4677,28 +4739,33 @@ class SquashManager {
4677
4739
  if (commitsToCherry.length > 0) {
4678
4740
  await execFileAsync('git', ['cherry-pick', ...commitsToCherry], {
4679
4741
  cwd: this.rootPath,
4742
+ timeout: GIT_TIMEOUT_CHERRY_PICK,
4680
4743
  });
4681
4744
  }
4682
- // 6. Force-update the original branch to the orphan
4683
- const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath });
4684
- await execFileAsync('git', ['branch', '-f', currentBranch, newHead.trim()], { cwd: this.rootPath });
4685
- // 7. Switch back to original branch
4686
- await execFileAsync('git', ['checkout', currentBranch], {
4745
+ // 6. Force-update the configured branch to the orphan
4746
+ const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4747
+ await execFileAsync('git', ['branch', '-f', targetBranch, newHead.trim()], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
4748
+ // 7. Switch back to configured branch
4749
+ await execFileAsync('git', ['checkout', targetBranch], {
4687
4750
  cwd: this.rootPath,
4751
+ timeout: GIT_TIMEOUT_STANDARD,
4688
4752
  });
4689
4753
  // 8. Delete orphan branch
4690
4754
  await execFileAsync('git', ['branch', '-D', orphanBranch], {
4691
4755
  cwd: this.rootPath,
4756
+ timeout: GIT_TIMEOUT_STANDARD,
4692
4757
  });
4693
4758
  }
4694
4759
  catch (error) {
4695
- // Attempt cleanup: try to get back to original branch
4760
+ // Attempt cleanup: try to get back to configured branch
4696
4761
  try {
4697
- await execFileAsync('git', ['checkout', '-f', currentBranch], {
4762
+ await execFileAsync('git', ['checkout', '-f', targetBranch], {
4698
4763
  cwd: this.rootPath,
4764
+ timeout: GIT_TIMEOUT_STANDARD,
4699
4765
  });
4700
4766
  await execFileAsync('git', ['branch', '-D', orphanBranch], {
4701
4767
  cwd: this.rootPath,
4768
+ timeout: GIT_TIMEOUT_STANDARD,
4702
4769
  });
4703
4770
  }
4704
4771
  catch {
@@ -4722,12 +4789,11 @@ class SquashManager {
4722
4789
  if (!this.remoteUrl)
4723
4790
  return;
4724
4791
  try {
4725
- const pushUrl = this.accessToken
4726
- ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
4727
- : this.remoteUrl;
4792
+ const pushUrl = buildAuthenticatedPushUrl(this.remoteUrl, this.accessToken);
4728
4793
  await execFileAsync('git', ['push', '--force', pushUrl, 'HEAD'], {
4729
4794
  cwd: this.rootPath,
4730
4795
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
4796
+ timeout: GIT_TIMEOUT_PUSH,
4731
4797
  });
4732
4798
  this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'Squash force push succeeded');
4733
4799
  }
@@ -4828,6 +4894,46 @@ async function configureRepoIdentity(rootPath, name, email) {
4828
4894
  cwd: rootPath,
4829
4895
  });
4830
4896
  }
4897
+ /**
4898
+ * Detect and recover from orphan branch state on startup.
4899
+ *
4900
+ * If the repo is on an unexpected branch (e.g. a leftover squash orphan),
4901
+ * force-updates the expected branch to the current HEAD and checks it out.
4902
+ * Orphan branches are NOT deleted — they serve as a recovery safety net.
4903
+ *
4904
+ * @param rootPath - Directory of the git repository.
4905
+ * @param expectedBranch - The configured branch name (e.g. "master").
4906
+ * @param logger - Logger instance for warnings and info.
4907
+ */
4908
+ async function detectAndRecoverOrphanBranch(rootPath, expectedBranch, logger) {
4909
+ const gitDir = join(rootPath, '.git');
4910
+ try {
4911
+ await access(gitDir);
4912
+ }
4913
+ catch {
4914
+ // Not a git repo — nothing to detect
4915
+ return;
4916
+ }
4917
+ const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
4918
+ const currentBranch = branchOut.trim();
4919
+ if (currentBranch === expectedBranch)
4920
+ return;
4921
+ logger.warn({ root: rootPath, currentBranch, expectedBranch }, 'Repo is on unexpected branch — recovering to configured branch');
4922
+ // Get current HEAD
4923
+ const { stdout: headOut } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
4924
+ const headHash = headOut.trim();
4925
+ // Force-update the expected branch to current HEAD
4926
+ await execFileAsync('git', ['branch', '-f', expectedBranch, headHash], {
4927
+ cwd: rootPath,
4928
+ timeout: GIT_TIMEOUT_STANDARD,
4929
+ });
4930
+ // Checkout the expected branch
4931
+ await execFileAsync('git', ['checkout', expectedBranch], {
4932
+ cwd: rootPath,
4933
+ timeout: GIT_TIMEOUT_STANDARD,
4934
+ });
4935
+ logger.info({ root: rootPath, recoveredFrom: currentBranch, expectedBranch, headHash }, 'Orphan branch recovery complete — now on configured branch');
4936
+ }
4831
4937
  /**
4832
4938
  * Ensure a .gitignore file exists at rootPath with all always-on entries.
4833
4939
  * Creates the file if missing. Appends only entries not already present.
@@ -4914,17 +5020,36 @@ function buildGlobMatcher(globs) {
4914
5020
  return isMatch(normalized);
4915
5021
  };
4916
5022
  }
5023
+ /**
5024
+ * Expand a bare directory path (no glob characters) to a recursive glob.
5025
+ * A path like `/opt/jeeves/content` becomes `/opt/jeeves/content/**`.
5026
+ * Paths that already contain glob characters are returned unchanged.
5027
+ *
5028
+ * @param glob - A glob pattern or bare directory path.
5029
+ * @returns The original glob, or the path suffixed with `/**` if bare.
5030
+ */
5031
+ function expandBareDirectoryGlob(glob) {
5032
+ const normalized = normalizeSlashes(glob);
5033
+ if (/[*?{[\]]/.test(normalized))
5034
+ return glob;
5035
+ return glob.replace(/\/+$/, '') + '/**';
5036
+ }
4917
5037
  /**
4918
5038
  * Convert an array of glob patterns into chokidar-compatible directory roots
4919
5039
  * and a filter function for post-hoc event filtering.
4920
5040
  *
5041
+ * Bare directory paths (no glob characters, e.g. `/opt/jeeves/content`) are
5042
+ * automatically expanded to recursive globs (`/opt/jeeves/content/**`) so that
5043
+ * picomatch matches all files underneath them.
5044
+ *
4921
5045
  * @param globs - Glob patterns from the watch config.
4922
5046
  * @returns Object with `roots` (directories for chokidar) and `matches` (filter function).
4923
5047
  */
4924
5048
  function resolveWatchPaths(globs) {
4925
- const rawRoots = globs.map(globRoot);
5049
+ const expandedGlobs = globs.map(expandBareDirectoryGlob);
5050
+ const rawRoots = expandedGlobs.map(globRoot);
4926
5051
  const roots = deduplicateRoots(rawRoots);
4927
- const matches = buildGlobMatcher(globs);
5052
+ const matches = buildGlobMatcher(expandedGlobs);
4928
5053
  return { roots, matches };
4929
5054
  }
4930
5055
  /**
@@ -4977,13 +5102,11 @@ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger
4977
5102
  if (!remoteUrl)
4978
5103
  return undefined;
4979
5104
  try {
4980
- // Build the authenticated URL if a token is available
4981
- const pushUrl = accessToken
4982
- ? remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`)
4983
- : remoteUrl;
5105
+ const pushUrl = buildAuthenticatedPushUrl(remoteUrl, accessToken);
4984
5106
  await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
4985
5107
  cwd: rootPath,
4986
5108
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
5109
+ timeout: GIT_TIMEOUT_PUSH,
4987
5110
  });
4988
5111
  const timestamp = new Date().toISOString();
4989
5112
  logger.info({ root: rootPath, remote: remoteUrl }, 'VCS push succeeded');
@@ -5029,6 +5152,7 @@ class VcsManager {
5029
5152
  commitInFlight = Promise.resolve();
5030
5153
  consecutiveCommitFailures = 0;
5031
5154
  started = false;
5155
+ paused = false;
5032
5156
  isBaseline = true;
5033
5157
  _lastPushTime = null;
5034
5158
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
@@ -5039,7 +5163,17 @@ class VcsManager {
5039
5163
  this.accessToken = accessToken;
5040
5164
  this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
5041
5165
  if (config.retention) {
5042
- this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
5166
+ this.squashManager = new SquashManager(rootPath, config.retention, logger, {
5167
+ branch: config.branch,
5168
+ pauseCommits: () => {
5169
+ return this.pause();
5170
+ },
5171
+ resumeCommits: () => {
5172
+ this.resume();
5173
+ },
5174
+ remoteUrl,
5175
+ accessToken,
5176
+ });
5043
5177
  }
5044
5178
  }
5045
5179
  get lastPushTime() {
@@ -5052,10 +5186,21 @@ class VcsManager {
5052
5186
  * Begin accepting file changes. Sets the manager to started state and
5053
5187
  * starts the squash manager if configured.
5054
5188
  *
5189
+ * Performs startup orphan detection/recovery before starting the squash
5190
+ * manager, ensuring the repo is on the configured branch.
5191
+ *
5055
5192
  * Baseline mode (commit prefix `"baseline:"`) remains active until
5056
5193
  * {@link endBaseline} is called by the coordinator after the initial scan.
5057
5194
  */
5058
- start() {
5195
+ async start() {
5196
+ // Bug 6: Detect and recover from orphan branches before normal operations
5197
+ const branch = this.config.branch;
5198
+ try {
5199
+ await detectAndRecoverOrphanBranch(this.rootPath, branch, this.logger);
5200
+ }
5201
+ catch (error) {
5202
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Orphan branch recovery failed — continuing with current state');
5203
+ }
5059
5204
  this.started = true;
5060
5205
  this.squashManager?.start();
5061
5206
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
@@ -5112,6 +5257,28 @@ class VcsManager {
5112
5257
  return;
5113
5258
  this.fileChanged(filePath);
5114
5259
  }
5260
+ /**
5261
+ * Pause the commit pipeline. Drains pending commits via flush(), then
5262
+ * sets a flag that makes commitBatch queue instead of execute.
5263
+ * Used by SquashManager to coordinate squash operations.
5264
+ */
5265
+ async pause() {
5266
+ await this.flush();
5267
+ this.paused = true;
5268
+ this.logger.debug({ root: this.rootPath }, 'VcsManager paused');
5269
+ }
5270
+ /**
5271
+ * Resume the commit pipeline after a pause.
5272
+ * Re-enables normal commit operations and starts a throttle timer
5273
+ * if there are pending files that accumulated during the pause.
5274
+ */
5275
+ resume() {
5276
+ this.paused = false;
5277
+ this.logger.debug({ root: this.rootPath }, 'VcsManager resumed');
5278
+ if (this.pending.size > 0) {
5279
+ this.resetThrottle();
5280
+ }
5281
+ }
5115
5282
  /**
5116
5283
  * Flush all pending files immediately.
5117
5284
  */
@@ -5189,13 +5356,31 @@ class VcsManager {
5189
5356
  }
5190
5357
  }
5191
5358
  }
5359
+ /**
5360
+ * Check whether a git error represents a "nothing to commit" state.
5361
+ * Git outputs this message to stdout (not stderr) on exit code 1.
5362
+ */
5363
+ isNothingToCommitError(error) {
5364
+ const { message, stderr, stdout } = getExecErrorFields(error);
5365
+ return (message.includes('nothing to commit') ||
5366
+ stderr.includes('nothing to commit') ||
5367
+ stdout.includes('nothing to commit'));
5368
+ }
5192
5369
  /**
5193
5370
  * Commit a batch of files to the git repo with index.lock retry,
5194
- * stale lock detection, circuit breaker, and re-queue cap.
5371
+ * stale lock detection, circuit breaker, pause support, and re-queue cap.
5195
5372
  */
5196
5373
  async commitBatch(files) {
5197
5374
  if (files.length === 0)
5198
5375
  return;
5376
+ // Pause support: re-queue files without counting as failure
5377
+ if (this.paused) {
5378
+ for (const f of files) {
5379
+ this.pending.add(f);
5380
+ }
5381
+ this.logger.debug({ root: this.rootPath, fileCount: files.length }, 'VcsManager paused — files re-queued');
5382
+ return;
5383
+ }
5199
5384
  // Circuit breaker: skip if too many consecutive failures
5200
5385
  if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
5201
5386
  this.logger.error({
@@ -5209,7 +5394,7 @@ class VcsManager {
5209
5394
  // Check for stale lock before first attempt
5210
5395
  await this.removeStaleLock();
5211
5396
  await retry(async (attempt) => {
5212
- await gitAddViaStdin(files, this.rootPath);
5397
+ await gitAddViaStdin(files, this.rootPath, GIT_TIMEOUT_STANDARD);
5213
5398
  // Build message after staging so getStagedDiff can see the changes.
5214
5399
  // Skip AI for baselines and retries — use template directly.
5215
5400
  const message = this.isBaseline || attempt > 1
@@ -5218,8 +5403,9 @@ class VcsManager {
5218
5403
  this.pendingReversions.length = 0;
5219
5404
  await execFileAsync('git', ['commit', '-m', message], {
5220
5405
  cwd: this.rootPath,
5406
+ timeout: GIT_TIMEOUT_STANDARD,
5221
5407
  });
5222
- const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
5408
+ const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
5223
5409
  const hash = hashOut.trim();
5224
5410
  this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
5225
5411
  const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
@@ -5237,6 +5423,11 @@ class VcsManager {
5237
5423
  this.consecutiveCommitFailures = 0;
5238
5424
  }
5239
5425
  catch (error) {
5426
+ // Bug 5: "nothing to commit" is a no-op, not a failure
5427
+ if (this.isNothingToCommitError(error)) {
5428
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'VCS commit skipped — nothing to commit');
5429
+ return;
5430
+ }
5240
5431
  this.consecutiveCommitFailures++;
5241
5432
  // Re-queue cap: only re-queue up to maxBatchSize files
5242
5433
  const cap = this.config.maxBatchSize;
@@ -5250,6 +5441,8 @@ class VcsManager {
5250
5441
  }
5251
5442
  this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
5252
5443
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
5444
+ // Bug 2: Restart throttle timer so re-queued files get retried
5445
+ this.resetThrottle();
5253
5446
  }
5254
5447
  }
5255
5448
  }
@@ -5310,11 +5503,14 @@ class VcsCoordinator {
5310
5503
  }
5311
5504
  /**
5312
5505
  * Start all VcsManager instances.
5506
+ * Runs startup orphan detection/recovery for each root.
5313
5507
  */
5314
- start() {
5508
+ async start() {
5509
+ const startPromises = [];
5315
5510
  this.managers.forEach((manager) => {
5316
- manager.start();
5511
+ startPromises.push(manager.start());
5317
5512
  });
5513
+ await Promise.all(startPromises);
5318
5514
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
5319
5515
  }
5320
5516
  /**
@@ -9131,7 +9327,7 @@ class JeevesWatcher {
9131
9327
  this.watcher = watcher;
9132
9328
  this.gitignoreFilter = gitignoreFilter;
9133
9329
  this.server = await this.startApiServer();
9134
- vcsCoordinator.start();
9330
+ await vcsCoordinator.start();
9135
9331
  this.watcher.start();
9136
9332
  this.startConfigWatch();
9137
9333
  logger.info('jeeves-watcher started');
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "dependencies": {
10
10
  "@commander-js/extra-typings": "^14.0.0",
11
11
  "@karmaniverous/jeeves": "^0.5.12",
12
- "@karmaniverous/jeeves-watcher-core": "^0.2.4",
12
+ "@karmaniverous/jeeves-watcher-core": "^0.2.5",
13
13
  "@karmaniverous/jsonmap": "^2.1.1",
14
14
  "@langchain/textsplitters": "*",
15
15
  "@qdrant/js-client-rest": "*",
@@ -149,5 +149,5 @@
149
149
  },
150
150
  "type": "module",
151
151
  "types": "dist/index.d.ts",
152
- "version": "0.18.10"
152
+ "version": "0.18.11"
153
153
  }