@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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, DEFAULT_PORTS, getBindAddress } from '@karmaniverous/jeeves';
3
- import { d as normalizeSlashes, n as normalizeError, e as execFileAsync, r as retry, b as gitAddViaStdin, c as normalizePathCase, f as findRootForPath, G as GitignoreFilter, a as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, i as isPathWatched, g as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, l as loadConfig } from './index.js';
4
- export { h as startFromConfig } from './index.js';
3
+ import { p as normalizeSlashes, n as normalizeError, e as execFileAsync, b as GIT_TIMEOUT_STANDARD, G as GIT_TIMEOUT_CHERRY_PICK, d as buildAuthenticatedPushUrl, a as GIT_TIMEOUT_PUSH, h as getExecErrorFields, r as retry, j as gitAddViaStdin, o as normalizePathCase, g as findRootForPath, c as GitignoreFilter, f as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, k as isPathWatched, i as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, l as loadConfig } from './index.js';
4
+ export { q as startFromConfig } from './index.js';
5
5
  import { mkdirSync, existsSync, readFileSync, writeFileSync, rmSync, renameSync, readdirSync, statSync } from 'node:fs';
6
6
  import { join, dirname, resolve, extname, basename, relative, sep, isAbsolute } from 'node:path';
7
7
  import Database from 'better-sqlite3';
@@ -12,7 +12,7 @@ import picomatch from 'picomatch';
12
12
  import { deserialize, serialize } from 'node:v8';
13
13
  import { normalizeWatchPaths, vcsRetentionConfigSchema, extractWatchPathStrings, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema, getEndpoint } from '@karmaniverous/jeeves-watcher-core';
14
14
  import { homedir } from 'node:os';
15
- import { access, rm, stat, readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
15
+ import { access, rm, readFile, writeFile, appendFile, stat, mkdir } from 'node:fs/promises';
16
16
  import { pathToFileURL, fileURLToPath } from 'node:url';
17
17
  import { packageDirectorySync } from 'package-directory';
18
18
  import { JsonMap } from '@karmaniverous/jsonmap';
@@ -803,17 +803,36 @@ function buildGlobMatcher(globs) {
803
803
  return isMatch(normalized);
804
804
  };
805
805
  }
806
+ /**
807
+ * Expand a bare directory path (no glob characters) to a recursive glob.
808
+ * A path like `/opt/jeeves/content` becomes `/opt/jeeves/content/**`.
809
+ * Paths that already contain glob characters are returned unchanged.
810
+ *
811
+ * @param glob - A glob pattern or bare directory path.
812
+ * @returns The original glob, or the path suffixed with `/**` if bare.
813
+ */
814
+ function expandBareDirectoryGlob(glob) {
815
+ const normalized = normalizeSlashes(glob);
816
+ if (/[*?{[\]]/.test(normalized))
817
+ return glob;
818
+ return glob.replace(/\/+$/, '') + '/**';
819
+ }
806
820
  /**
807
821
  * Convert an array of glob patterns into chokidar-compatible directory roots
808
822
  * and a filter function for post-hoc event filtering.
809
823
  *
824
+ * Bare directory paths (no glob characters, e.g. `/opt/jeeves/content`) are
825
+ * automatically expanded to recursive globs (`/opt/jeeves/content/**`) so that
826
+ * picomatch matches all files underneath them.
827
+ *
810
828
  * @param globs - Glob patterns from the watch config.
811
829
  * @returns Object with `roots` (directories for chokidar) and `matches` (filter function).
812
830
  */
813
831
  function resolveWatchPaths(globs) {
814
- const rawRoots = globs.map(globRoot);
832
+ const expandedGlobs = globs.map(expandBareDirectoryGlob);
833
+ const rawRoots = expandedGlobs.map(globRoot);
815
834
  const roots = deduplicateRoots(rawRoots);
816
- const matches = buildGlobMatcher(globs);
835
+ const matches = buildGlobMatcher(expandedGlobs);
817
836
  return { roots, matches };
818
837
  }
819
838
  /**
@@ -1033,8 +1052,8 @@ class CommitMessageBuilder {
1033
1052
  */
1034
1053
  async getStagedDiff() {
1035
1054
  try {
1036
- const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
1037
- const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
1055
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1056
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1038
1057
  return `${stat}\n${patch}`;
1039
1058
  }
1040
1059
  catch {
@@ -1102,29 +1121,26 @@ function partMatches(part, value, min, max) {
1102
1121
  // Number with step doesn't make sense for single values, treat as exact
1103
1122
  return value === num;
1104
1123
  }
1105
- /**
1106
- * Manages scheduled squash retention for a single VCS root.
1107
- *
1108
- * Runs on a cron schedule (checked every 60s). When triggered, squashes
1109
- * commits older than the retention boundary (the tighter of maxAgeDays
1110
- * and maxVersions) into a single "historical baseline" orphan commit,
1111
- * then cherry-picks retained commits on top. Force-pushes to remote
1112
- * if configured (D13).
1113
- */
1114
1124
  class SquashManager {
1115
1125
  rootPath;
1116
1126
  retention;
1127
+ branch;
1128
+ pauseCommits;
1129
+ resumeCommits;
1117
1130
  remoteUrl;
1118
1131
  accessToken;
1119
1132
  logger;
1120
1133
  intervalTimer;
1121
1134
  running = false;
1122
- constructor(rootPath, retention, logger, remoteUrl, accessToken) {
1135
+ constructor(rootPath, retention, logger, options = {}) {
1123
1136
  this.rootPath = rootPath;
1124
1137
  this.retention = retention;
1125
1138
  this.logger = logger;
1126
- this.remoteUrl = remoteUrl;
1127
- this.accessToken = accessToken;
1139
+ this.branch = options.branch ?? 'master';
1140
+ this.pauseCommits = options.pauseCommits;
1141
+ this.resumeCommits = options.resumeCommits;
1142
+ this.remoteUrl = options.remoteUrl;
1143
+ this.accessToken = options.accessToken;
1128
1144
  }
1129
1145
  /**
1130
1146
  * Start the cron-based squash scheduler.
@@ -1154,6 +1170,9 @@ class SquashManager {
1154
1170
  }
1155
1171
  /**
1156
1172
  * Run the squash operation. Exposed publicly for testing.
1173
+ *
1174
+ * Coordinates with VcsManager by calling pause/resume callbacks
1175
+ * to drain and suspend the commit pipeline during the squash.
1157
1176
  */
1158
1177
  async runSquash() {
1159
1178
  // Check for index.lock before starting multi-step operation
@@ -1167,6 +1186,16 @@ class SquashManager {
1167
1186
  catch {
1168
1187
  // No lock — proceed
1169
1188
  }
1189
+ // Bug 3: Pause the commit pipeline before squash
1190
+ try {
1191
+ await this.pauseCommits?.();
1192
+ }
1193
+ catch (error) {
1194
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Failed to pause commit pipeline before squash');
1195
+ // Resume in case pause partially executed (flush succeeded but flag not set)
1196
+ this.resumeCommits?.();
1197
+ return { squashed: false, error: normalizeError(error).message };
1198
+ }
1170
1199
  try {
1171
1200
  const commits = await this.getCommitLog();
1172
1201
  if (commits.length <= 1) {
@@ -1191,13 +1220,17 @@ class SquashManager {
1191
1220
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
1192
1221
  return { squashed: false, error: message };
1193
1222
  }
1223
+ finally {
1224
+ // Bug 3: Always resume the commit pipeline
1225
+ this.resumeCommits?.();
1226
+ }
1194
1227
  }
1195
1228
  /**
1196
1229
  * Get all commits in chronological order (oldest first).
1197
1230
  */
1198
1231
  async getCommitLog() {
1199
1232
  try {
1200
- const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath });
1233
+ const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1201
1234
  if (!stdout.trim())
1202
1235
  return [];
1203
1236
  return stdout
@@ -1244,32 +1277,35 @@ class SquashManager {
1244
1277
  }
1245
1278
  /**
1246
1279
  * Perform the squash: create orphan with baseline + cherry-pick retained commits.
1280
+ * Uses the configured branch name instead of dynamic detection (Bug 1).
1247
1281
  */
1248
1282
  async performSquash(commits, boundaryIndex) {
1249
1283
  // The commit just before boundary is the last one to squash
1250
1284
  const baselineHash = commits[boundaryIndex - 1].hash;
1251
- // Get the current branch name
1252
- const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: this.rootPath });
1253
- const currentBranch = branchOut.trim();
1285
+ // Bug 1: Use configured branch name, not dynamic detection
1286
+ const targetBranch = this.branch;
1254
1287
  const orphanBranch = `__squash_orphan_${String(Date.now())}`;
1255
1288
  try {
1256
1289
  // 1. Create orphan branch with the tree state at the baseline commit
1257
1290
  await execFileAsync('git', ['checkout', '--orphan', orphanBranch], {
1258
1291
  cwd: this.rootPath,
1292
+ timeout: GIT_TIMEOUT_STANDARD,
1259
1293
  });
1260
1294
  // 2. Reset index to the baseline tree (the last squashed commit's tree)
1261
1295
  await execFileAsync('git', ['reset', '--hard', baselineHash], {
1262
1296
  cwd: this.rootPath,
1297
+ timeout: GIT_TIMEOUT_STANDARD,
1263
1298
  });
1264
1299
  // 3. Create the baseline commit with the same tree
1265
1300
  // Use commit-tree for a clean orphan commit
1266
- const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath });
1301
+ const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1267
1302
  const treeHash = treeOut.trim();
1268
- const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath });
1303
+ const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1269
1304
  const baselineCommitHash = commitOut.trim();
1270
1305
  // 4. Reset orphan branch to this new baseline commit
1271
1306
  await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
1272
1307
  cwd: this.rootPath,
1308
+ timeout: GIT_TIMEOUT_STANDARD,
1273
1309
  });
1274
1310
  // 5. Cherry-pick boundary..HEAD (the retained commits)
1275
1311
  // Get the list of commits to cherry-pick (from boundary to the end)
@@ -1277,28 +1313,33 @@ class SquashManager {
1277
1313
  if (commitsToCherry.length > 0) {
1278
1314
  await execFileAsync('git', ['cherry-pick', ...commitsToCherry], {
1279
1315
  cwd: this.rootPath,
1316
+ timeout: GIT_TIMEOUT_CHERRY_PICK,
1280
1317
  });
1281
1318
  }
1282
- // 6. Force-update the original branch to the orphan
1283
- const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath });
1284
- await execFileAsync('git', ['branch', '-f', currentBranch, newHead.trim()], { cwd: this.rootPath });
1285
- // 7. Switch back to original branch
1286
- await execFileAsync('git', ['checkout', currentBranch], {
1319
+ // 6. Force-update the configured branch to the orphan
1320
+ const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1321
+ await execFileAsync('git', ['branch', '-f', targetBranch, newHead.trim()], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1322
+ // 7. Switch back to configured branch
1323
+ await execFileAsync('git', ['checkout', targetBranch], {
1287
1324
  cwd: this.rootPath,
1325
+ timeout: GIT_TIMEOUT_STANDARD,
1288
1326
  });
1289
1327
  // 8. Delete orphan branch
1290
1328
  await execFileAsync('git', ['branch', '-D', orphanBranch], {
1291
1329
  cwd: this.rootPath,
1330
+ timeout: GIT_TIMEOUT_STANDARD,
1292
1331
  });
1293
1332
  }
1294
1333
  catch (error) {
1295
- // Attempt cleanup: try to get back to original branch
1334
+ // Attempt cleanup: try to get back to configured branch
1296
1335
  try {
1297
- await execFileAsync('git', ['checkout', '-f', currentBranch], {
1336
+ await execFileAsync('git', ['checkout', '-f', targetBranch], {
1298
1337
  cwd: this.rootPath,
1338
+ timeout: GIT_TIMEOUT_STANDARD,
1299
1339
  });
1300
1340
  await execFileAsync('git', ['branch', '-D', orphanBranch], {
1301
1341
  cwd: this.rootPath,
1342
+ timeout: GIT_TIMEOUT_STANDARD,
1302
1343
  });
1303
1344
  }
1304
1345
  catch {
@@ -1322,12 +1363,11 @@ class SquashManager {
1322
1363
  if (!this.remoteUrl)
1323
1364
  return;
1324
1365
  try {
1325
- const pushUrl = this.accessToken
1326
- ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
1327
- : this.remoteUrl;
1366
+ const pushUrl = buildAuthenticatedPushUrl(this.remoteUrl, this.accessToken);
1328
1367
  await execFileAsync('git', ['push', '--force', pushUrl, 'HEAD'], {
1329
1368
  cwd: this.rootPath,
1330
1369
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1370
+ timeout: GIT_TIMEOUT_PUSH,
1331
1371
  });
1332
1372
  this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'Squash force push succeeded');
1333
1373
  }
@@ -1337,6 +1377,133 @@ class SquashManager {
1337
1377
  }
1338
1378
  }
1339
1379
 
1380
+ /**
1381
+ * @module vcs/vcsBootstrap
1382
+ * Static bootstrap utilities for VCS-managed watch roots.
1383
+ */
1384
+ /** Always-on .gitignore entries for VCS-managed watch roots. */
1385
+ const ALWAYS_GITIGNORE_ENTRIES = [
1386
+ '.git/',
1387
+ 'node_modules/',
1388
+ '.jeeves-watcher/',
1389
+ '.jeeves-metadata/',
1390
+ ];
1391
+ /**
1392
+ * Check whether git is available on the system PATH.
1393
+ *
1394
+ * @returns true if git is available, false otherwise.
1395
+ */
1396
+ async function checkGitAvailable() {
1397
+ try {
1398
+ await execFileAsync('git', ['--version']);
1399
+ return true;
1400
+ }
1401
+ catch {
1402
+ return false;
1403
+ }
1404
+ }
1405
+ /**
1406
+ * Initialize a git repository at the given path if none exists.
1407
+ * Idempotent — does nothing if .git/ already exists.
1408
+ *
1409
+ * @param rootPath - Directory to initialize as a git repo.
1410
+ */
1411
+ async function initRepo(rootPath) {
1412
+ const gitDir = join(rootPath, '.git');
1413
+ try {
1414
+ await access(gitDir);
1415
+ // .git exists, nothing to do
1416
+ }
1417
+ catch {
1418
+ await execFileAsync('git', ['init'], { cwd: rootPath });
1419
+ }
1420
+ }
1421
+ /**
1422
+ * Configure local git identity for a repository.
1423
+ * Sets repo-level (not global) user.name and user.email.
1424
+ *
1425
+ * @param rootPath - Directory of the git repository.
1426
+ * @param name - Author name for commits.
1427
+ * @param email - Author email for commits.
1428
+ */
1429
+ async function configureRepoIdentity(rootPath, name, email) {
1430
+ await execFileAsync('git', ['config', '--local', 'user.name', name], {
1431
+ cwd: rootPath,
1432
+ });
1433
+ await execFileAsync('git', ['config', '--local', 'user.email', email], {
1434
+ cwd: rootPath,
1435
+ });
1436
+ }
1437
+ /**
1438
+ * Detect and recover from orphan branch state on startup.
1439
+ *
1440
+ * If the repo is on an unexpected branch (e.g. a leftover squash orphan),
1441
+ * force-updates the expected branch to the current HEAD and checks it out.
1442
+ * Orphan branches are NOT deleted — they serve as a recovery safety net.
1443
+ *
1444
+ * @param rootPath - Directory of the git repository.
1445
+ * @param expectedBranch - The configured branch name (e.g. "master").
1446
+ * @param logger - Logger instance for warnings and info.
1447
+ */
1448
+ async function detectAndRecoverOrphanBranch(rootPath, expectedBranch, logger) {
1449
+ const gitDir = join(rootPath, '.git');
1450
+ try {
1451
+ await access(gitDir);
1452
+ }
1453
+ catch {
1454
+ // Not a git repo — nothing to detect
1455
+ return;
1456
+ }
1457
+ const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
1458
+ const currentBranch = branchOut.trim();
1459
+ if (currentBranch === expectedBranch)
1460
+ return;
1461
+ logger.warn({ root: rootPath, currentBranch, expectedBranch }, 'Repo is on unexpected branch — recovering to configured branch');
1462
+ // Get current HEAD
1463
+ const { stdout: headOut } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
1464
+ const headHash = headOut.trim();
1465
+ // Force-update the expected branch to current HEAD
1466
+ await execFileAsync('git', ['branch', '-f', expectedBranch, headHash], {
1467
+ cwd: rootPath,
1468
+ timeout: GIT_TIMEOUT_STANDARD,
1469
+ });
1470
+ // Checkout the expected branch
1471
+ await execFileAsync('git', ['checkout', expectedBranch], {
1472
+ cwd: rootPath,
1473
+ timeout: GIT_TIMEOUT_STANDARD,
1474
+ });
1475
+ logger.info({ root: rootPath, recoveredFrom: currentBranch, expectedBranch, headHash }, 'Orphan branch recovery complete — now on configured branch');
1476
+ }
1477
+ /**
1478
+ * Ensure a .gitignore file exists at rootPath with all always-on entries.
1479
+ * Creates the file if missing. Appends only entries not already present.
1480
+ *
1481
+ * @param rootPath - Directory containing the .gitignore.
1482
+ * @param alwaysOnEntries - Additional always-on entries beyond the defaults.
1483
+ */
1484
+ async function ensureGitignore(rootPath, alwaysOnEntries = []) {
1485
+ const gitignorePath = join(rootPath, '.gitignore');
1486
+ const requiredEntries = [...ALWAYS_GITIGNORE_ENTRIES, ...alwaysOnEntries];
1487
+ let existingContent = '';
1488
+ try {
1489
+ existingContent = await readFile(gitignorePath, 'utf8');
1490
+ }
1491
+ catch {
1492
+ // File doesn't exist yet
1493
+ }
1494
+ const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
1495
+ const missing = requiredEntries.filter((entry) => !existingLines.has(entry));
1496
+ if (missing.length === 0)
1497
+ return;
1498
+ if (existingContent.length === 0) {
1499
+ await writeFile(gitignorePath, missing.join('\n') + '\n', 'utf8');
1500
+ }
1501
+ else {
1502
+ const prefix = existingContent.endsWith('\n') ? '' : '\n';
1503
+ await appendFile(gitignorePath, prefix + missing.join('\n') + '\n', 'utf8');
1504
+ }
1505
+ }
1506
+
1340
1507
  /**
1341
1508
  * @module vcs/vcsPush
1342
1509
  * Push logic for VCS commits.
@@ -1358,13 +1525,11 @@ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger
1358
1525
  if (!remoteUrl)
1359
1526
  return undefined;
1360
1527
  try {
1361
- // Build the authenticated URL if a token is available
1362
- const pushUrl = accessToken
1363
- ? remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(accessToken)}@`)
1364
- : remoteUrl;
1528
+ const pushUrl = buildAuthenticatedPushUrl(remoteUrl, accessToken);
1365
1529
  await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
1366
1530
  cwd: rootPath,
1367
1531
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1532
+ timeout: GIT_TIMEOUT_PUSH,
1368
1533
  });
1369
1534
  const timestamp = new Date().toISOString();
1370
1535
  logger.info({ root: rootPath, remote: remoteUrl }, 'VCS push succeeded');
@@ -1410,6 +1575,7 @@ class VcsManager {
1410
1575
  commitInFlight = Promise.resolve();
1411
1576
  consecutiveCommitFailures = 0;
1412
1577
  started = false;
1578
+ paused = false;
1413
1579
  isBaseline = true;
1414
1580
  _lastPushTime = null;
1415
1581
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
@@ -1420,7 +1586,17 @@ class VcsManager {
1420
1586
  this.accessToken = accessToken;
1421
1587
  this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
1422
1588
  if (config.retention) {
1423
- this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
1589
+ this.squashManager = new SquashManager(rootPath, config.retention, logger, {
1590
+ branch: config.branch,
1591
+ pauseCommits: () => {
1592
+ return this.pause();
1593
+ },
1594
+ resumeCommits: () => {
1595
+ this.resume();
1596
+ },
1597
+ remoteUrl,
1598
+ accessToken,
1599
+ });
1424
1600
  }
1425
1601
  }
1426
1602
  get lastPushTime() {
@@ -1433,10 +1609,21 @@ class VcsManager {
1433
1609
  * Begin accepting file changes. Sets the manager to started state and
1434
1610
  * starts the squash manager if configured.
1435
1611
  *
1612
+ * Performs startup orphan detection/recovery before starting the squash
1613
+ * manager, ensuring the repo is on the configured branch.
1614
+ *
1436
1615
  * Baseline mode (commit prefix `"baseline:"`) remains active until
1437
1616
  * {@link endBaseline} is called by the coordinator after the initial scan.
1438
1617
  */
1439
- start() {
1618
+ async start() {
1619
+ // Bug 6: Detect and recover from orphan branches before normal operations
1620
+ const branch = this.config.branch;
1621
+ try {
1622
+ await detectAndRecoverOrphanBranch(this.rootPath, branch, this.logger);
1623
+ }
1624
+ catch (error) {
1625
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Orphan branch recovery failed — continuing with current state');
1626
+ }
1440
1627
  this.started = true;
1441
1628
  this.squashManager?.start();
1442
1629
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
@@ -1493,6 +1680,28 @@ class VcsManager {
1493
1680
  return;
1494
1681
  this.fileChanged(filePath);
1495
1682
  }
1683
+ /**
1684
+ * Pause the commit pipeline. Drains pending commits via flush(), then
1685
+ * sets a flag that makes commitBatch queue instead of execute.
1686
+ * Used by SquashManager to coordinate squash operations.
1687
+ */
1688
+ async pause() {
1689
+ await this.flush();
1690
+ this.paused = true;
1691
+ this.logger.debug({ root: this.rootPath }, 'VcsManager paused');
1692
+ }
1693
+ /**
1694
+ * Resume the commit pipeline after a pause.
1695
+ * Re-enables normal commit operations and starts a throttle timer
1696
+ * if there are pending files that accumulated during the pause.
1697
+ */
1698
+ resume() {
1699
+ this.paused = false;
1700
+ this.logger.debug({ root: this.rootPath }, 'VcsManager resumed');
1701
+ if (this.pending.size > 0) {
1702
+ this.resetThrottle();
1703
+ }
1704
+ }
1496
1705
  /**
1497
1706
  * Flush all pending files immediately.
1498
1707
  */
@@ -1570,13 +1779,31 @@ class VcsManager {
1570
1779
  }
1571
1780
  }
1572
1781
  }
1782
+ /**
1783
+ * Check whether a git error represents a "nothing to commit" state.
1784
+ * Git outputs this message to stdout (not stderr) on exit code 1.
1785
+ */
1786
+ isNothingToCommitError(error) {
1787
+ const { message, stderr, stdout } = getExecErrorFields(error);
1788
+ return (message.includes('nothing to commit') ||
1789
+ stderr.includes('nothing to commit') ||
1790
+ stdout.includes('nothing to commit'));
1791
+ }
1573
1792
  /**
1574
1793
  * Commit a batch of files to the git repo with index.lock retry,
1575
- * stale lock detection, circuit breaker, and re-queue cap.
1794
+ * stale lock detection, circuit breaker, pause support, and re-queue cap.
1576
1795
  */
1577
1796
  async commitBatch(files) {
1578
1797
  if (files.length === 0)
1579
1798
  return;
1799
+ // Pause support: re-queue files without counting as failure
1800
+ if (this.paused) {
1801
+ for (const f of files) {
1802
+ this.pending.add(f);
1803
+ }
1804
+ this.logger.debug({ root: this.rootPath, fileCount: files.length }, 'VcsManager paused — files re-queued');
1805
+ return;
1806
+ }
1580
1807
  // Circuit breaker: skip if too many consecutive failures
1581
1808
  if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
1582
1809
  this.logger.error({
@@ -1590,7 +1817,7 @@ class VcsManager {
1590
1817
  // Check for stale lock before first attempt
1591
1818
  await this.removeStaleLock();
1592
1819
  await retry(async (attempt) => {
1593
- await gitAddViaStdin(files, this.rootPath);
1820
+ await gitAddViaStdin(files, this.rootPath, GIT_TIMEOUT_STANDARD);
1594
1821
  // Build message after staging so getStagedDiff can see the changes.
1595
1822
  // Skip AI for baselines and retries — use template directly.
1596
1823
  const message = this.isBaseline || attempt > 1
@@ -1599,8 +1826,9 @@ class VcsManager {
1599
1826
  this.pendingReversions.length = 0;
1600
1827
  await execFileAsync('git', ['commit', '-m', message], {
1601
1828
  cwd: this.rootPath,
1829
+ timeout: GIT_TIMEOUT_STANDARD,
1602
1830
  });
1603
- const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
1831
+ const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1604
1832
  const hash = hashOut.trim();
1605
1833
  this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
1606
1834
  const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
@@ -1618,6 +1846,11 @@ class VcsManager {
1618
1846
  this.consecutiveCommitFailures = 0;
1619
1847
  }
1620
1848
  catch (error) {
1849
+ // Bug 5: "nothing to commit" is a no-op, not a failure
1850
+ if (this.isNothingToCommitError(error)) {
1851
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'VCS commit skipped — nothing to commit');
1852
+ return;
1853
+ }
1621
1854
  this.consecutiveCommitFailures++;
1622
1855
  // Re-queue cap: only re-queue up to maxBatchSize files
1623
1856
  const cap = this.config.maxBatchSize;
@@ -1631,6 +1864,8 @@ class VcsManager {
1631
1864
  }
1632
1865
  this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
1633
1866
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
1867
+ // Bug 2: Restart throttle timer so re-queued files get retried
1868
+ this.resetThrottle();
1634
1869
  }
1635
1870
  }
1636
1871
  }
@@ -1691,11 +1926,14 @@ class VcsCoordinator {
1691
1926
  }
1692
1927
  /**
1693
1928
  * Start all VcsManager instances.
1929
+ * Runs startup orphan detection/recovery for each root.
1694
1930
  */
1695
- start() {
1931
+ async start() {
1932
+ const startPromises = [];
1696
1933
  this.managers.forEach((manager) => {
1697
- manager.start();
1934
+ startPromises.push(manager.start());
1698
1935
  });
1936
+ await Promise.all(startPromises);
1699
1937
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
1700
1938
  }
1701
1939
  /**
@@ -3046,93 +3284,6 @@ function validateStateDirOverlap(stateDir, watchPaths) {
3046
3284
  }
3047
3285
  }
3048
3286
 
3049
- /**
3050
- * @module vcs/vcsBootstrap
3051
- * Static bootstrap utilities for VCS-managed watch roots.
3052
- */
3053
- /** Always-on .gitignore entries for VCS-managed watch roots. */
3054
- const ALWAYS_GITIGNORE_ENTRIES = [
3055
- '.git/',
3056
- 'node_modules/',
3057
- '.jeeves-watcher/',
3058
- '.jeeves-metadata/',
3059
- ];
3060
- /**
3061
- * Check whether git is available on the system PATH.
3062
- *
3063
- * @returns true if git is available, false otherwise.
3064
- */
3065
- async function checkGitAvailable() {
3066
- try {
3067
- await execFileAsync('git', ['--version']);
3068
- return true;
3069
- }
3070
- catch {
3071
- return false;
3072
- }
3073
- }
3074
- /**
3075
- * Initialize a git repository at the given path if none exists.
3076
- * Idempotent — does nothing if .git/ already exists.
3077
- *
3078
- * @param rootPath - Directory to initialize as a git repo.
3079
- */
3080
- async function initRepo(rootPath) {
3081
- const gitDir = join(rootPath, '.git');
3082
- try {
3083
- await access(gitDir);
3084
- // .git exists, nothing to do
3085
- }
3086
- catch {
3087
- await execFileAsync('git', ['init'], { cwd: rootPath });
3088
- }
3089
- }
3090
- /**
3091
- * Configure local git identity for a repository.
3092
- * Sets repo-level (not global) user.name and user.email.
3093
- *
3094
- * @param rootPath - Directory of the git repository.
3095
- * @param name - Author name for commits.
3096
- * @param email - Author email for commits.
3097
- */
3098
- async function configureRepoIdentity(rootPath, name, email) {
3099
- await execFileAsync('git', ['config', '--local', 'user.name', name], {
3100
- cwd: rootPath,
3101
- });
3102
- await execFileAsync('git', ['config', '--local', 'user.email', email], {
3103
- cwd: rootPath,
3104
- });
3105
- }
3106
- /**
3107
- * Ensure a .gitignore file exists at rootPath with all always-on entries.
3108
- * Creates the file if missing. Appends only entries not already present.
3109
- *
3110
- * @param rootPath - Directory containing the .gitignore.
3111
- * @param alwaysOnEntries - Additional always-on entries beyond the defaults.
3112
- */
3113
- async function ensureGitignore(rootPath, alwaysOnEntries = []) {
3114
- const gitignorePath = join(rootPath, '.gitignore');
3115
- const requiredEntries = [...ALWAYS_GITIGNORE_ENTRIES, ...alwaysOnEntries];
3116
- let existingContent = '';
3117
- try {
3118
- existingContent = await readFile(gitignorePath, 'utf8');
3119
- }
3120
- catch {
3121
- // File doesn't exist yet
3122
- }
3123
- const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
3124
- const missing = requiredEntries.filter((entry) => !existingLines.has(entry));
3125
- if (missing.length === 0)
3126
- return;
3127
- if (existingContent.length === 0) {
3128
- await writeFile(gitignorePath, missing.join('\n') + '\n', 'utf8');
3129
- }
3130
- else {
3131
- const prefix = existingContent.endsWith('\n') ? '' : '\n';
3132
- await appendFile(gitignorePath, prefix + missing.join('\n') + '\n', 'utf8');
3133
- }
3134
- }
3135
-
3136
3287
  /**
3137
3288
  * @module app/watcherFactory
3138
3289
  * Factory helpers for creating and rebuilding the filesystem watcher.
@@ -7857,7 +8008,7 @@ class JeevesWatcher {
7857
8008
  this.watcher = watcher;
7858
8009
  this.gitignoreFilter = gitignoreFilter;
7859
8010
  this.server = await this.startApiServer();
7860
- vcsCoordinator.start();
8011
+ await vcsCoordinator.start();
7861
8012
  this.watcher.start();
7862
8013
  this.startConfigWatch();
7863
8014
  logger.info('jeeves-watcher started');