@karmaniverous/jeeves-watcher 0.19.0 → 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 +2 -2
package/dist/index.js
CHANGED
|
@@ -403,6 +403,7 @@ function computeDelayMs(attempt, baseDelayMs, maxDelayMs, jitter = 0) {
|
|
|
403
403
|
*/
|
|
404
404
|
async function retry(fn, options) {
|
|
405
405
|
const attempts = Math.max(1, options.attempts);
|
|
406
|
+
const shouldRetry = options.shouldRetry ?? (() => true);
|
|
406
407
|
let lastError;
|
|
407
408
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
408
409
|
try {
|
|
@@ -411,7 +412,7 @@ async function retry(fn, options) {
|
|
|
411
412
|
catch (error) {
|
|
412
413
|
lastError = error;
|
|
413
414
|
const isLast = attempt >= attempts;
|
|
414
|
-
if (isLast)
|
|
415
|
+
if (isLast || !shouldRetry(error))
|
|
415
416
|
break;
|
|
416
417
|
const delayMs = computeDelayMs(attempt, options.baseDelayMs, options.maxDelayMs, options.jitter);
|
|
417
418
|
options.onRetry?.({ attempt, attempts, delayMs, error });
|
|
@@ -423,10 +424,28 @@ async function retry(fn, options) {
|
|
|
423
424
|
|
|
424
425
|
/**
|
|
425
426
|
* @module vcs/gitExec
|
|
426
|
-
* Shared git execution utilities
|
|
427
|
+
* Shared git execution utilities: the pinned argv prefix, timeouts, error
|
|
428
|
+
* field extraction and classification, and path comparison helpers.
|
|
427
429
|
*/
|
|
428
430
|
/** Promisified execFile for git commands. */
|
|
429
431
|
const execFileAsync = promisify(execFile);
|
|
432
|
+
/**
|
|
433
|
+
* Leading git arguments applied to every VCS-owned git invocation.
|
|
434
|
+
*
|
|
435
|
+
* `-c core.longpaths=true` lets Windows checkouts handle paths beyond the
|
|
436
|
+
* legacy MAX_PATH limit (260 chars) without requiring a machine-wide
|
|
437
|
+
* `core.longpaths` git config or registry change. See #249.
|
|
438
|
+
*/
|
|
439
|
+
const GIT_BASE_ARGS = ['-c', 'core.longpaths=true'];
|
|
440
|
+
/**
|
|
441
|
+
* Build a git argv array with {@link GIT_BASE_ARGS} prefixed.
|
|
442
|
+
*
|
|
443
|
+
* @param args - The subcommand and its arguments (e.g. `'commit', '-m', msg`).
|
|
444
|
+
* @returns The full argv array, base args first.
|
|
445
|
+
*/
|
|
446
|
+
function gitArgs(...args) {
|
|
447
|
+
return [...GIT_BASE_ARGS, ...args];
|
|
448
|
+
}
|
|
430
449
|
/** Standard timeout (ms) for most git operations. */
|
|
431
450
|
const GIT_TIMEOUT_STANDARD = 30_000;
|
|
432
451
|
/** Extended timeout (ms) for cherry-pick operations. */
|
|
@@ -448,49 +467,37 @@ function getExecErrorFields(error) {
|
|
|
448
467
|
return { message: error.message || '', stderr, stdout };
|
|
449
468
|
}
|
|
450
469
|
/**
|
|
451
|
-
*
|
|
470
|
+
* Read the `code` property of an unknown rejection value, if present.
|
|
471
|
+
* execFile rejects with the child's numeric exit status in `code`; fs calls
|
|
472
|
+
* reject with a string errno code (e.g. `'ENOENT'`).
|
|
452
473
|
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
474
|
+
* @param error - The caught value.
|
|
475
|
+
* @returns The code, or undefined if the value has none.
|
|
476
|
+
*/
|
|
477
|
+
function getErrorCode(error) {
|
|
478
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
const { code } = error;
|
|
482
|
+
return typeof code === 'string' || typeof code === 'number'
|
|
483
|
+
? code
|
|
484
|
+
: undefined;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Check whether an error is caused by `index.lock` contention.
|
|
458
488
|
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
489
|
+
* Requires the literal `index.lock` in the message or stderr: a bare
|
|
490
|
+
* `EEXIST` (any file that already exists) is not lock contention and must
|
|
491
|
+
* not be retried as if it were. Only Error instances qualify.
|
|
492
|
+
*
|
|
493
|
+
* @param error - The caught value.
|
|
494
|
+
* @returns true if the error names `index.lock`.
|
|
462
495
|
*/
|
|
463
|
-
function
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (timedOut)
|
|
469
|
-
return;
|
|
470
|
-
if (error)
|
|
471
|
-
reject(error);
|
|
472
|
-
else
|
|
473
|
-
resolve();
|
|
474
|
-
});
|
|
475
|
-
const timer = setTimeout(() => {
|
|
476
|
-
timedOut = true;
|
|
477
|
-
child.kill();
|
|
478
|
-
reject(new Error(`git add timed out after ${String(timeoutMs)}ms`));
|
|
479
|
-
}, timeoutMs);
|
|
480
|
-
if (!child.stdin) {
|
|
481
|
-
clearTimeout(timer);
|
|
482
|
-
reject(new Error('Failed to open stdin for git add'));
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
// Suppress EPIPE — expected if git exits before we finish writing
|
|
486
|
-
child.stdin.on('error', (err) => {
|
|
487
|
-
if (err.code !== 'EPIPE') {
|
|
488
|
-
clearTimeout(timer);
|
|
489
|
-
reject(err);
|
|
490
|
-
}
|
|
491
|
-
});
|
|
492
|
-
child.stdin.end(files.join('\0'));
|
|
493
|
-
});
|
|
496
|
+
function isIndexLockError(error) {
|
|
497
|
+
if (!(error instanceof Error))
|
|
498
|
+
return false;
|
|
499
|
+
const { message, stderr } = getExecErrorFields(error);
|
|
500
|
+
return message.includes('index.lock') || stderr.includes('index.lock');
|
|
494
501
|
}
|
|
495
502
|
/**
|
|
496
503
|
* Normalize a path for case-insensitive comparison on Windows.
|
|
@@ -503,6 +510,17 @@ function gitAddViaStdin(files, cwd, timeoutMs = 30_000) {
|
|
|
503
510
|
function normalizePathCase(p, platform = process.platform) {
|
|
504
511
|
return platform === 'win32' ? p.toLowerCase() : p;
|
|
505
512
|
}
|
|
513
|
+
/**
|
|
514
|
+
* Build a comparison key for an absolute path: resolved, forward slashes,
|
|
515
|
+
* and lowercased on Windows.
|
|
516
|
+
*
|
|
517
|
+
* @param p - The path.
|
|
518
|
+
* @param platform - The platform to check against (default: `process.platform`).
|
|
519
|
+
* @returns The comparison key.
|
|
520
|
+
*/
|
|
521
|
+
function pathKey(p, platform = process.platform) {
|
|
522
|
+
return normalizePathCase(normalizeSlashes(resolve(p)), platform);
|
|
523
|
+
}
|
|
506
524
|
/**
|
|
507
525
|
* Find the longest-prefix-match root for a normalized path.
|
|
508
526
|
* Roots must be sorted longest-first for correct nested matching.
|
|
@@ -3661,6 +3679,28 @@ function resolveWatchRootsForGlob(coordinator, glob) {
|
|
|
3661
3679
|
return results;
|
|
3662
3680
|
}
|
|
3663
3681
|
|
|
3682
|
+
/**
|
|
3683
|
+
* @module vcs/runGit
|
|
3684
|
+
* Run a git subcommand in a repository with the pinned argv prefix and a
|
|
3685
|
+
* timeout. Lives in its own module (rather than gitExec) so callers resolve
|
|
3686
|
+
* `execFileAsync` through the gitExec module binding.
|
|
3687
|
+
*/
|
|
3688
|
+
/**
|
|
3689
|
+
* Run `git <args>` in `cwd` with {@link gitArgs} applied.
|
|
3690
|
+
*
|
|
3691
|
+
* @param cwd - Repository root (working directory for git).
|
|
3692
|
+
* @param args - Subcommand and arguments.
|
|
3693
|
+
* @param options - Timeout and buffer limits.
|
|
3694
|
+
* @returns The child's stdout and stderr.
|
|
3695
|
+
*/
|
|
3696
|
+
function runGit(cwd, args, { timeoutMs = GIT_TIMEOUT_STANDARD, maxBuffer } = {}) {
|
|
3697
|
+
return execFileAsync('git', gitArgs(...args), {
|
|
3698
|
+
cwd,
|
|
3699
|
+
timeout: timeoutMs,
|
|
3700
|
+
...(maxBuffer === undefined ? {} : { maxBuffer }),
|
|
3701
|
+
});
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3664
3704
|
/**
|
|
3665
3705
|
* @module api/handlers/vcs/vcsCheckExclusion
|
|
3666
3706
|
* Fastify route handler for GET /vcs/check-exclusion. Checks gitignore status of a path.
|
|
@@ -3685,7 +3725,11 @@ function createVcsCheckExclusionHandler(deps) {
|
|
|
3685
3725
|
return;
|
|
3686
3726
|
}
|
|
3687
3727
|
try {
|
|
3688
|
-
const { stdout } = await
|
|
3728
|
+
const { stdout } = await runGit(resolved.root, [
|
|
3729
|
+
'check-ignore',
|
|
3730
|
+
'-v',
|
|
3731
|
+
resolved.relativePath,
|
|
3732
|
+
]);
|
|
3689
3733
|
// Output format: <source>:<linenum>:<pattern>\t<pathname>
|
|
3690
3734
|
const trimmed = stdout.trim();
|
|
3691
3735
|
const match = /^(.+):(\d+):(.+)\t/.exec(trimmed);
|
|
@@ -3733,7 +3777,7 @@ function createVcsDiffHandler(deps) {
|
|
|
3733
3777
|
const range = commitEnd ? `${commit}..${commitEnd}` : `${commit}..HEAD`;
|
|
3734
3778
|
const args = ['diff', range, '--', relativePath || '.'];
|
|
3735
3779
|
try {
|
|
3736
|
-
const { stdout } = await
|
|
3780
|
+
const { stdout } = await runGit(root, args);
|
|
3737
3781
|
if (stdout.trim())
|
|
3738
3782
|
diffs.push(stdout);
|
|
3739
3783
|
}
|
|
@@ -3947,7 +3991,7 @@ function createVcsHistoryHandler(deps) {
|
|
|
3947
3991
|
args.push(`--until=${until}`);
|
|
3948
3992
|
args.push('--', relativePath || '.');
|
|
3949
3993
|
try {
|
|
3950
|
-
const { stdout } = await
|
|
3994
|
+
const { stdout } = await runGit(root, args);
|
|
3951
3995
|
allEntries.push(...parseGitLog(stdout));
|
|
3952
3996
|
}
|
|
3953
3997
|
catch {
|
|
@@ -3994,7 +4038,7 @@ function createVcsRevertHandler(deps) {
|
|
|
3994
4038
|
const args = relativePath === '' || relativePath === '.'
|
|
3995
4039
|
? ['ls-tree', '-r', '--name-only', commit]
|
|
3996
4040
|
: ['ls-tree', '-r', '--name-only', commit, '--', relativePath];
|
|
3997
|
-
const { stdout } = await
|
|
4041
|
+
const { stdout } = await runGit(root, args);
|
|
3998
4042
|
fileList = stdout
|
|
3999
4043
|
.trim()
|
|
4000
4044
|
.split('\n')
|
|
@@ -4018,7 +4062,10 @@ function createVcsRevertHandler(deps) {
|
|
|
4018
4062
|
// Get file content at the target commit
|
|
4019
4063
|
let content;
|
|
4020
4064
|
try {
|
|
4021
|
-
const { stdout } = await
|
|
4065
|
+
const { stdout } = await runGit(root, [
|
|
4066
|
+
'show',
|
|
4067
|
+
`${commit}:${relFile}`,
|
|
4068
|
+
]);
|
|
4022
4069
|
content = stdout;
|
|
4023
4070
|
}
|
|
4024
4071
|
catch {
|
|
@@ -4083,7 +4130,10 @@ function createVcsShowHandler(deps) {
|
|
|
4083
4130
|
return;
|
|
4084
4131
|
}
|
|
4085
4132
|
try {
|
|
4086
|
-
const { stdout } = await
|
|
4133
|
+
const { stdout } = await runGit(resolved.root, [
|
|
4134
|
+
'show',
|
|
4135
|
+
`${commit}:${resolved.relativePath}`,
|
|
4136
|
+
]);
|
|
4087
4137
|
const ext = extname(path).toLowerCase();
|
|
4088
4138
|
const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
|
4089
4139
|
void reply.header('content-type', contentType).send(stdout);
|
|
@@ -4100,37 +4150,34 @@ function createVcsShowHandler(deps) {
|
|
|
4100
4150
|
* @module api/handlers/vcs/vcsStatus
|
|
4101
4151
|
* Fastify route handler for GET /vcs/status. Returns VCS state for all roots.
|
|
4102
4152
|
*/
|
|
4103
|
-
|
|
4153
|
+
/**
|
|
4154
|
+
* Run a read-only git query; a failure (no commits, no remote, not a repo)
|
|
4155
|
+
* or empty output yields null.
|
|
4156
|
+
*/
|
|
4157
|
+
async function queryGit(cwd, args) {
|
|
4104
4158
|
try {
|
|
4105
|
-
const
|
|
4106
|
-
|
|
4107
|
-
if (!trimmed)
|
|
4108
|
-
return null;
|
|
4109
|
-
const [hash, message, timestamp] = trimmed.split('|');
|
|
4110
|
-
return { hash, message, timestamp };
|
|
4159
|
+
const out = (await runGit(cwd, args)).stdout.trim();
|
|
4160
|
+
return out || null;
|
|
4111
4161
|
}
|
|
4112
4162
|
catch {
|
|
4113
4163
|
return null;
|
|
4114
4164
|
}
|
|
4115
4165
|
}
|
|
4116
|
-
async function
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
return parseInt(stdout.trim(), 10);
|
|
4120
|
-
}
|
|
4121
|
-
catch {
|
|
4122
|
-
return 0;
|
|
4123
|
-
}
|
|
4124
|
-
}
|
|
4125
|
-
async function getRemoteUrl(cwd) {
|
|
4126
|
-
try {
|
|
4127
|
-
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd });
|
|
4128
|
-
const url = stdout.trim();
|
|
4129
|
-
return url || null;
|
|
4130
|
-
}
|
|
4131
|
-
catch {
|
|
4166
|
+
async function getLastCommit(cwd) {
|
|
4167
|
+
const out = await queryGit(cwd, ['log', '-1', '--format=%H|%s|%aI']);
|
|
4168
|
+
if (out === null)
|
|
4132
4169
|
return null;
|
|
4133
|
-
|
|
4170
|
+
// Hash and timestamp never contain '|'; the subject may.
|
|
4171
|
+
const parts = out.split('|');
|
|
4172
|
+
return {
|
|
4173
|
+
hash: parts[0],
|
|
4174
|
+
message: parts.slice(1, -1).join('|'),
|
|
4175
|
+
timestamp: parts[parts.length - 1],
|
|
4176
|
+
};
|
|
4177
|
+
}
|
|
4178
|
+
async function getCommitCount(cwd) {
|
|
4179
|
+
const out = await queryGit(cwd, ['rev-list', '--count', 'HEAD']);
|
|
4180
|
+
return out === null ? 0 : parseInt(out, 10);
|
|
4134
4181
|
}
|
|
4135
4182
|
/**
|
|
4136
4183
|
* Create handler for GET /vcs/status.
|
|
@@ -4142,9 +4189,9 @@ function createVcsStatusHandler(deps) {
|
|
|
4142
4189
|
const rootStatuses = await Promise.all(roots.map(async (root) => {
|
|
4143
4190
|
const manager = deps.coordinator.getManager(root);
|
|
4144
4191
|
const [tracked, lastCommit, remoteUrl] = await Promise.all([
|
|
4145
|
-
|
|
4192
|
+
getCommitCount(root),
|
|
4146
4193
|
getLastCommit(root),
|
|
4147
|
-
|
|
4194
|
+
queryGit(root, ['remote', 'get-url', 'origin']),
|
|
4148
4195
|
]);
|
|
4149
4196
|
return {
|
|
4150
4197
|
path: root,
|
|
@@ -4153,6 +4200,7 @@ function createVcsStatusHandler(deps) {
|
|
|
4153
4200
|
remoteUrl: manager?.remoteUrl ?? remoteUrl,
|
|
4154
4201
|
lastPush: manager?.lastPushTime ?? null,
|
|
4155
4202
|
pushErrors: manager?.pushErrors ?? [],
|
|
4203
|
+
breaker: manager?.breakerState ?? null,
|
|
4156
4204
|
};
|
|
4157
4205
|
}));
|
|
4158
4206
|
return { enabled, roots: rootStatuses };
|
|
@@ -4442,6 +4490,57 @@ class CommitMessageGenerator {
|
|
|
4442
4490
|
}
|
|
4443
4491
|
}
|
|
4444
4492
|
|
|
4493
|
+
/**
|
|
4494
|
+
* @module vcs/cronMatch
|
|
4495
|
+
* Minimal 5-field cron matcher used by the squash retention scheduler.
|
|
4496
|
+
*/
|
|
4497
|
+
function partMatches(part, value, min, max) {
|
|
4498
|
+
// Handle step: */N or range/N
|
|
4499
|
+
const [rangePart, stepStr] = part.split('/');
|
|
4500
|
+
const step = stepStr ? parseInt(stepStr, 10) : 1;
|
|
4501
|
+
if (rangePart === '*') {
|
|
4502
|
+
return (value - min) % step === 0;
|
|
4503
|
+
}
|
|
4504
|
+
// Handle range: N-M
|
|
4505
|
+
if (rangePart.includes('-')) {
|
|
4506
|
+
const [startStr, endStr] = rangePart.split('-');
|
|
4507
|
+
const start = parseInt(startStr, 10);
|
|
4508
|
+
const end = parseInt(endStr, 10);
|
|
4509
|
+
if (value < start || value > end)
|
|
4510
|
+
return false;
|
|
4511
|
+
return (value - start) % step === 0;
|
|
4512
|
+
}
|
|
4513
|
+
// Plain number. For day-of-week, 7 also means Sunday (0).
|
|
4514
|
+
const num = parseInt(rangePart, 10);
|
|
4515
|
+
if (step === 1 && max === 7 && num === 7)
|
|
4516
|
+
return value === 0;
|
|
4517
|
+
return value === num;
|
|
4518
|
+
}
|
|
4519
|
+
function fieldMatches(field, value, min, max) {
|
|
4520
|
+
return field
|
|
4521
|
+
.split(',')
|
|
4522
|
+
.some((part) => partMatches(part.trim(), value, min, max));
|
|
4523
|
+
}
|
|
4524
|
+
/**
|
|
4525
|
+
* Check whether a standard 5-field cron expression matches a time.
|
|
4526
|
+
* Fields: minute hour day-of-month month day-of-week.
|
|
4527
|
+
* Supports numbers, wildcards, lists (1,3,5), ranges (1-5) and steps.
|
|
4528
|
+
*
|
|
4529
|
+
* @param cronExpr - The cron expression.
|
|
4530
|
+
* @param now - The time to test (default: now).
|
|
4531
|
+
* @returns true if every field matches.
|
|
4532
|
+
*/
|
|
4533
|
+
function cronMatchesNow(cronExpr, now = new Date()) {
|
|
4534
|
+
const fields = cronExpr.trim().split(/\s+/);
|
|
4535
|
+
if (fields.length !== 5)
|
|
4536
|
+
return false;
|
|
4537
|
+
return (fieldMatches(fields[0], now.getMinutes(), 0, 59) &&
|
|
4538
|
+
fieldMatches(fields[1], now.getHours(), 0, 23) &&
|
|
4539
|
+
fieldMatches(fields[2], now.getDate(), 1, 31) &&
|
|
4540
|
+
fieldMatches(fields[3], now.getMonth() + 1, 1, 12) &&
|
|
4541
|
+
fieldMatches(fields[4], now.getDay(), 0, 7));
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4445
4544
|
/**
|
|
4446
4545
|
* @module vcs/gitNetwork
|
|
4447
4546
|
* Non-interactive, credential-safe execution of git commands that touch the
|
|
@@ -4606,7 +4705,7 @@ function sanitizeGitError(error, secret) {
|
|
|
4606
4705
|
*/
|
|
4607
4706
|
async function execGitNetwork(args, options) {
|
|
4608
4707
|
try {
|
|
4609
|
-
return await execFileAsync('git',
|
|
4708
|
+
return await execFileAsync('git', gitArgs(...buildGitNetworkArgs(options.clearCredentialHelpers), ...args), {
|
|
4610
4709
|
cwd: options.cwd,
|
|
4611
4710
|
env: buildGitNetworkEnv(process.env, options.config),
|
|
4612
4711
|
timeout: options.timeout,
|
|
@@ -4643,64 +4742,122 @@ async function gitPushNonInteractive(options) {
|
|
|
4643
4742
|
}
|
|
4644
4743
|
|
|
4645
4744
|
/**
|
|
4646
|
-
* @module vcs/
|
|
4647
|
-
*
|
|
4745
|
+
* @module vcs/gitRepoState
|
|
4746
|
+
* Read-mostly checks of a repository's state that gate VCS operations:
|
|
4747
|
+
* abandoned sequencer/rebase/merge state, uncommitted tracked changes, and
|
|
4748
|
+
* `index.lock` presence and staleness. See #249.
|
|
4648
4749
|
*/
|
|
4649
4750
|
/**
|
|
4650
|
-
*
|
|
4651
|
-
*
|
|
4652
|
-
* Supports: numbers, wildcards, lists (1,3,5), ranges (1-5), steps (star/5)
|
|
4751
|
+
* Paths under `.git/` that indicate an in-progress rebase, cherry-pick, or
|
|
4752
|
+
* merge. Rewriting history (or committing) on top of one is unsafe.
|
|
4653
4753
|
*/
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
fieldMatches(fields[2], dom, 1, 31) &&
|
|
4666
|
-
fieldMatches(fields[3], month, 1, 12) &&
|
|
4667
|
-
fieldMatches(fields[4], dow, 0, 7) // 7 also means Sunday
|
|
4668
|
-
);
|
|
4754
|
+
const STALE_OPERATION_MARKERS = [
|
|
4755
|
+
'sequencer',
|
|
4756
|
+
'rebase-merge',
|
|
4757
|
+
'rebase-apply',
|
|
4758
|
+
'CHERRY_PICK_HEAD',
|
|
4759
|
+
'MERGE_HEAD',
|
|
4760
|
+
];
|
|
4761
|
+
/** Manual resolution hint logged alongside stale-operation reports. */
|
|
4762
|
+
const STALE_OPERATION_HINT = 'resolve it manually (e.g. git cherry-pick --quit / git rebase --abort / git merge --abort)';
|
|
4763
|
+
function indexLockPath(rootPath) {
|
|
4764
|
+
return join(rootPath, '.git', 'index.lock');
|
|
4669
4765
|
}
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4766
|
+
/**
|
|
4767
|
+
* Detect an abandoned cherry-pick, rebase, or merge left in `.git/`.
|
|
4768
|
+
* Report-only: never clears the state.
|
|
4769
|
+
*
|
|
4770
|
+
* @param rootPath - Repository root.
|
|
4771
|
+
* @returns The first marker found, or undefined if none.
|
|
4772
|
+
*/
|
|
4773
|
+
async function detectStaleGitOperation(rootPath) {
|
|
4774
|
+
for (const marker of STALE_OPERATION_MARKERS) {
|
|
4775
|
+
try {
|
|
4776
|
+
await access(join(rootPath, '.git', marker));
|
|
4777
|
+
return marker;
|
|
4778
|
+
}
|
|
4779
|
+
catch {
|
|
4780
|
+
// Not present: check the next marker.
|
|
4781
|
+
}
|
|
4674
4782
|
}
|
|
4675
|
-
return
|
|
4783
|
+
return undefined;
|
|
4676
4784
|
}
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4785
|
+
/**
|
|
4786
|
+
* Check for uncommitted changes to tracked files (staged or unstaged).
|
|
4787
|
+
* Untracked files are ignored.
|
|
4788
|
+
*
|
|
4789
|
+
* @param rootPath - Repository root.
|
|
4790
|
+
* @returns true if any tracked file differs from HEAD.
|
|
4791
|
+
*/
|
|
4792
|
+
async function hasDirtyTrackedFiles(rootPath) {
|
|
4793
|
+
const { stdout } = await runGit(rootPath, [
|
|
4794
|
+
'status',
|
|
4795
|
+
'--porcelain',
|
|
4796
|
+
'-z',
|
|
4797
|
+
'--untracked-files=no',
|
|
4798
|
+
]);
|
|
4799
|
+
return stdout.length > 0;
|
|
4800
|
+
}
|
|
4801
|
+
/**
|
|
4802
|
+
* Check whether `.git/index.lock` exists.
|
|
4803
|
+
*
|
|
4804
|
+
* @param rootPath - Repository root.
|
|
4805
|
+
* @returns true if the lock file is present.
|
|
4806
|
+
*/
|
|
4807
|
+
async function indexLockExists(rootPath) {
|
|
4808
|
+
try {
|
|
4809
|
+
await access(indexLockPath(rootPath));
|
|
4810
|
+
return true;
|
|
4683
4811
|
}
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
const [startStr, endStr] = rangePart.split('-');
|
|
4687
|
-
const start = parseInt(startStr, 10);
|
|
4688
|
-
const end = parseInt(endStr, 10);
|
|
4689
|
-
if (value < start || value > end)
|
|
4690
|
-
return false;
|
|
4691
|
-
return (value - start) % step === 0;
|
|
4812
|
+
catch {
|
|
4813
|
+
return false;
|
|
4692
4814
|
}
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4815
|
+
}
|
|
4816
|
+
/**
|
|
4817
|
+
* Remove `.git/index.lock` unconditionally (missing is fine).
|
|
4818
|
+
*
|
|
4819
|
+
* @param rootPath - Repository root.
|
|
4820
|
+
*/
|
|
4821
|
+
async function removeIndexLock(rootPath) {
|
|
4822
|
+
await rm(indexLockPath(rootPath), { force: true });
|
|
4823
|
+
}
|
|
4824
|
+
/**
|
|
4825
|
+
* Remove `.git/index.lock` if it is older than `thresholdMs`. A fresh lock
|
|
4826
|
+
* (held by a live git process) is left alone.
|
|
4827
|
+
*
|
|
4828
|
+
* @param rootPath - Repository root.
|
|
4829
|
+
* @param thresholdMs - Minimum age for a lock to count as stale.
|
|
4830
|
+
* @param logger - Logger for removal and failure reports.
|
|
4831
|
+
*/
|
|
4832
|
+
async function removeStaleIndexLock(rootPath, thresholdMs, logger) {
|
|
4833
|
+
try {
|
|
4834
|
+
const ageMs = Date.now() - (await stat(indexLockPath(rootPath))).mtimeMs;
|
|
4835
|
+
if (ageMs > thresholdMs) {
|
|
4836
|
+
await removeIndexLock(rootPath);
|
|
4837
|
+
logger.warn({ root: rootPath, ageMs }, 'Removed stale index.lock');
|
|
4838
|
+
}
|
|
4839
|
+
}
|
|
4840
|
+
catch (error) {
|
|
4841
|
+
if (getErrorCode(error) !== 'ENOENT') {
|
|
4842
|
+
logger.warn({ root: rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
|
|
4843
|
+
}
|
|
4700
4844
|
}
|
|
4701
|
-
// Number with step doesn't make sense for single values, treat as exact
|
|
4702
|
-
return value === num;
|
|
4703
4845
|
}
|
|
4846
|
+
|
|
4847
|
+
/**
|
|
4848
|
+
* @module vcs/SquashManager
|
|
4849
|
+
* Handles scheduled squash retention for git-backed VCS roots.
|
|
4850
|
+
*/
|
|
4851
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4852
|
+
/**
|
|
4853
|
+
* Manages scheduled squash retention for a single VCS root.
|
|
4854
|
+
*
|
|
4855
|
+
* Runs on a cron schedule (checked every 60s). When triggered, squashes
|
|
4856
|
+
* commits older than the retention boundary (the tighter of maxAgeDays
|
|
4857
|
+
* and maxVersions) into a single "historical baseline" orphan commit,
|
|
4858
|
+
* then cherry-picks retained commits on top. Force-pushes to remote
|
|
4859
|
+
* if configured (D13).
|
|
4860
|
+
*/
|
|
4704
4861
|
class SquashManager {
|
|
4705
4862
|
rootPath;
|
|
4706
4863
|
retention;
|
|
@@ -4755,17 +4912,9 @@ class SquashManager {
|
|
|
4755
4912
|
* to drain and suspend the commit pipeline during the squash.
|
|
4756
4913
|
*/
|
|
4757
4914
|
async runSquash() {
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
await access(lockPath);
|
|
4762
|
-
// Lock exists — abort and retry next cycle
|
|
4763
|
-
this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
|
|
4764
|
-
return { squashed: false, error: 'index.lock exists' };
|
|
4765
|
-
}
|
|
4766
|
-
catch {
|
|
4767
|
-
// No lock — proceed
|
|
4768
|
-
}
|
|
4915
|
+
const blocked = await this.checkRepoPreconditions();
|
|
4916
|
+
if (blocked !== undefined)
|
|
4917
|
+
return { squashed: false, error: blocked };
|
|
4769
4918
|
// Bug 3: Pause the commit pipeline before squash
|
|
4770
4919
|
try {
|
|
4771
4920
|
await this.pauseCommits?.();
|
|
@@ -4777,40 +4926,65 @@ class SquashManager {
|
|
|
4777
4926
|
return { squashed: false, error: normalizeError(error).message };
|
|
4778
4927
|
}
|
|
4779
4928
|
try {
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4929
|
+
// #249 guard: the squash uses `reset --hard` / `checkout -f`, which
|
|
4930
|
+
// would destroy uncommitted changes to tracked files. After draining
|
|
4931
|
+
// the commit pipeline the tracked tree should be clean; if it isn't,
|
|
4932
|
+
// refuse rather than lose content. Untracked files don't block.
|
|
4933
|
+
if (await hasDirtyTrackedFiles(this.rootPath)) {
|
|
4934
|
+
const error = 'Squash refused: working tree has uncommitted changes to tracked files';
|
|
4935
|
+
this.logger.warn({ root: this.rootPath }, error);
|
|
4936
|
+
return { squashed: false, error };
|
|
4783
4937
|
}
|
|
4784
|
-
const
|
|
4785
|
-
|
|
4786
|
-
|
|
4938
|
+
const commits = await this.getCommitLog();
|
|
4939
|
+
const boundaryIndex = commits.length <= 1 ? 0 : this.calculateRetentionBoundary(commits);
|
|
4940
|
+
if (boundaryIndex <= 0)
|
|
4787
4941
|
return { squashed: false };
|
|
4788
|
-
}
|
|
4789
|
-
// Perform the squash
|
|
4790
4942
|
await this.performSquash(commits, boundaryIndex);
|
|
4791
4943
|
const commitsRemoved = boundaryIndex;
|
|
4792
4944
|
const commitsRetained = commits.length - boundaryIndex;
|
|
4793
4945
|
this.logger.info({ root: this.rootPath, commitsRemoved, commitsRetained }, 'Squash retention completed');
|
|
4794
|
-
// Force push if remote configured
|
|
4795
4946
|
await this.forcePushIfConfigured();
|
|
4796
4947
|
return { squashed: true, commitsRemoved, commitsRetained };
|
|
4797
4948
|
}
|
|
4798
4949
|
catch (error) {
|
|
4799
|
-
const message = normalizeError(error).message;
|
|
4800
4950
|
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
|
|
4801
|
-
return { squashed: false, error: message };
|
|
4951
|
+
return { squashed: false, error: normalizeError(error).message };
|
|
4802
4952
|
}
|
|
4803
4953
|
finally {
|
|
4804
4954
|
// Bug 3: Always resume the commit pipeline
|
|
4805
4955
|
this.resumeCommits?.();
|
|
4806
4956
|
}
|
|
4807
4957
|
}
|
|
4958
|
+
/**
|
|
4959
|
+
* Refuse to start a multi-step history rewrite while another git
|
|
4960
|
+
* operation holds the index or an abandoned cherry-pick, rebase, or merge
|
|
4961
|
+
* is present (#249: report only, never auto-clear).
|
|
4962
|
+
*
|
|
4963
|
+
* @returns The refusal reason, or undefined if the squash may proceed.
|
|
4964
|
+
*/
|
|
4965
|
+
async checkRepoPreconditions() {
|
|
4966
|
+
if (await indexLockExists(this.rootPath)) {
|
|
4967
|
+
this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
|
|
4968
|
+
return 'index.lock exists';
|
|
4969
|
+
}
|
|
4970
|
+
const marker = await detectStaleGitOperation(this.rootPath);
|
|
4971
|
+
if (marker !== undefined) {
|
|
4972
|
+
const error = `Squash refused: in-progress git operation detected (.git/${marker})`;
|
|
4973
|
+
this.logger.error({ root: this.rootPath, marker }, `${error} — ${STALE_OPERATION_HINT}`);
|
|
4974
|
+
return error;
|
|
4975
|
+
}
|
|
4976
|
+
return undefined;
|
|
4977
|
+
}
|
|
4808
4978
|
/**
|
|
4809
4979
|
* Get all commits in chronological order (oldest first).
|
|
4810
4980
|
*/
|
|
4811
4981
|
async getCommitLog() {
|
|
4812
4982
|
try {
|
|
4813
|
-
const { stdout } = await
|
|
4983
|
+
const { stdout } = await runGit(this.rootPath, [
|
|
4984
|
+
'log',
|
|
4985
|
+
'--format=%H %aI',
|
|
4986
|
+
'--reverse',
|
|
4987
|
+
]);
|
|
4814
4988
|
if (!stdout.trim())
|
|
4815
4989
|
return [];
|
|
4816
4990
|
return stdout
|
|
@@ -4839,17 +5013,10 @@ class SquashManager {
|
|
|
4839
5013
|
* The boundary is the one that retains FEWER old commits (tighter).
|
|
4840
5014
|
*/
|
|
4841
5015
|
calculateRetentionBoundary(commits) {
|
|
4842
|
-
const
|
|
4843
|
-
|
|
4844
|
-
const
|
|
4845
|
-
|
|
4846
|
-
let ageBoundary = commits.length; // Default: all too old
|
|
4847
|
-
for (let i = 0; i < commits.length; i++) {
|
|
4848
|
-
if (commits[i].date >= cutoffDate) {
|
|
4849
|
-
ageBoundary = i;
|
|
4850
|
-
break;
|
|
4851
|
-
}
|
|
4852
|
-
}
|
|
5016
|
+
const cutoffDate = new Date(Date.now() - this.retention.maxAgeDays * DAY_MS);
|
|
5017
|
+
// First commit within the age window (to keep); default: all too old.
|
|
5018
|
+
const firstKept = commits.findIndex((c) => c.date >= cutoffDate);
|
|
5019
|
+
const ageBoundary = firstKept === -1 ? commits.length : firstKept;
|
|
4853
5020
|
// Count boundary: keep the last maxVersions commits
|
|
4854
5021
|
const countBoundary = Math.max(0, commits.length - this.retention.maxVersions);
|
|
4855
5022
|
// The tighter constraint wins (keeps fewer old commits = higher boundary index)
|
|
@@ -4860,80 +5027,59 @@ class SquashManager {
|
|
|
4860
5027
|
* Uses the configured branch name instead of dynamic detection (Bug 1).
|
|
4861
5028
|
*/
|
|
4862
5029
|
async performSquash(commits, boundaryIndex) {
|
|
5030
|
+
const git = (...args) => runGit(this.rootPath, args);
|
|
4863
5031
|
// The commit just before boundary is the last one to squash
|
|
4864
5032
|
const baselineHash = commits[boundaryIndex - 1].hash;
|
|
4865
|
-
// Bug 1: Use configured branch name, not dynamic detection
|
|
4866
5033
|
const targetBranch = this.branch;
|
|
4867
5034
|
const orphanBranch = `__squash_orphan_${String(Date.now())}`;
|
|
4868
5035
|
try {
|
|
4869
|
-
// 1.
|
|
4870
|
-
await
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
});
|
|
4874
|
-
|
|
4875
|
-
await
|
|
4876
|
-
|
|
4877
|
-
timeout: GIT_TIMEOUT_STANDARD,
|
|
4878
|
-
});
|
|
4879
|
-
// 3. Create the baseline commit with the same tree
|
|
4880
|
-
// Use commit-tree for a clean orphan commit
|
|
4881
|
-
const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
|
|
4882
|
-
const treeHash = treeOut.trim();
|
|
4883
|
-
const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
|
|
4884
|
-
const baselineCommitHash = commitOut.trim();
|
|
4885
|
-
// 4. Reset orphan branch to this new baseline commit
|
|
4886
|
-
await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
|
|
4887
|
-
cwd: this.rootPath,
|
|
4888
|
-
timeout: GIT_TIMEOUT_STANDARD,
|
|
4889
|
-
});
|
|
4890
|
-
// 5. Cherry-pick boundary..HEAD (the retained commits)
|
|
4891
|
-
// Get the list of commits to cherry-pick (from boundary to the end)
|
|
5036
|
+
// 1-2. Orphan branch at the baseline commit's tree
|
|
5037
|
+
await git('checkout', '--orphan', orphanBranch);
|
|
5038
|
+
await git('reset', '--hard', baselineHash);
|
|
5039
|
+
// 3-4. Single parentless baseline commit with that tree
|
|
5040
|
+
const { stdout: treeOut } = await git('rev-parse', `${baselineHash}^{tree}`);
|
|
5041
|
+
const { stdout: commitOut } = await git('commit-tree', treeOut.trim(), '-m', 'historical baseline');
|
|
5042
|
+
await git('reset', '--hard', commitOut.trim());
|
|
5043
|
+
// 5. Cherry-pick the retained commits on top
|
|
4892
5044
|
const commitsToCherry = commits.slice(boundaryIndex).map((c) => c.hash);
|
|
4893
5045
|
if (commitsToCherry.length > 0) {
|
|
4894
|
-
await
|
|
4895
|
-
|
|
4896
|
-
timeout: GIT_TIMEOUT_CHERRY_PICK,
|
|
5046
|
+
await runGit(this.rootPath, ['cherry-pick', ...commitsToCherry], {
|
|
5047
|
+
timeoutMs: GIT_TIMEOUT_CHERRY_PICK,
|
|
4897
5048
|
});
|
|
4898
5049
|
}
|
|
4899
|
-
// 6.
|
|
4900
|
-
const { stdout: newHead } = await
|
|
4901
|
-
await
|
|
4902
|
-
|
|
4903
|
-
await
|
|
4904
|
-
cwd: this.rootPath,
|
|
4905
|
-
timeout: GIT_TIMEOUT_STANDARD,
|
|
4906
|
-
});
|
|
4907
|
-
// 8. Delete orphan branch
|
|
4908
|
-
await execFileAsync('git', ['branch', '-D', orphanBranch], {
|
|
4909
|
-
cwd: this.rootPath,
|
|
4910
|
-
timeout: GIT_TIMEOUT_STANDARD,
|
|
4911
|
-
});
|
|
5050
|
+
// 6-8. Move the configured branch to the new history and clean up
|
|
5051
|
+
const { stdout: newHead } = await git('rev-parse', 'HEAD');
|
|
5052
|
+
await git('branch', '-f', targetBranch, newHead.trim());
|
|
5053
|
+
await git('checkout', targetBranch);
|
|
5054
|
+
await git('branch', '-D', orphanBranch);
|
|
4912
5055
|
}
|
|
4913
5056
|
catch (error) {
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
catch {
|
|
4926
|
-
// Cleanup failed — log but re-throw original error
|
|
4927
|
-
}
|
|
4928
|
-
// Remove index.lock if left behind
|
|
5057
|
+
await this.cleanupFailedSquash(targetBranch, orphanBranch);
|
|
5058
|
+
throw error;
|
|
5059
|
+
}
|
|
5060
|
+
}
|
|
5061
|
+
/**
|
|
5062
|
+
* Best-effort cleanup after a failed squash. Aborts any half-finished
|
|
5063
|
+
* cherry-pick first, so a failure never leaves a sequencer behind (#249),
|
|
5064
|
+
* then returns to the configured branch and removes a leftover lock.
|
|
5065
|
+
*/
|
|
5066
|
+
async cleanupFailedSquash(targetBranch, orphanBranch) {
|
|
5067
|
+
const attempt = async (...args) => {
|
|
4929
5068
|
try {
|
|
4930
|
-
|
|
4931
|
-
await rm(lockFile, { force: true });
|
|
5069
|
+
await runGit(this.rootPath, args);
|
|
4932
5070
|
}
|
|
4933
5071
|
catch {
|
|
4934
|
-
//
|
|
5072
|
+
// Best effort: the state may not need this step.
|
|
4935
5073
|
}
|
|
4936
|
-
|
|
5074
|
+
};
|
|
5075
|
+
await attempt('cherry-pick', '--abort');
|
|
5076
|
+
await attempt('checkout', '-f', targetBranch);
|
|
5077
|
+
await attempt('branch', '-D', orphanBranch);
|
|
5078
|
+
try {
|
|
5079
|
+
await removeIndexLock(this.rootPath);
|
|
5080
|
+
}
|
|
5081
|
+
catch {
|
|
5082
|
+
// Best effort: the original squash error is what gets reported.
|
|
4937
5083
|
}
|
|
4938
5084
|
}
|
|
4939
5085
|
/**
|
|
@@ -5010,7 +5156,7 @@ const ALWAYS_GITIGNORE_ENTRIES = [
|
|
|
5010
5156
|
*/
|
|
5011
5157
|
async function checkGitAvailable() {
|
|
5012
5158
|
try {
|
|
5013
|
-
await execFileAsync('git',
|
|
5159
|
+
await execFileAsync('git', gitArgs('--version'));
|
|
5014
5160
|
return true;
|
|
5015
5161
|
}
|
|
5016
5162
|
catch {
|
|
@@ -5030,7 +5176,7 @@ async function initRepo(rootPath) {
|
|
|
5030
5176
|
// .git exists, nothing to do
|
|
5031
5177
|
}
|
|
5032
5178
|
catch {
|
|
5033
|
-
await
|
|
5179
|
+
await runGit(rootPath, ['init']);
|
|
5034
5180
|
}
|
|
5035
5181
|
}
|
|
5036
5182
|
/**
|
|
@@ -5042,12 +5188,8 @@ async function initRepo(rootPath) {
|
|
|
5042
5188
|
* @param email - Author email for commits.
|
|
5043
5189
|
*/
|
|
5044
5190
|
async function configureRepoIdentity(rootPath, name, email) {
|
|
5045
|
-
await
|
|
5046
|
-
|
|
5047
|
-
});
|
|
5048
|
-
await execFileAsync('git', ['config', '--local', 'user.email', email], {
|
|
5049
|
-
cwd: rootPath,
|
|
5050
|
-
});
|
|
5191
|
+
await runGit(rootPath, ['config', '--local', 'user.name', name]);
|
|
5192
|
+
await runGit(rootPath, ['config', '--local', 'user.email', email]);
|
|
5051
5193
|
}
|
|
5052
5194
|
/**
|
|
5053
5195
|
* Detect and recover from orphan branch state on startup.
|
|
@@ -5069,24 +5211,22 @@ async function detectAndRecoverOrphanBranch(rootPath, expectedBranch, logger) {
|
|
|
5069
5211
|
// Not a git repo — nothing to detect
|
|
5070
5212
|
return;
|
|
5071
5213
|
}
|
|
5072
|
-
const { stdout: branchOut } = await
|
|
5214
|
+
const { stdout: branchOut } = await runGit(rootPath, [
|
|
5215
|
+
'rev-parse',
|
|
5216
|
+
'--abbrev-ref',
|
|
5217
|
+
'HEAD',
|
|
5218
|
+
]);
|
|
5073
5219
|
const currentBranch = branchOut.trim();
|
|
5074
5220
|
if (currentBranch === expectedBranch)
|
|
5075
5221
|
return;
|
|
5076
5222
|
logger.warn({ root: rootPath, currentBranch, expectedBranch }, 'Repo is on unexpected branch — recovering to configured branch');
|
|
5077
5223
|
// Get current HEAD
|
|
5078
|
-
const { stdout: headOut } = await
|
|
5224
|
+
const { stdout: headOut } = await runGit(rootPath, ['rev-parse', 'HEAD']);
|
|
5079
5225
|
const headHash = headOut.trim();
|
|
5080
5226
|
// Force-update the expected branch to current HEAD
|
|
5081
|
-
await
|
|
5082
|
-
cwd: rootPath,
|
|
5083
|
-
timeout: GIT_TIMEOUT_STANDARD,
|
|
5084
|
-
});
|
|
5227
|
+
await runGit(rootPath, ['branch', '-f', expectedBranch, headHash]);
|
|
5085
5228
|
// Checkout the expected branch
|
|
5086
|
-
await
|
|
5087
|
-
cwd: rootPath,
|
|
5088
|
-
timeout: GIT_TIMEOUT_STANDARD,
|
|
5089
|
-
});
|
|
5229
|
+
await runGit(rootPath, ['checkout', expectedBranch]);
|
|
5090
5230
|
logger.info({ root: rootPath, recoveredFrom: currentBranch, expectedBranch, headHash }, 'Orphan branch recovery complete — now on configured branch');
|
|
5091
5231
|
}
|
|
5092
5232
|
/**
|
|
@@ -5266,6 +5406,295 @@ function resolveCommitMessageApiKey(provider, configApiKey, logger) {
|
|
|
5266
5406
|
return undefined;
|
|
5267
5407
|
}
|
|
5268
5408
|
|
|
5409
|
+
/**
|
|
5410
|
+
* @module vcs/gitIndex
|
|
5411
|
+
* Git index operations used by the commit pipeline: staging additions and
|
|
5412
|
+
* removals through stdin, listing tracked-but-deleted paths, detecting a
|
|
5413
|
+
* staged diff, and creating a commit. Every call carries
|
|
5414
|
+
* {@link GIT_BASE_ARGS} via {@link gitArgs}.
|
|
5415
|
+
*/
|
|
5416
|
+
/** Max stdout buffered from `git ls-files` (large corpora can list many paths). */
|
|
5417
|
+
const LS_FILES_MAX_BUFFER = 256 * 1024 * 1024;
|
|
5418
|
+
/**
|
|
5419
|
+
* Run a git subcommand with a NUL-delimited file list piped through stdin.
|
|
5420
|
+
*
|
|
5421
|
+
* This sidesteps the Windows CreateProcessW 32 767-char limit that
|
|
5422
|
+
* triggers ENAMETOOLONG when batches contain many files with long
|
|
5423
|
+
* absolute paths.
|
|
5424
|
+
*
|
|
5425
|
+
* @param argv - Full git argv (base args, subcommand, and flags).
|
|
5426
|
+
* @param files - Absolute paths to pipe through stdin (NUL-delimited).
|
|
5427
|
+
* @param cwd - Repository root (working directory for git).
|
|
5428
|
+
* @param timeoutMs - Kill the child process after this many milliseconds.
|
|
5429
|
+
*/
|
|
5430
|
+
function execGitStdin(argv, files, cwd, timeoutMs) {
|
|
5431
|
+
return new Promise((resolvePromise, reject) => {
|
|
5432
|
+
let timedOut = false;
|
|
5433
|
+
const child = execFile('git', argv, { cwd }, (error) => {
|
|
5434
|
+
clearTimeout(timer);
|
|
5435
|
+
if (timedOut)
|
|
5436
|
+
return;
|
|
5437
|
+
if (error)
|
|
5438
|
+
reject(error);
|
|
5439
|
+
else
|
|
5440
|
+
resolvePromise();
|
|
5441
|
+
});
|
|
5442
|
+
const timer = setTimeout(() => {
|
|
5443
|
+
timedOut = true;
|
|
5444
|
+
child.kill();
|
|
5445
|
+
reject(new Error(`git ${argv.join(' ')} timed out after ${String(timeoutMs)}ms`));
|
|
5446
|
+
}, timeoutMs);
|
|
5447
|
+
if (!child.stdin) {
|
|
5448
|
+
clearTimeout(timer);
|
|
5449
|
+
reject(new Error('Failed to open stdin for git command'));
|
|
5450
|
+
return;
|
|
5451
|
+
}
|
|
5452
|
+
// Suppress EPIPE: expected if git exits before we finish writing.
|
|
5453
|
+
child.stdin.on('error', (err) => {
|
|
5454
|
+
if (err.code !== 'EPIPE') {
|
|
5455
|
+
clearTimeout(timer);
|
|
5456
|
+
reject(err);
|
|
5457
|
+
}
|
|
5458
|
+
});
|
|
5459
|
+
child.stdin.end(files.join('\0'));
|
|
5460
|
+
});
|
|
5461
|
+
}
|
|
5462
|
+
/**
|
|
5463
|
+
* Stage files with `git add --pathspec-from-file=- --pathspec-file-nul`.
|
|
5464
|
+
*
|
|
5465
|
+
* @param files - Absolute paths of files to stage.
|
|
5466
|
+
* @param cwd - Repository root (working directory for git).
|
|
5467
|
+
* @param timeoutMs - Kill the child process after this many milliseconds.
|
|
5468
|
+
*/
|
|
5469
|
+
async function gitAddViaStdin(files, cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
|
|
5470
|
+
if (files.length === 0)
|
|
5471
|
+
return;
|
|
5472
|
+
await execGitStdin(gitArgs('add', '--pathspec-from-file=-', '--pathspec-file-nul'), files, cwd, timeoutMs);
|
|
5473
|
+
}
|
|
5474
|
+
/**
|
|
5475
|
+
* Stage the removal (index only) of tracked files that are missing from
|
|
5476
|
+
* disk. `--ignore-unmatch` means a path that isn't actually tracked is
|
|
5477
|
+
* skipped instead of failing the whole command. See #249.
|
|
5478
|
+
*
|
|
5479
|
+
* @param files - Absolute paths of tracked files to remove from the index.
|
|
5480
|
+
* @param cwd - Repository root (working directory for git).
|
|
5481
|
+
* @param timeoutMs - Kill the child process after this many milliseconds.
|
|
5482
|
+
*/
|
|
5483
|
+
async function gitRmCachedViaStdin(files, cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
|
|
5484
|
+
if (files.length === 0)
|
|
5485
|
+
return;
|
|
5486
|
+
await execGitStdin(gitArgs('rm', '--cached', '--ignore-unmatch', '--quiet', '--pathspec-from-file=-', '--pathspec-file-nul'), files, cwd, timeoutMs);
|
|
5487
|
+
}
|
|
5488
|
+
/**
|
|
5489
|
+
* List tracked paths that are missing from the working tree, via a single
|
|
5490
|
+
* `git ls-files --deleted -z` call (never one process per file).
|
|
5491
|
+
*
|
|
5492
|
+
* `git ls-files` has no `--pathspec-from-file`, so rather than passing the
|
|
5493
|
+
* candidate paths (which could exceed the Windows command-line limit) this
|
|
5494
|
+
* asks for every tracked-but-deleted path; callers intersect with their own
|
|
5495
|
+
* candidates. Output is bounded by the number of deleted tracked files.
|
|
5496
|
+
*
|
|
5497
|
+
* @param cwd - Repository root (working directory for git).
|
|
5498
|
+
* @param timeoutMs - Kill the child process after this many milliseconds.
|
|
5499
|
+
* @returns Absolute paths (forward slashes) of tracked files missing on disk.
|
|
5500
|
+
*/
|
|
5501
|
+
async function listDeletedTrackedPaths(cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
|
|
5502
|
+
const { stdout } = await runGit(cwd, ['ls-files', '--deleted', '-z'], {
|
|
5503
|
+
timeoutMs,
|
|
5504
|
+
maxBuffer: LS_FILES_MAX_BUFFER,
|
|
5505
|
+
});
|
|
5506
|
+
return stdout
|
|
5507
|
+
.split('\0')
|
|
5508
|
+
.filter((rel) => rel.length > 0)
|
|
5509
|
+
.map((rel) => normalizeSlashes(resolve(cwd, rel)));
|
|
5510
|
+
}
|
|
5511
|
+
/**
|
|
5512
|
+
* Check whether anything is staged (`git diff --cached --quiet`).
|
|
5513
|
+
* Exit code 0 means the index matches HEAD; exit code 1 means there is a
|
|
5514
|
+
* staged diff. Any other outcome is a real error and is rethrown. See #249.
|
|
5515
|
+
*
|
|
5516
|
+
* @param cwd - Repository root (working directory for git).
|
|
5517
|
+
* @param timeoutMs - Kill the child process after this many milliseconds.
|
|
5518
|
+
* @returns true if there is a staged diff to commit.
|
|
5519
|
+
*/
|
|
5520
|
+
async function hasStagedChanges(cwd, timeoutMs = GIT_TIMEOUT_STANDARD) {
|
|
5521
|
+
try {
|
|
5522
|
+
await runGit(cwd, ['diff', '--cached', '--quiet'], { timeoutMs });
|
|
5523
|
+
return false;
|
|
5524
|
+
}
|
|
5525
|
+
catch (error) {
|
|
5526
|
+
if (getErrorCode(error) === 1)
|
|
5527
|
+
return true;
|
|
5528
|
+
throw error;
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
/**
|
|
5532
|
+
* Commit the staged index and return the new commit's short hash.
|
|
5533
|
+
*
|
|
5534
|
+
* @param cwd - Repository root (working directory for git).
|
|
5535
|
+
* @param message - Commit message.
|
|
5536
|
+
* @param timeoutMs - Kill each child process after this many milliseconds.
|
|
5537
|
+
* @returns The short hash of the new HEAD.
|
|
5538
|
+
*/
|
|
5539
|
+
async function gitCommit(cwd, message, timeoutMs = GIT_TIMEOUT_STANDARD) {
|
|
5540
|
+
await runGit(cwd, ['commit', '-m', message], { timeoutMs });
|
|
5541
|
+
const { stdout } = await runGit(cwd, ['rev-parse', '--short', 'HEAD'], {
|
|
5542
|
+
timeoutMs,
|
|
5543
|
+
});
|
|
5544
|
+
return stdout.trim();
|
|
5545
|
+
}
|
|
5546
|
+
|
|
5547
|
+
/**
|
|
5548
|
+
* @module vcs/batchStaging
|
|
5549
|
+
* Stage a commit batch so that a missing path never fails it: existing
|
|
5550
|
+
* files are added, tracked files that vanished have their deletion staged,
|
|
5551
|
+
* and never-tracked files that vanished are dropped. See #249.
|
|
5552
|
+
*/
|
|
5553
|
+
/** Errno codes that mean "this path does not exist". */
|
|
5554
|
+
const MISSING_CODES = new Set([
|
|
5555
|
+
'ENOENT',
|
|
5556
|
+
'ENOTDIR',
|
|
5557
|
+
]);
|
|
5558
|
+
/**
|
|
5559
|
+
* Check whether a path exists. Only "does not exist" errors count as
|
|
5560
|
+
* missing; any other failure (permissions, I/O) propagates so the batch is
|
|
5561
|
+
* retained and retried rather than silently dropping a real change.
|
|
5562
|
+
*
|
|
5563
|
+
* @param path - Absolute path to check.
|
|
5564
|
+
* @param statFn - Stat implementation (injectable for tests).
|
|
5565
|
+
* @returns true if the path exists.
|
|
5566
|
+
*/
|
|
5567
|
+
async function pathExists(path, statFn) {
|
|
5568
|
+
try {
|
|
5569
|
+
await statFn(path);
|
|
5570
|
+
return true;
|
|
5571
|
+
}
|
|
5572
|
+
catch (error) {
|
|
5573
|
+
if (MISSING_CODES.has(getErrorCode(error) ?? ''))
|
|
5574
|
+
return false;
|
|
5575
|
+
throw error;
|
|
5576
|
+
}
|
|
5577
|
+
}
|
|
5578
|
+
/**
|
|
5579
|
+
* Split a batch into existing, missing-tracked, and missing-untracked
|
|
5580
|
+
* paths. Uses one `git ls-files --deleted` call, and only when something is
|
|
5581
|
+
* missing.
|
|
5582
|
+
*
|
|
5583
|
+
* @param files - Absolute paths in the batch.
|
|
5584
|
+
* @param rootPath - Repository root.
|
|
5585
|
+
* @param statFn - Stat implementation (injectable for tests).
|
|
5586
|
+
* @returns The classified batch.
|
|
5587
|
+
*/
|
|
5588
|
+
async function classifyBatchPaths(files, rootPath, statFn = stat) {
|
|
5589
|
+
const exists = await Promise.all(files.map((f) => pathExists(f, statFn)));
|
|
5590
|
+
const existing = files.filter((_, i) => exists[i]);
|
|
5591
|
+
const missing = files.filter((_, i) => !exists[i]);
|
|
5592
|
+
if (missing.length === 0) {
|
|
5593
|
+
return { existing, missingTracked: [], missingUntracked: [] };
|
|
5594
|
+
}
|
|
5595
|
+
const deletedTracked = new Set((await listDeletedTrackedPaths(rootPath)).map((p) => pathKey(p)));
|
|
5596
|
+
const isTracked = (f) => deletedTracked.has(pathKey(f));
|
|
5597
|
+
return {
|
|
5598
|
+
existing,
|
|
5599
|
+
missingTracked: missing.filter(isTracked),
|
|
5600
|
+
missingUntracked: missing.filter((f) => !isTracked(f)),
|
|
5601
|
+
};
|
|
5602
|
+
}
|
|
5603
|
+
/**
|
|
5604
|
+
* Stage a batch: add existing files, stage removal of tracked files that
|
|
5605
|
+
* are gone, and drop never-tracked files that are gone.
|
|
5606
|
+
*
|
|
5607
|
+
* @param files - Absolute paths in the batch.
|
|
5608
|
+
* @param rootPath - Repository root.
|
|
5609
|
+
* @param logger - Logger (dropped paths are reported at debug).
|
|
5610
|
+
* @param timeoutMs - Timeout for each git call.
|
|
5611
|
+
*/
|
|
5612
|
+
async function stageBatch(files, rootPath, logger, timeoutMs = GIT_TIMEOUT_STANDARD) {
|
|
5613
|
+
const { existing, missingTracked, missingUntracked } = await classifyBatchPaths(files, rootPath);
|
|
5614
|
+
if (missingUntracked.length > 0) {
|
|
5615
|
+
logger.debug({ root: rootPath, count: missingUntracked.length }, 'Dropping missing, untracked paths from batch');
|
|
5616
|
+
}
|
|
5617
|
+
await gitAddViaStdin(existing, rootPath, timeoutMs);
|
|
5618
|
+
await gitRmCachedViaStdin(missingTracked, rootPath, timeoutMs);
|
|
5619
|
+
}
|
|
5620
|
+
|
|
5621
|
+
/**
|
|
5622
|
+
* @module vcs/CommitCircuitBreaker
|
|
5623
|
+
* Per-root circuit breaker for the VCS commit pipeline. Counts consecutive
|
|
5624
|
+
* failures, trips at a threshold, and allows one half-open attempt per
|
|
5625
|
+
* cooldown. It holds no files: callers retain pending work while it is
|
|
5626
|
+
* tripped. See #249.
|
|
5627
|
+
*/
|
|
5628
|
+
/** Circuit breaker with a time-based half-open recovery. */
|
|
5629
|
+
class CommitCircuitBreaker {
|
|
5630
|
+
maxConsecutiveFailures;
|
|
5631
|
+
cooldownMs;
|
|
5632
|
+
now;
|
|
5633
|
+
failures = 0;
|
|
5634
|
+
trippedAtMs;
|
|
5635
|
+
lastError;
|
|
5636
|
+
/**
|
|
5637
|
+
* @param maxConsecutiveFailures - Failures that trip the breaker.
|
|
5638
|
+
* @param cooldownMs - Time after a trip before one attempt is allowed.
|
|
5639
|
+
* @param now - Clock (injectable for tests).
|
|
5640
|
+
*/
|
|
5641
|
+
constructor(maxConsecutiveFailures, cooldownMs, now = Date.now) {
|
|
5642
|
+
this.maxConsecutiveFailures = maxConsecutiveFailures;
|
|
5643
|
+
this.cooldownMs = cooldownMs;
|
|
5644
|
+
this.now = now;
|
|
5645
|
+
}
|
|
5646
|
+
/** Whether the breaker is tripped. */
|
|
5647
|
+
get isTripped() {
|
|
5648
|
+
return this.trippedAtMs !== undefined;
|
|
5649
|
+
}
|
|
5650
|
+
/**
|
|
5651
|
+
* Milliseconds until an attempt is allowed. 0 when not tripped or when
|
|
5652
|
+
* the cooldown has elapsed (half-open).
|
|
5653
|
+
*/
|
|
5654
|
+
remainingCooldownMs() {
|
|
5655
|
+
if (this.trippedAtMs === undefined)
|
|
5656
|
+
return 0;
|
|
5657
|
+
return Math.max(0, this.cooldownMs - (this.now() - this.trippedAtMs));
|
|
5658
|
+
}
|
|
5659
|
+
/**
|
|
5660
|
+
* Record a failed attempt.
|
|
5661
|
+
*
|
|
5662
|
+
* @param message - The failure message.
|
|
5663
|
+
* @returns true if this failure tripped (or re-armed) the breaker.
|
|
5664
|
+
*/
|
|
5665
|
+
recordFailure(message) {
|
|
5666
|
+
this.failures++;
|
|
5667
|
+
this.lastError = message;
|
|
5668
|
+
if (this.failures < this.maxConsecutiveFailures)
|
|
5669
|
+
return false;
|
|
5670
|
+
this.trippedAtMs = this.now();
|
|
5671
|
+
return true;
|
|
5672
|
+
}
|
|
5673
|
+
/**
|
|
5674
|
+
* Record a successful attempt (a commit or a no-op) and reset.
|
|
5675
|
+
*
|
|
5676
|
+
* @returns true if the breaker was tripped before this success.
|
|
5677
|
+
*/
|
|
5678
|
+
recordSuccess() {
|
|
5679
|
+
const wasTripped = this.isTripped;
|
|
5680
|
+
this.failures = 0;
|
|
5681
|
+
this.trippedAtMs = undefined;
|
|
5682
|
+
this.lastError = undefined;
|
|
5683
|
+
return wasTripped;
|
|
5684
|
+
}
|
|
5685
|
+
/** Current state for status reporting. */
|
|
5686
|
+
snapshot() {
|
|
5687
|
+
return {
|
|
5688
|
+
consecutiveFailures: this.failures,
|
|
5689
|
+
tripped: this.isTripped,
|
|
5690
|
+
trippedAt: this.trippedAtMs === undefined
|
|
5691
|
+
? null
|
|
5692
|
+
: new Date(this.trippedAtMs).toISOString(),
|
|
5693
|
+
lastError: this.lastError ?? null,
|
|
5694
|
+
};
|
|
5695
|
+
}
|
|
5696
|
+
}
|
|
5697
|
+
|
|
5269
5698
|
/**
|
|
5270
5699
|
* @module vcs/CommitMessageBuilder
|
|
5271
5700
|
* Builds commit messages — both template-based and AI-generated.
|
|
@@ -5341,8 +5770,15 @@ class CommitMessageBuilder {
|
|
|
5341
5770
|
*/
|
|
5342
5771
|
async getStagedDiff() {
|
|
5343
5772
|
try {
|
|
5344
|
-
const { stdout: stat } = await
|
|
5345
|
-
|
|
5773
|
+
const { stdout: stat } = await runGit(this.rootPath, [
|
|
5774
|
+
'diff',
|
|
5775
|
+
'--cached',
|
|
5776
|
+
'--stat',
|
|
5777
|
+
]);
|
|
5778
|
+
const { stdout: patch } = await runGit(this.rootPath, [
|
|
5779
|
+
'diff',
|
|
5780
|
+
'--cached',
|
|
5781
|
+
]);
|
|
5346
5782
|
return `${stat}\n${patch}`;
|
|
5347
5783
|
}
|
|
5348
5784
|
catch {
|
|
@@ -5405,7 +5841,9 @@ async function pushToRemote(rootPath, remoteUrl, accessToken, pushErrors, logger
|
|
|
5405
5841
|
* (with optional AI-generated messages), and optionally pushed to a remote.
|
|
5406
5842
|
*
|
|
5407
5843
|
* Concurrency: only one commit is in-flight at a time per root. Index.lock
|
|
5408
|
-
* contention is handled with exponential backoff retries (D10).
|
|
5844
|
+
* contention is handled with exponential backoff retries (D10). Failed
|
|
5845
|
+
* batches are always retained; a circuit breaker spaces out retries of a
|
|
5846
|
+
* persistently failing root (#249).
|
|
5409
5847
|
*/
|
|
5410
5848
|
class VcsManager {
|
|
5411
5849
|
config;
|
|
@@ -5414,13 +5852,15 @@ class VcsManager {
|
|
|
5414
5852
|
accessToken;
|
|
5415
5853
|
logger;
|
|
5416
5854
|
commitMessageBuilder;
|
|
5855
|
+
breaker;
|
|
5417
5856
|
pending = new Set();
|
|
5418
5857
|
pendingReversions = [];
|
|
5419
5858
|
_pushErrors = [];
|
|
5420
5859
|
squashManager;
|
|
5421
5860
|
throttleTimer;
|
|
5861
|
+
breakerRetryTimer;
|
|
5422
5862
|
commitInFlight = Promise.resolve();
|
|
5423
|
-
|
|
5863
|
+
inFlightFileCount = 0;
|
|
5424
5864
|
started = false;
|
|
5425
5865
|
paused = false;
|
|
5426
5866
|
isBaseline = true;
|
|
@@ -5432,12 +5872,11 @@ class VcsManager {
|
|
|
5432
5872
|
this.remoteUrl = remoteUrl;
|
|
5433
5873
|
this.accessToken = accessToken;
|
|
5434
5874
|
this.commitMessageBuilder = new CommitMessageBuilder(rootPath, logger, commitMessageGenerator);
|
|
5875
|
+
this.breaker = new CommitCircuitBreaker(config.maxConsecutiveFailures, config.circuitBreakerCooldownMs);
|
|
5435
5876
|
if (config.retention) {
|
|
5436
5877
|
this.squashManager = new SquashManager(rootPath, config.retention, logger, {
|
|
5437
5878
|
branch: config.branch,
|
|
5438
|
-
pauseCommits: () =>
|
|
5439
|
-
return this.pause();
|
|
5440
|
-
},
|
|
5879
|
+
pauseCommits: () => this.pause(),
|
|
5441
5880
|
resumeCommits: () => {
|
|
5442
5881
|
this.resume();
|
|
5443
5882
|
},
|
|
@@ -5452,25 +5891,36 @@ class VcsManager {
|
|
|
5452
5891
|
get pushErrors() {
|
|
5453
5892
|
return this._pushErrors;
|
|
5454
5893
|
}
|
|
5894
|
+
/** Current circuit breaker state and backlog, for /vcs/status. */
|
|
5895
|
+
get breakerState() {
|
|
5896
|
+
return {
|
|
5897
|
+
...this.breaker.snapshot(),
|
|
5898
|
+
pendingCount: this.pending.size + this.inFlightFileCount,
|
|
5899
|
+
};
|
|
5900
|
+
}
|
|
5455
5901
|
/**
|
|
5456
5902
|
* Begin accepting file changes. Sets the manager to started state and
|
|
5457
5903
|
* starts the squash manager if configured.
|
|
5458
5904
|
*
|
|
5459
5905
|
* Performs startup orphan detection/recovery before starting the squash
|
|
5460
|
-
* manager, ensuring the repo is on the configured branch
|
|
5906
|
+
* manager, ensuring the repo is on the configured branch, and reports
|
|
5907
|
+
* (never clears) an abandoned cherry-pick, rebase, or merge.
|
|
5461
5908
|
*
|
|
5462
5909
|
* Baseline mode (commit prefix `"baseline:"`) remains active until
|
|
5463
5910
|
* {@link endBaseline} is called by the coordinator after the initial scan.
|
|
5464
5911
|
*/
|
|
5465
5912
|
async start() {
|
|
5466
5913
|
// Bug 6: Detect and recover from orphan branches before normal operations
|
|
5467
|
-
const branch = this.config.branch;
|
|
5468
5914
|
try {
|
|
5469
|
-
await detectAndRecoverOrphanBranch(this.rootPath, branch, this.logger);
|
|
5915
|
+
await detectAndRecoverOrphanBranch(this.rootPath, this.config.branch, this.logger);
|
|
5470
5916
|
}
|
|
5471
5917
|
catch (error) {
|
|
5472
5918
|
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Orphan branch recovery failed — continuing with current state');
|
|
5473
5919
|
}
|
|
5920
|
+
const marker = await detectStaleGitOperation(this.rootPath);
|
|
5921
|
+
if (marker !== undefined) {
|
|
5922
|
+
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}`);
|
|
5923
|
+
}
|
|
5474
5924
|
this.started = true;
|
|
5475
5925
|
this.squashManager?.start();
|
|
5476
5926
|
this.logger.info({ root: this.rootPath }, 'VcsManager started');
|
|
@@ -5484,26 +5934,22 @@ class VcsManager {
|
|
|
5484
5934
|
}
|
|
5485
5935
|
/**
|
|
5486
5936
|
* Add a file to the pending set and start the throttle timer.
|
|
5487
|
-
* If the pending set
|
|
5937
|
+
* If the pending set reaches maxBatchSize, commit a batch immediately.
|
|
5938
|
+
*
|
|
5939
|
+
* The circuit breaker is intentionally not reset here: recovery is
|
|
5940
|
+
* time-based, so a busy root cannot hammer a deterministic failure.
|
|
5488
5941
|
*
|
|
5489
5942
|
* @param filePath - Absolute path of the changed file.
|
|
5490
5943
|
*/
|
|
5491
5944
|
fileChanged(filePath) {
|
|
5492
5945
|
if (!this.started)
|
|
5493
5946
|
return;
|
|
5494
|
-
// Reset circuit breaker on new file change so the system can recover
|
|
5495
|
-
if (this.consecutiveCommitFailures >= this.config.maxConsecutiveFailures) {
|
|
5496
|
-
this.logger.info({ root: this.rootPath }, 'VCS circuit breaker reset — new file change received, retrying commits');
|
|
5497
|
-
this.consecutiveCommitFailures = 0;
|
|
5498
|
-
}
|
|
5499
5947
|
this.pending.add(filePath);
|
|
5500
5948
|
if (this.pending.size >= this.config.maxBatchSize) {
|
|
5501
5949
|
this.clearThrottle();
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
if (this.pending.size > 0) {
|
|
5950
|
+
this.enqueueBatch(this.takeBatch());
|
|
5951
|
+
if (this.pending.size > 0)
|
|
5505
5952
|
this.resetThrottle();
|
|
5506
|
-
}
|
|
5507
5953
|
return;
|
|
5508
5954
|
}
|
|
5509
5955
|
this.resetThrottle();
|
|
@@ -5517,14 +5963,11 @@ class VcsManager {
|
|
|
5517
5963
|
this.pendingReversions.push(reversion);
|
|
5518
5964
|
}
|
|
5519
5965
|
/**
|
|
5520
|
-
*
|
|
5521
|
-
* git add handles deleted files when the file is gone from disk.
|
|
5966
|
+
* Queue a deleted file. Staging records the deletion if git tracks it.
|
|
5522
5967
|
*
|
|
5523
5968
|
* @param filePath - Absolute path of the deleted file.
|
|
5524
5969
|
*/
|
|
5525
5970
|
handleUnlink(filePath) {
|
|
5526
|
-
if (!this.started)
|
|
5527
|
-
return;
|
|
5528
5971
|
this.fileChanged(filePath);
|
|
5529
5972
|
}
|
|
5530
5973
|
/**
|
|
@@ -5545,19 +5988,16 @@ class VcsManager {
|
|
|
5545
5988
|
resume() {
|
|
5546
5989
|
this.paused = false;
|
|
5547
5990
|
this.logger.debug({ root: this.rootPath }, 'VcsManager resumed');
|
|
5548
|
-
if (this.pending.size > 0)
|
|
5991
|
+
if (this.pending.size > 0)
|
|
5549
5992
|
this.resetThrottle();
|
|
5550
|
-
}
|
|
5551
5993
|
}
|
|
5552
5994
|
/**
|
|
5553
|
-
*
|
|
5995
|
+
* Commit all pending files now, in batches of at most maxBatchSize.
|
|
5554
5996
|
*/
|
|
5555
5997
|
async flush() {
|
|
5556
5998
|
this.clearThrottle();
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
this.pending.clear();
|
|
5560
|
-
this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
|
|
5999
|
+
while (this.pending.size > 0) {
|
|
6000
|
+
this.enqueueBatch(this.takeBatch());
|
|
5561
6001
|
}
|
|
5562
6002
|
await this.commitInFlight;
|
|
5563
6003
|
}
|
|
@@ -5567,32 +6007,61 @@ class VcsManager {
|
|
|
5567
6007
|
async stop() {
|
|
5568
6008
|
this.started = false;
|
|
5569
6009
|
this.squashManager?.stop();
|
|
6010
|
+
this.clearBreakerRetry();
|
|
5570
6011
|
await this.flush();
|
|
5571
6012
|
this.logger.info({ root: this.rootPath }, 'VcsManager stopped');
|
|
5572
6013
|
}
|
|
5573
6014
|
/**
|
|
5574
|
-
*
|
|
6015
|
+
* Add tracked-but-deleted paths (`git ls-files --deleted`) in this
|
|
6016
|
+
* manager's watch scope to pending, so the next commit (typically the
|
|
6017
|
+
* baseline) records deletions that happened while nothing was watching.
|
|
6018
|
+
* Called by the coordinator before flush/endBaseline on startup. See #249.
|
|
6019
|
+
*
|
|
6020
|
+
* @param isInScope - Returns true if the absolute path routes to this
|
|
6021
|
+
* manager (the coordinator's routing and watch-scope logic).
|
|
5575
6022
|
*/
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
6023
|
+
async reconcileDeletions(isInScope) {
|
|
6024
|
+
try {
|
|
6025
|
+
const deleted = (await listDeletedTrackedPaths(this.rootPath)).filter(isInScope);
|
|
6026
|
+
for (const path of deleted)
|
|
6027
|
+
this.pending.add(path);
|
|
6028
|
+
if (deleted.length > 0) {
|
|
6029
|
+
this.logger.info({ root: this.rootPath, count: deleted.length }, 'VCS startup reconciliation — queued tracked deletions');
|
|
6030
|
+
}
|
|
5582
6031
|
}
|
|
5583
|
-
|
|
5584
|
-
this.
|
|
6032
|
+
catch (error) {
|
|
6033
|
+
this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'VCS startup deletion reconciliation failed');
|
|
5585
6034
|
}
|
|
6035
|
+
}
|
|
6036
|
+
/** Take up to maxBatchSize files from the pending set. */
|
|
6037
|
+
takeBatch() {
|
|
6038
|
+
const batch = [...this.pending].slice(0, this.config.maxBatchSize);
|
|
6039
|
+
for (const item of batch)
|
|
6040
|
+
this.pending.delete(item);
|
|
5586
6041
|
return batch;
|
|
5587
6042
|
}
|
|
5588
|
-
/**
|
|
5589
|
-
|
|
5590
|
-
|
|
6043
|
+
/** Chain a batch behind the in-flight commit, tracking it as backlog. */
|
|
6044
|
+
enqueueBatch(batch) {
|
|
6045
|
+
this.inFlightFileCount += batch.length;
|
|
6046
|
+
this.commitInFlight = this.commitInFlight.then(async () => {
|
|
6047
|
+
try {
|
|
6048
|
+
await this.commitBatch(batch);
|
|
6049
|
+
}
|
|
6050
|
+
finally {
|
|
6051
|
+
this.inFlightFileCount -= batch.length;
|
|
6052
|
+
}
|
|
6053
|
+
});
|
|
6054
|
+
}
|
|
6055
|
+
/** Put files back in the pending set. Pending is a set, so this cannot grow it past the distinct paths seen. */
|
|
6056
|
+
retain(files) {
|
|
6057
|
+
for (const f of files)
|
|
6058
|
+
this.pending.add(f);
|
|
6059
|
+
}
|
|
5591
6060
|
clearThrottle() {
|
|
5592
|
-
if (this.throttleTimer
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
6061
|
+
if (this.throttleTimer === undefined)
|
|
6062
|
+
return;
|
|
6063
|
+
clearTimeout(this.throttleTimer);
|
|
6064
|
+
this.throttleTimer = undefined;
|
|
5596
6065
|
}
|
|
5597
6066
|
/**
|
|
5598
6067
|
* Start the throttle timer to fire flush() after commitThrottleMs.
|
|
@@ -5606,117 +6075,148 @@ class VcsManager {
|
|
|
5606
6075
|
void this.flush();
|
|
5607
6076
|
}, this.config.commitThrottleMs);
|
|
5608
6077
|
}
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
try {
|
|
5615
|
-
const lockStat = await stat(lockPath);
|
|
5616
|
-
const ageMs = Date.now() - lockStat.mtimeMs;
|
|
5617
|
-
if (ageMs > this.config.staleLockThresholdMs) {
|
|
5618
|
-
await rm(lockPath, { force: true });
|
|
5619
|
-
this.logger.warn({ root: this.rootPath, ageMs }, 'Removed stale index.lock');
|
|
5620
|
-
}
|
|
5621
|
-
}
|
|
5622
|
-
catch (error) {
|
|
5623
|
-
const code = error.code;
|
|
5624
|
-
if (code !== 'ENOENT') {
|
|
5625
|
-
this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Unable to remove stale index.lock');
|
|
5626
|
-
}
|
|
5627
|
-
}
|
|
6078
|
+
clearBreakerRetry() {
|
|
6079
|
+
if (this.breakerRetryTimer === undefined)
|
|
6080
|
+
return;
|
|
6081
|
+
clearTimeout(this.breakerRetryTimer);
|
|
6082
|
+
this.breakerRetryTimer = undefined;
|
|
5628
6083
|
}
|
|
5629
6084
|
/**
|
|
5630
|
-
*
|
|
5631
|
-
*
|
|
6085
|
+
* Ensure a timer will flush pending files when the breaker's cooldown
|
|
6086
|
+
* elapses, without needing a new file-change event.
|
|
5632
6087
|
*/
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
6088
|
+
scheduleBreakerRetry() {
|
|
6089
|
+
if (!this.started || this.breakerRetryTimer !== undefined)
|
|
6090
|
+
return;
|
|
6091
|
+
this.breakerRetryTimer = setTimeout(() => {
|
|
6092
|
+
this.breakerRetryTimer = undefined;
|
|
6093
|
+
void this.flush();
|
|
6094
|
+
}, this.breaker.remainingCooldownMs());
|
|
5638
6095
|
}
|
|
5639
6096
|
/**
|
|
5640
|
-
* Commit a batch
|
|
5641
|
-
*
|
|
6097
|
+
* Commit a batch with index.lock retry, stale lock detection, pause
|
|
6098
|
+
* support, and the circuit breaker. A failed batch is always retained.
|
|
5642
6099
|
*/
|
|
5643
6100
|
async commitBatch(files) {
|
|
5644
6101
|
if (files.length === 0)
|
|
5645
6102
|
return;
|
|
5646
|
-
// Pause support: re-queue files without counting as failure
|
|
5647
6103
|
if (this.paused) {
|
|
5648
|
-
|
|
5649
|
-
this.pending.add(f);
|
|
5650
|
-
}
|
|
6104
|
+
this.retain(files);
|
|
5651
6105
|
this.logger.debug({ root: this.rootPath, fileCount: files.length }, 'VcsManager paused — files re-queued');
|
|
5652
6106
|
return;
|
|
5653
6107
|
}
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
}, 'VCS circuit breaker
|
|
5661
|
-
return;
|
|
6108
|
+
if (this.breaker.isTripped) {
|
|
6109
|
+
if (this.breaker.remainingCooldownMs() > 0) {
|
|
6110
|
+
this.retain(files);
|
|
6111
|
+
this.scheduleBreakerRetry();
|
|
6112
|
+
return;
|
|
6113
|
+
}
|
|
6114
|
+
this.logger.info({ root: this.rootPath }, 'VCS circuit breaker half-open — attempting recovery commit');
|
|
5662
6115
|
}
|
|
5663
6116
|
try {
|
|
5664
|
-
|
|
5665
|
-
await this.
|
|
5666
|
-
await retry(async (attempt) => {
|
|
5667
|
-
await gitAddViaStdin(files, this.rootPath, GIT_TIMEOUT_STANDARD);
|
|
5668
|
-
// Build message after staging so getStagedDiff can see the changes.
|
|
5669
|
-
// Skip AI for baselines and retries — use template directly.
|
|
5670
|
-
const message = this.isBaseline || attempt > 1
|
|
5671
|
-
? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
|
|
5672
|
-
: await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
|
|
5673
|
-
this.pendingReversions.length = 0;
|
|
5674
|
-
await execFileAsync('git', ['commit', '-m', message], {
|
|
5675
|
-
cwd: this.rootPath,
|
|
5676
|
-
timeout: GIT_TIMEOUT_STANDARD,
|
|
5677
|
-
});
|
|
5678
|
-
const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath, timeout: GIT_TIMEOUT_STANDARD });
|
|
5679
|
-
const hash = hashOut.trim();
|
|
5680
|
-
this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
|
|
5681
|
-
const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
|
|
5682
|
-
if (pushTime)
|
|
5683
|
-
this._lastPushTime = pushTime;
|
|
5684
|
-
}, {
|
|
6117
|
+
await removeStaleIndexLock(this.rootPath, this.config.staleLockThresholdMs, this.logger);
|
|
6118
|
+
const committed = await retry((attempt) => this.tryCommit(files, attempt), {
|
|
5685
6119
|
attempts: 4,
|
|
5686
6120
|
baseDelayMs: 500,
|
|
5687
6121
|
maxDelayMs: 2000,
|
|
6122
|
+
// Only index.lock contention is transient. Anything else fails on
|
|
6123
|
+
// the first attempt with its real message. See #249.
|
|
6124
|
+
shouldRetry: isIndexLockError,
|
|
5688
6125
|
onRetry: ({ attempt, delayMs }) => {
|
|
5689
6126
|
this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
|
|
5690
6127
|
},
|
|
5691
6128
|
});
|
|
5692
|
-
|
|
5693
|
-
|
|
6129
|
+
if (!committed) {
|
|
6130
|
+
this.logger.info({ root: this.rootPath, fileCount: files.length }, 'VCS commit skipped — nothing staged');
|
|
6131
|
+
}
|
|
6132
|
+
this.onCommitSuccess();
|
|
5694
6133
|
}
|
|
5695
6134
|
catch (error) {
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
this.
|
|
5713
|
-
this.
|
|
6135
|
+
this.onCommitFailure(files, error);
|
|
6136
|
+
}
|
|
6137
|
+
}
|
|
6138
|
+
/**
|
|
6139
|
+
* One commit attempt: stage, skip if nothing is staged, commit, push.
|
|
6140
|
+
*
|
|
6141
|
+
* @returns true if a commit was created; false for a no-op.
|
|
6142
|
+
*/
|
|
6143
|
+
async tryCommit(files, attempt) {
|
|
6144
|
+
await stageBatch(files, this.rootPath, this.logger);
|
|
6145
|
+
// "Nothing staged" is a no-op, detected without parsing git's text.
|
|
6146
|
+
if (!(await hasStagedChanges(this.rootPath)))
|
|
6147
|
+
return false;
|
|
6148
|
+
// Build message after staging so getStagedDiff can see the changes.
|
|
6149
|
+
// Skip AI for baselines and retries — use template directly.
|
|
6150
|
+
const message = this.isBaseline || attempt > 1
|
|
6151
|
+
? this.commitMessageBuilder.buildTemplateMessage(files.length, this.isBaseline, this.pendingReversions)
|
|
6152
|
+
: await this.commitMessageBuilder.buildCommitMessage(files, this.isBaseline, this.pendingReversions);
|
|
6153
|
+
this.pendingReversions.length = 0;
|
|
6154
|
+
const hash = await gitCommit(this.rootPath, message);
|
|
6155
|
+
this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
|
|
6156
|
+
const pushTime = await pushToRemote(this.rootPath, this.remoteUrl, this.accessToken, this._pushErrors, this.logger);
|
|
6157
|
+
if (pushTime)
|
|
6158
|
+
this._lastPushTime = pushTime;
|
|
6159
|
+
return true;
|
|
6160
|
+
}
|
|
6161
|
+
onCommitSuccess() {
|
|
6162
|
+
if (!this.breaker.recordSuccess())
|
|
6163
|
+
return;
|
|
6164
|
+
this.clearBreakerRetry();
|
|
6165
|
+
this.logger.info({ root: this.rootPath }, 'VCS circuit breaker recovered — resuming normal operation');
|
|
6166
|
+
}
|
|
6167
|
+
onCommitFailure(files, error) {
|
|
6168
|
+
const err = normalizeError(error);
|
|
6169
|
+
this.retain(files);
|
|
6170
|
+
this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
|
|
6171
|
+
this.logger.error({ root: this.rootPath, err }, 'VCS commit failed');
|
|
6172
|
+
if (!this.breaker.recordFailure(err.message)) {
|
|
5714
6173
|
// Bug 2: Restart throttle timer so re-queued files get retried
|
|
5715
6174
|
this.resetThrottle();
|
|
6175
|
+
return;
|
|
5716
6176
|
}
|
|
6177
|
+
this.logger.error({
|
|
6178
|
+
root: this.rootPath,
|
|
6179
|
+
consecutiveFailures: this.breaker.snapshot().consecutiveFailures,
|
|
6180
|
+
pendingCount: this.pending.size,
|
|
6181
|
+
cooldownMs: this.config.circuitBreakerCooldownMs,
|
|
6182
|
+
}, 'VCS circuit breaker tripped — pending files retained, will retry after cooldown');
|
|
6183
|
+
this.scheduleBreakerRetry();
|
|
5717
6184
|
}
|
|
5718
6185
|
}
|
|
5719
6186
|
|
|
6187
|
+
/**
|
|
6188
|
+
* @module vcs/watchScope
|
|
6189
|
+
* Build a predicate for "would the filesystem watcher observe this path?"
|
|
6190
|
+
* from `watch.paths` and `watch.ignored`, using the watcher's own glob
|
|
6191
|
+
* resolution. Used to scope VCS startup deletion reconciliation. See #249.
|
|
6192
|
+
*/
|
|
6193
|
+
/**
|
|
6194
|
+
* Create a watch-scope predicate. A path is in scope if it matches a watch
|
|
6195
|
+
* glob and neither it nor any ancestor directory matches a `watch.ignored`
|
|
6196
|
+
* pattern (chokidar applies `ignored` to directories as well as files).
|
|
6197
|
+
*
|
|
6198
|
+
* @param watch - The `watch` section of the config.
|
|
6199
|
+
* @returns The scope predicate.
|
|
6200
|
+
*/
|
|
6201
|
+
function createWatchScope(watch) {
|
|
6202
|
+
const matchesGlobs = resolveWatchPaths(extractWatchPathStrings(watch.paths)).matches;
|
|
6203
|
+
// Config `ignored` entries are strings, which resolveIgnored always turns
|
|
6204
|
+
// into picomatch functions; the filter only narrows the union type.
|
|
6205
|
+
const ignored = resolveIgnored(watch.ignored).filter((entry) => typeof entry === 'function');
|
|
6206
|
+
return (normalizedPath) => {
|
|
6207
|
+
if (!matchesGlobs(normalizedPath))
|
|
6208
|
+
return false;
|
|
6209
|
+
for (let current = normalizedPath;;) {
|
|
6210
|
+
if (ignored.some((isIgnored) => isIgnored(current)))
|
|
6211
|
+
return false;
|
|
6212
|
+
const parent = normalizeSlashes(dirname(current));
|
|
6213
|
+
if (parent === current)
|
|
6214
|
+
return true;
|
|
6215
|
+
current = parent;
|
|
6216
|
+
}
|
|
6217
|
+
};
|
|
6218
|
+
}
|
|
6219
|
+
|
|
5720
6220
|
/**
|
|
5721
6221
|
* @module vcs/VcsCoordinator
|
|
5722
6222
|
* Orchestrates VCS across all watch roots. Routes file events to the correct
|
|
@@ -5729,10 +6229,14 @@ class VcsCoordinator {
|
|
|
5729
6229
|
managers = new Map();
|
|
5730
6230
|
roots = [];
|
|
5731
6231
|
logger;
|
|
6232
|
+
isInWatchScope = () => false;
|
|
5732
6233
|
constructor(config, logger) {
|
|
5733
6234
|
this.logger = logger;
|
|
5734
6235
|
if (!config.vcs?.enabled)
|
|
5735
6236
|
return;
|
|
6237
|
+
// Same watch-scope logic as the filesystem watcher (globs + ignored),
|
|
6238
|
+
// used to filter startup deletion reconciliation. See #249.
|
|
6239
|
+
this.isInWatchScope = createWatchScope(config.watch);
|
|
5736
6240
|
const normalized = normalizeWatchPaths(config.watch.paths);
|
|
5737
6241
|
for (const entry of normalized) {
|
|
5738
6242
|
const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
|
|
@@ -5811,6 +6315,11 @@ class VcsCoordinator {
|
|
|
5811
6315
|
*/
|
|
5812
6316
|
async onInitialScanComplete() {
|
|
5813
6317
|
for (const manager of this.managers.values()) {
|
|
6318
|
+
// Reconcile tracked-but-deleted paths within this manager's watch
|
|
6319
|
+
// scope before flushing, so the baseline commit records deletions
|
|
6320
|
+
// that happened while the process wasn't watching. See #249.
|
|
6321
|
+
await manager.reconcileDeletions((absPath) => this.findManagerForPath(absPath) === manager &&
|
|
6322
|
+
this.isInWatchScope(absPath));
|
|
5814
6323
|
await manager.flush();
|
|
5815
6324
|
manager.endBaseline();
|
|
5816
6325
|
}
|