@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.
- package/config.schema.json +207 -183
- package/dist/cli/jeeves-watcher/{index-CD0v-oGg.js → index-C_bf9c9m.js} +821 -330
- package/dist/cli/jeeves-watcher/index.js +62 -44
- package/dist/index.d.ts +90 -28
- package/dist/index.js +871 -362
- package/package.json +3 -3
|
@@ -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
|
|
4
|
-
export {
|
|
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,
|
|
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,
|
|
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
|
|
1058
|
-
|
|
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',
|
|
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
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
|
1407
|
-
|
|
1408
|
-
|
|
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
|
|
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
|
|
1465
|
-
|
|
1466
|
-
const
|
|
1467
|
-
|
|
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.
|
|
1492
|
-
await
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
});
|
|
1496
|
-
|
|
1497
|
-
await
|
|
1498
|
-
|
|
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
|
|
1517
|
-
|
|
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.
|
|
1522
|
-
const { stdout: newHead } = await
|
|
1523
|
-
await
|
|
1524
|
-
|
|
1525
|
-
await
|
|
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
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
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
|
-
|
|
1553
|
-
await rm(lockFile, { force: true });
|
|
1961
|
+
await runGit(this.rootPath, args);
|
|
1554
1962
|
}
|
|
1555
1963
|
catch {
|
|
1556
|
-
//
|
|
1964
|
+
// Best effort: the state may not need this step.
|
|
1557
1965
|
}
|
|
1558
|
-
|
|
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',
|
|
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
|
|
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
|
|
1634
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1859
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2325
|
+
* Commit all pending files now, in batches of at most maxBatchSize.
|
|
1910
2326
|
*/
|
|
1911
2327
|
async flush() {
|
|
1912
2328
|
this.clearThrottle();
|
|
1913
|
-
|
|
1914
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
-
|
|
1940
|
-
this.
|
|
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
|
-
|
|
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
|
|
1949
|
-
|
|
1950
|
-
|
|
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
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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
|
-
*
|
|
1987
|
-
*
|
|
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
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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
|
|
1997
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
}, 'VCS circuit breaker
|
|
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
|
-
|
|
2021
|
-
await this.
|
|
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
|
-
|
|
2049
|
-
|
|
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
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
this.
|
|
2069
|
-
this.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
5475
|
-
|
|
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
|
|
5486
|
-
|
|
5487
|
-
|
|
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
|
-
|
|
6004
|
+
getCommitCount(root),
|
|
5515
6005
|
getLastCommit(root),
|
|
5516
|
-
|
|
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 };
|