@karmaniverous/jeeves-watcher 0.19.0-3 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createConfigApplyHandler, createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, DEFAULT_PORTS, getBindAddress } from '@karmaniverous/jeeves';
3
- import { n as normalizeSlashes, a as normalizeError, e as execFileAsync, G as GIT_TIMEOUT_STANDARD, b as GIT_TIMEOUT_CHERRY_PICK, c as GIT_TIMEOUT_PUSH, g as getExecErrorFields, r as retry, d as gitAddViaStdin, f as normalizePathCase, h as findRootForPath, i as GitignoreFilter, j as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, k as isPathWatched, l as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, o as loadConfig } from './index.js';
4
- export { p as startFromConfig } from './index.js';
3
+ import { n as normalizeSlashes, a as normalizeError, e as execFileAsync, g as gitArgs, G as GIT_TIMEOUT_STANDARD, b as getErrorCode, p as pathKey, c as GIT_TIMEOUT_CHERRY_PICK, d as GIT_TIMEOUT_PUSH, r as retry, i as isIndexLockError, f as normalizePathCase, h as findRootForPath, j as GitignoreFilter, k as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, l as isPathWatched, o as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, q as loadConfig } from './index.js';
4
+ export { t 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, sep, isAbsolute, relative } from 'node:path';
7
7
  import { DatabaseSync } from 'node:sqlite';
@@ -10,9 +10,10 @@ 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, getEndpoint } from '@karmaniverous/jeeves-watcher-core';
13
+ import { extractWatchPathStrings, normalizeWatchPaths, vcsRetentionConfigSchema, vcsAuthorConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema, getEndpoint } from '@karmaniverous/jeeves-watcher-core';
14
14
  import { homedir } from 'node:os';
15
- import { access, rm, readFile, writeFile, appendFile, stat, mkdir } from 'node:fs/promises';
15
+ import { stat, access, rm, readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
16
+ import { execFile } from 'node:child_process';
16
17
  import { pathToFileURL, fileURLToPath } from 'node:url';
17
18
  import { packageDirectorySync } from 'package-directory';
18
19
  import { JsonMap } from '@karmaniverous/jsonmap';
@@ -38,7 +39,6 @@ import { MarkdownTextSplitter, RecursiveCharacterTextSplitter } from '@langchain
38
39
  import { QdrantClient } from '@qdrant/js-client-rest';
39
40
  import 'cosmiconfig';
40
41
  import 'ignore';
41
- import 'node:child_process';
42
42
  import 'node:util';
43
43
 
44
44
  /**
@@ -979,6 +979,317 @@ function resolveCommitMessageApiKey(provider, configApiKey, logger) {
979
979
  return undefined;
980
980
  }
981
981
 
982
+ /**
983
+ * @module vcs/runGit
984
+ * Run a git subcommand in a repository with the pinned argv prefix and a
985
+ * timeout. Lives in its own module (rather than gitExec) so callers resolve
986
+ * `execFileAsync` through the gitExec module binding.
987
+ */
988
+ /**
989
+ * Run `git <args>` in `cwd` with {@link gitArgs} applied.
990
+ *
991
+ * @param cwd - Repository root (working directory for git).
992
+ * @param args - Subcommand and arguments.
993
+ * @param options - Timeout and buffer limits.
994
+ * @returns The child's stdout and stderr.
995
+ */
996
+ function runGit(cwd, args, { timeoutMs = GIT_TIMEOUT_STANDARD, maxBuffer } = {}) {
997
+ return execFileAsync('git', gitArgs(...args), {
998
+ cwd,
999
+ timeout: timeoutMs,
1000
+ ...(maxBuffer === undefined ? {} : { maxBuffer }),
1001
+ });
1002
+ }
1003
+
1004
+ /**
1005
+ * @module vcs/gitIndex
1006
+ * Git index operations used by the commit pipeline: staging additions and
1007
+ * removals through stdin, listing tracked-but-deleted paths, detecting a
1008
+ * staged diff, and creating a commit. Every call carries
1009
+ * {@link GIT_BASE_ARGS} via {@link gitArgs}.
1010
+ */
1011
+ /** Max stdout buffered from `git ls-files` (large corpora can list many paths). */
1012
+ const LS_FILES_MAX_BUFFER = 256 * 1024 * 1024;
1013
+ /**
1014
+ * Run a git subcommand with a NUL-delimited file list piped through stdin.
1015
+ *
1016
+ * This sidesteps the Windows CreateProcessW 32 767-char limit that
1017
+ * triggers ENAMETOOLONG when batches contain many files with long
1018
+ * absolute paths.
1019
+ *
1020
+ * @param argv - Full git argv (base args, subcommand, and flags).
1021
+ * @param files - Absolute paths to pipe through stdin (NUL-delimited).
1022
+ * @param cwd - Repository root (working directory for git).
1023
+ * @param timeoutMs - Kill the child process after this many milliseconds.
1024
+ */
1025
+ function execGitStdin(argv, files, cwd, timeoutMs) {
1026
+ return new Promise((resolvePromise, reject) => {
1027
+ let timedOut = false;
1028
+ const child = execFile('git', argv, { cwd }, (error) => {
1029
+ clearTimeout(timer);
1030
+ if (timedOut)
1031
+ return;
1032
+ if (error)
1033
+ reject(error);
1034
+ else
1035
+ resolvePromise();
1036
+ });
1037
+ const timer = setTimeout(() => {
1038
+ timedOut = true;
1039
+ child.kill();
1040
+ reject(new Error(`git ${argv.join(' ')} timed out after ${String(timeoutMs)}ms`));
1041
+ }, timeoutMs);
1042
+ if (!child.stdin) {
1043
+ clearTimeout(timer);
1044
+ reject(new Error('Failed to open stdin for git command'));
1045
+ return;
1046
+ }
1047
+ // Suppress EPIPE: expected if git exits before we finish writing.
1048
+ child.stdin.on('error', (err) => {
1049
+ if (err.code !== 'EPIPE') {
1050
+ clearTimeout(timer);
1051
+ reject(err);
1052
+ }
1053
+ });
1054
+ child.stdin.end(files.join('\0'));
1055
+ });
1056
+ }
1057
+ /**
1058
+ * Stage files with `git add --pathspec-from-file=- --pathspec-file-nul`.
1059
+ *
1060
+ * @param files - Absolute paths of files to stage.
1061
+ * @param cwd - Repository root (working directory for git).
1062
+ * @param timeoutMs - Kill the child process after this many milliseconds.
1063
+ */
1064
+ async function gitAddViaStdin(files, cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
1065
+ if (files.length === 0)
1066
+ return;
1067
+ await execGitStdin(gitArgs('add', '--pathspec-from-file=-', '--pathspec-file-nul'), files, cwd, timeoutMs);
1068
+ }
1069
+ /**
1070
+ * Stage the removal (index only) of tracked files that are missing from
1071
+ * disk. `--ignore-unmatch` means a path that isn't actually tracked is
1072
+ * skipped instead of failing the whole command. See #249.
1073
+ *
1074
+ * @param files - Absolute paths of tracked files to remove from the index.
1075
+ * @param cwd - Repository root (working directory for git).
1076
+ * @param timeoutMs - Kill the child process after this many milliseconds.
1077
+ */
1078
+ async function gitRmCachedViaStdin(files, cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
1079
+ if (files.length === 0)
1080
+ return;
1081
+ await execGitStdin(gitArgs('rm', '--cached', '--ignore-unmatch', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'), files, cwd, timeoutMs);
1082
+ }
1083
+ /**
1084
+ * List tracked paths that are missing from the working tree, via a single
1085
+ * `git ls-files --deleted -z` call (never one process per file).
1086
+ *
1087
+ * `git ls-files` has no `--pathspec-from-file`, so rather than passing the
1088
+ * candidate paths (which could exceed the Windows command-line limit) this
1089
+ * asks for every tracked-but-deleted path; callers intersect with their own
1090
+ * candidates. Output is bounded by the number of deleted tracked files.
1091
+ *
1092
+ * @param cwd - Repository root (working directory for git).
1093
+ * @param timeoutMs - Kill the child process after this many milliseconds.
1094
+ * @returns Absolute paths (forward slashes) of tracked files missing on disk.
1095
+ */
1096
+ async function listDeletedTrackedPaths(cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
1097
+ const { stdout } = await runGit(cwd, ['ls-files', '--deleted', '-z'], {
1098
+ timeoutMs,
1099
+ maxBuffer: LS_FILES_MAX_BUFFER,
1100
+ });
1101
+ return stdout
1102
+ .split('\0')
1103
+ .filter((rel) => rel.length > 0)
1104
+ .map((rel) => normalizeSlashes(resolve(cwd, rel)));
1105
+ }
1106
+ /**
1107
+ * Check whether anything is staged (`git diff --cached --quiet`).
1108
+ * Exit code 0 means the index matches HEAD; exit code 1 means there is a
1109
+ * staged diff. Any other outcome is a real error and is rethrown. See #249.
1110
+ *
1111
+ * @param cwd - Repository root (working directory for git).
1112
+ * @param timeoutMs - Kill the child process after this many milliseconds.
1113
+ * @returns true if there is a staged diff to commit.
1114
+ */
1115
+ async function hasStagedChanges(cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
1116
+ try {
1117
+ await runGit(cwd, ['diff', '--cached', '--quiet'], { timeoutMs });
1118
+ return false;
1119
+ }
1120
+ catch (error) {
1121
+ if (getErrorCode(error) === 1)
1122
+ return true;
1123
+ throw error;
1124
+ }
1125
+ }
1126
+ /**
1127
+ * Commit the staged index and return the new commit's short hash.
1128
+ *
1129
+ * @param cwd - Repository root (working directory for git).
1130
+ * @param message - Commit message.
1131
+ * @param timeoutMs - Kill each child process after this many milliseconds.
1132
+ * @returns The short hash of the new HEAD.
1133
+ */
1134
+ async function gitCommit(cwd, message, timeoutMs = GIT_TIMEOUT_STANDARD) {
1135
+ await runGit(cwd, ['commit', '-m', message], { timeoutMs });
1136
+ const { stdout } = await runGit(cwd, ['rev-parse', '--short', 'HEAD'], {
1137
+ timeoutMs,
1138
+ });
1139
+ return stdout.trim();
1140
+ }
1141
+
1142
+ /**
1143
+ * @module vcs/batchStaging
1144
+ * Stage a commit batch so that a missing path never fails it: existing
1145
+ * files are added, tracked files that vanished have their deletion staged,
1146
+ * and never-tracked files that vanished are dropped. See #249.
1147
+ */
1148
+ /** Errno codes that mean "this path does not exist". */
1149
+ const MISSING_CODES = new Set([
1150
+ 'ENOENT',
1151
+ 'ENOTDIR',
1152
+ ]);
1153
+ /**
1154
+ * Check whether a path exists. Only "does not exist" errors count as
1155
+ * missing; any other failure (permissions, I/O) propagates so the batch is
1156
+ * retained and retried rather than silently dropping a real change.
1157
+ *
1158
+ * @param path - Absolute path to check.
1159
+ * @param statFn - Stat implementation (injectable for tests).
1160
+ * @returns true if the path exists.
1161
+ */
1162
+ async function pathExists(path, statFn) {
1163
+ try {
1164
+ await statFn(path);
1165
+ return true;
1166
+ }
1167
+ catch (error) {
1168
+ if (MISSING_CODES.has(getErrorCode(error) ?? ''))
1169
+ return false;
1170
+ throw error;
1171
+ }
1172
+ }
1173
+ /**
1174
+ * Split a batch into existing, missing-tracked, and missing-untracked
1175
+ * paths. Uses one `git ls-files --deleted` call, and only when something is
1176
+ * missing.
1177
+ *
1178
+ * @param files - Absolute paths in the batch.
1179
+ * @param rootPath - Repository root.
1180
+ * @param statFn - Stat implementation (injectable for tests).
1181
+ * @returns The classified batch.
1182
+ */
1183
+ async function classifyBatchPaths(files, rootPath, statFn = stat) {
1184
+ const exists = await Promise.all(files.map((f) => pathExists(f, statFn)));
1185
+ const existing = files.filter((_, i) => exists[i]);
1186
+ const missing = files.filter((_, i) => !exists[i]);
1187
+ if (missing.length === 0) {
1188
+ return { existing, missingTracked: [], missingUntracked: [] };
1189
+ }
1190
+ const deletedTracked = new Set((await listDeletedTrackedPaths(rootPath)).map((p) => pathKey(p)));
1191
+ const isTracked = (f) => deletedTracked.has(pathKey(f));
1192
+ return {
1193
+ existing,
1194
+ missingTracked: missing.filter(isTracked),
1195
+ missingUntracked: missing.filter((f) => !isTracked(f)),
1196
+ };
1197
+ }
1198
+ /**
1199
+ * Stage a batch: add existing files, stage removal of tracked files that
1200
+ * are gone, and drop never-tracked files that are gone.
1201
+ *
1202
+ * @param files - Absolute paths in the batch.
1203
+ * @param rootPath - Repository root.
1204
+ * @param logger - Logger (dropped paths are reported at debug).
1205
+ * @param timeoutMs - Timeout for each git call.
1206
+ */
1207
+ async function stageBatch(files, rootPath, logger, timeoutMs = GIT_TIMEOUT_STANDARD) {
1208
+ const { existing, missingTracked, missingUntracked } = await classifyBatchPaths(files, rootPath);
1209
+ if (missingUntracked.length > 0) {
1210
+ logger.debug({ root: rootPath, count: missingUntracked.length }, 'Dropping missing, untracked paths from batch');
1211
+ }
1212
+ await gitAddViaStdin(existing, rootPath, timeoutMs);
1213
+ await gitRmCachedViaStdin(missingTracked, rootPath, timeoutMs);
1214
+ }
1215
+
1216
+ /**
1217
+ * @module vcs/CommitCircuitBreaker
1218
+ * Per-root circuit breaker for the VCS commit pipeline. Counts consecutive
1219
+ * failures, trips at a threshold, and allows one half-open attempt per
1220
+ * cooldown. It holds no files: callers retain pending work while it is
1221
+ * tripped. See #249.
1222
+ */
1223
+ /** Circuit breaker with a time-based half-open recovery. */
1224
+ class CommitCircuitBreaker {
1225
+ maxConsecutiveFailures;
1226
+ cooldownMs;
1227
+ now;
1228
+ failures = 0;
1229
+ trippedAtMs;
1230
+ lastError;
1231
+ /**
1232
+ * @param maxConsecutiveFailures - Failures that trip the breaker.
1233
+ * @param cooldownMs - Time after a trip before one attempt is allowed.
1234
+ * @param now - Clock (injectable for tests).
1235
+ */
1236
+ constructor(maxConsecutiveFailures, cooldownMs, now = Date.now) {
1237
+ this.maxConsecutiveFailures = maxConsecutiveFailures;
1238
+ this.cooldownMs = cooldownMs;
1239
+ this.now = now;
1240
+ }
1241
+ /** Whether the breaker is tripped. */
1242
+ get isTripped() {
1243
+ return this.trippedAtMs !== undefined;
1244
+ }
1245
+ /**
1246
+ * Milliseconds until an attempt is allowed. 0 when not tripped or when
1247
+ * the cooldown has elapsed (half-open).
1248
+ */
1249
+ remainingCooldownMs() {
1250
+ if (this.trippedAtMs === undefined)
1251
+ return 0;
1252
+ return Math.max(0, this.cooldownMs - (this.now() - this.trippedAtMs));
1253
+ }
1254
+ /**
1255
+ * Record a failed attempt.
1256
+ *
1257
+ * @param message - The failure message.
1258
+ * @returns true if this failure tripped (or re-armed) the breaker.
1259
+ */
1260
+ recordFailure(message) {
1261
+ this.failures++;
1262
+ this.lastError = message;
1263
+ if (this.failures < this.maxConsecutiveFailures)
1264
+ return false;
1265
+ this.trippedAtMs = this.now();
1266
+ return true;
1267
+ }
1268
+ /**
1269
+ * Record a successful attempt (a commit or a no-op) and reset.
1270
+ *
1271
+ * @returns true if the breaker was tripped before this success.
1272
+ */
1273
+ recordSuccess() {
1274
+ const wasTripped = this.isTripped;
1275
+ this.failures = 0;
1276
+ this.trippedAtMs = undefined;
1277
+ this.lastError = undefined;
1278
+ return wasTripped;
1279
+ }
1280
+ /** Current state for status reporting. */
1281
+ snapshot() {
1282
+ return {
1283
+ consecutiveFailures: this.failures,
1284
+ tripped: this.isTripped,
1285
+ trippedAt: this.trippedAtMs === undefined
1286
+ ? null
1287
+ : new Date(this.trippedAtMs).toISOString(),
1288
+ lastError: this.lastError ?? null,
1289
+ };
1290
+ }
1291
+ }
1292
+
982
1293
  /**
983
1294
  * @module vcs/CommitMessageBuilder
984
1295
  * Builds commit messages — both template-based and AI-generated.
@@ -1054,8 +1365,15 @@ class CommitMessageBuilder {
1054
1365
  */
1055
1366
  async getStagedDiff() {
1056
1367
  try {
1057
- const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1058
- const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1368
+ const { stdout: stat } = await runGit(this.rootPath, [
1369
+ 'diff',
1370
+ '--cached',
1371
+ '--stat',
1372
+ ]);
1373
+ const { stdout: patch } = await runGit(this.rootPath, [
1374
+ 'diff',
1375
+ '--cached',
1376
+ ]);
1059
1377
  return `${stat}\n${patch}`;
1060
1378
  }
1061
1379
  catch {
@@ -1064,6 +1382,160 @@ class CommitMessageBuilder {
1064
1382
  }
1065
1383
  }
1066
1384
 
1385
+ /**
1386
+ * @module vcs/gitRepoState
1387
+ * Read-mostly checks of a repository's state that gate VCS operations:
1388
+ * abandoned sequencer/rebase/merge state, uncommitted tracked changes, and
1389
+ * `index.lock` presence and staleness. See #249.
1390
+ */
1391
+ /**
1392
+ * Paths under `.git/` that indicate an in-progress rebase, cherry-pick, or
1393
+ * merge. Rewriting history (or committing) on top of one is unsafe.
1394
+ */
1395
+ const STALE_OPERATION_MARKERS = [
1396
+ 'sequencer',
1397
+ 'rebase-merge',
1398
+ 'rebase-apply',
1399
+ 'CHERRY_PICK_HEAD',
1400
+ 'MERGE_HEAD',
1401
+ ];
1402
+ /** Manual resolution hint logged alongside stale-operation reports. */
1403
+ const STALE_OPERATION_HINT = 'resolve it manually (e.g. git cherry-pick --quit / git rebase --abort / git merge --abort)';
1404
+ function indexLockPath(rootPath) {
1405
+ return join(rootPath, '.git', 'index.lock');
1406
+ }
1407
+ /**
1408
+ * Detect an abandoned cherry-pick, rebase, or merge left in `.git/`.
1409
+ * Report-only: never clears the state.
1410
+ *
1411
+ * @param rootPath - Repository root.
1412
+ * @returns The first marker found, or undefined if none.
1413
+ */
1414
+ async function detectStaleGitOperation(rootPath) {
1415
+ for (const marker of STALE_OPERATION_MARKERS) {
1416
+ try {
1417
+ await access(join(rootPath, '.git', marker));
1418
+ return marker;
1419
+ }
1420
+ catch {
1421
+ // Not present: check the next marker.
1422
+ }
1423
+ }
1424
+ return undefined;
1425
+ }
1426
+ /**
1427
+ * Check for uncommitted changes to tracked files (staged or unstaged).
1428
+ * Untracked files are ignored.
1429
+ *
1430
+ * @param rootPath - Repository root.
1431
+ * @returns true if any tracked file differs from HEAD.
1432
+ */
1433
+ async function hasDirtyTrackedFiles(rootPath) {
1434
+ const { stdout } = await runGit(rootPath, [
1435
+ 'status',
1436
+ '--porcelain',
1437
+ '-z',
1438
+ '--untracked-files=no',
1439
+ ]);
1440
+ return stdout.length > 0;
1441
+ }
1442
+ /**
1443
+ * Check whether `.git/index.lock` exists.
1444
+ *
1445
+ * @param rootPath - Repository root.
1446
+ * @returns true if the lock file is present.
1447
+ */
1448
+ async function indexLockExists(rootPath) {
1449
+ try {
1450
+ await access(indexLockPath(rootPath));
1451
+ return true;
1452
+ }
1453
+ catch {
1454
+ return false;
1455
+ }
1456
+ }
1457
+ /**
1458
+ * Remove `.git/index.lock` unconditionally (missing is fine).
1459
+ *
1460
+ * @param rootPath - Repository root.
1461
+ */
1462
+ async function removeIndexLock(rootPath) {
1463
+ await rm(indexLockPath(rootPath), { force: true });
1464
+ }
1465
+ /**
1466
+ * Remove `.git/index.lock` if it is older than `thresholdMs`. A fresh lock
1467
+ * (held by a live git process) is left alone.
1468
+ *
1469
+ * @param rootPath - Repository root.
1470
+ * @param thresholdMs - Minimum age for a lock to count as stale.
1471
+ * @param logger - Logger for removal and failure reports.
1472
+ */
1473
+ async function removeStaleIndexLock(rootPath, thresholdMs, logger) {
1474
+ try {
1475
+ const ageMs = Date.now() - (await stat(indexLockPath(rootPath))).mtimeMs;
1476
+ if (ageMs > thresholdMs) {
1477
+ await removeIndexLock(rootPath);
1478
+ logger.warn({ root: rootPath, ageMs }, 'Removed stale index.lock');
1479
+ }
1480
+ }
1481
+ catch (error) {
1482
+ if (getErrorCode(error) !== 'ENOENT') {
1483
+ logger.warn({ root: rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
1484
+ }
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * @module vcs/cronMatch
1490
+ * Minimal 5-field cron matcher used by the squash retention scheduler.
1491
+ */
1492
+ function partMatches(part, value, min, max) {
1493
+ // Handle step: */N or range/N
1494
+ const [rangePart, stepStr] = part.split('/');
1495
+ const step = stepStr ? parseInt(stepStr, 10) : 1;
1496
+ if (rangePart === '*') {
1497
+ return (value - min) % step === 0;
1498
+ }
1499
+ // Handle range: N-M
1500
+ if (rangePart.includes('-')) {
1501
+ const [startStr, endStr] = rangePart.split('-');
1502
+ const start = parseInt(startStr, 10);
1503
+ const end = parseInt(endStr, 10);
1504
+ if (value < start || value > end)
1505
+ return false;
1506
+ return (value - start) % step === 0;
1507
+ }
1508
+ // Plain number. For day-of-week, 7 also means Sunday (0).
1509
+ const num = parseInt(rangePart, 10);
1510
+ if (step === 1 && max === 7 && num === 7)
1511
+ return value === 0;
1512
+ return value === num;
1513
+ }
1514
+ function fieldMatches(field, value, min, max) {
1515
+ return field
1516
+ .split(',')
1517
+ .some((part) => partMatches(part.trim(), value, min, max));
1518
+ }
1519
+ /**
1520
+ * Check whether a standard 5-field cron expression matches a time.
1521
+ * Fields: minute hour day-of-month month day-of-week.
1522
+ * Supports numbers, wildcards, lists (1,3,5), ranges (1-5) and steps.
1523
+ *
1524
+ * @param cronExpr - The cron expression.
1525
+ * @param now - The time to test (default: now).
1526
+ * @returns true if every field matches.
1527
+ */
1528
+ function cronMatchesNow(cronExpr, now = new Date()) {
1529
+ const fields = cronExpr.trim().split(/\s+/);
1530
+ if (fields.length !== 5)
1531
+ return false;
1532
+ return (fieldMatches(fields[0], now.getMinutes(), 0, 59) &&
1533
+ fieldMatches(fields[1], now.getHours(), 0, 23) &&
1534
+ fieldMatches(fields[2], now.getDate(), 1, 31) &&
1535
+ fieldMatches(fields[3], now.getMonth() + 1, 1, 12) &&
1536
+ fieldMatches(fields[4], now.getDay(), 0, 7));
1537
+ }
1538
+
1067
1539
  /**
1068
1540
  * @module vcs/gitNetwork
1069
1541
  * Non-interactive, credential-safe execution of git commands that touch the
@@ -1228,7 +1700,7 @@ function sanitizeGitError(error, secret) {
1228
1700
  */
1229
1701
  async function execGitNetwork(args, options) {
1230
1702
  try {
1231
- return await execFileAsync('git', [...buildGitNetworkArgs(options.clearCredentialHelpers), ...args], {
1703
+ return await execFileAsync('git', gitArgs(...buildGitNetworkArgs(options.clearCredentialHelpers), ...args), {
1232
1704
  cwd: options.cwd,
1233
1705
  env: buildGitNetworkEnv(process.env, options.config),
1234
1706
  timeout: options.timeout,
@@ -1268,61 +1740,16 @@ async function gitPushNonInteractive(options) {
1268
1740
  * @module vcs/SquashManager
1269
1741
  * Handles scheduled squash retention for git-backed VCS roots.
1270
1742
  */
1743
+ const DAY_MS = 24 * 60 * 60 * 1000;
1271
1744
  /**
1272
- * Parse a standard 5-field cron expression and check if the current time matches.
1273
- * Fields: minute hour dom month dow
1274
- * Supports: numbers, wildcards, lists (1,3,5), ranges (1-5), steps (star/5)
1745
+ * Manages scheduled squash retention for a single VCS root.
1746
+ *
1747
+ * Runs on a cron schedule (checked every 60s). When triggered, squashes
1748
+ * commits older than the retention boundary (the tighter of maxAgeDays
1749
+ * and maxVersions) into a single "historical baseline" orphan commit,
1750
+ * then cherry-picks retained commits on top. Force-pushes to remote
1751
+ * if configured (D13).
1275
1752
  */
1276
- function cronMatchesNow(cronExpr, now = new Date()) {
1277
- const fields = cronExpr.trim().split(/\s+/);
1278
- if (fields.length !== 5)
1279
- return false;
1280
- const minute = now.getMinutes();
1281
- const hour = now.getHours();
1282
- const dom = now.getDate();
1283
- const month = now.getMonth() + 1; // 1-based
1284
- const dow = now.getDay(); // 0=Sunday
1285
- return (fieldMatches(fields[0], minute, 0, 59) &&
1286
- fieldMatches(fields[1], hour, 0, 23) &&
1287
- fieldMatches(fields[2], dom, 1, 31) &&
1288
- fieldMatches(fields[3], month, 1, 12) &&
1289
- fieldMatches(fields[4], dow, 0, 7) // 7 also means Sunday
1290
- );
1291
- }
1292
- function fieldMatches(field, value, min, max) {
1293
- for (const part of field.split(',')) {
1294
- if (partMatches(part.trim(), value, min, max))
1295
- return true;
1296
- }
1297
- return false;
1298
- }
1299
- function partMatches(part, value, min, max) {
1300
- // Handle step: */N or range/N
1301
- const [rangePart, stepStr] = part.split('/');
1302
- const step = stepStr ? parseInt(stepStr, 10) : 1;
1303
- if (rangePart === '*') {
1304
- return (value - min) % step === 0;
1305
- }
1306
- // Handle range: N-M
1307
- if (rangePart.includes('-')) {
1308
- const [startStr, endStr] = rangePart.split('-');
1309
- const start = parseInt(startStr, 10);
1310
- const end = parseInt(endStr, 10);
1311
- if (value < start || value > end)
1312
- return false;
1313
- return (value - start) % step === 0;
1314
- }
1315
- // Plain number
1316
- const num = parseInt(rangePart, 10);
1317
- if (step === 1) {
1318
- // For dow, treat 7 as 0 (Sunday)
1319
- if (max === 7 && num === 7)
1320
- return value === 0;
1321
- return value === num;
1322
- }
1323
- // Number with step doesn't make sense for single values, treat as exact
1324
- return value === num;
1325
- }
1326
1753
  class SquashManager {
1327
1754
  rootPath;
1328
1755
  retention;
@@ -1377,17 +1804,9 @@ class SquashManager {
1377
1804
  * to drain and suspend the commit pipeline during the squash.
1378
1805
  */
1379
1806
  async runSquash() {
1380
- // Check for index.lock before starting multi-step operation
1381
- const lockPath = join(this.rootPath, '.git', 'index.lock');
1382
- try {
1383
- await access(lockPath);
1384
- // Lock exists — abort and retry next cycle
1385
- this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
1386
- return { squashed: false, error: 'index.lock exists' };
1387
- }
1388
- catch {
1389
- // No lock — proceed
1390
- }
1807
+ const blocked = await this.checkRepoPreconditions();
1808
+ if (blocked !== undefined)
1809
+ return { squashed: false, error: blocked };
1391
1810
  // Bug 3: Pause the commit pipeline before squash
1392
1811
  try {
1393
1812
  await this.pauseCommits?.();
@@ -1399,40 +1818,65 @@ class SquashManager {
1399
1818
  return { squashed: false, error: normalizeError(error).message };
1400
1819
  }
1401
1820
  try {
1402
- const commits = await this.getCommitLog();
1403
- if (commits.length <= 1) {
1404
- return { squashed: false };
1821
+ // #249 guard: the squash uses `reset --hard` / `checkout -f`, which
1822
+ // would destroy uncommitted changes to tracked files. After draining
1823
+ // the commit pipeline the tracked tree should be clean; if it isn't,
1824
+ // refuse rather than lose content. Untracked files don't block.
1825
+ if (await hasDirtyTrackedFiles(this.rootPath)) {
1826
+ const error = 'Squash refused: working tree has uncommitted changes to tracked files';
1827
+ this.logger.warn({ root: this.rootPath }, error);
1828
+ return { squashed: false, error };
1405
1829
  }
1406
- const boundaryIndex = this.calculateRetentionBoundary(commits);
1407
- if (boundaryIndex <= 0) {
1408
- // All commits are within retention or only one commit before boundary
1830
+ const commits = await this.getCommitLog();
1831
+ const boundaryIndex = commits.length <= 1 ? 0 : this.calculateRetentionBoundary(commits);
1832
+ if (boundaryIndex <= 0)
1409
1833
  return { squashed: false };
1410
- }
1411
- // Perform the squash
1412
1834
  await this.performSquash(commits, boundaryIndex);
1413
1835
  const commitsRemoved = boundaryIndex;
1414
1836
  const commitsRetained = commits.length - boundaryIndex;
1415
1837
  this.logger.info({ root: this.rootPath, commitsRemoved, commitsRetained }, 'Squash retention completed');
1416
- // Force push if remote configured
1417
1838
  await this.forcePushIfConfigured();
1418
1839
  return { squashed: true, commitsRemoved, commitsRetained };
1419
1840
  }
1420
1841
  catch (error) {
1421
- const message = normalizeError(error).message;
1422
1842
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
1423
- return { squashed: false, error: message };
1843
+ return { squashed: false, error: normalizeError(error).message };
1424
1844
  }
1425
1845
  finally {
1426
1846
  // Bug 3: Always resume the commit pipeline
1427
1847
  this.resumeCommits?.();
1428
1848
  }
1429
1849
  }
1850
+ /**
1851
+ * Refuse to start a multi-step history rewrite while another git
1852
+ * operation holds the index or an abandoned cherry-pick, rebase, or merge
1853
+ * is present (#249: report only, never auto-clear).
1854
+ *
1855
+ * @returns The refusal reason, or undefined if the squash may proceed.
1856
+ */
1857
+ async checkRepoPreconditions() {
1858
+ if (await indexLockExists(this.rootPath)) {
1859
+ this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
1860
+ return 'index.lock exists';
1861
+ }
1862
+ const marker = await detectStaleGitOperation(this.rootPath);
1863
+ if (marker !== undefined) {
1864
+ const error = `Squash refused: in-progress git operation detected (.git/${marker})`;
1865
+ this.logger.error({ root: this.rootPath, marker }, `${error} — ${STALE_OPERATION_HINT}`);
1866
+ return error;
1867
+ }
1868
+ return undefined;
1869
+ }
1430
1870
  /**
1431
1871
  * Get all commits in chronological order (oldest first).
1432
1872
  */
1433
1873
  async getCommitLog() {
1434
1874
  try {
1435
- const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1875
+ const { stdout } = await runGit(this.rootPath, [
1876
+ 'log',
1877
+ '--format=%H %aI',
1878
+ '--reverse',
1879
+ ]);
1436
1880
  if (!stdout.trim())
1437
1881
  return [];
1438
1882
  return stdout
@@ -1461,17 +1905,10 @@ class SquashManager {
1461
1905
  * The boundary is the one that retains FEWER old commits (tighter).
1462
1906
  */
1463
1907
  calculateRetentionBoundary(commits) {
1464
- const now = new Date();
1465
- const maxAgeMs = this.retention.maxAgeDays * 24 * 60 * 60 * 1000;
1466
- const cutoffDate = new Date(now.getTime() - maxAgeMs);
1467
- // Find the first commit that is within the age window (to keep)
1468
- let ageBoundary = commits.length; // Default: all too old
1469
- for (let i = 0; i < commits.length; i++) {
1470
- if (commits[i].date >= cutoffDate) {
1471
- ageBoundary = i;
1472
- break;
1473
- }
1474
- }
1908
+ const cutoffDate = new Date(Date.now() - this.retention.maxAgeDays * DAY_MS);
1909
+ // First commit within the age window (to keep); default: all too old.
1910
+ const firstKept = commits.findIndex((c) => c.date >= cutoffDate);
1911
+ const ageBoundary = firstKept === -1 ? commits.length : firstKept;
1475
1912
  // Count boundary: keep the last maxVersions commits
1476
1913
  const countBoundary = Math.max(0, commits.length - this.retention.maxVersions);
1477
1914
  // The tighter constraint wins (keeps fewer old commits = higher boundary index)
@@ -1482,80 +1919,59 @@ class SquashManager {
1482
1919
  * Uses the configured branch name instead of dynamic detection (Bug 1).
1483
1920
  */
1484
1921
  async performSquash(commits, boundaryIndex) {
1922
+ const git = (...args) => runGit(this.rootPath, args);
1485
1923
  // The commit just before boundary is the last one to squash
1486
1924
  const baselineHash = commits[boundaryIndex - 1].hash;
1487
- // Bug 1: Use configured branch name, not dynamic detection
1488
1925
  const targetBranch = this.branch;
1489
1926
  const orphanBranch = `__squash_orphan_${String(Date.now())}`;
1490
1927
  try {
1491
- // 1. Create orphan branch with the tree state at the baseline commit
1492
- await execFileAsync('git', ['checkout', '--orphan', orphanBranch], {
1493
- cwd: this.rootPath,
1494
- timeout: GIT_TIMEOUT_STANDARD,
1495
- });
1496
- // 2. Reset index to the baseline tree (the last squashed commit's tree)
1497
- await execFileAsync('git', ['reset', '--hard', baselineHash], {
1498
- cwd: this.rootPath,
1499
- timeout: GIT_TIMEOUT_STANDARD,
1500
- });
1501
- // 3. Create the baseline commit with the same tree
1502
- // Use commit-tree for a clean orphan commit
1503
- const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1504
- const treeHash = treeOut.trim();
1505
- const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1506
- const baselineCommitHash = commitOut.trim();
1507
- // 4. Reset orphan branch to this new baseline commit
1508
- await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
1509
- cwd: this.rootPath,
1510
- timeout: GIT_TIMEOUT_STANDARD,
1511
- });
1512
- // 5. Cherry-pick boundary..HEAD (the retained commits)
1513
- // Get the list of commits to cherry-pick (from boundary to the end)
1928
+ // 1-2. Orphan branch at the baseline commit's tree
1929
+ await git('checkout', '--orphan', orphanBranch);
1930
+ await git('reset', '--hard', baselineHash);
1931
+ // 3-4. Single parentless baseline commit with that tree
1932
+ const { stdout: treeOut } = await git('rev-parse', `${baselineHash}^{tree}`);
1933
+ const { stdout: commitOut } = await git('commit-tree', treeOut.trim(), '-m', 'historical baseline');
1934
+ await git('reset', '--hard', commitOut.trim());
1935
+ // 5. Cherry-pick the retained commits on top
1514
1936
  const commitsToCherry = commits.slice(boundaryIndex).map((c) => c.hash);
1515
1937
  if (commitsToCherry.length > 0) {
1516
- await execFileAsync('git', ['cherry-pick', ...commitsToCherry], {
1517
- cwd: this.rootPath,
1518
- timeout: GIT_TIMEOUT_CHERRY_PICK,
1938
+ await runGit(this.rootPath, ['cherry-pick', ...commitsToCherry], {
1939
+ timeoutMs: GIT_TIMEOUT_CHERRY_PICK,
1519
1940
  });
1520
1941
  }
1521
- // 6. Force-update the configured branch to the orphan
1522
- const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1523
- await execFileAsync('git', ['branch', '-f', targetBranch, newHead.trim()], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
1524
- // 7. Switch back to configured branch
1525
- await execFileAsync('git', ['checkout', targetBranch], {
1526
- cwd: this.rootPath,
1527
- timeout: GIT_TIMEOUT_STANDARD,
1528
- });
1529
- // 8. Delete orphan branch
1530
- await execFileAsync('git', ['branch', '-D', orphanBranch], {
1531
- cwd: this.rootPath,
1532
- timeout: GIT_TIMEOUT_STANDARD,
1533
- });
1942
+ // 6-8. Move the configured branch to the new history and clean up
1943
+ const { stdout: newHead } = await git('rev-parse', 'HEAD');
1944
+ await git('branch', '-f', targetBranch, newHead.trim());
1945
+ await git('checkout', targetBranch);
1946
+ await git('branch', '-D', orphanBranch);
1534
1947
  }
1535
1948
  catch (error) {
1536
- // Attempt cleanup: try to get back to configured branch
1537
- try {
1538
- await execFileAsync('git', ['checkout', '-f', targetBranch], {
1539
- cwd: this.rootPath,
1540
- timeout: GIT_TIMEOUT_STANDARD,
1541
- });
1542
- await execFileAsync('git', ['branch', '-D', orphanBranch], {
1543
- cwd: this.rootPath,
1544
- timeout: GIT_TIMEOUT_STANDARD,
1545
- });
1546
- }
1547
- catch {
1548
- // Cleanup failed — log but re-throw original error
1549
- }
1550
- // Remove index.lock if left behind
1949
+ await this.cleanupFailedSquash(targetBranch, orphanBranch);
1950
+ throw error;
1951
+ }
1952
+ }
1953
+ /**
1954
+ * Best-effort cleanup after a failed squash. Aborts any half-finished
1955
+ * cherry-pick first, so a failure never leaves a sequencer behind (#249),
1956
+ * then returns to the configured branch and removes a leftover lock.
1957
+ */
1958
+ async cleanupFailedSquash(targetBranch, orphanBranch) {
1959
+ const attempt = async (...args) => {
1551
1960
  try {
1552
- const lockFile = join(this.rootPath, '.git', 'index.lock');
1553
- await rm(lockFile, { force: true });
1961
+ await runGit(this.rootPath, args);
1554
1962
  }
1555
1963
  catch {
1556
- // ignore
1964
+ // Best effort: the state may not need this step.
1557
1965
  }
1558
- throw error;
1966
+ };
1967
+ await attempt('cherry-pick', '--abort');
1968
+ await attempt('checkout', '-f', targetBranch);
1969
+ await attempt('branch', '-D', orphanBranch);
1970
+ try {
1971
+ await removeIndexLock(this.rootPath);
1972
+ }
1973
+ catch {
1974
+ // Best effort: the original squash error is what gets reported.
1559
1975
  }
1560
1976
  }
1561
1977
  /**
@@ -1598,7 +2014,7 @@ const ALWAYS_GITIGNORE_ENTRIES = [
1598
2014
  */
1599
2015
  async function checkGitAvailable() {
1600
2016
  try {
1601
- await execFileAsync('git', ['--version']);
2017
+ await execFileAsync('git', gitArgs('--version'));
1602
2018
  return true;
1603
2019
  }
1604
2020
  catch {
@@ -1618,7 +2034,7 @@ async function initRepo(rootPath) {
1618
2034
  // .git exists, nothing to do
1619
2035
  }
1620
2036
  catch {
1621
- await execFileAsync('git', ['init'], { cwd: rootPath });
2037
+ await runGit(rootPath, ['init']);
1622
2038
  }
1623
2039
  }
1624
2040
  /**
@@ -1630,12 +2046,8 @@ async function initRepo(rootPath) {
1630
2046
  * @param email - Author email for commits.
1631
2047
  */
1632
2048
  async function configureRepoIdentity(rootPath, name, email) {
1633
- await execFileAsync('git', ['config', '--local', 'user.name', name], {
1634
- cwd: rootPath,
1635
- });
1636
- await execFileAsync('git', ['config', '--local', 'user.email', email], {
1637
- cwd: rootPath,
1638
- });
2049
+ await runGit(rootPath, ['config', '--local', 'user.name', name]);
2050
+ await runGit(rootPath, ['config', '--local', 'user.email', email]);
1639
2051
  }
1640
2052
  /**
1641
2053
  * Detect and recover from orphan branch state on startup.
@@ -1657,24 +2069,22 @@ async function detectAndRecoverOrphanBranch(rootPath, expectedBranch, logger) {
1657
2069
  // Not a git repo — nothing to detect
1658
2070
  return;
1659
2071
  }
1660
- const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
2072
+ const { stdout: branchOut } = await runGit(rootPath, [
2073
+ 'rev-parse',
2074
+ '--abbrev-ref',
2075
+ 'HEAD',
2076
+ ]);
1661
2077
  const currentBranch = branchOut.trim();
1662
2078
  if (currentBranch === expectedBranch)
1663
2079
  return;
1664
2080
  logger.warn({ root: rootPath, currentBranch, expectedBranch }, 'Repo is on unexpected branch — recovering to configured branch');
1665
2081
  // Get current HEAD
1666
- const { stdout: headOut } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: rootPath, timeout: GIT_TIMEOUT_STANDARD });
2082
+ const { stdout: headOut } = await runGit(rootPath, ['rev-parse', 'HEAD']);
1667
2083
  const headHash = headOut.trim();
1668
2084
  // Force-update the expected branch to current HEAD
1669
- await execFileAsync('git', ['branch', '-f', expectedBranch, headHash], {
1670
- cwd: rootPath,
1671
- timeout: GIT_TIMEOUT_STANDARD,
1672
- });
2085
+ await runGit(rootPath, ['branch', '-f', expectedBranch, headHash]);
1673
2086
  // Checkout the expected branch
1674
- await execFileAsync('git', ['checkout', expectedBranch], {
1675
- cwd: rootPath,
1676
- timeout: GIT_TIMEOUT_STANDARD,
1677
- });
2087
+ await runGit(rootPath, ['checkout', expectedBranch]);
1678
2088
  logger.info({ root: rootPath, recoveredFrom: currentBranch, expectedBranch, headHash }, 'Orphan branch recovery complete — now on configured branch');
1679
2089
  }
1680
2090
  /**
@@ -1761,7 +2171,9 @@ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger
1761
2171
  * (with optional AI-generated messages), and optionally pushed to a remote.
1762
2172
  *
1763
2173
  * Concurrency: only one commit is in-flight at a time per root. Index.lock
1764
- * contention is handled with exponential backoff retries (D10).
2174
+ * contention is handled with exponential backoff retries (D10). Failed
2175
+ * batches are always retained; a circuit breaker spaces out retries of a
2176
+ * persistently failing root (#249).
1765
2177
  */
1766
2178
  class VcsManager {
1767
2179
  config;
@@ -1770,13 +2182,15 @@ class VcsManager {
1770
2182
  accessToken;
1771
2183
  logger;
1772
2184
  commitMessageBuilder;
2185
+ breaker;
1773
2186
  pending = new Set();
1774
2187
  pendingReversions = [];
1775
2188
  _pushErrors = [];
1776
2189
  squashManager;
1777
2190
  throttleTimer;
2191
+ breakerRetryTimer;
1778
2192
  commitInFlight = Promise.resolve();
1779
- consecutiveCommitFailures = 0;
2193
+ inFlightFileCount = 0;
1780
2194
  started = false;
1781
2195
  paused = false;
1782
2196
  isBaseline = true;
@@ -1788,12 +2202,11 @@ class VcsManager {
1788
2202
  this.remoteUrl = remoteUrl;
1789
2203
  this.accessToken = accessToken;
1790
2204
  this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
2205
+ this.breaker = new CommitCircuitBreaker(config.maxConsecutiveFailures, config.circuitBreakerCooldownMs);
1791
2206
  if (config.retention) {
1792
2207
  this.squashManager = new SquashManager(rootPath, config.retention, logger, {
1793
2208
  branch: config.branch,
1794
- pauseCommits: () => {
1795
- return this.pause();
1796
- },
2209
+ pauseCommits: () => this.pause(),
1797
2210
  resumeCommits: () => {
1798
2211
  this.resume();
1799
2212
  },
@@ -1808,25 +2221,36 @@ class VcsManager {
1808
2221
  get pushErrors() {
1809
2222
  return this._pushErrors;
1810
2223
  }
2224
+ /** Current circuit breaker state and backlog, for /vcs/status. */
2225
+ get breakerState() {
2226
+ return {
2227
+ ...this.breaker.snapshot(),
2228
+ pendingCount: this.pending.size + this.inFlightFileCount,
2229
+ };
2230
+ }
1811
2231
  /**
1812
2232
  * Begin accepting file changes. Sets the manager to started state and
1813
2233
  * starts the squash manager if configured.
1814
2234
  *
1815
2235
  * Performs startup orphan detection/recovery before starting the squash
1816
- * manager, ensuring the repo is on the configured branch.
2236
+ * manager, ensuring the repo is on the configured branch, and reports
2237
+ * (never clears) an abandoned cherry-pick, rebase, or merge.
1817
2238
  *
1818
2239
  * Baseline mode (commit prefix `"baseline:"`) remains active until
1819
2240
  * {@link endBaseline} is called by the coordinator after the initial scan.
1820
2241
  */
1821
2242
  async start() {
1822
2243
  // Bug 6: Detect and recover from orphan branches before normal operations
1823
- const branch = this.config.branch;
1824
2244
  try {
1825
- await detectAndRecoverOrphanBranch(this.rootPath, branch, this.logger);
2245
+ await detectAndRecoverOrphanBranch(this.rootPath, this.config.branch, this.logger);
1826
2246
  }
1827
2247
  catch (error) {
1828
2248
  this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Orphan branch recovery failed — continuing with current state');
1829
2249
  }
2250
+ const marker = await detectStaleGitOperation(this.rootPath);
2251
+ if (marker !== undefined) {
2252
+ this.logger.error({ root: this.rootPath, marker }, `Stale in-progress git operation detected (.git/${marker}) — squash retention will refuse to run until you ${STALE_OPERATION_HINT}`);
2253
+ }
1830
2254
  this.started = true;
1831
2255
  this.squashManager?.start();
1832
2256
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
@@ -1840,26 +2264,22 @@ class VcsManager {
1840
2264
  }
1841
2265
  /**
1842
2266
  * Add a file to the pending set and start the throttle timer.
1843
- * If the pending set exceeds maxBatchSize, flush immediately.
2267
+ * If the pending set reaches maxBatchSize, commit a batch immediately.
2268
+ *
2269
+ * The circuit breaker is intentionally not reset here: recovery is
2270
+ * time-based, so a busy root cannot hammer a deterministic failure.
1844
2271
  *
1845
2272
  * @param filePath - Absolute path of the changed file.
1846
2273
  */
1847
2274
  fileChanged(filePath) {
1848
2275
  if (!this.started)
1849
2276
  return;
1850
- // Reset circuit breaker on new file change so the system can recover
1851
- if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
1852
- this.logger.info({ root: this.rootPath }, 'VCS circuit breaker reset — new file change received, retrying commits');
1853
- this.consecutiveCommitFailures = 0;
1854
- }
1855
2277
  this.pending.add(filePath);
1856
2278
  if (this.pending.size >= this.config.maxBatchSize) {
1857
2279
  this.clearThrottle();
1858
- const batch = this.takeBatch(this.config.maxBatchSize);
1859
- this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
1860
- if (this.pending.size > 0) {
2280
+ this.enqueueBatch(this.takeBatch());
2281
+ if (this.pending.size > 0)
1861
2282
  this.resetThrottle();
1862
- }
1863
2283
  return;
1864
2284
  }
1865
2285
  this.resetThrottle();
@@ -1873,14 +2293,11 @@ class VcsManager {
1873
2293
  this.pendingReversions.push(reversion);
1874
2294
  }
1875
2295
  /**
1876
- * Stage a file deletion and add to pending set.
1877
- * git add handles deleted files when the file is gone from disk.
2296
+ * Queue a deleted file. Staging records the deletion if git tracks it.
1878
2297
  *
1879
2298
  * @param filePath - Absolute path of the deleted file.
1880
2299
  */
1881
2300
  handleUnlink(filePath) {
1882
- if (!this.started)
1883
- return;
1884
2301
  this.fileChanged(filePath);
1885
2302
  }
1886
2303
  /**
@@ -1901,19 +2318,16 @@ class VcsManager {
1901
2318
  resume() {
1902
2319
  this.paused = false;
1903
2320
  this.logger.debug({ root: this.rootPath }, 'VcsManager resumed');
1904
- if (this.pending.size > 0) {
2321
+ if (this.pending.size > 0)
1905
2322
  this.resetThrottle();
1906
- }
1907
2323
  }
1908
2324
  /**
1909
- * Flush all pending files immediately.
2325
+ * Commit all pending files now, in batches of at most maxBatchSize.
1910
2326
  */
1911
2327
  async flush() {
1912
2328
  this.clearThrottle();
1913
- if (this.pending.size > 0) {
1914
- const batch = [...this.pending];
1915
- this.pending.clear();
1916
- this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
2329
+ while (this.pending.size > 0) {
2330
+ this.enqueueBatch(this.takeBatch());
1917
2331
  }
1918
2332
  await this.commitInFlight;
1919
2333
  }
@@ -1923,32 +2337,61 @@ class VcsManager {
1923
2337
  async stop() {
1924
2338
  this.started = false;
1925
2339
  this.squashManager?.stop();
2340
+ this.clearBreakerRetry();
1926
2341
  await this.flush();
1927
2342
  this.logger.info({ root: this.rootPath }, 'VcsManager stopped');
1928
2343
  }
1929
2344
  /**
1930
- * Take up to N items from the pending set.
2345
+ * Add tracked-but-deleted paths (`git ls-files --deleted`) in this
2346
+ * manager's watch scope to pending, so the next commit (typically the
2347
+ * baseline) records deletions that happened while nothing was watching.
2348
+ * Called by the coordinator before flush/endBaseline on startup. See #249.
2349
+ *
2350
+ * @param isInScope - Returns true if the absolute path routes to this
2351
+ * manager (the coordinator's routing and watch-scope logic).
1931
2352
  */
1932
- takeBatch(n) {
1933
- const batch = [];
1934
- for (const item of this.pending) {
1935
- if (batch.length >= n)
1936
- break;
1937
- batch.push(item);
2353
+ async reconcileDeletions(isInScope) {
2354
+ try {
2355
+ const deleted = (await listDeletedTrackedPaths(this.rootPath)).filter(isInScope);
2356
+ for (const path of deleted)
2357
+ this.pending.add(path);
2358
+ if (deleted.length > 0) {
2359
+ this.logger.info({ root: this.rootPath, count: deleted.length }, 'VCS startup reconciliation — queued tracked deletions');
2360
+ }
1938
2361
  }
1939
- for (const item of batch) {
1940
- this.pending.delete(item);
2362
+ catch (error) {
2363
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'VCS startup deletion reconciliation failed');
1941
2364
  }
2365
+ }
2366
+ /** Take up to maxBatchSize files from the pending set. */
2367
+ takeBatch() {
2368
+ const batch = [...this.pending].slice(0, this.config.maxBatchSize);
2369
+ for (const item of batch)
2370
+ this.pending.delete(item);
1942
2371
  return batch;
1943
2372
  }
1944
- /**
1945
- * Clear the throttle timer.
1946
- */
2373
+ /** Chain a batch behind the in-flight commit, tracking it as backlog. */
2374
+ enqueueBatch(batch) {
2375
+ this.inFlightFileCount += batch.length;
2376
+ this.commitInFlight = this.commitInFlight.then(async () => {
2377
+ try {
2378
+ await this.commitBatch(batch);
2379
+ }
2380
+ finally {
2381
+ this.inFlightFileCount -= batch.length;
2382
+ }
2383
+ });
2384
+ }
2385
+ /** Put files back in the pending set. Pending is a set, so this cannot grow it past the distinct paths seen. */
2386
+ retain(files) {
2387
+ for (const f of files)
2388
+ this.pending.add(f);
2389
+ }
1947
2390
  clearThrottle() {
1948
- if (this.throttleTimer !== undefined) {
1949
- clearTimeout(this.throttleTimer);
1950
- this.throttleTimer = undefined;
1951
- }
2391
+ if (this.throttleTimer === undefined)
2392
+ return;
2393
+ clearTimeout(this.throttleTimer);
2394
+ this.throttleTimer = undefined;
1952
2395
  }
1953
2396
  /**
1954
2397
  * Start the throttle timer to fire flush() after commitThrottleMs.
@@ -1962,117 +2405,148 @@ class VcsManager {
1962
2405
  void this.flush();
1963
2406
  }, this.config.commitThrottleMs);
1964
2407
  }
1965
- /**
1966
- * Check for and remove stale index.lock files before retrying.
1967
- */
1968
- async removeStaleLock() {
1969
- const lockPath = join(this.rootPath, '.git', 'index.lock');
1970
- try {
1971
- const lockStat = await stat(lockPath);
1972
- const ageMs = Date.now() - lockStat.mtimeMs;
1973
- if (ageMs > this.config.staleLockThresholdMs) {
1974
- await rm(lockPath, { force: true });
1975
- this.logger.warn({ root: this.rootPath, ageMs }, 'Removed stale index.lock');
1976
- }
1977
- }
1978
- catch (error) {
1979
- const code = error.code;
1980
- if (code !== 'ENOENT') {
1981
- this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
1982
- }
1983
- }
2408
+ clearBreakerRetry() {
2409
+ if (this.breakerRetryTimer === undefined)
2410
+ return;
2411
+ clearTimeout(this.breakerRetryTimer);
2412
+ this.breakerRetryTimer = undefined;
1984
2413
  }
1985
2414
  /**
1986
- * Check whether a git error represents a "nothing to commit" state.
1987
- * Git outputs this message to stdout (not stderr) on exit code 1.
2415
+ * Ensure a timer will flush pending files when the breaker's cooldown
2416
+ * elapses, without needing a new file-change event.
1988
2417
  */
1989
- isNothingToCommitError(error) {
1990
- const { message, stderr, stdout } = getExecErrorFields(error);
1991
- return (message.includes('nothing to commit') ||
1992
- stderr.includes('nothing to commit') ||
1993
- stdout.includes('nothing to commit'));
2418
+ scheduleBreakerRetry() {
2419
+ if (!this.started || this.breakerRetryTimer !== undefined)
2420
+ return;
2421
+ this.breakerRetryTimer = setTimeout(() => {
2422
+ this.breakerRetryTimer = undefined;
2423
+ void this.flush();
2424
+ }, this.breaker.remainingCooldownMs());
1994
2425
  }
1995
2426
  /**
1996
- * Commit a batch of files to the git repo with index.lock retry,
1997
- * stale lock detection, circuit breaker, pause support, and re-queue cap.
2427
+ * Commit a batch with index.lock retry, stale lock detection, pause
2428
+ * support, and the circuit breaker. A failed batch is always retained.
1998
2429
  */
1999
2430
  async commitBatch(files) {
2000
2431
  if (files.length === 0)
2001
2432
  return;
2002
- // Pause support: re-queue files without counting as failure
2003
2433
  if (this.paused) {
2004
- for (const f of files) {
2005
- this.pending.add(f);
2006
- }
2434
+ this.retain(files);
2007
2435
  this.logger.debug({ root: this.rootPath, fileCount: files.length }, 'VcsManager paused — files re-queued');
2008
2436
  return;
2009
2437
  }
2010
- // Circuit breaker: skip if too many consecutive failures
2011
- if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
2012
- this.logger.error({
2013
- root: this.rootPath,
2014
- consecutiveFailures: this.consecutiveCommitFailures,
2015
- discardedFiles: files.length,
2016
- }, 'VCS circuit breaker tripped — pending files discarded until next successful commit');
2017
- return;
2438
+ if (this.breaker.isTripped) {
2439
+ if (this.breaker.remainingCooldownMs() > 0) {
2440
+ this.retain(files);
2441
+ this.scheduleBreakerRetry();
2442
+ return;
2443
+ }
2444
+ this.logger.info({ root: this.rootPath }, 'VCS circuit breaker half-open — attempting recovery commit');
2018
2445
  }
2019
2446
  try {
2020
- // Check for stale lock before first attempt
2021
- await this.removeStaleLock();
2022
- await retry(async (attempt) => {
2023
- await gitAddViaStdin(files, this.rootPath, GIT_TIMEOUT_STANDARD);
2024
- // Build message after staging so getStagedDiff can see the changes.
2025
- // Skip AI for baselines and retries — use template directly.
2026
- const message = this.isBaseline || attempt > 1
2027
- ? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
2028
- : await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
2029
- this.pendingReversions.length = 0;
2030
- await execFileAsync('git', ['commit', '-m', message], {
2031
- cwd: this.rootPath,
2032
- timeout: GIT_TIMEOUT_STANDARD,
2033
- });
2034
- const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
2035
- const hash = hashOut.trim();
2036
- this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
2037
- const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
2038
- if (pushTime)
2039
- this._lastPushTime = pushTime;
2040
- }, {
2447
+ await removeStaleIndexLock(this.rootPath, this.config.staleLockThresholdMs, this.logger);
2448
+ const committed = await retry((attempt) => this.tryCommit(files, attempt), {
2041
2449
  attempts: 4,
2042
2450
  baseDelayMs: 500,
2043
2451
  maxDelayMs: 2000,
2452
+ // Only index.lock contention is transient. Anything else fails on
2453
+ // the first attempt with its real message. See #249.
2454
+ shouldRetry: isIndexLockError,
2044
2455
  onRetry: ({ attempt, delayMs }) => {
2045
2456
  this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
2046
2457
  },
2047
2458
  });
2048
- // Success — reset circuit breaker
2049
- this.consecutiveCommitFailures = 0;
2459
+ if (!committed) {
2460
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'VCS commit skipped — nothing staged');
2461
+ }
2462
+ this.onCommitSuccess();
2050
2463
  }
2051
2464
  catch (error) {
2052
- // Bug 5: "nothing to commit" is a no-op, not a failure
2053
- if (this.isNothingToCommitError(error)) {
2054
- this.logger.info({ root: this.rootPath, fileCount: files.length }, 'VCS commit skipped — nothing to commit');
2055
- return;
2056
- }
2057
- this.consecutiveCommitFailures++;
2058
- // Re-queue cap: only re-queue up to maxBatchSize files
2059
- const cap = this.config.maxBatchSize;
2060
- const toRequeue = files.slice(0, cap);
2061
- const overflow = files.length - toRequeue.length;
2062
- for (const f of toRequeue) {
2063
- this.pending.add(f);
2064
- }
2065
- if (overflow > 0) {
2066
- this.logger.warn({ root: this.rootPath, overflow, cap }, 'Re-queue cap exceeded — discarded overflow files');
2067
- }
2068
- this.logger.warn({ root: this.rootPath, fileCount: toRequeue.length }, 'Re-queued files after commit failure');
2069
- this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
2465
+ this.onCommitFailure(files, error);
2466
+ }
2467
+ }
2468
+ /**
2469
+ * One commit attempt: stage, skip if nothing is staged, commit, push.
2470
+ *
2471
+ * @returns true if a commit was created; false for a no-op.
2472
+ */
2473
+ async tryCommit(files, attempt) {
2474
+ await stageBatch(files, this.rootPath, this.logger);
2475
+ // "Nothing staged" is a no-op, detected without parsing git's text.
2476
+ if (!(await hasStagedChanges(this.rootPath)))
2477
+ return false;
2478
+ // Build message after staging so getStagedDiff can see the changes.
2479
+ // Skip AI for baselines and retries — use template directly.
2480
+ const message = this.isBaseline || attempt > 1
2481
+ ? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
2482
+ : await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
2483
+ this.pendingReversions.length = 0;
2484
+ const hash = await gitCommit(this.rootPath, message);
2485
+ this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
2486
+ const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
2487
+ if (pushTime)
2488
+ this._lastPushTime = pushTime;
2489
+ return true;
2490
+ }
2491
+ onCommitSuccess() {
2492
+ if (!this.breaker.recordSuccess())
2493
+ return;
2494
+ this.clearBreakerRetry();
2495
+ this.logger.info({ root: this.rootPath }, 'VCS circuit breaker recovered — resuming normal operation');
2496
+ }
2497
+ onCommitFailure(files, error) {
2498
+ const err = normalizeError(error);
2499
+ this.retain(files);
2500
+ this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
2501
+ this.logger.error({ root: this.rootPath, err }, 'VCS commit failed');
2502
+ if (!this.breaker.recordFailure(err.message)) {
2070
2503
  // Bug 2: Restart throttle timer so re-queued files get retried
2071
2504
  this.resetThrottle();
2505
+ return;
2072
2506
  }
2507
+ this.logger.error({
2508
+ root: this.rootPath,
2509
+ consecutiveFailures: this.breaker.snapshot().consecutiveFailures,
2510
+ pendingCount: this.pending.size,
2511
+ cooldownMs: this.config.circuitBreakerCooldownMs,
2512
+ }, 'VCS circuit breaker tripped — pending files retained, will retry after cooldown');
2513
+ this.scheduleBreakerRetry();
2073
2514
  }
2074
2515
  }
2075
2516
 
2517
+ /**
2518
+ * @module vcs/watchScope
2519
+ * Build a predicate for "would the filesystem watcher observe this path?"
2520
+ * from `watch.paths` and `watch.ignored`, using the watcher's own glob
2521
+ * resolution. Used to scope VCS startup deletion reconciliation. See #249.
2522
+ */
2523
+ /**
2524
+ * Create a watch-scope predicate. A path is in scope if it matches a watch
2525
+ * glob and neither it nor any ancestor directory matches a `watch.ignored`
2526
+ * pattern (chokidar applies `ignored` to directories as well as files).
2527
+ *
2528
+ * @param watch - The `watch` section of the config.
2529
+ * @returns The scope predicate.
2530
+ */
2531
+ function createWatchScope(watch) {
2532
+ const matchesGlobs = resolveWatchPaths(extractWatchPathStrings(watch.paths)).matches;
2533
+ // Config `ignored` entries are strings, which resolveIgnored always turns
2534
+ // into picomatch functions; the filter only narrows the union type.
2535
+ const ignored = resolveIgnored(watch.ignored).filter((entry) => typeof entry === 'function');
2536
+ return (normalizedPath) => {
2537
+ if (!matchesGlobs(normalizedPath))
2538
+ return false;
2539
+ for (let current = normalizedPath;;) {
2540
+ if (ignored.some((isIgnored) => isIgnored(current)))
2541
+ return false;
2542
+ const parent = normalizeSlashes(dirname(current));
2543
+ if (parent === current)
2544
+ return true;
2545
+ current = parent;
2546
+ }
2547
+ };
2548
+ }
2549
+
2076
2550
  /**
2077
2551
  * @module vcs/VcsCoordinator
2078
2552
  * Orchestrates VCS across all watch roots. Routes file events to the correct
@@ -2085,10 +2559,14 @@ class VcsCoordinator {
2085
2559
  managers = new Map();
2086
2560
  roots = [];
2087
2561
  logger;
2562
+ isInWatchScope = () => false;
2088
2563
  constructor(config, logger) {
2089
2564
  this.logger = logger;
2090
2565
  if (!config.vcs?.enabled)
2091
2566
  return;
2567
+ // Same watch-scope logic as the filesystem watcher (globs + ignored),
2568
+ // used to filter startup deletion reconciliation. See #249.
2569
+ this.isInWatchScope = createWatchScope(config.watch);
2092
2570
  const normalized = normalizeWatchPaths(config.watch.paths);
2093
2571
  for (const entry of normalized) {
2094
2572
  const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
@@ -2167,6 +2645,11 @@ class VcsCoordinator {
2167
2645
  */
2168
2646
  async onInitialScanComplete() {
2169
2647
  for (const manager of this.managers.values()) {
2648
+ // Reconcile tracked-but-deleted paths within this manager's watch
2649
+ // scope before flushing, so the baseline commit records deletions
2650
+ // that happened while the process wasn't watching. See #249.
2651
+ await manager.reconcileDeletions((absPath) => this.findManagerForPath(absPath) === manager &&
2652
+ this.isInWatchScope(absPath));
2170
2653
  await manager.flush();
2171
2654
  manager.endBaseline();
2172
2655
  }
@@ -5054,7 +5537,11 @@ function createVcsCheckExclusionHandler(deps) {
5054
5537
  return;
5055
5538
  }
5056
5539
  try {
5057
- const { stdout } = await execFileAsync('git', ['check-ignore', '-v', resolved.relativePath], { cwd: resolved.root });
5540
+ const { stdout } = await runGit(resolved.root, [
5541
+ 'check-ignore',
5542
+ '-v',
5543
+ resolved.relativePath,
5544
+ ]);
5058
5545
  // Output format: <source>:<linenum>:<pattern>\t<pathname>
5059
5546
  const trimmed = stdout.trim();
5060
5547
  const match = /^(.+):(\d+):(.+)\t/.exec(trimmed);
@@ -5102,7 +5589,7 @@ function createVcsDiffHandler(deps) {
5102
5589
  const range = commitEnd ? `${commit}..${commitEnd}` : `${commit}..HEAD`;
5103
5590
  const args = ['diff', range, '--', relativePath || '.'];
5104
5591
  try {
5105
- const { stdout } = await execFileAsync('git', args, { cwd: root });
5592
+ const { stdout } = await runGit(root, args);
5106
5593
  if (stdout.trim())
5107
5594
  diffs.push(stdout);
5108
5595
  }
@@ -5316,7 +5803,7 @@ function createVcsHistoryHandler(deps) {
5316
5803
  args.push(`--until=${until}`);
5317
5804
  args.push('--', relativePath || '.');
5318
5805
  try {
5319
- const { stdout } = await execFileAsync('git', args, { cwd: root });
5806
+ const { stdout } = await runGit(root, args);
5320
5807
  allEntries.push(...parseGitLog(stdout));
5321
5808
  }
5322
5809
  catch {
@@ -5363,7 +5850,7 @@ function createVcsRevertHandler(deps) {
5363
5850
  const args = relativePath === '' || relativePath === '.'
5364
5851
  ? ['ls-tree', '-r', '--name-only', commit]
5365
5852
  : ['ls-tree', '-r', '--name-only', commit, '--', relativePath];
5366
- const { stdout } = await execFileAsync('git', args, { cwd: root });
5853
+ const { stdout } = await runGit(root, args);
5367
5854
  fileList = stdout
5368
5855
  .trim()
5369
5856
  .split('\n')
@@ -5387,7 +5874,10 @@ function createVcsRevertHandler(deps) {
5387
5874
  // Get file content at the target commit
5388
5875
  let content;
5389
5876
  try {
5390
- const { stdout } = await execFileAsync('git', ['show', `${commit}:${relFile}`], { cwd: root });
5877
+ const { stdout } = await runGit(root, [
5878
+ 'show',
5879
+ `${commit}:${relFile}`,
5880
+ ]);
5391
5881
  content = stdout;
5392
5882
  }
5393
5883
  catch {
@@ -5452,7 +5942,10 @@ function createVcsShowHandler(deps) {
5452
5942
  return;
5453
5943
  }
5454
5944
  try {
5455
- const { stdout } = await execFileAsync('git', ['show', `${commit}:${resolved.relativePath}`], { cwd: resolved.root });
5945
+ const { stdout } = await runGit(resolved.root, [
5946
+ 'show',
5947
+ `${commit}:${resolved.relativePath}`,
5948
+ ]);
5456
5949
  const ext = extname(path).toLowerCase();
5457
5950
  const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
5458
5951
  void reply.header('content-type', contentType).send(stdout);
@@ -5469,37 +5962,34 @@ function createVcsShowHandler(deps) {
5469
5962
  * @module api/handlers/vcs/vcsStatus
5470
5963
  * Fastify route handler for GET /vcs/status. Returns VCS state for all roots.
5471
5964
  */
5472
- async function getLastCommit(cwd) {
5965
+ /**
5966
+ * Run a read-only git query; a failure (no commits, no remote, not a repo)
5967
+ * or empty output yields null.
5968
+ */
5969
+ async function queryGit(cwd, args) {
5473
5970
  try {
5474
- const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%H|%s|%aI'], { cwd });
5475
- const trimmed = stdout.trim();
5476
- if (!trimmed)
5477
- return null;
5478
- const [hash, message, timestamp] = trimmed.split('|');
5479
- return { hash, message, timestamp };
5971
+ const out = (await runGit(cwd, args)).stdout.trim();
5972
+ return out || null;
5480
5973
  }
5481
5974
  catch {
5482
5975
  return null;
5483
5976
  }
5484
5977
  }
5485
- async function getTrackedCount(cwd) {
5486
- try {
5487
- const { stdout } = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], { cwd });
5488
- return parseInt(stdout.trim(), 10);
5489
- }
5490
- catch {
5491
- return 0;
5492
- }
5493
- }
5494
- async function getRemoteUrl(cwd) {
5495
- try {
5496
- const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd });
5497
- const url = stdout.trim();
5498
- return url || null;
5499
- }
5500
- catch {
5978
+ async function getLastCommit(cwd) {
5979
+ const out = await queryGit(cwd, ['log', '-1', '--format=%H|%s|%aI']);
5980
+ if (out === null)
5501
5981
  return null;
5502
- }
5982
+ // Hash and timestamp never contain '|'; the subject may.
5983
+ const parts = out.split('|');
5984
+ return {
5985
+ hash: parts[0],
5986
+ message: parts.slice(1, -1).join('|'),
5987
+ timestamp: parts[parts.length - 1],
5988
+ };
5989
+ }
5990
+ async function getCommitCount(cwd) {
5991
+ const out = await queryGit(cwd, ['rev-list', '--count', 'HEAD']);
5992
+ return out === null ? 0 : parseInt(out, 10);
5503
5993
  }
5504
5994
  /**
5505
5995
  * Create handler for GET /vcs/status.
@@ -5511,9 +6001,9 @@ function createVcsStatusHandler(deps) {
5511
6001
  const rootStatuses = await Promise.all(roots.map(async (root) => {
5512
6002
  const manager = deps.coordinator.getManager(root);
5513
6003
  const [tracked, lastCommit, remoteUrl] = await Promise.all([
5514
- getTrackedCount(root),
6004
+ getCommitCount(root),
5515
6005
  getLastCommit(root),
5516
- getRemoteUrl(root),
6006
+ queryGit(root, ['remote', 'get-url', 'origin']),
5517
6007
  ]);
5518
6008
  return {
5519
6009
  path: root,
@@ -5522,6 +6012,7 @@ function createVcsStatusHandler(deps) {
5522
6012
  remoteUrl: manager?.remoteUrl ?? remoteUrl,
5523
6013
  lastPush: manager?.lastPushTime ?? null,
5524
6014
  pushErrors: manager?.pushErrors ?? [],
6015
+ breaker: manager?.breakerState ?? null,
5525
6016
  };
5526
6017
  }));
5527
6018
  return { enabled, roots: rootStatuses };