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