@karmaniverous/jeeves-watcher 0.18.9 → 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';
@@ -10,9 +10,9 @@ import Ajv from 'ajv';
10
10
  import addFormats from 'ajv-formats';
11
11
  import picomatch from 'picomatch';
12
12
  import { deserialize, serialize } from 'node:v8';
13
- import { normalizeWatchPaths, vcsRetentionConfigSchema, extractWatchPathStrings, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
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');
@@ -1389,7 +1554,7 @@ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger
1389
1554
  * Per-root VCS manager for git-backed content versioning.
1390
1555
  *
1391
1556
  * Each VCS-enabled watch root gets its own VcsManager instance, which owns
1392
- * a debounced commit pipeline: file changes are batched, staged, committed
1557
+ * a throttled commit pipeline: file changes are batched, staged, committed
1393
1558
  * (with optional AI-generated messages), and optionally pushed to a remote.
1394
1559
  *
1395
1560
  * Concurrency: only one commit is in-flight at a time per root. Index.lock
@@ -1406,10 +1571,11 @@ class VcsManager {
1406
1571
  pendingReversions = [];
1407
1572
  _pushErrors = [];
1408
1573
  squashManager;
1409
- debounceTimer;
1574
+ throttleTimer;
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');
@@ -1449,7 +1636,7 @@ class VcsManager {
1449
1636
  this.isBaseline = false;
1450
1637
  }
1451
1638
  /**
1452
- * Add a file to the pending set and reset the debounce timer.
1639
+ * Add a file to the pending set and start the throttle timer.
1453
1640
  * If the pending set exceeds maxBatchSize, flush immediately.
1454
1641
  *
1455
1642
  * @param filePath - Absolute path of the changed file.
@@ -1464,15 +1651,15 @@ class VcsManager {
1464
1651
  }
1465
1652
  this.pending.add(filePath);
1466
1653
  if (this.pending.size >= this.config.maxBatchSize) {
1467
- this.clearDebounce();
1654
+ this.clearThrottle();
1468
1655
  const batch = this.takeBatch(this.config.maxBatchSize);
1469
1656
  this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
1470
1657
  if (this.pending.size > 0) {
1471
- this.resetDebounce();
1658
+ this.resetThrottle();
1472
1659
  }
1473
1660
  return;
1474
1661
  }
1475
- this.resetDebounce();
1662
+ this.resetThrottle();
1476
1663
  }
1477
1664
  /**
1478
1665
  * Record reversion metadata so the next commit uses a revert-prefixed message.
@@ -1493,11 +1680,33 @@ 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
  */
1499
1708
  async flush() {
1500
- this.clearDebounce();
1709
+ this.clearThrottle();
1501
1710
  if (this.pending.size > 0) {
1502
1711
  const batch = [...this.pending];
1503
1712
  this.pending.clear();
@@ -1530,23 +1739,25 @@ class VcsManager {
1530
1739
  return batch;
1531
1740
  }
1532
1741
  /**
1533
- * Clear the debounce timer.
1742
+ * Clear the throttle timer.
1534
1743
  */
1535
- clearDebounce() {
1536
- if (this.debounceTimer !== undefined) {
1537
- clearTimeout(this.debounceTimer);
1538
- this.debounceTimer = undefined;
1744
+ clearThrottle() {
1745
+ if (this.throttleTimer !== undefined) {
1746
+ clearTimeout(this.throttleTimer);
1747
+ this.throttleTimer = undefined;
1539
1748
  }
1540
1749
  }
1541
1750
  /**
1542
- * Reset the debounce timer to fire flush() after commitDebounceMs.
1751
+ * Start the throttle timer to fire flush() after commitThrottleMs.
1752
+ * If a timer is already running, do not reset it — this is throttle, not debounce.
1543
1753
  */
1544
- resetDebounce() {
1545
- this.clearDebounce();
1546
- this.debounceTimer = setTimeout(() => {
1547
- this.debounceTimer = undefined;
1754
+ resetThrottle() {
1755
+ if (this.throttleTimer !== undefined)
1756
+ return;
1757
+ this.throttleTimer = setTimeout(() => {
1758
+ this.throttleTimer = undefined;
1548
1759
  void this.flush();
1549
- }, this.config.commitDebounceMs);
1760
+ }, this.config.commitThrottleMs);
1550
1761
  }
1551
1762
  /**
1552
1763
  * Check for and remove stale index.lock files before retrying.
@@ -1568,13 +1779,31 @@ class VcsManager {
1568
1779
  }
1569
1780
  }
1570
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
+ }
1571
1792
  /**
1572
1793
  * Commit a batch of files to the git repo with index.lock retry,
1573
- * stale lock detection, circuit breaker, and re-queue cap.
1794
+ * stale lock detection, circuit breaker, pause support, and re-queue cap.
1574
1795
  */
1575
1796
  async commitBatch(files) {
1576
1797
  if (files.length === 0)
1577
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
+ }
1578
1807
  // Circuit breaker: skip if too many consecutive failures
1579
1808
  if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
1580
1809
  this.logger.error({
@@ -1588,7 +1817,7 @@ class VcsManager {
1588
1817
  // Check for stale lock before first attempt
1589
1818
  await this.removeStaleLock();
1590
1819
  await retry(async (attempt) => {
1591
- await gitAddViaStdin(files, this.rootPath);
1820
+ await gitAddViaStdin(files, this.rootPath, GIT_TIMEOUT_STANDARD);
1592
1821
  // Build message after staging so getStagedDiff can see the changes.
1593
1822
  // Skip AI for baselines and retries — use template directly.
1594
1823
  const message = this.isBaseline || attempt > 1
@@ -1597,8 +1826,9 @@ class VcsManager {
1597
1826
  this.pendingReversions.length = 0;
1598
1827
  await execFileAsync('git', ['commit', '-m', message], {
1599
1828
  cwd: this.rootPath,
1829
+ timeout: GIT_TIMEOUT_STANDARD,
1600
1830
  });
1601
- 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 });
1602
1832
  const hash = hashOut.trim();
1603
1833
  this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
1604
1834
  const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
@@ -1616,6 +1846,11 @@ class VcsManager {
1616
1846
  this.consecutiveCommitFailures = 0;
1617
1847
  }
1618
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
+ }
1619
1854
  this.consecutiveCommitFailures++;
1620
1855
  // Re-queue cap: only re-queue up to maxBatchSize files
1621
1856
  const cap = this.config.maxBatchSize;
@@ -1629,6 +1864,8 @@ class VcsManager {
1629
1864
  }
1630
1865
  this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
1631
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();
1632
1869
  }
1633
1870
  }
1634
1871
  }
@@ -1689,11 +1926,14 @@ class VcsCoordinator {
1689
1926
  }
1690
1927
  /**
1691
1928
  * Start all VcsManager instances.
1929
+ * Runs startup orphan detection/recovery for each root.
1692
1930
  */
1693
- start() {
1931
+ async start() {
1932
+ const startPromises = [];
1694
1933
  this.managers.forEach((manager) => {
1695
- manager.start();
1934
+ startPromises.push(manager.start());
1696
1935
  });
1936
+ await Promise.all(startPromises);
1697
1937
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
1698
1938
  }
1699
1939
  /**
@@ -1717,8 +1957,8 @@ class VcsCoordinator {
1717
1957
  /**
1718
1958
  * Signal that the initial filesystem scan is complete.
1719
1959
  *
1720
- * Flushes each manager's debounce buffer before ending baseline mode, ensuring
1721
- * any files still pending in the VCS debounce are committed with a
1960
+ * Flushes each manager's throttle buffer before ending baseline mode, ensuring
1961
+ * any files still pending in the VCS throttle are committed with a
1722
1962
  * `"baseline:"` prefix rather than `"watcher:"`. AI generation is not enabled
1723
1963
  * until all managers have completed their flush.
1724
1964
  */
@@ -3044,93 +3284,6 @@ function validateStateDirOverlap(stateDir, watchPaths) {
3044
3284
  }
3045
3285
  }
3046
3286
 
3047
- /**
3048
- * @module vcs/vcsBootstrap
3049
- * Static bootstrap utilities for VCS-managed watch roots.
3050
- */
3051
- /** Always-on .gitignore entries for VCS-managed watch roots. */
3052
- const ALWAYS_GITIGNORE_ENTRIES = [
3053
- '.git/',
3054
- 'node_modules/',
3055
- '.jeeves-watcher/',
3056
- '.jeeves-metadata/',
3057
- ];
3058
- /**
3059
- * Check whether git is available on the system PATH.
3060
- *
3061
- * @returns true if git is available, false otherwise.
3062
- */
3063
- async function checkGitAvailable() {
3064
- try {
3065
- await execFileAsync('git', ['--version']);
3066
- return true;
3067
- }
3068
- catch {
3069
- return false;
3070
- }
3071
- }
3072
- /**
3073
- * Initialize a git repository at the given path if none exists.
3074
- * Idempotent — does nothing if .git/ already exists.
3075
- *
3076
- * @param rootPath - Directory to initialize as a git repo.
3077
- */
3078
- async function initRepo(rootPath) {
3079
- const gitDir = join(rootPath, '.git');
3080
- try {
3081
- await access(gitDir);
3082
- // .git exists, nothing to do
3083
- }
3084
- catch {
3085
- await execFileAsync('git', ['init'], { cwd: rootPath });
3086
- }
3087
- }
3088
- /**
3089
- * Configure local git identity for a repository.
3090
- * Sets repo-level (not global) user.name and user.email.
3091
- *
3092
- * @param rootPath - Directory of the git repository.
3093
- * @param name - Author name for commits.
3094
- * @param email - Author email for commits.
3095
- */
3096
- async function configureRepoIdentity(rootPath, name, email) {
3097
- await execFileAsync('git', ['config', '--local', 'user.name', name], {
3098
- cwd: rootPath,
3099
- });
3100
- await execFileAsync('git', ['config', '--local', 'user.email', email], {
3101
- cwd: rootPath,
3102
- });
3103
- }
3104
- /**
3105
- * Ensure a .gitignore file exists at rootPath with all always-on entries.
3106
- * Creates the file if missing. Appends only entries not already present.
3107
- *
3108
- * @param rootPath - Directory containing the .gitignore.
3109
- * @param alwaysOnEntries - Additional always-on entries beyond the defaults.
3110
- */
3111
- async function ensureGitignore(rootPath, alwaysOnEntries = []) {
3112
- const gitignorePath = join(rootPath, '.gitignore');
3113
- const requiredEntries = [...ALWAYS_GITIGNORE_ENTRIES, ...alwaysOnEntries];
3114
- let existingContent = '';
3115
- try {
3116
- existingContent = await readFile(gitignorePath, 'utf8');
3117
- }
3118
- catch {
3119
- // File doesn't exist yet
3120
- }
3121
- const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
3122
- const missing = requiredEntries.filter((entry) => !existingLines.has(entry));
3123
- if (missing.length === 0)
3124
- return;
3125
- if (existingContent.length === 0) {
3126
- await writeFile(gitignorePath, missing.join('\n') + '\n', 'utf8');
3127
- }
3128
- else {
3129
- const prefix = existingContent.endsWith('\n') ? '' : '\n';
3130
- await appendFile(gitignorePath, prefix + missing.join('\n') + '\n', 'utf8');
3131
- }
3132
- }
3133
-
3134
3287
  /**
3135
3288
  * @module app/watcherFactory
3136
3289
  * Factory helpers for creating and rebuilding the filesystem watcher.
@@ -5412,36 +5565,36 @@ function createApiServer(options) {
5412
5565
  };
5413
5566
  },
5414
5567
  });
5415
- app.get('/status', withCache(cacheTtlMs, async () => {
5568
+ app.get(getEndpoint('status').path, withCache(cacheTtlMs, async () => {
5416
5569
  const result = await coreStatusHandler();
5417
5570
  return result.body;
5418
5571
  }));
5419
- app.post('/metadata', createMetadataHandler({
5572
+ app.post(getEndpoint('metadata').path, createMetadataHandler({
5420
5573
  processor,
5421
5574
  getConfig,
5422
5575
  logger,
5423
5576
  configDir: dirname(configPath),
5424
5577
  }));
5425
- app.post('/render', withCache(cacheTtlMs, createRenderHandler({
5578
+ app.post(getEndpoint('render').path, withCache(cacheTtlMs, createRenderHandler({
5426
5579
  processor,
5427
5580
  getWatch: () => getConfig().watch,
5428
5581
  logger,
5429
5582
  })));
5430
- app.get('/search/facets', createFacetsHandler({
5583
+ app.get(getEndpoint('searchFacets').path, createFacetsHandler({
5431
5584
  getConfig,
5432
5585
  valuesManager,
5433
5586
  configDir: dirname(configPath),
5434
5587
  }));
5435
- app.post('/scan', createScanHandler({
5588
+ app.post(getEndpoint('scan').path, createScanHandler({
5436
5589
  vectorStore,
5437
5590
  logger,
5438
5591
  }));
5439
- app.post('/walk', createWalkHandler({
5592
+ app.post(getEndpoint('walk').path, createWalkHandler({
5440
5593
  getWatchPaths: () => extractWatchPathStrings(getConfig().watch.paths),
5441
5594
  getFileSystemWatcher,
5442
5595
  logger,
5443
5596
  }));
5444
- app.post('/search', createSearchHandler({
5597
+ app.post(getEndpoint('search').path, createSearchHandler({
5445
5598
  embeddingProvider,
5446
5599
  vectorStore,
5447
5600
  logger,
@@ -5452,12 +5605,12 @@ function createApiServer(options) {
5452
5605
  : undefined;
5453
5606
  },
5454
5607
  }));
5455
- app.post('/rebuild-metadata', createRebuildMetadataHandler({
5608
+ app.post(getEndpoint('rebuildMetadata').path, createRebuildMetadataHandler({
5456
5609
  enrichmentStore: options.enrichmentStore,
5457
5610
  vectorStore,
5458
5611
  logger,
5459
5612
  }));
5460
- app.post('/reindex', createConfigReindexHandler({
5613
+ app.post(getEndpoint('reindex').path, createConfigReindexHandler({
5461
5614
  getConfig,
5462
5615
  processor,
5463
5616
  logger,
@@ -5468,10 +5621,10 @@ function createApiServer(options) {
5468
5621
  vectorStore,
5469
5622
  queue,
5470
5623
  }));
5471
- app.get('/issues', withCache(cacheTtlMs, createIssuesHandler({ issuesManager })));
5472
- app.get('/config/schema', withCache(cacheTtlMs, createConfigSchemaHandler()));
5473
- app.post('/config/match', createConfigMatchHandler({ getConfig, logger }));
5474
- app.get('/config', withCache(cacheTtlMs, createConfigQueryHandler({
5624
+ app.get(getEndpoint('issues').path, withCache(cacheTtlMs, createIssuesHandler({ issuesManager })));
5625
+ app.get(getEndpoint('configSchema').path, withCache(cacheTtlMs, createConfigSchemaHandler()));
5626
+ app.post(getEndpoint('configMatch').path, createConfigMatchHandler({ getConfig, logger }));
5627
+ app.get(getEndpoint('config').path, withCache(cacheTtlMs, createConfigQueryHandler({
5475
5628
  getConfig,
5476
5629
  valuesManager,
5477
5630
  issuesManager,
@@ -5481,7 +5634,7 @@ function createApiServer(options) {
5481
5634
  ? () => virtualRuleStore.getAll()
5482
5635
  : undefined,
5483
5636
  })));
5484
- app.post('/config/validate', createConfigValidateHandler({
5637
+ app.post(getEndpoint('configValidate').path, createConfigValidateHandler({
5485
5638
  getConfig,
5486
5639
  logger,
5487
5640
  configDir: dirname(configPath),
@@ -5495,7 +5648,7 @@ function createApiServer(options) {
5495
5648
  return Promise.resolve();
5496
5649
  },
5497
5650
  });
5498
- app.post('/config/apply', async (request, reply) => {
5651
+ app.post(getEndpoint('configApply').path, async (request, reply) => {
5499
5652
  const { patch, replace } = request.body;
5500
5653
  const result = await coreConfigApplyHandler({ patch, replace });
5501
5654
  return reply.status(result.status).send(result.body);
@@ -5514,34 +5667,34 @@ function createApiServer(options) {
5514
5667
  gitignoreFilter,
5515
5668
  vectorStore,
5516
5669
  });
5517
- app.post('/rules/register', createRulesRegisterHandler({
5670
+ app.post(getEndpoint('rulesRegister').path, createRulesRegisterHandler({
5518
5671
  virtualRuleStore,
5519
5672
  logger,
5520
5673
  onRulesChanged,
5521
5674
  }));
5522
- app.delete('/rules/unregister', createRulesUnregisterHandler({
5675
+ app.delete(getEndpoint('rulesUnregister').path, createRulesUnregisterHandler({
5523
5676
  virtualRuleStore,
5524
5677
  logger,
5525
5678
  onRulesChanged,
5526
5679
  }));
5527
- app.delete('/rules/unregister/:source', createRulesUnregisterParamHandler({
5680
+ app.delete(getEndpoint('rulesUnregisterBySource').path, createRulesUnregisterParamHandler({
5528
5681
  virtualRuleStore,
5529
5682
  logger,
5530
5683
  onRulesChanged,
5531
5684
  }));
5532
- app.post('/points/delete', createPointsDeleteHandler({ vectorStore, logger }));
5533
- app.post('/rules/reapply', createRulesReapplyHandler({ processor, vectorStore, logger }));
5685
+ app.post(getEndpoint('pointsDelete').path, createPointsDeleteHandler({ vectorStore, logger }));
5686
+ app.post(getEndpoint('rulesReapply').path, createRulesReapplyHandler({ processor, vectorStore, logger }));
5534
5687
  }
5535
5688
  // VCS routes — only registered when a VcsCoordinator is provided
5536
5689
  if (options.vcsCoordinator) {
5537
5690
  const coordinator = options.vcsCoordinator;
5538
- app.get('/vcs/status', createVcsStatusHandler({ coordinator, logger }));
5539
- app.get('/vcs/history', createVcsHistoryHandler({ coordinator, logger }));
5540
- app.get('/vcs/show', createVcsShowHandler({ coordinator, logger }));
5541
- app.get('/vcs/diff', createVcsDiffHandler({ coordinator, logger }));
5542
- app.get('/vcs/check-exclusion', createVcsCheckExclusionHandler({ coordinator, logger }));
5543
- app.post('/vcs/revert', createVcsRevertHandler({ coordinator, logger }));
5544
- app.post('/vcs/exclude', createVcsExcludeHandler({ coordinator, logger }));
5691
+ app.get(getEndpoint('vcsStatus').path, createVcsStatusHandler({ coordinator, logger }));
5692
+ app.get(getEndpoint('vcsHistory').path, createVcsHistoryHandler({ coordinator, logger }));
5693
+ app.get(getEndpoint('vcsShow').path, createVcsShowHandler({ coordinator, logger }));
5694
+ app.get(getEndpoint('vcsDiff').path, createVcsDiffHandler({ coordinator, logger }));
5695
+ app.get(getEndpoint('vcsCheckExclusion').path, createVcsCheckExclusionHandler({ coordinator, logger }));
5696
+ app.post(getEndpoint('vcsRevert').path, createVcsRevertHandler({ coordinator, logger }));
5697
+ app.post(getEndpoint('vcsExclude').path, createVcsExcludeHandler({ coordinator, logger }));
5545
5698
  }
5546
5699
  return app;
5547
5700
  }
@@ -7855,7 +8008,7 @@ class JeevesWatcher {
7855
8008
  this.watcher = watcher;
7856
8009
  this.gitignoreFilter = gitignoreFilter;
7857
8010
  this.server = await this.startApiServer();
7858
- vcsCoordinator.start();
8011
+ await vcsCoordinator.start();
7859
8012
  this.watcher.start();
7860
8013
  this.startConfigWatch();
7861
8014
  logger.info('jeeves-watcher started');