@karmaniverous/jeeves-watcher 0.17.10 → 0.18.0
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 +128 -0
- package/config.schema.json +465 -176
- package/dist/cli/jeeves-watcher/{index-07J6R3B8.js → index-DtyqhT2s.js} +1590 -21
- package/dist/cli/jeeves-watcher/index.js +18 -60
- package/dist/index.d.ts +285 -5
- package/dist/index.js +1669 -142
- package/package.json +71 -72
package/dist/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { join, dirname, resolve, relative, extname, basename, isAbsolute } from 'node:path';
|
|
2
|
-
import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, DEFAULT_PORTS, getBindAddress, postJson, fetchJson, getPackageVersion } from '@karmaniverous/jeeves';
|
|
1
|
+
import { join, dirname, resolve, relative, extname, basename, isAbsolute, sep } from 'node:path';
|
|
2
|
+
import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, substituteEnvVars, DEFAULT_PORTS, getBindAddress, postJson, fetchJson, getPackageVersion } from '@karmaniverous/jeeves';
|
|
3
|
+
import { extractWatchPathStrings, inferenceRuleSchema, jeevesWatcherConfigSchema, normalizeWatchPaths, LOGGING_DEFAULTS, API_DEFAULTS, EMBEDDING_DEFAULTS, CONFIG_WATCH_DEFAULTS, WATCH_DEFAULTS, ROOT_DEFAULTS, INIT_CONFIG_TEMPLATE, DEFAULT_PORT as DEFAULT_PORT$1, PLUGIN_PACKAGE, SERVICE_PACKAGE, COMPONENT_NAME } from '@karmaniverous/jeeves-watcher-core';
|
|
4
|
+
export { apiConfigSchema, configWatchConfigSchema, embeddingConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema, loggingConfigSchema, vectorStoreConfigSchema, watchConfigSchema } from '@karmaniverous/jeeves-watcher-core';
|
|
3
5
|
import Fastify from 'fastify';
|
|
4
|
-
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
6
|
+
import { readdir, stat, readFile, writeFile, mkdir, appendFile, access, rm } from 'node:fs/promises';
|
|
5
7
|
import { parallel, capitalize, title, camel, snake, dash, isEqual, get, omit } from 'radash';
|
|
6
8
|
import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
|
|
7
9
|
import ignore from 'ignore';
|
|
@@ -9,8 +11,6 @@ import picomatch from 'picomatch';
|
|
|
9
11
|
import Ajv from 'ajv';
|
|
10
12
|
import addFormats from 'ajv-formats';
|
|
11
13
|
import { z, ZodError } from 'zod';
|
|
12
|
-
import { inferenceRuleSchema, jeevesWatcherConfigSchema, LOGGING_DEFAULTS, API_DEFAULTS, EMBEDDING_DEFAULTS, CONFIG_WATCH_DEFAULTS, WATCH_DEFAULTS, ROOT_DEFAULTS, INIT_CONFIG_TEMPLATE, DEFAULT_PORT as DEFAULT_PORT$1, PLUGIN_PACKAGE, SERVICE_PACKAGE, COMPONENT_NAME } from '@karmaniverous/jeeves-watcher-core';
|
|
13
|
-
export { apiConfigSchema, configWatchConfigSchema, embeddingConfigSchema, inferenceRuleSchema, jeevesWatcherConfigSchema, loggingConfigSchema, vectorStoreConfigSchema, watchConfigSchema } from '@karmaniverous/jeeves-watcher-core';
|
|
14
14
|
import { JsonMap } from '@karmaniverous/jsonmap';
|
|
15
15
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
16
16
|
import Handlebars from 'handlebars';
|
|
@@ -21,8 +21,11 @@ import { toMarkdown } from 'mdast-util-to-markdown';
|
|
|
21
21
|
import rehypeParse from 'rehype-parse';
|
|
22
22
|
import { unified } from 'unified';
|
|
23
23
|
import yaml from 'js-yaml';
|
|
24
|
+
import { execFile } from 'node:child_process';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
24
26
|
import crypto from 'crypto';
|
|
25
27
|
import { packageDirectorySync } from 'package-directory';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
26
29
|
import Database from 'better-sqlite3';
|
|
27
30
|
import { deserialize, serialize } from 'node:v8';
|
|
28
31
|
import chokidar from 'chokidar';
|
|
@@ -307,11 +310,12 @@ function normalizeSlashes(path) {
|
|
|
307
310
|
*/
|
|
308
311
|
function isPathWatched(filePath, watchPaths, ignoredPaths) {
|
|
309
312
|
const normalised = normalizeSlashes(filePath);
|
|
310
|
-
const
|
|
313
|
+
const opts = { dot: true, nocase: process.platform === 'win32' };
|
|
314
|
+
const isIncluded = picomatch(watchPaths, opts);
|
|
311
315
|
if (!isIncluded(normalised))
|
|
312
316
|
return false;
|
|
313
317
|
if (ignoredPaths && ignoredPaths.length > 0) {
|
|
314
|
-
const isExcluded = picomatch(ignoredPaths,
|
|
318
|
+
const isExcluded = picomatch(ignoredPaths, opts);
|
|
315
319
|
if (isExcluded(normalised))
|
|
316
320
|
return false;
|
|
317
321
|
}
|
|
@@ -695,7 +699,7 @@ async function computePrunePlan(deps) {
|
|
|
695
699
|
continue;
|
|
696
700
|
}
|
|
697
701
|
seenFiles.add(filePath);
|
|
698
|
-
const watched = isPathWatched(filePath, config.watch.paths, config.watch.ignored);
|
|
702
|
+
const watched = isPathWatched(filePath, extractWatchPathStrings(config.watch.paths), config.watch.ignored);
|
|
699
703
|
const gitignored = gitignoreFilter
|
|
700
704
|
? gitignoreFilter.isIgnored(filePath)
|
|
701
705
|
: false;
|
|
@@ -715,7 +719,7 @@ async function computePrunePlan(deps) {
|
|
|
715
719
|
incomplete = true;
|
|
716
720
|
}
|
|
717
721
|
const orphanedFileList = [...orphanedFiles];
|
|
718
|
-
const byRoot = groupByRoot(orphanedFileList, config.watch.paths);
|
|
722
|
+
const byRoot = groupByRoot(orphanedFileList, extractWatchPathStrings(config.watch.paths));
|
|
719
723
|
return {
|
|
720
724
|
plan: {
|
|
721
725
|
total: totalPoints,
|
|
@@ -831,18 +835,18 @@ async function executeReindex(deps, scope, path, dryRun = false) {
|
|
|
831
835
|
}
|
|
832
836
|
else if (scope === 'rules' && pathArray && pathArray.length > 0) {
|
|
833
837
|
// Rules scope with path filter: list from watch roots intersected with caller globs
|
|
834
|
-
fileList = await listFilesFromWatchRoots(config.watch.paths, config.watch.ignored, pathArray, isGitignored);
|
|
838
|
+
fileList = await listFilesFromWatchRoots(extractWatchPathStrings(config.watch.paths), config.watch.ignored, pathArray, isGitignored);
|
|
835
839
|
}
|
|
836
840
|
else {
|
|
837
841
|
// rules or full: list files from globs
|
|
838
|
-
fileList = await listFilesFromGlobs(config.watch.paths, config.watch.ignored, isGitignored);
|
|
842
|
+
fileList = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
|
|
839
843
|
}
|
|
840
844
|
if (fileList !== undefined) {
|
|
841
845
|
plan = {
|
|
842
846
|
total: fileList.length,
|
|
843
847
|
toProcess: fileList.length,
|
|
844
848
|
toDelete: 0,
|
|
845
|
-
byRoot: groupByRoot(fileList, config.watch.paths),
|
|
849
|
+
byRoot: groupByRoot(fileList, extractWatchPathStrings(config.watch.paths)),
|
|
846
850
|
};
|
|
847
851
|
}
|
|
848
852
|
if (dryRun) {
|
|
@@ -853,7 +857,7 @@ async function executeReindex(deps, scope, path, dryRun = false) {
|
|
|
853
857
|
total: pathFiles.length,
|
|
854
858
|
toProcess: pathFiles.length,
|
|
855
859
|
toDelete: 0,
|
|
856
|
-
byRoot: groupByRoot(pathFiles, config.watch.paths),
|
|
860
|
+
byRoot: groupByRoot(pathFiles, extractWatchPathStrings(config.watch.paths)),
|
|
857
861
|
};
|
|
858
862
|
}
|
|
859
863
|
return { filesProcessed: 0, durationMs: 0, errors: 0, plan };
|
|
@@ -880,7 +884,7 @@ async function executeReindex(deps, scope, path, dryRun = false) {
|
|
|
880
884
|
});
|
|
881
885
|
}
|
|
882
886
|
else if (scope === 'rules') {
|
|
883
|
-
filesProcessed = await processAllFiles(config.watch.paths, config.watch.ignored, processor, 'processRulesUpdate', concurrency, {
|
|
887
|
+
filesProcessed = await processAllFiles(extractWatchPathStrings(config.watch.paths), config.watch.ignored, processor, 'processRulesUpdate', concurrency, {
|
|
884
888
|
onTotal: (total) => reindexTracker?.setTotal(total),
|
|
885
889
|
onFileProcessed: () => reindexTracker?.incrementProcessed(),
|
|
886
890
|
}, isGitignored, fileList);
|
|
@@ -891,7 +895,7 @@ async function executeReindex(deps, scope, path, dryRun = false) {
|
|
|
891
895
|
total: uniqueFiles.length,
|
|
892
896
|
toProcess: uniqueFiles.length,
|
|
893
897
|
toDelete: 0,
|
|
894
|
-
byRoot: groupByRoot(uniqueFiles, config.watch.paths),
|
|
898
|
+
byRoot: groupByRoot(uniqueFiles, extractWatchPathStrings(config.watch.paths)),
|
|
895
899
|
};
|
|
896
900
|
reindexTracker?.setTotal(uniqueFiles.length);
|
|
897
901
|
await parallel(concurrency, uniqueFiles, async (filePath) => {
|
|
@@ -902,7 +906,7 @@ async function executeReindex(deps, scope, path, dryRun = false) {
|
|
|
902
906
|
}
|
|
903
907
|
else {
|
|
904
908
|
// Full reindex
|
|
905
|
-
filesProcessed = await processAllFiles(config.watch.paths, config.watch.ignored, processor, 'processFile', concurrency, {
|
|
909
|
+
filesProcessed = await processAllFiles(extractWatchPathStrings(config.watch.paths), config.watch.ignored, processor, 'processFile', concurrency, {
|
|
906
910
|
onTotal: (total) => reindexTracker?.setTotal(total),
|
|
907
911
|
onFileProcessed: () => reindexTracker?.incrementProcessed(),
|
|
908
912
|
}, isGitignored, fileList);
|
|
@@ -950,7 +954,7 @@ async function collectPathFiles(pathArray, config, gitignoreFilter) {
|
|
|
950
954
|
* Get the file list for a path-scoped reindex (for plan computation).
|
|
951
955
|
*/
|
|
952
956
|
async function getPathFileList(targetPath, config, gitignoreFilter) {
|
|
953
|
-
const watched = isPathWatched(targetPath, config.watch.paths, config.watch.ignored);
|
|
957
|
+
const watched = isPathWatched(targetPath, extractWatchPathStrings(config.watch.paths), config.watch.ignored);
|
|
954
958
|
if (!watched) {
|
|
955
959
|
throw new Error(`Path is outside watch scope: ${targetPath}`);
|
|
956
960
|
}
|
|
@@ -962,7 +966,7 @@ async function getPathFileList(targetPath, config, gitignoreFilter) {
|
|
|
962
966
|
return [targetPath];
|
|
963
967
|
if (stats.isDirectory()) {
|
|
964
968
|
const isGitignored = createIsGitignored(gitignoreFilter);
|
|
965
|
-
const allWatchedFiles = await listFilesFromGlobs(config.watch.paths, config.watch.ignored, isGitignored);
|
|
969
|
+
const allWatchedFiles = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
|
|
966
970
|
const targetAbsPath = resolve(targetPath);
|
|
967
971
|
return allWatchedFiles.filter((f) => resolve(f).startsWith(targetAbsPath));
|
|
968
972
|
}
|
|
@@ -1147,7 +1151,9 @@ function createConfigMatchHandler(options) {
|
|
|
1147
1151
|
const config = getConfig();
|
|
1148
1152
|
if (config !== cachedConfig) {
|
|
1149
1153
|
compiledRules = compileRules(config.inferenceRules ?? []);
|
|
1150
|
-
watchMatcher = picomatch(config.watch.paths, {
|
|
1154
|
+
watchMatcher = picomatch(extractWatchPathStrings(config.watch.paths), {
|
|
1155
|
+
dot: true,
|
|
1156
|
+
});
|
|
1151
1157
|
ignoreMatcher = config.watch.ignored.length
|
|
1152
1158
|
? picomatch(config.watch.ignored, { dot: true })
|
|
1153
1159
|
: undefined;
|
|
@@ -1426,10 +1432,15 @@ function createConfigReindexHandler(deps) {
|
|
|
1426
1432
|
plan: result.plan,
|
|
1427
1433
|
});
|
|
1428
1434
|
}
|
|
1429
|
-
//
|
|
1430
|
-
|
|
1435
|
+
// Live prune: fire and forget without pre-computing plan (avoids double-scrolling the collection).
|
|
1436
|
+
if (validScope === 'prune') {
|
|
1437
|
+
void executeReindex(reindexDeps, 'prune', pathParam, false);
|
|
1438
|
+
return await reply
|
|
1439
|
+
.status(200)
|
|
1440
|
+
.send({ status: 'started', scope: 'prune' });
|
|
1441
|
+
}
|
|
1442
|
+
// Non-prune scopes: compute plan first then execute async.
|
|
1431
1443
|
const planResult = await executeReindex(reindexDeps, validScope, pathParam, true);
|
|
1432
|
-
// Now fire actual reindex async
|
|
1433
1444
|
void executeReindex(reindexDeps, validScope, pathParam, false);
|
|
1434
1445
|
return await reply
|
|
1435
1446
|
.status(200)
|
|
@@ -3165,7 +3176,7 @@ function createRenderHandler(deps) {
|
|
|
3165
3176
|
}
|
|
3166
3177
|
// Validate path is within watched scope (live config)
|
|
3167
3178
|
const watch = getWatch();
|
|
3168
|
-
if (!isPathWatched(filePath, watch.paths, watch.ignored)) {
|
|
3179
|
+
if (!isPathWatched(filePath, extractWatchPathStrings(watch.paths), watch.ignored)) {
|
|
3169
3180
|
return reply.status(403).send({ error: 'Path is outside watched scope' });
|
|
3170
3181
|
}
|
|
3171
3182
|
try {
|
|
@@ -3352,12 +3363,585 @@ function createSearchHandler(deps) {
|
|
|
3352
3363
|
const vectors = await deps.embeddingProvider.embed([query]);
|
|
3353
3364
|
const hybridConfig = deps.getHybridConfig?.();
|
|
3354
3365
|
if (hybridConfig?.enabled) {
|
|
3355
|
-
return deps.vectorStore.hybridSearch(vectors[0], query, limit, hybridConfig.textWeight, filter);
|
|
3366
|
+
return deps.vectorStore.hybridSearch(vectors[0], query, limit, hybridConfig.textWeight, filter, offset);
|
|
3356
3367
|
}
|
|
3357
3368
|
return deps.vectorStore.search(vectors[0], limit, filter, offset);
|
|
3358
3369
|
}, deps.logger, 'Search');
|
|
3359
3370
|
}
|
|
3360
3371
|
|
|
3372
|
+
/**
|
|
3373
|
+
* @module vcs/gitExec
|
|
3374
|
+
* Shared git execution utilities.
|
|
3375
|
+
*/
|
|
3376
|
+
/** Promisified execFile for git commands. */
|
|
3377
|
+
const execFileAsync = promisify(execFile);
|
|
3378
|
+
/**
|
|
3379
|
+
* Find the longest-prefix-match root for a normalized path.
|
|
3380
|
+
* Roots must be sorted longest-first for correct nested matching.
|
|
3381
|
+
*
|
|
3382
|
+
* @param roots - Root paths sorted longest-first.
|
|
3383
|
+
* @param normalizedPath - Normalized absolute path (forward slashes).
|
|
3384
|
+
* @returns The matching root, or undefined.
|
|
3385
|
+
*/
|
|
3386
|
+
function findRootForPath(roots, normalizedPath) {
|
|
3387
|
+
for (const root of roots) {
|
|
3388
|
+
if (normalizedPath === root || normalizedPath.startsWith(root + '/')) {
|
|
3389
|
+
return root;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
return undefined;
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
/**
|
|
3396
|
+
* @module vcs/resolveWatchRoot
|
|
3397
|
+
* Resolves which VCS-enabled watch root(s) own a given absolute path or glob.
|
|
3398
|
+
*/
|
|
3399
|
+
/**
|
|
3400
|
+
* Resolve which VCS-enabled watch root owns an absolute path.
|
|
3401
|
+
*
|
|
3402
|
+
* @param coordinator - The VcsCoordinator with root information.
|
|
3403
|
+
* @param absolutePath - An absolute filesystem path.
|
|
3404
|
+
* @returns The matching root and relative path, or undefined if no root matches.
|
|
3405
|
+
*/
|
|
3406
|
+
function resolveWatchRoot(coordinator, absolutePath) {
|
|
3407
|
+
const normalized = normalizeSlashes(resolve(absolutePath));
|
|
3408
|
+
const root = findRootForPath(coordinator.getRoots(), normalized);
|
|
3409
|
+
if (!root)
|
|
3410
|
+
return undefined;
|
|
3411
|
+
return {
|
|
3412
|
+
root,
|
|
3413
|
+
relativePath: normalizeSlashes(relative(root, normalized)),
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
/**
|
|
3417
|
+
* Resolve which VCS-enabled watch roots match a glob pattern.
|
|
3418
|
+
* For globs that could span multiple roots, returns all matching roots.
|
|
3419
|
+
*
|
|
3420
|
+
* @param coordinator - The VcsCoordinator with root information.
|
|
3421
|
+
* @param glob - An absolute glob pattern.
|
|
3422
|
+
* @returns Array of matching roots with relative glob patterns.
|
|
3423
|
+
*/
|
|
3424
|
+
function resolveWatchRootsForGlob(coordinator, glob) {
|
|
3425
|
+
const normalizedGlob = normalizeSlashes(glob);
|
|
3426
|
+
const roots = coordinator.getRoots();
|
|
3427
|
+
const results = [];
|
|
3428
|
+
for (const root of roots) {
|
|
3429
|
+
if (normalizedGlob.startsWith(root + '/') || normalizedGlob === root) {
|
|
3430
|
+
// Glob is under this root
|
|
3431
|
+
results.push({
|
|
3432
|
+
root,
|
|
3433
|
+
relativePath: normalizeSlashes(relative(root, normalizedGlob)),
|
|
3434
|
+
});
|
|
3435
|
+
}
|
|
3436
|
+
else if (root.startsWith(normalizedGlob.replace(/[*?[\]{}]/g, ''))) {
|
|
3437
|
+
// Root is under the glob's base path — include with root-relative glob
|
|
3438
|
+
results.push({
|
|
3439
|
+
root,
|
|
3440
|
+
relativePath: '.',
|
|
3441
|
+
});
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
return results;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
/**
|
|
3448
|
+
* @module api/handlers/vcs/vcsCheckExclusion
|
|
3449
|
+
* Fastify route handler for GET /vcs/check-exclusion. Checks gitignore status of a path.
|
|
3450
|
+
*/
|
|
3451
|
+
/**
|
|
3452
|
+
* Create handler for GET /vcs/check-exclusion.
|
|
3453
|
+
*/
|
|
3454
|
+
function createVcsCheckExclusionHandler(deps) {
|
|
3455
|
+
return wrapHandler(async (request, reply) => {
|
|
3456
|
+
const { path } = request.query;
|
|
3457
|
+
if (!path) {
|
|
3458
|
+
void reply
|
|
3459
|
+
.status(400)
|
|
3460
|
+
.send({ error: 'Missing required query parameter: path' });
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
3463
|
+
const resolved = resolveWatchRoot(deps.coordinator, path);
|
|
3464
|
+
if (!resolved) {
|
|
3465
|
+
void reply
|
|
3466
|
+
.status(404)
|
|
3467
|
+
.send({ error: 'Path does not match any VCS-enabled watch root' });
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
try {
|
|
3471
|
+
const { stdout } = await execFileAsync('git', ['check-ignore', '-v', resolved.relativePath], { cwd: resolved.root });
|
|
3472
|
+
// Output format: <source>:<linenum>:<pattern>\t<pathname>
|
|
3473
|
+
const trimmed = stdout.trim();
|
|
3474
|
+
const match = /^(.+):(\d+):(.+)\t/.exec(trimmed);
|
|
3475
|
+
if (match) {
|
|
3476
|
+
return {
|
|
3477
|
+
excluded: true,
|
|
3478
|
+
rule: match[3].trim(),
|
|
3479
|
+
source: match[1],
|
|
3480
|
+
};
|
|
3481
|
+
}
|
|
3482
|
+
return { excluded: true };
|
|
3483
|
+
}
|
|
3484
|
+
catch {
|
|
3485
|
+
// Exit code 1 means the path is NOT ignored
|
|
3486
|
+
return { excluded: false };
|
|
3487
|
+
}
|
|
3488
|
+
}, deps.logger, 'VcsCheckExclusion');
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
/**
|
|
3492
|
+
* @module api/handlers/vcs/vcsDiff
|
|
3493
|
+
* Fastify route handler for GET /vcs/diff. Returns diff text for a glob between commits.
|
|
3494
|
+
*/
|
|
3495
|
+
/**
|
|
3496
|
+
* Create handler for GET /vcs/diff.
|
|
3497
|
+
*/
|
|
3498
|
+
function createVcsDiffHandler(deps) {
|
|
3499
|
+
return wrapHandler(async (request, reply) => {
|
|
3500
|
+
const { glob, commit, commitEnd } = request.query;
|
|
3501
|
+
if (!glob || !commit) {
|
|
3502
|
+
void reply
|
|
3503
|
+
.status(400)
|
|
3504
|
+
.send({ error: 'Missing required query parameters: glob, commit' });
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3507
|
+
const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
|
|
3508
|
+
if (resolved.length === 0) {
|
|
3509
|
+
void reply
|
|
3510
|
+
.status(404)
|
|
3511
|
+
.send({ error: 'No VCS-enabled watch root matches the given glob' });
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
const diffs = [];
|
|
3515
|
+
for (const { root, relativePath } of resolved) {
|
|
3516
|
+
const range = commitEnd ? `${commit}..${commitEnd}` : `${commit}..HEAD`;
|
|
3517
|
+
const args = ['diff', range, '--', relativePath || '.'];
|
|
3518
|
+
try {
|
|
3519
|
+
const { stdout } = await execFileAsync('git', args, { cwd: root });
|
|
3520
|
+
if (stdout.trim())
|
|
3521
|
+
diffs.push(stdout);
|
|
3522
|
+
}
|
|
3523
|
+
catch {
|
|
3524
|
+
// Invalid commit or path — skip
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
3527
|
+
void reply.header('content-type', 'text/plain').send(diffs.join('\n'));
|
|
3528
|
+
}, deps.logger, 'VcsDiff');
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
/**
|
|
3532
|
+
* @module api/handlers/vcs/vcsExclude
|
|
3533
|
+
* Fastify route handler for POST /vcs/exclude. Manages .gitignore exclusions
|
|
3534
|
+
* with locality-based placement.
|
|
3535
|
+
*/
|
|
3536
|
+
/**
|
|
3537
|
+
* Determine the deepest directory for .gitignore placement and the pattern
|
|
3538
|
+
* to add. Splits the glob at the first wildcard character.
|
|
3539
|
+
*
|
|
3540
|
+
* @param relativeGlob - Glob relative to the watch root.
|
|
3541
|
+
* @returns The directory (relative to root) and the pattern for .gitignore.
|
|
3542
|
+
*/
|
|
3543
|
+
function splitGlobForLocality(relativeGlob) {
|
|
3544
|
+
// Find the first path segment containing a wildcard
|
|
3545
|
+
const segments = relativeGlob.split('/');
|
|
3546
|
+
const dirSegments = [];
|
|
3547
|
+
for (const segment of segments) {
|
|
3548
|
+
if (/[*?[\]{}]/.test(segment))
|
|
3549
|
+
break;
|
|
3550
|
+
dirSegments.push(segment);
|
|
3551
|
+
}
|
|
3552
|
+
// Last non-wildcard segment might be a filename, not a directory.
|
|
3553
|
+
// If no wildcards at all, the directory is the parent of the file.
|
|
3554
|
+
if (dirSegments.length === segments.length) {
|
|
3555
|
+
// No wildcards — the glob is a literal path, directory is parent
|
|
3556
|
+
const dir = dirSegments.slice(0, -1).join('/') || '.';
|
|
3557
|
+
const pattern = dirSegments[dirSegments.length - 1];
|
|
3558
|
+
return { dir, pattern };
|
|
3559
|
+
}
|
|
3560
|
+
const dir = dirSegments.join('/') || '.';
|
|
3561
|
+
const patternSegments = segments.slice(dirSegments.length);
|
|
3562
|
+
const pattern = patternSegments.join('/');
|
|
3563
|
+
return { dir, pattern };
|
|
3564
|
+
}
|
|
3565
|
+
/**
|
|
3566
|
+
* Create handler for POST /vcs/exclude.
|
|
3567
|
+
*/
|
|
3568
|
+
function createVcsExcludeHandler(deps) {
|
|
3569
|
+
return wrapHandler(async (request, reply) => {
|
|
3570
|
+
const { glob, root: requestedRoot, remove } = request.body;
|
|
3571
|
+
if (!glob) {
|
|
3572
|
+
void reply
|
|
3573
|
+
.status(400)
|
|
3574
|
+
.send({ error: 'Missing required body parameter: glob' });
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
3577
|
+
// Resolve the root
|
|
3578
|
+
let root;
|
|
3579
|
+
if (requestedRoot) {
|
|
3580
|
+
const normalizedRoot = normalizeSlashes(requestedRoot);
|
|
3581
|
+
const manager = deps.coordinator.getManager(normalizedRoot);
|
|
3582
|
+
if (!manager) {
|
|
3583
|
+
void reply
|
|
3584
|
+
.status(404)
|
|
3585
|
+
.send({ error: 'Root is not a VCS-enabled watch root' });
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
root = normalizedRoot;
|
|
3589
|
+
}
|
|
3590
|
+
else {
|
|
3591
|
+
// Resolve root from the glob path
|
|
3592
|
+
const normalizedGlob = normalizeSlashes(glob);
|
|
3593
|
+
const basePath = normalizedGlob.replace(/[*?[\]{}].*/g, '');
|
|
3594
|
+
const matchedRoot = findRootForPath(deps.coordinator.getRoots(), basePath);
|
|
3595
|
+
if (!matchedRoot) {
|
|
3596
|
+
void reply
|
|
3597
|
+
.status(404)
|
|
3598
|
+
.send({ error: 'Glob does not match any VCS-enabled watch root' });
|
|
3599
|
+
return;
|
|
3600
|
+
}
|
|
3601
|
+
root = matchedRoot;
|
|
3602
|
+
}
|
|
3603
|
+
// Compute relative glob from root
|
|
3604
|
+
const normalizedGlob = normalizeSlashes(glob);
|
|
3605
|
+
let relativeGlob;
|
|
3606
|
+
if (normalizedGlob.startsWith(root + '/')) {
|
|
3607
|
+
relativeGlob = normalizedGlob.slice(root.length + 1);
|
|
3608
|
+
}
|
|
3609
|
+
else {
|
|
3610
|
+
// Glob is the root itself or doesn't start with root — use as-is
|
|
3611
|
+
relativeGlob = normalizedGlob;
|
|
3612
|
+
}
|
|
3613
|
+
// Determine .gitignore directory and pattern using locality principle
|
|
3614
|
+
const { dir, pattern } = splitGlobForLocality(relativeGlob);
|
|
3615
|
+
const gitignoreDir = dir === '.' ? root : normalizeSlashes(join(root, dir));
|
|
3616
|
+
const gitignorePath = normalizeSlashes(join(gitignoreDir, '.gitignore'));
|
|
3617
|
+
if (remove) {
|
|
3618
|
+
// Remove the pattern from .gitignore
|
|
3619
|
+
let content;
|
|
3620
|
+
try {
|
|
3621
|
+
content = await readFile(gitignorePath, 'utf8');
|
|
3622
|
+
}
|
|
3623
|
+
catch {
|
|
3624
|
+
void reply
|
|
3625
|
+
.status(404)
|
|
3626
|
+
.send({ error: 'No .gitignore found at the expected location' });
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3629
|
+
const lines = content.split('\n');
|
|
3630
|
+
const filtered = lines.filter((line) => line.trim() !== pattern);
|
|
3631
|
+
if (filtered.length === lines.length) {
|
|
3632
|
+
void reply
|
|
3633
|
+
.status(404)
|
|
3634
|
+
.send({ error: 'Pattern not found in .gitignore' });
|
|
3635
|
+
return;
|
|
3636
|
+
}
|
|
3637
|
+
await writeFile(gitignorePath, filtered.join('\n'), 'utf8');
|
|
3638
|
+
return { ok: true, gitignorePath, action: 'removed' };
|
|
3639
|
+
}
|
|
3640
|
+
// Add the pattern to .gitignore
|
|
3641
|
+
await mkdir(gitignoreDir, { recursive: true });
|
|
3642
|
+
let existingContent = '';
|
|
3643
|
+
try {
|
|
3644
|
+
existingContent = await readFile(gitignorePath, 'utf8');
|
|
3645
|
+
}
|
|
3646
|
+
catch {
|
|
3647
|
+
// File doesn't exist yet
|
|
3648
|
+
}
|
|
3649
|
+
// Check if pattern already exists
|
|
3650
|
+
const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
|
|
3651
|
+
if (existingLines.has(pattern)) {
|
|
3652
|
+
return { ok: true, gitignorePath, action: 'added' };
|
|
3653
|
+
}
|
|
3654
|
+
if (existingContent.length === 0) {
|
|
3655
|
+
await writeFile(gitignorePath, pattern + '\n', 'utf8');
|
|
3656
|
+
}
|
|
3657
|
+
else {
|
|
3658
|
+
const prefix = existingContent.endsWith('\n') ? '' : '\n';
|
|
3659
|
+
await appendFile(gitignorePath, prefix + pattern + '\n', 'utf8');
|
|
3660
|
+
}
|
|
3661
|
+
return { ok: true, gitignorePath, action: 'added' };
|
|
3662
|
+
}, deps.logger, 'VcsExclude');
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
/**
|
|
3666
|
+
* @module api/handlers/vcs/vcsHistory
|
|
3667
|
+
* Fastify route handler for GET /vcs/history. Returns commit history for a glob.
|
|
3668
|
+
*/
|
|
3669
|
+
/** Matches a commit header line: 40-hex-char hash, pipe, message, pipe, ISO timestamp. */
|
|
3670
|
+
const HEADER_RE = /^([0-9a-f]{40})\|(.+)\|(\d{4}-\d{2}-\d{2}T.+)$/;
|
|
3671
|
+
/**
|
|
3672
|
+
* Parse git log --format='%H|%s|%aI' --name-only output into structured entries.
|
|
3673
|
+
*
|
|
3674
|
+
* Output format per commit:
|
|
3675
|
+
* hash|subject|ISO-date
|
|
3676
|
+
* (empty line)
|
|
3677
|
+
* file1
|
|
3678
|
+
* file2
|
|
3679
|
+
* (empty line)
|
|
3680
|
+
*/
|
|
3681
|
+
function parseGitLog(stdout) {
|
|
3682
|
+
const entries = [];
|
|
3683
|
+
const lines = stdout.split('\n');
|
|
3684
|
+
let current;
|
|
3685
|
+
for (const line of lines) {
|
|
3686
|
+
const trimmed = line.trim();
|
|
3687
|
+
const headerMatch = HEADER_RE.exec(trimmed);
|
|
3688
|
+
if (headerMatch) {
|
|
3689
|
+
// Start of a new commit
|
|
3690
|
+
current = {
|
|
3691
|
+
commit: headerMatch[1],
|
|
3692
|
+
message: headerMatch[2],
|
|
3693
|
+
timestamp: headerMatch[3],
|
|
3694
|
+
files: [],
|
|
3695
|
+
};
|
|
3696
|
+
entries.push(current);
|
|
3697
|
+
}
|
|
3698
|
+
else if (trimmed.length > 0 && current) {
|
|
3699
|
+
current.files.push(trimmed);
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
return entries;
|
|
3703
|
+
}
|
|
3704
|
+
/**
|
|
3705
|
+
* Create handler for GET /vcs/history.
|
|
3706
|
+
*/
|
|
3707
|
+
function createVcsHistoryHandler(deps) {
|
|
3708
|
+
return wrapHandler(async (request, reply) => {
|
|
3709
|
+
const { glob, since, until, limit: limitStr } = request.query;
|
|
3710
|
+
if (!glob) {
|
|
3711
|
+
void reply
|
|
3712
|
+
.status(400)
|
|
3713
|
+
.send({ error: 'Missing required query parameter: glob' });
|
|
3714
|
+
return;
|
|
3715
|
+
}
|
|
3716
|
+
const limit = limitStr ? parseInt(limitStr, 10) : 20;
|
|
3717
|
+
const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
|
|
3718
|
+
if (resolved.length === 0) {
|
|
3719
|
+
void reply
|
|
3720
|
+
.status(404)
|
|
3721
|
+
.send({ error: 'No VCS-enabled watch root matches the given glob' });
|
|
3722
|
+
return;
|
|
3723
|
+
}
|
|
3724
|
+
const allEntries = [];
|
|
3725
|
+
for (const { root, relativePath } of resolved) {
|
|
3726
|
+
const args = ['log', '--format=%H|%s|%aI', '--name-only'];
|
|
3727
|
+
if (since)
|
|
3728
|
+
args.push(`--since=${since}`);
|
|
3729
|
+
if (until)
|
|
3730
|
+
args.push(`--until=${until}`);
|
|
3731
|
+
args.push('--', relativePath || '.');
|
|
3732
|
+
try {
|
|
3733
|
+
const { stdout } = await execFileAsync('git', args, { cwd: root });
|
|
3734
|
+
allEntries.push(...parseGitLog(stdout));
|
|
3735
|
+
}
|
|
3736
|
+
catch {
|
|
3737
|
+
// No commits or invalid path — skip
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
// Sort by timestamp descending
|
|
3741
|
+
allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
3742
|
+
return allEntries.slice(0, limit);
|
|
3743
|
+
}, deps.logger, 'VcsHistory');
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
/**
|
|
3747
|
+
* @module api/handlers/vcs/vcsRevert
|
|
3748
|
+
* Fastify route handler for POST /vcs/revert. Restores files from a past commit.
|
|
3749
|
+
*/
|
|
3750
|
+
/**
|
|
3751
|
+
* Create handler for POST /vcs/revert.
|
|
3752
|
+
*/
|
|
3753
|
+
function createVcsRevertHandler(deps) {
|
|
3754
|
+
return wrapHandler(async (request, reply) => {
|
|
3755
|
+
const { glob, commit, existingOnly } = request.body;
|
|
3756
|
+
if (!glob || !commit) {
|
|
3757
|
+
void reply
|
|
3758
|
+
.status(400)
|
|
3759
|
+
.send({ error: 'Missing required body parameters: glob, commit' });
|
|
3760
|
+
return;
|
|
3761
|
+
}
|
|
3762
|
+
const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
|
|
3763
|
+
if (resolved.length === 0) {
|
|
3764
|
+
void reply
|
|
3765
|
+
.status(404)
|
|
3766
|
+
.send({ error: 'Glob does not match any VCS-enabled watch root' });
|
|
3767
|
+
return;
|
|
3768
|
+
}
|
|
3769
|
+
const allFiles = [];
|
|
3770
|
+
for (const { root, relativePath } of resolved) {
|
|
3771
|
+
const manager = deps.coordinator.getManager(root);
|
|
3772
|
+
if (!manager)
|
|
3773
|
+
continue;
|
|
3774
|
+
// List files matching the glob at the target commit
|
|
3775
|
+
let fileList;
|
|
3776
|
+
try {
|
|
3777
|
+
const args = relativePath === '' || relativePath === '.'
|
|
3778
|
+
? ['ls-tree', '-r', '--name-only', commit]
|
|
3779
|
+
: ['ls-tree', '-r', '--name-only', commit, '--', relativePath];
|
|
3780
|
+
const { stdout } = await execFileAsync('git', args, { cwd: root });
|
|
3781
|
+
fileList = stdout
|
|
3782
|
+
.trim()
|
|
3783
|
+
.split('\n')
|
|
3784
|
+
.filter((line) => line.length > 0);
|
|
3785
|
+
}
|
|
3786
|
+
catch {
|
|
3787
|
+
continue;
|
|
3788
|
+
}
|
|
3789
|
+
const restoredPaths = [];
|
|
3790
|
+
for (const relFile of fileList) {
|
|
3791
|
+
const absPath = normalizeSlashes(join(root, relFile));
|
|
3792
|
+
// If existingOnly, skip files that don't exist on disk
|
|
3793
|
+
if (existingOnly) {
|
|
3794
|
+
try {
|
|
3795
|
+
await access(absPath);
|
|
3796
|
+
}
|
|
3797
|
+
catch {
|
|
3798
|
+
continue;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
// Get file content at the target commit
|
|
3802
|
+
let content;
|
|
3803
|
+
try {
|
|
3804
|
+
const { stdout } = await execFileAsync('git', ['show', `${commit}:${relFile}`], { cwd: root });
|
|
3805
|
+
content = stdout;
|
|
3806
|
+
}
|
|
3807
|
+
catch {
|
|
3808
|
+
continue;
|
|
3809
|
+
}
|
|
3810
|
+
// Ensure parent directory exists
|
|
3811
|
+
const dir = dirname(absPath);
|
|
3812
|
+
await mkdir(dir, { recursive: true });
|
|
3813
|
+
// Write the file
|
|
3814
|
+
await writeFile(absPath, content, 'utf8');
|
|
3815
|
+
restoredPaths.push(absPath);
|
|
3816
|
+
// Notify VcsManager of the change (enters debounce cycle)
|
|
3817
|
+
manager.fileChanged(absPath);
|
|
3818
|
+
}
|
|
3819
|
+
if (restoredPaths.length > 0) {
|
|
3820
|
+
manager.addPendingReversion({
|
|
3821
|
+
glob,
|
|
3822
|
+
commit,
|
|
3823
|
+
paths: restoredPaths,
|
|
3824
|
+
});
|
|
3825
|
+
allFiles.push(...restoredPaths);
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
return { restored: allFiles.length, files: allFiles };
|
|
3829
|
+
}, deps.logger, 'VcsRevert');
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
/**
|
|
3833
|
+
* @module api/handlers/vcs/vcsShow
|
|
3834
|
+
* Fastify route handler for GET /vcs/show. Returns file content at a specific commit.
|
|
3835
|
+
*/
|
|
3836
|
+
const CONTENT_TYPES = {
|
|
3837
|
+
'.txt': 'text/plain',
|
|
3838
|
+
'.md': 'text/markdown',
|
|
3839
|
+
'.json': 'application/json',
|
|
3840
|
+
'.xml': 'application/xml',
|
|
3841
|
+
'.html': 'text/html',
|
|
3842
|
+
'.css': 'text/css',
|
|
3843
|
+
'.js': 'application/javascript',
|
|
3844
|
+
'.ts': 'text/plain',
|
|
3845
|
+
'.yaml': 'text/yaml',
|
|
3846
|
+
'.yml': 'text/yaml',
|
|
3847
|
+
'.csv': 'text/csv',
|
|
3848
|
+
};
|
|
3849
|
+
/**
|
|
3850
|
+
* Create handler for GET /vcs/show.
|
|
3851
|
+
*/
|
|
3852
|
+
function createVcsShowHandler(deps) {
|
|
3853
|
+
return wrapHandler(async (request, reply) => {
|
|
3854
|
+
const { path, commit } = request.query;
|
|
3855
|
+
if (!path || !commit) {
|
|
3856
|
+
void reply
|
|
3857
|
+
.status(400)
|
|
3858
|
+
.send({ error: 'Missing required query parameters: path, commit' });
|
|
3859
|
+
return;
|
|
3860
|
+
}
|
|
3861
|
+
const resolved = resolveWatchRoot(deps.coordinator, path);
|
|
3862
|
+
if (!resolved) {
|
|
3863
|
+
void reply
|
|
3864
|
+
.status(404)
|
|
3865
|
+
.send({ error: 'Path does not match any VCS-enabled watch root' });
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
try {
|
|
3869
|
+
const { stdout } = await execFileAsync('git', ['show', `${commit}:${resolved.relativePath}`], { cwd: resolved.root });
|
|
3870
|
+
const ext = extname(path).toLowerCase();
|
|
3871
|
+
const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
|
3872
|
+
void reply.header('content-type', contentType).send(stdout);
|
|
3873
|
+
}
|
|
3874
|
+
catch {
|
|
3875
|
+
void reply
|
|
3876
|
+
.status(404)
|
|
3877
|
+
.send({ error: 'File not found at the specified commit' });
|
|
3878
|
+
}
|
|
3879
|
+
}, deps.logger, 'VcsShow');
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
/**
|
|
3883
|
+
* @module api/handlers/vcs/vcsStatus
|
|
3884
|
+
* Fastify route handler for GET /vcs/status. Returns VCS state for all roots.
|
|
3885
|
+
*/
|
|
3886
|
+
async function getLastCommit(cwd) {
|
|
3887
|
+
try {
|
|
3888
|
+
const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%H|%s|%aI'], { cwd });
|
|
3889
|
+
const trimmed = stdout.trim();
|
|
3890
|
+
if (!trimmed)
|
|
3891
|
+
return null;
|
|
3892
|
+
const [hash, message, timestamp] = trimmed.split('|');
|
|
3893
|
+
return { hash, message, timestamp };
|
|
3894
|
+
}
|
|
3895
|
+
catch {
|
|
3896
|
+
return null;
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
async function getTrackedCount(cwd) {
|
|
3900
|
+
try {
|
|
3901
|
+
const { stdout } = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], { cwd });
|
|
3902
|
+
return parseInt(stdout.trim(), 10);
|
|
3903
|
+
}
|
|
3904
|
+
catch {
|
|
3905
|
+
return 0;
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
async function getRemoteUrl(cwd) {
|
|
3909
|
+
try {
|
|
3910
|
+
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd });
|
|
3911
|
+
const url = stdout.trim();
|
|
3912
|
+
return url || null;
|
|
3913
|
+
}
|
|
3914
|
+
catch {
|
|
3915
|
+
return null;
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
/**
|
|
3919
|
+
* Create handler for GET /vcs/status.
|
|
3920
|
+
*/
|
|
3921
|
+
function createVcsStatusHandler(deps) {
|
|
3922
|
+
return wrapHandler(async () => {
|
|
3923
|
+
const roots = deps.coordinator.getRoots();
|
|
3924
|
+
const enabled = roots.length > 0;
|
|
3925
|
+
const rootStatuses = await Promise.all(roots.map(async (root) => {
|
|
3926
|
+
const manager = deps.coordinator.getManager(root);
|
|
3927
|
+
const [tracked, lastCommit, remoteUrl] = await Promise.all([
|
|
3928
|
+
getTrackedCount(root),
|
|
3929
|
+
getLastCommit(root),
|
|
3930
|
+
getRemoteUrl(root),
|
|
3931
|
+
]);
|
|
3932
|
+
return {
|
|
3933
|
+
path: root,
|
|
3934
|
+
tracked,
|
|
3935
|
+
lastCommit,
|
|
3936
|
+
remoteUrl: manager?.remoteUrl ?? remoteUrl,
|
|
3937
|
+
lastPush: manager?.lastPushTime ?? null,
|
|
3938
|
+
pushErrors: manager?.pushErrors ?? [],
|
|
3939
|
+
};
|
|
3940
|
+
}));
|
|
3941
|
+
return { enabled, roots: rootStatuses };
|
|
3942
|
+
}, deps.logger, 'VcsStatus');
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3361
3945
|
/**
|
|
3362
3946
|
* @module api/handlers/walk
|
|
3363
3947
|
* Fastify route handler for POST /walk. Enumerates watched files matching caller-provided globs
|
|
@@ -3469,93 +4053,1004 @@ function withCache(ttlMs, handler) {
|
|
|
3469
4053
|
return result;
|
|
3470
4054
|
};
|
|
3471
4055
|
}
|
|
3472
|
-
|
|
4056
|
+
|
|
4057
|
+
/**
|
|
4058
|
+
* @module helpers/introspect
|
|
4059
|
+
* JSDoc introspection for helper modules. Extracts function exports and their descriptions.
|
|
4060
|
+
*/
|
|
4061
|
+
/**
|
|
4062
|
+
* Extract JSDoc descriptions from source code for exported functions.
|
|
4063
|
+
*
|
|
4064
|
+
* Looks for JSDoc comments immediately before `export function name` or `export const name`.
|
|
4065
|
+
*
|
|
4066
|
+
* @param source - The source file content.
|
|
4067
|
+
* @returns Map of export name to JSDoc description.
|
|
4068
|
+
*/
|
|
4069
|
+
function extractJsDocDescriptions(source) {
|
|
4070
|
+
const result = {};
|
|
4071
|
+
// Match JSDoc comment followed by export function/const
|
|
4072
|
+
const pattern = /\/\*\*([\s\S]*?)\*\/\s*export\s+(?:function|const)\s+(\w+)/g;
|
|
4073
|
+
let match;
|
|
4074
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
4075
|
+
const [, commentBody, name] = match;
|
|
4076
|
+
if (!name || !commentBody)
|
|
4077
|
+
continue;
|
|
4078
|
+
// Extract the description: lines before any @tag
|
|
4079
|
+
const lines = commentBody
|
|
4080
|
+
.split('\n')
|
|
4081
|
+
.map((line) => line.replace(/^\s*\*\s?/, '').trim())
|
|
4082
|
+
.filter((line) => line.length > 0 && !line.startsWith('@'));
|
|
4083
|
+
result[name] = lines.join(' ');
|
|
4084
|
+
}
|
|
4085
|
+
return result;
|
|
4086
|
+
}
|
|
4087
|
+
/**
|
|
4088
|
+
* Introspect a single helper module, enumerating function exports and extracting JSDoc descriptions.
|
|
4089
|
+
*
|
|
4090
|
+
* @param filePath - Resolved path to the helper module.
|
|
4091
|
+
* @param namespace - The namespace prefix for this module.
|
|
4092
|
+
* @returns Introspection result with namespaced export names and descriptions.
|
|
4093
|
+
*/
|
|
4094
|
+
async function introspectHelperModule(filePath, namespace) {
|
|
4095
|
+
const exports = {};
|
|
4096
|
+
// Load module exports using shared utility (use parent dir as configDir, path as relative)
|
|
4097
|
+
const namespaced = await loadNamespacedExports({ [namespace]: { path: filePath } }, '', (val) => typeof val === 'function');
|
|
4098
|
+
// Try to read source for JSDoc extraction
|
|
4099
|
+
let jsDocMap = {};
|
|
4100
|
+
for (const ext of ['.ts', '.js', '']) {
|
|
4101
|
+
const sourcePath = filePath.replace(/\.[jt]s$/, '') + ext;
|
|
4102
|
+
try {
|
|
4103
|
+
const source = readFileSync(ext === '' ? filePath : sourcePath, 'utf-8');
|
|
4104
|
+
jsDocMap = extractJsDocDescriptions(source);
|
|
4105
|
+
if (Object.keys(jsDocMap).length > 0)
|
|
4106
|
+
break;
|
|
4107
|
+
}
|
|
4108
|
+
catch {
|
|
4109
|
+
// File not found, try next
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
const nsExports = namespaced[namespace] ?? {};
|
|
4113
|
+
for (const key of Object.keys(nsExports)) {
|
|
4114
|
+
exports[`${namespace}_${key}`] = jsDocMap[key] ?? '';
|
|
4115
|
+
}
|
|
4116
|
+
return { exports };
|
|
4117
|
+
}
|
|
4118
|
+
/**
|
|
4119
|
+
* Introspect all helper modules from config, returning exports for both mapHelpers and templateHelpers.
|
|
4120
|
+
*
|
|
4121
|
+
* @param config - Object with optional mapHelpers and templateHelpers config.
|
|
4122
|
+
* @param configDir - Directory to resolve relative paths against.
|
|
4123
|
+
* @returns Full introspection results for injection into the merged document.
|
|
4124
|
+
*/
|
|
4125
|
+
async function introspectAllHelpers(config, configDir) {
|
|
4126
|
+
const result = {
|
|
4127
|
+
mapHelpers: {},
|
|
4128
|
+
templateHelpers: {},
|
|
4129
|
+
};
|
|
4130
|
+
if (config.mapHelpers) {
|
|
4131
|
+
for (const [namespace, { path: p }] of Object.entries(config.mapHelpers)) {
|
|
4132
|
+
const resolved = resolve(configDir, p);
|
|
4133
|
+
result.mapHelpers[namespace] = await introspectHelperModule(resolved, namespace);
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
if (config.templateHelpers) {
|
|
4137
|
+
for (const [namespace, { path: p }] of Object.entries(config.templateHelpers)) {
|
|
4138
|
+
const resolved = resolve(configDir, p);
|
|
4139
|
+
result.templateHelpers[namespace] = await introspectHelperModule(resolved, namespace);
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
return result;
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
/**
|
|
4146
|
+
* @module vcs/CommitMessageGenerator
|
|
4147
|
+
* Generates AI-powered commit messages via the Anthropic Messages API.
|
|
4148
|
+
*/
|
|
4149
|
+
/** Maximum characters of diff to send to the AI. */
|
|
4150
|
+
const MAX_DIFF_CHARS = 4000;
|
|
4151
|
+
/** Maximum length of the generated commit message. */
|
|
4152
|
+
const MAX_MESSAGE_LENGTH = 100;
|
|
4153
|
+
/** Request timeout in milliseconds. */
|
|
4154
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
4155
|
+
/** System prompt for the commit message generator. */
|
|
4156
|
+
const SYSTEM_PROMPT = 'You are a commit message generator. Given file changes, produce a single concise one-line commit message. No prefix. No punctuation at end';
|
|
4157
|
+
/**
|
|
4158
|
+
* Generates AI commit messages using the Anthropic Messages API.
|
|
4159
|
+
*/
|
|
4160
|
+
class CommitMessageGenerator {
|
|
4161
|
+
provider;
|
|
4162
|
+
model;
|
|
4163
|
+
apiKey;
|
|
4164
|
+
logger;
|
|
4165
|
+
constructor(provider, model, apiKey, logger) {
|
|
4166
|
+
this.provider = provider;
|
|
4167
|
+
this.model = model;
|
|
4168
|
+
this.apiKey = apiKey;
|
|
4169
|
+
this.logger = logger;
|
|
4170
|
+
}
|
|
4171
|
+
/**
|
|
4172
|
+
* Generate a commit message from file changes.
|
|
4173
|
+
*
|
|
4174
|
+
* @param files - List of changed file paths.
|
|
4175
|
+
* @param diffSummary - Combined diff output (stat + patch).
|
|
4176
|
+
* @returns A one-line commit message, or null on failure.
|
|
4177
|
+
*/
|
|
4178
|
+
async generate(files, diffSummary) {
|
|
4179
|
+
if (!this.apiKey) {
|
|
4180
|
+
this.logger.warn('No API key configured for commit message generation');
|
|
4181
|
+
return null;
|
|
4182
|
+
}
|
|
4183
|
+
if (this.provider !== 'anthropic') {
|
|
4184
|
+
this.logger.warn({ provider: this.provider }, 'Unsupported commit message provider; only "anthropic" is supported');
|
|
4185
|
+
return null;
|
|
4186
|
+
}
|
|
4187
|
+
const truncatedDiff = diffSummary.slice(0, MAX_DIFF_CHARS);
|
|
4188
|
+
const userPrompt = `Files changed:\n${files.join('\n')}\n\nDiff:\n${truncatedDiff}`;
|
|
4189
|
+
try {
|
|
4190
|
+
const body = JSON.stringify({
|
|
4191
|
+
model: this.model,
|
|
4192
|
+
max_tokens: 256,
|
|
4193
|
+
system: SYSTEM_PROMPT,
|
|
4194
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
4195
|
+
});
|
|
4196
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
4197
|
+
method: 'POST',
|
|
4198
|
+
headers: {
|
|
4199
|
+
'Content-Type': 'application/json',
|
|
4200
|
+
'x-api-key': this.apiKey,
|
|
4201
|
+
'anthropic-version': '2023-06-01',
|
|
4202
|
+
},
|
|
4203
|
+
body,
|
|
4204
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
4205
|
+
});
|
|
4206
|
+
if (!response.ok) {
|
|
4207
|
+
const text = await response.text();
|
|
4208
|
+
this.logger.warn({ status: response.status, body: text }, 'Anthropic API returned non-OK status');
|
|
4209
|
+
return null;
|
|
4210
|
+
}
|
|
4211
|
+
const data = (await response.json());
|
|
4212
|
+
const textBlock = data.content?.find((b) => b.type === 'text');
|
|
4213
|
+
if (!textBlock?.text) {
|
|
4214
|
+
this.logger.warn('Anthropic API returned no text content');
|
|
4215
|
+
return null;
|
|
4216
|
+
}
|
|
4217
|
+
const message = textBlock.text.trim().slice(0, MAX_MESSAGE_LENGTH);
|
|
4218
|
+
this.logger.debug({ message }, 'AI commit message generated');
|
|
4219
|
+
return message;
|
|
4220
|
+
}
|
|
4221
|
+
catch (error) {
|
|
4222
|
+
this.logger.warn({ err: normalizeError(error) }, 'Failed to generate AI commit message');
|
|
4223
|
+
return null;
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
/**
|
|
4229
|
+
* @module vcs/resolveCommitMessageApiKey
|
|
4230
|
+
* Resolves the API key for AI commit message generation.
|
|
4231
|
+
*/
|
|
4232
|
+
/**
|
|
4233
|
+
* Resolve the API key for commit message generation.
|
|
4234
|
+
* Checks config first, then falls back to OpenClaw gateway credentials.
|
|
4235
|
+
*/
|
|
4236
|
+
function resolveCommitMessageApiKey(provider, configApiKey, logger) {
|
|
4237
|
+
if (configApiKey) {
|
|
4238
|
+
logger.debug('Using commit message API key from config');
|
|
4239
|
+
return configApiKey;
|
|
4240
|
+
}
|
|
4241
|
+
try {
|
|
4242
|
+
const openclawPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
4243
|
+
const raw = readFileSync(openclawPath, 'utf8');
|
|
4244
|
+
const parsed = JSON.parse(raw);
|
|
4245
|
+
const gatewayKey = parsed.models?.providers?.[provider]?.apiKey;
|
|
4246
|
+
if (gatewayKey) {
|
|
4247
|
+
logger.debug({ provider }, 'Using commit message API key from OpenClaw gateway config');
|
|
4248
|
+
return gatewayKey;
|
|
4249
|
+
}
|
|
4250
|
+
logger.warn({ provider }, 'OpenClaw gateway config found but no API key for provider');
|
|
4251
|
+
}
|
|
4252
|
+
catch {
|
|
4253
|
+
logger.warn('No commit message API key in config or OpenClaw gateway');
|
|
4254
|
+
}
|
|
4255
|
+
return undefined;
|
|
4256
|
+
}
|
|
4257
|
+
|
|
4258
|
+
/**
|
|
4259
|
+
* @module vcs/SquashManager
|
|
4260
|
+
* Handles scheduled squash retention for git-backed VCS roots.
|
|
4261
|
+
*/
|
|
4262
|
+
/**
|
|
4263
|
+
* Parse a standard 5-field cron expression and check if the current time matches.
|
|
4264
|
+
* Fields: minute hour dom month dow
|
|
4265
|
+
* Supports: numbers, wildcards, lists (1,3,5), ranges (1-5), steps (star/5)
|
|
4266
|
+
*/
|
|
4267
|
+
function cronMatchesNow(cronExpr, now = new Date()) {
|
|
4268
|
+
const fields = cronExpr.trim().split(/\s+/);
|
|
4269
|
+
if (fields.length !== 5)
|
|
4270
|
+
return false;
|
|
4271
|
+
const minute = now.getMinutes();
|
|
4272
|
+
const hour = now.getHours();
|
|
4273
|
+
const dom = now.getDate();
|
|
4274
|
+
const month = now.getMonth() + 1; // 1-based
|
|
4275
|
+
const dow = now.getDay(); // 0=Sunday
|
|
4276
|
+
return (fieldMatches(fields[0], minute, 0, 59) &&
|
|
4277
|
+
fieldMatches(fields[1], hour, 0, 23) &&
|
|
4278
|
+
fieldMatches(fields[2], dom, 1, 31) &&
|
|
4279
|
+
fieldMatches(fields[3], month, 1, 12) &&
|
|
4280
|
+
fieldMatches(fields[4], dow, 0, 7) // 7 also means Sunday
|
|
4281
|
+
);
|
|
4282
|
+
}
|
|
4283
|
+
function fieldMatches(field, value, min, max) {
|
|
4284
|
+
for (const part of field.split(',')) {
|
|
4285
|
+
if (partMatches(part.trim(), value, min, max))
|
|
4286
|
+
return true;
|
|
4287
|
+
}
|
|
4288
|
+
return false;
|
|
4289
|
+
}
|
|
4290
|
+
function partMatches(part, value, min, max) {
|
|
4291
|
+
// Handle step: */N or range/N
|
|
4292
|
+
const [rangePart, stepStr] = part.split('/');
|
|
4293
|
+
const step = stepStr ? parseInt(stepStr, 10) : 1;
|
|
4294
|
+
if (rangePart === '*') {
|
|
4295
|
+
return (value - min) % step === 0;
|
|
4296
|
+
}
|
|
4297
|
+
// Handle range: N-M
|
|
4298
|
+
if (rangePart.includes('-')) {
|
|
4299
|
+
const [startStr, endStr] = rangePart.split('-');
|
|
4300
|
+
const start = parseInt(startStr, 10);
|
|
4301
|
+
const end = parseInt(endStr, 10);
|
|
4302
|
+
if (value < start || value > end)
|
|
4303
|
+
return false;
|
|
4304
|
+
return (value - start) % step === 0;
|
|
4305
|
+
}
|
|
4306
|
+
// Plain number
|
|
4307
|
+
const num = parseInt(rangePart, 10);
|
|
4308
|
+
if (step === 1) {
|
|
4309
|
+
// For dow, treat 7 as 0 (Sunday)
|
|
4310
|
+
if (max === 7 && num === 7)
|
|
4311
|
+
return value === 0;
|
|
4312
|
+
return value === num;
|
|
4313
|
+
}
|
|
4314
|
+
// Number with step doesn't make sense for single values, treat as exact
|
|
4315
|
+
return value === num;
|
|
4316
|
+
}
|
|
4317
|
+
/**
|
|
4318
|
+
* Manages scheduled squash retention for a single VCS root.
|
|
4319
|
+
*
|
|
4320
|
+
* Runs on a cron schedule (checked every 60s). When triggered, squashes
|
|
4321
|
+
* commits older than the retention boundary (the tighter of maxAgeDays
|
|
4322
|
+
* and maxVersions) into a single "historical baseline" orphan commit,
|
|
4323
|
+
* then cherry-picks retained commits on top. Force-pushes to remote
|
|
4324
|
+
* if configured (D13).
|
|
4325
|
+
*/
|
|
4326
|
+
class SquashManager {
|
|
4327
|
+
rootPath;
|
|
4328
|
+
retention;
|
|
4329
|
+
remoteUrl;
|
|
4330
|
+
accessToken;
|
|
4331
|
+
logger;
|
|
4332
|
+
intervalTimer;
|
|
4333
|
+
running = false;
|
|
4334
|
+
constructor(rootPath, retention, logger, remoteUrl, accessToken) {
|
|
4335
|
+
this.rootPath = rootPath;
|
|
4336
|
+
this.retention = retention;
|
|
4337
|
+
this.logger = logger;
|
|
4338
|
+
this.remoteUrl = remoteUrl;
|
|
4339
|
+
this.accessToken = accessToken;
|
|
4340
|
+
}
|
|
4341
|
+
/**
|
|
4342
|
+
* Start the cron-based squash scheduler.
|
|
4343
|
+
* Checks every 60 seconds if the cron expression matches.
|
|
4344
|
+
*/
|
|
4345
|
+
start() {
|
|
4346
|
+
if (this.running)
|
|
4347
|
+
return;
|
|
4348
|
+
this.running = true;
|
|
4349
|
+
this.intervalTimer = setInterval(() => {
|
|
4350
|
+
if (cronMatchesNow(this.retention.squashCron)) {
|
|
4351
|
+
void this.runSquash();
|
|
4352
|
+
}
|
|
4353
|
+
}, 60_000);
|
|
4354
|
+
this.logger.info({ root: this.rootPath, cron: this.retention.squashCron }, 'SquashManager started');
|
|
4355
|
+
}
|
|
4356
|
+
/**
|
|
4357
|
+
* Stop the cron scheduler.
|
|
4358
|
+
*/
|
|
4359
|
+
stop() {
|
|
4360
|
+
this.running = false;
|
|
4361
|
+
if (this.intervalTimer !== undefined) {
|
|
4362
|
+
clearInterval(this.intervalTimer);
|
|
4363
|
+
this.intervalTimer = undefined;
|
|
4364
|
+
}
|
|
4365
|
+
this.logger.info({ root: this.rootPath }, 'SquashManager stopped');
|
|
4366
|
+
}
|
|
4367
|
+
/**
|
|
4368
|
+
* Run the squash operation. Exposed publicly for testing.
|
|
4369
|
+
*/
|
|
4370
|
+
async runSquash() {
|
|
4371
|
+
// Check for index.lock before starting multi-step operation
|
|
4372
|
+
const lockPath = join(this.rootPath, '.git', 'index.lock');
|
|
4373
|
+
try {
|
|
4374
|
+
await access(lockPath);
|
|
4375
|
+
// Lock exists — abort and retry next cycle
|
|
4376
|
+
this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
|
|
4377
|
+
return { squashed: false, error: 'index.lock exists' };
|
|
4378
|
+
}
|
|
4379
|
+
catch {
|
|
4380
|
+
// No lock — proceed
|
|
4381
|
+
}
|
|
4382
|
+
try {
|
|
4383
|
+
const commits = await this.getCommitLog();
|
|
4384
|
+
if (commits.length <= 1) {
|
|
4385
|
+
return { squashed: false };
|
|
4386
|
+
}
|
|
4387
|
+
const boundaryIndex = this.calculateRetentionBoundary(commits);
|
|
4388
|
+
if (boundaryIndex <= 0) {
|
|
4389
|
+
// All commits are within retention or only one commit before boundary
|
|
4390
|
+
return { squashed: false };
|
|
4391
|
+
}
|
|
4392
|
+
// Perform the squash
|
|
4393
|
+
await this.performSquash(commits, boundaryIndex);
|
|
4394
|
+
const commitsRemoved = boundaryIndex;
|
|
4395
|
+
const commitsRetained = commits.length - boundaryIndex;
|
|
4396
|
+
this.logger.info({ root: this.rootPath, commitsRemoved, commitsRetained }, 'Squash retention completed');
|
|
4397
|
+
// Force push if remote configured
|
|
4398
|
+
await this.forcePushIfConfigured();
|
|
4399
|
+
return { squashed: true, commitsRemoved, commitsRetained };
|
|
4400
|
+
}
|
|
4401
|
+
catch (error) {
|
|
4402
|
+
const message = normalizeError(error).message;
|
|
4403
|
+
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
|
|
4404
|
+
return { squashed: false, error: message };
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
/**
|
|
4408
|
+
* Get all commits in chronological order (oldest first).
|
|
4409
|
+
*/
|
|
4410
|
+
async getCommitLog() {
|
|
4411
|
+
try {
|
|
4412
|
+
const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath });
|
|
4413
|
+
if (!stdout.trim())
|
|
4414
|
+
return [];
|
|
4415
|
+
return stdout
|
|
4416
|
+
.trim()
|
|
4417
|
+
.split('\n')
|
|
4418
|
+
.map((line) => {
|
|
4419
|
+
const spaceIdx = line.indexOf(' ');
|
|
4420
|
+
return {
|
|
4421
|
+
hash: line.slice(0, spaceIdx),
|
|
4422
|
+
date: new Date(line.slice(spaceIdx + 1)),
|
|
4423
|
+
};
|
|
4424
|
+
});
|
|
4425
|
+
}
|
|
4426
|
+
catch {
|
|
4427
|
+
return [];
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
/**
|
|
4431
|
+
* Calculate the retention boundary index.
|
|
4432
|
+
* Returns the index of the oldest commit to KEEP.
|
|
4433
|
+
* All commits before this index will be squashed into a baseline.
|
|
4434
|
+
*
|
|
4435
|
+
* Strategy: find the tighter constraint between age and count.
|
|
4436
|
+
* - Age: keep commits newer than maxAgeDays
|
|
4437
|
+
* - Count: keep the last maxVersions commits
|
|
4438
|
+
* The boundary is the one that retains FEWER old commits (tighter).
|
|
4439
|
+
*/
|
|
4440
|
+
calculateRetentionBoundary(commits) {
|
|
4441
|
+
const now = new Date();
|
|
4442
|
+
const maxAgeMs = this.retention.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
4443
|
+
const cutoffDate = new Date(now.getTime() - maxAgeMs);
|
|
4444
|
+
// Find the first commit that is within the age window (to keep)
|
|
4445
|
+
let ageBoundary = commits.length; // Default: all too old
|
|
4446
|
+
for (let i = 0; i < commits.length; i++) {
|
|
4447
|
+
if (commits[i].date >= cutoffDate) {
|
|
4448
|
+
ageBoundary = i;
|
|
4449
|
+
break;
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
// Count boundary: keep the last maxVersions commits
|
|
4453
|
+
const countBoundary = Math.max(0, commits.length - this.retention.maxVersions);
|
|
4454
|
+
// The tighter constraint wins (keeps fewer old commits = higher boundary index)
|
|
4455
|
+
return Math.max(ageBoundary, countBoundary);
|
|
4456
|
+
}
|
|
4457
|
+
/**
|
|
4458
|
+
* Perform the squash: create orphan with baseline + cherry-pick retained commits.
|
|
4459
|
+
*/
|
|
4460
|
+
async performSquash(commits, boundaryIndex) {
|
|
4461
|
+
// The commit just before boundary is the last one to squash
|
|
4462
|
+
const baselineHash = commits[boundaryIndex - 1].hash;
|
|
4463
|
+
// Get the current branch name
|
|
4464
|
+
const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: this.rootPath });
|
|
4465
|
+
const currentBranch = branchOut.trim();
|
|
4466
|
+
const orphanBranch = `__squash_orphan_${String(Date.now())}`;
|
|
4467
|
+
try {
|
|
4468
|
+
// 1. Create orphan branch with the tree state at the baseline commit
|
|
4469
|
+
await execFileAsync('git', ['checkout', '--orphan', orphanBranch], {
|
|
4470
|
+
cwd: this.rootPath,
|
|
4471
|
+
});
|
|
4472
|
+
// 2. Reset index to the baseline tree (the last squashed commit's tree)
|
|
4473
|
+
await execFileAsync('git', ['reset', '--hard', baselineHash], {
|
|
4474
|
+
cwd: this.rootPath,
|
|
4475
|
+
});
|
|
4476
|
+
// 3. Create the baseline commit with the same tree
|
|
4477
|
+
// Use commit-tree for a clean orphan commit
|
|
4478
|
+
const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath });
|
|
4479
|
+
const treeHash = treeOut.trim();
|
|
4480
|
+
const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath });
|
|
4481
|
+
const baselineCommitHash = commitOut.trim();
|
|
4482
|
+
// 4. Reset orphan branch to this new baseline commit
|
|
4483
|
+
await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
|
|
4484
|
+
cwd: this.rootPath,
|
|
4485
|
+
});
|
|
4486
|
+
// 5. Cherry-pick boundary..HEAD (the retained commits)
|
|
4487
|
+
// Get the list of commits to cherry-pick (from boundary to the end)
|
|
4488
|
+
const commitsToCherry = commits.slice(boundaryIndex).map((c) => c.hash);
|
|
4489
|
+
if (commitsToCherry.length > 0) {
|
|
4490
|
+
await execFileAsync('git', ['cherry-pick', ...commitsToCherry], {
|
|
4491
|
+
cwd: this.rootPath,
|
|
4492
|
+
});
|
|
4493
|
+
}
|
|
4494
|
+
// 6. Force-update the original branch to the orphan
|
|
4495
|
+
const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath });
|
|
4496
|
+
await execFileAsync('git', ['branch', '-f', currentBranch, newHead.trim()], { cwd: this.rootPath });
|
|
4497
|
+
// 7. Switch back to original branch
|
|
4498
|
+
await execFileAsync('git', ['checkout', currentBranch], {
|
|
4499
|
+
cwd: this.rootPath,
|
|
4500
|
+
});
|
|
4501
|
+
// 8. Delete orphan branch
|
|
4502
|
+
await execFileAsync('git', ['branch', '-D', orphanBranch], {
|
|
4503
|
+
cwd: this.rootPath,
|
|
4504
|
+
});
|
|
4505
|
+
}
|
|
4506
|
+
catch (error) {
|
|
4507
|
+
// Attempt cleanup: try to get back to original branch
|
|
4508
|
+
try {
|
|
4509
|
+
await execFileAsync('git', ['checkout', '-f', currentBranch], {
|
|
4510
|
+
cwd: this.rootPath,
|
|
4511
|
+
});
|
|
4512
|
+
await execFileAsync('git', ['branch', '-D', orphanBranch], {
|
|
4513
|
+
cwd: this.rootPath,
|
|
4514
|
+
});
|
|
4515
|
+
}
|
|
4516
|
+
catch {
|
|
4517
|
+
// Cleanup failed — log but re-throw original error
|
|
4518
|
+
}
|
|
4519
|
+
// Remove index.lock if left behind
|
|
4520
|
+
try {
|
|
4521
|
+
const lockFile = join(this.rootPath, '.git', 'index.lock');
|
|
4522
|
+
await rm(lockFile, { force: true });
|
|
4523
|
+
}
|
|
4524
|
+
catch {
|
|
4525
|
+
// ignore
|
|
4526
|
+
}
|
|
4527
|
+
throw error;
|
|
4528
|
+
}
|
|
4529
|
+
}
|
|
4530
|
+
/**
|
|
4531
|
+
* Force push to remote after squash (history rewrite requires force).
|
|
4532
|
+
*/
|
|
4533
|
+
async forcePushIfConfigured() {
|
|
4534
|
+
if (!this.remoteUrl)
|
|
4535
|
+
return;
|
|
4536
|
+
try {
|
|
4537
|
+
const pushUrl = this.accessToken
|
|
4538
|
+
? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
|
|
4539
|
+
: this.remoteUrl;
|
|
4540
|
+
await execFileAsync('git', ['push', '--force', pushUrl, 'HEAD'], {
|
|
4541
|
+
cwd: this.rootPath,
|
|
4542
|
+
});
|
|
4543
|
+
this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'Squash force push succeeded');
|
|
4544
|
+
}
|
|
4545
|
+
catch (error) {
|
|
4546
|
+
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash force push failed');
|
|
4547
|
+
}
|
|
4548
|
+
}
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
/**
|
|
4552
|
+
* @module vcs/validateStateDirOverlap
|
|
4553
|
+
* Validates that stateDir does not overlap with any watch path.
|
|
4554
|
+
*/
|
|
4555
|
+
/**
|
|
4556
|
+
* Validate that stateDir does not overlap with any watch path.
|
|
4557
|
+
* Overlap means stateDir is a parent, child, or equal to a watch path.
|
|
4558
|
+
*
|
|
4559
|
+
* @param stateDir - The state directory path.
|
|
4560
|
+
* @param watchPaths - Array of watch path strings.
|
|
4561
|
+
* @throws Error if stateDir overlaps with any watch path.
|
|
4562
|
+
*/
|
|
4563
|
+
function validateStateDirOverlap(stateDir, watchPaths) {
|
|
4564
|
+
const resolvedStateDir = resolve(stateDir);
|
|
4565
|
+
for (const watchPath of watchPaths) {
|
|
4566
|
+
const resolvedWatch = resolve(watchPath);
|
|
4567
|
+
if (resolvedStateDir === resolvedWatch) {
|
|
4568
|
+
throw new Error(`stateDir "${stateDir}" is the same as watch path "${watchPath}". They must not overlap.`);
|
|
4569
|
+
}
|
|
4570
|
+
const stateDirWithSep = resolvedStateDir.endsWith(sep)
|
|
4571
|
+
? resolvedStateDir
|
|
4572
|
+
: resolvedStateDir + sep;
|
|
4573
|
+
const watchWithSep = resolvedWatch.endsWith(sep)
|
|
4574
|
+
? resolvedWatch
|
|
4575
|
+
: resolvedWatch + sep;
|
|
4576
|
+
if (resolvedWatch.startsWith(stateDirWithSep)) {
|
|
4577
|
+
throw new Error(`Watch path "${watchPath}" is inside stateDir "${stateDir}". They must not overlap.`);
|
|
4578
|
+
}
|
|
4579
|
+
if (resolvedStateDir.startsWith(watchWithSep)) {
|
|
4580
|
+
throw new Error(`stateDir "${stateDir}" is inside watch path "${watchPath}". They must not overlap.`);
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
/**
|
|
4586
|
+
* @module vcs/vcsBootstrap
|
|
4587
|
+
* Static bootstrap utilities for VCS-managed watch roots.
|
|
4588
|
+
*/
|
|
4589
|
+
/** Always-on .gitignore entries for VCS-managed watch roots. */
|
|
4590
|
+
const ALWAYS_GITIGNORE_ENTRIES = [
|
|
4591
|
+
'.git/',
|
|
4592
|
+
'node_modules/',
|
|
4593
|
+
'.jeeves-watcher/',
|
|
4594
|
+
'.jeeves-metadata/',
|
|
4595
|
+
];
|
|
4596
|
+
/**
|
|
4597
|
+
* Check whether git is available on the system PATH.
|
|
4598
|
+
*
|
|
4599
|
+
* @returns true if git is available, false otherwise.
|
|
4600
|
+
*/
|
|
4601
|
+
async function checkGitAvailable() {
|
|
4602
|
+
try {
|
|
4603
|
+
await execFileAsync('git', ['--version']);
|
|
4604
|
+
return true;
|
|
4605
|
+
}
|
|
4606
|
+
catch {
|
|
4607
|
+
return false;
|
|
4608
|
+
}
|
|
4609
|
+
}
|
|
3473
4610
|
/**
|
|
3474
|
-
*
|
|
3475
|
-
*
|
|
4611
|
+
* Initialize a git repository at the given path if none exists.
|
|
4612
|
+
* Idempotent — does nothing if .git/ already exists.
|
|
4613
|
+
*
|
|
4614
|
+
* @param rootPath - Directory to initialize as a git repo.
|
|
3476
4615
|
*/
|
|
4616
|
+
async function initRepo(rootPath) {
|
|
4617
|
+
const gitDir = join(rootPath, '.git');
|
|
4618
|
+
try {
|
|
4619
|
+
await access(gitDir);
|
|
4620
|
+
// .git exists, nothing to do
|
|
4621
|
+
}
|
|
4622
|
+
catch {
|
|
4623
|
+
await execFileAsync('git', ['init'], { cwd: rootPath });
|
|
4624
|
+
}
|
|
4625
|
+
}
|
|
3477
4626
|
/**
|
|
3478
|
-
*
|
|
3479
|
-
*
|
|
3480
|
-
* Looks for JSDoc comments immediately before `export function name` or `export const name`.
|
|
4627
|
+
* Ensure a .gitignore file exists at rootPath with all always-on entries.
|
|
4628
|
+
* Creates the file if missing. Appends only entries not already present.
|
|
3481
4629
|
*
|
|
3482
|
-
* @param
|
|
3483
|
-
* @
|
|
4630
|
+
* @param rootPath - Directory containing the .gitignore.
|
|
4631
|
+
* @param alwaysOnEntries - Additional always-on entries beyond the defaults.
|
|
3484
4632
|
*/
|
|
3485
|
-
function
|
|
3486
|
-
const
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
4633
|
+
async function ensureGitignore(rootPath, alwaysOnEntries = []) {
|
|
4634
|
+
const gitignorePath = join(rootPath, '.gitignore');
|
|
4635
|
+
const requiredEntries = [...ALWAYS_GITIGNORE_ENTRIES, ...alwaysOnEntries];
|
|
4636
|
+
let existingContent = '';
|
|
4637
|
+
try {
|
|
4638
|
+
existingContent = await readFile(gitignorePath, 'utf8');
|
|
4639
|
+
}
|
|
4640
|
+
catch {
|
|
4641
|
+
// File doesn't exist yet
|
|
4642
|
+
}
|
|
4643
|
+
const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
|
|
4644
|
+
const missing = requiredEntries.filter((entry) => !existingLines.has(entry));
|
|
4645
|
+
if (missing.length === 0)
|
|
4646
|
+
return;
|
|
4647
|
+
if (existingContent.length === 0) {
|
|
4648
|
+
await writeFile(gitignorePath, missing.join('\n') + '\n', 'utf8');
|
|
4649
|
+
}
|
|
4650
|
+
else {
|
|
4651
|
+
const prefix = existingContent.endsWith('\n') ? '' : '\n';
|
|
4652
|
+
await appendFile(gitignorePath, prefix + missing.join('\n') + '\n', 'utf8');
|
|
3500
4653
|
}
|
|
3501
|
-
return result;
|
|
3502
4654
|
}
|
|
4655
|
+
|
|
3503
4656
|
/**
|
|
3504
|
-
*
|
|
4657
|
+
* @module vcs/VcsManager
|
|
4658
|
+
* Per-root VCS manager for git-backed content versioning.
|
|
4659
|
+
*/
|
|
4660
|
+
/**
|
|
4661
|
+
* Per-root VCS manager for git-backed content versioning.
|
|
3505
4662
|
*
|
|
3506
|
-
*
|
|
3507
|
-
*
|
|
3508
|
-
*
|
|
4663
|
+
* Each VCS-enabled watch root gets its own VcsManager instance, which owns
|
|
4664
|
+
* a debounced commit pipeline: file changes are batched, staged, committed
|
|
4665
|
+
* (with optional AI-generated messages), and optionally pushed to a remote.
|
|
4666
|
+
*
|
|
4667
|
+
* Concurrency: only one commit is in-flight at a time per root. Index.lock
|
|
4668
|
+
* contention is handled with exponential backoff retries (D10).
|
|
3509
4669
|
*/
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
4670
|
+
class VcsManager {
|
|
4671
|
+
config;
|
|
4672
|
+
rootPath;
|
|
4673
|
+
remoteUrl;
|
|
4674
|
+
accessToken;
|
|
4675
|
+
logger;
|
|
4676
|
+
commitMessageGenerator;
|
|
4677
|
+
pending = new Set();
|
|
4678
|
+
pendingReversions = [];
|
|
4679
|
+
_pushErrors = [];
|
|
4680
|
+
squashManager;
|
|
4681
|
+
debounceTimer;
|
|
4682
|
+
commitInFlight = Promise.resolve();
|
|
4683
|
+
started = false;
|
|
4684
|
+
_lastPushTime = null;
|
|
4685
|
+
constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
|
|
4686
|
+
this.rootPath = rootPath;
|
|
4687
|
+
this.config = config;
|
|
4688
|
+
this.logger = logger;
|
|
4689
|
+
this.commitMessageGenerator = commitMessageGenerator;
|
|
4690
|
+
this.remoteUrl = remoteUrl;
|
|
4691
|
+
this.accessToken = accessToken;
|
|
4692
|
+
if (config.retention) {
|
|
4693
|
+
this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
get lastPushTime() {
|
|
4697
|
+
return this._lastPushTime;
|
|
4698
|
+
}
|
|
4699
|
+
get pushErrors() {
|
|
4700
|
+
return this._pushErrors;
|
|
4701
|
+
}
|
|
4702
|
+
/**
|
|
4703
|
+
* Begin accepting file changes. Sets up the debounce mechanism.
|
|
4704
|
+
*/
|
|
4705
|
+
start() {
|
|
4706
|
+
this.started = true;
|
|
4707
|
+
this.squashManager?.start();
|
|
4708
|
+
this.logger.info({ root: this.rootPath }, 'VcsManager started');
|
|
4709
|
+
}
|
|
4710
|
+
/**
|
|
4711
|
+
* Add a file to the pending set and reset the debounce timer.
|
|
4712
|
+
* If the pending set exceeds maxBatchSize, flush immediately.
|
|
4713
|
+
*
|
|
4714
|
+
* @param filePath - Absolute path of the changed file.
|
|
4715
|
+
*/
|
|
4716
|
+
fileChanged(filePath) {
|
|
4717
|
+
if (!this.started)
|
|
4718
|
+
return;
|
|
4719
|
+
this.pending.add(filePath);
|
|
4720
|
+
if (this.pending.size >= this.config.maxBatchSize) {
|
|
4721
|
+
this.clearDebounce();
|
|
4722
|
+
const batch = this.takeBatch(this.config.maxBatchSize);
|
|
4723
|
+
this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
|
|
4724
|
+
if (this.pending.size > 0) {
|
|
4725
|
+
this.resetDebounce();
|
|
4726
|
+
}
|
|
4727
|
+
return;
|
|
4728
|
+
}
|
|
4729
|
+
this.resetDebounce();
|
|
4730
|
+
}
|
|
4731
|
+
/**
|
|
4732
|
+
* Record reversion metadata so the next commit uses a revert-prefixed message.
|
|
4733
|
+
*
|
|
4734
|
+
* @param reversion - The reversion metadata to record.
|
|
4735
|
+
*/
|
|
4736
|
+
addPendingReversion(reversion) {
|
|
4737
|
+
this.pendingReversions.push(reversion);
|
|
4738
|
+
}
|
|
4739
|
+
/**
|
|
4740
|
+
* Stage a file deletion and add to pending set.
|
|
4741
|
+
* git add handles deleted files when the file is gone from disk.
|
|
4742
|
+
*
|
|
4743
|
+
* @param filePath - Absolute path of the deleted file.
|
|
4744
|
+
*/
|
|
4745
|
+
handleUnlink(filePath) {
|
|
4746
|
+
if (!this.started)
|
|
4747
|
+
return;
|
|
4748
|
+
this.fileChanged(filePath);
|
|
4749
|
+
}
|
|
4750
|
+
/**
|
|
4751
|
+
* Flush all pending files immediately.
|
|
4752
|
+
*/
|
|
4753
|
+
async flush() {
|
|
4754
|
+
this.clearDebounce();
|
|
4755
|
+
if (this.pending.size > 0) {
|
|
4756
|
+
const batch = [...this.pending];
|
|
4757
|
+
this.pending.clear();
|
|
4758
|
+
this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
|
|
4759
|
+
}
|
|
4760
|
+
await this.commitInFlight;
|
|
4761
|
+
}
|
|
4762
|
+
/**
|
|
4763
|
+
* Stop accepting file changes, flush pending, and clean up timers.
|
|
4764
|
+
*/
|
|
4765
|
+
async stop() {
|
|
4766
|
+
this.started = false;
|
|
4767
|
+
this.squashManager?.stop();
|
|
4768
|
+
await this.flush();
|
|
4769
|
+
this.logger.info({ root: this.rootPath }, 'VcsManager stopped');
|
|
4770
|
+
}
|
|
4771
|
+
/**
|
|
4772
|
+
* Take up to N items from the pending set.
|
|
4773
|
+
*/
|
|
4774
|
+
takeBatch(n) {
|
|
4775
|
+
const batch = [];
|
|
4776
|
+
for (const item of this.pending) {
|
|
4777
|
+
if (batch.length >= n)
|
|
3522
4778
|
break;
|
|
4779
|
+
batch.push(item);
|
|
4780
|
+
}
|
|
4781
|
+
for (const item of batch) {
|
|
4782
|
+
this.pending.delete(item);
|
|
4783
|
+
}
|
|
4784
|
+
return batch;
|
|
4785
|
+
}
|
|
4786
|
+
/**
|
|
4787
|
+
* Clear the debounce timer.
|
|
4788
|
+
*/
|
|
4789
|
+
clearDebounce() {
|
|
4790
|
+
if (this.debounceTimer !== undefined) {
|
|
4791
|
+
clearTimeout(this.debounceTimer);
|
|
4792
|
+
this.debounceTimer = undefined;
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
/**
|
|
4796
|
+
* Reset the debounce timer to fire flush() after commitDebounceMs.
|
|
4797
|
+
*/
|
|
4798
|
+
resetDebounce() {
|
|
4799
|
+
this.clearDebounce();
|
|
4800
|
+
this.debounceTimer = setTimeout(() => {
|
|
4801
|
+
this.debounceTimer = undefined;
|
|
4802
|
+
void this.flush();
|
|
4803
|
+
}, this.config.commitDebounceMs);
|
|
4804
|
+
}
|
|
4805
|
+
/**
|
|
4806
|
+
* Get the cached diff for staged files, for AI context.
|
|
4807
|
+
*/
|
|
4808
|
+
async getStagedDiff() {
|
|
4809
|
+
try {
|
|
4810
|
+
const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
|
|
4811
|
+
const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
|
|
4812
|
+
return `${stat}\n${patch}`;
|
|
3523
4813
|
}
|
|
3524
4814
|
catch {
|
|
3525
|
-
|
|
4815
|
+
return '';
|
|
3526
4816
|
}
|
|
3527
4817
|
}
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
4818
|
+
/**
|
|
4819
|
+
* Build the template commit message (no AI).
|
|
4820
|
+
*/
|
|
4821
|
+
buildTemplateMessage(fileCount) {
|
|
4822
|
+
if (this.pendingReversions.length === 0) {
|
|
4823
|
+
const timestamp = new Date().toISOString();
|
|
4824
|
+
return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
|
|
4825
|
+
}
|
|
4826
|
+
// Count total reverted files across all pending reversions
|
|
4827
|
+
const revertedPaths = new Set(this.pendingReversions.flatMap((r) => r.paths));
|
|
4828
|
+
const revertedCount = revertedPaths.size;
|
|
4829
|
+
const otherCount = fileCount - revertedCount;
|
|
4830
|
+
// Use the first reversion's glob and commit for the message
|
|
4831
|
+
const { glob, commit } = this.pendingReversions[0];
|
|
4832
|
+
const shortCommit = commit.slice(0, 7);
|
|
4833
|
+
let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
|
|
4834
|
+
if (otherCount > 0) {
|
|
4835
|
+
message += ` (+ ${String(otherCount)} other changes)`;
|
|
4836
|
+
}
|
|
4837
|
+
return message;
|
|
4838
|
+
}
|
|
4839
|
+
/**
|
|
4840
|
+
* Build the commit message, using AI when available with template fallback.
|
|
4841
|
+
*/
|
|
4842
|
+
async buildCommitMessage(files) {
|
|
4843
|
+
const fileCount = files.length;
|
|
4844
|
+
const templateMessage = this.buildTemplateMessage(fileCount);
|
|
4845
|
+
if (!this.commitMessageGenerator) {
|
|
4846
|
+
return templateMessage;
|
|
4847
|
+
}
|
|
4848
|
+
try {
|
|
4849
|
+
const diffSummary = await this.getStagedDiff();
|
|
4850
|
+
const aiMessage = await this.commitMessageGenerator.generate(files, diffSummary);
|
|
4851
|
+
if (!aiMessage) {
|
|
4852
|
+
this.logger.warn('AI commit message generation returned null; using template');
|
|
4853
|
+
return templateMessage;
|
|
4854
|
+
}
|
|
4855
|
+
// For reversions: prefix with revert info + AI description
|
|
4856
|
+
if (this.pendingReversions.length > 0) {
|
|
4857
|
+
const { glob, commit } = this.pendingReversions[0];
|
|
4858
|
+
const shortCommit = commit.slice(0, 7);
|
|
4859
|
+
return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
|
|
4860
|
+
}
|
|
4861
|
+
return aiMessage;
|
|
4862
|
+
}
|
|
4863
|
+
catch (error) {
|
|
4864
|
+
this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
|
|
4865
|
+
return templateMessage;
|
|
4866
|
+
}
|
|
4867
|
+
}
|
|
4868
|
+
/**
|
|
4869
|
+
* Commit a batch of files to the git repo with index.lock retry.
|
|
4870
|
+
*/
|
|
4871
|
+
async commitBatch(files) {
|
|
4872
|
+
if (files.length === 0)
|
|
4873
|
+
return;
|
|
4874
|
+
try {
|
|
4875
|
+
await retry(async (attempt) => {
|
|
4876
|
+
await execFileAsync('git', ['add', '--', ...files], {
|
|
4877
|
+
cwd: this.rootPath,
|
|
4878
|
+
});
|
|
4879
|
+
// Build message after staging so getStagedDiff can see the changes
|
|
4880
|
+
const message = attempt === 1
|
|
4881
|
+
? await this.buildCommitMessage(files)
|
|
4882
|
+
: this.buildTemplateMessage(files.length);
|
|
4883
|
+
this.pendingReversions.length = 0;
|
|
4884
|
+
await execFileAsync('git', ['commit', '-m', message], {
|
|
4885
|
+
cwd: this.rootPath,
|
|
4886
|
+
});
|
|
4887
|
+
const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
|
|
4888
|
+
const hash = hashOut.trim();
|
|
4889
|
+
this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
|
|
4890
|
+
await this.pushIfConfigured();
|
|
4891
|
+
}, {
|
|
4892
|
+
attempts: 4,
|
|
4893
|
+
baseDelayMs: 500,
|
|
4894
|
+
maxDelayMs: 2000,
|
|
4895
|
+
onRetry: ({ attempt, delayMs }) => {
|
|
4896
|
+
this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
|
|
4897
|
+
},
|
|
4898
|
+
});
|
|
4899
|
+
}
|
|
4900
|
+
catch (error) {
|
|
4901
|
+
for (const f of files) {
|
|
4902
|
+
this.pending.add(f);
|
|
4903
|
+
}
|
|
4904
|
+
this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
|
|
4905
|
+
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
|
|
4906
|
+
}
|
|
4907
|
+
}
|
|
4908
|
+
/**
|
|
4909
|
+
* Push to the configured remote if remoteUrl is set.
|
|
4910
|
+
* On failure: logs the error and records it in pushErrors; never throws.
|
|
4911
|
+
*/
|
|
4912
|
+
async pushIfConfigured() {
|
|
4913
|
+
if (!this.remoteUrl)
|
|
4914
|
+
return;
|
|
4915
|
+
try {
|
|
4916
|
+
// Build the authenticated URL if a token is available
|
|
4917
|
+
const pushUrl = this.accessToken
|
|
4918
|
+
? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
|
|
4919
|
+
: this.remoteUrl;
|
|
4920
|
+
await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
|
|
4921
|
+
cwd: this.rootPath,
|
|
4922
|
+
});
|
|
4923
|
+
this._lastPushTime = new Date().toISOString();
|
|
4924
|
+
this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'VCS push succeeded');
|
|
4925
|
+
}
|
|
4926
|
+
catch (error) {
|
|
4927
|
+
const pushError = {
|
|
4928
|
+
timestamp: new Date().toISOString(),
|
|
4929
|
+
message: normalizeError(error).message,
|
|
4930
|
+
};
|
|
4931
|
+
this._pushErrors.push(pushError);
|
|
4932
|
+
this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS push failed');
|
|
4933
|
+
}
|
|
3531
4934
|
}
|
|
3532
|
-
return { exports };
|
|
3533
4935
|
}
|
|
4936
|
+
|
|
3534
4937
|
/**
|
|
3535
|
-
*
|
|
3536
|
-
*
|
|
3537
|
-
*
|
|
3538
|
-
* @param configDir - Directory to resolve relative paths against.
|
|
3539
|
-
* @returns Full introspection results for injection into the merged document.
|
|
4938
|
+
* @module vcs/VcsCoordinator
|
|
4939
|
+
* Orchestrates VCS across all watch roots. Routes file events to the correct
|
|
4940
|
+
* root's VcsManager instance.
|
|
3540
4941
|
*/
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
4942
|
+
/**
|
|
4943
|
+
* Orchestrates VCS across all VCS-enabled watch roots.
|
|
4944
|
+
*/
|
|
4945
|
+
class VcsCoordinator {
|
|
4946
|
+
managers = new Map();
|
|
4947
|
+
roots = [];
|
|
4948
|
+
logger;
|
|
4949
|
+
constructor(config, logger) {
|
|
4950
|
+
this.logger = logger;
|
|
4951
|
+
if (!config.vcs?.enabled)
|
|
4952
|
+
return;
|
|
4953
|
+
const normalized = normalizeWatchPaths(config.watch.paths);
|
|
4954
|
+
for (const entry of normalized) {
|
|
4955
|
+
const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
|
|
4956
|
+
if (!rootVcs)
|
|
4957
|
+
continue;
|
|
4958
|
+
if (/[*?{[]/.test(entry.path)) {
|
|
4959
|
+
logger.warn({ path: entry.path }, 'Skipping VCS for watch path containing glob characters');
|
|
4960
|
+
continue;
|
|
4961
|
+
}
|
|
4962
|
+
const resolvedRoot = normalizeSlashes(resolve(entry.path));
|
|
4963
|
+
const mergedConfig = {
|
|
4964
|
+
...config.vcs,
|
|
4965
|
+
...entry.vcs,
|
|
4966
|
+
enabled: true,
|
|
4967
|
+
};
|
|
4968
|
+
// Create CommitMessageGenerator if AI commit messages are configured
|
|
4969
|
+
const cmConfig = mergedConfig.commitMessage;
|
|
4970
|
+
const rootLogger = logger.child({ vcsRoot: resolvedRoot });
|
|
4971
|
+
const resolvedApiKey = cmConfig?.enabled !== false
|
|
4972
|
+
? resolveCommitMessageApiKey(cmConfig?.provider ?? 'anthropic', cmConfig?.apiKey, rootLogger)
|
|
4973
|
+
: undefined;
|
|
4974
|
+
const generator = cmConfig?.enabled !== false && resolvedApiKey
|
|
4975
|
+
? new CommitMessageGenerator(cmConfig?.provider ?? 'anthropic', cmConfig?.model ?? 'claude-haiku-4-0', resolvedApiKey, rootLogger)
|
|
4976
|
+
: undefined;
|
|
4977
|
+
// Resolve remote config: per-root overrides fall back to root-level defaults
|
|
4978
|
+
const remoteUrl = entry.vcs?.remote;
|
|
4979
|
+
const accessToken = entry.vcs?.accessToken ?? config.vcs.defaultAccessToken;
|
|
4980
|
+
const manager = new VcsManager(resolvedRoot, mergedConfig, logger.child({ vcsRoot: resolvedRoot }), generator, remoteUrl, accessToken);
|
|
4981
|
+
this.managers.set(resolvedRoot, manager);
|
|
4982
|
+
this.roots.push(resolvedRoot);
|
|
3550
4983
|
}
|
|
4984
|
+
// Sort roots longest-first so nested paths match before parents.
|
|
4985
|
+
this.roots.sort((a, b) => b.length - a.length);
|
|
3551
4986
|
}
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
4987
|
+
/**
|
|
4988
|
+
* Start all VcsManager instances.
|
|
4989
|
+
*/
|
|
4990
|
+
start() {
|
|
4991
|
+
this.managers.forEach((manager) => {
|
|
4992
|
+
manager.start();
|
|
4993
|
+
});
|
|
4994
|
+
this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
|
|
4995
|
+
}
|
|
4996
|
+
/**
|
|
4997
|
+
* Route a file event to the correct root's VcsManager.
|
|
4998
|
+
*
|
|
4999
|
+
* @param filePath - Absolute path of the changed file.
|
|
5000
|
+
* @param event - The event type: add, change, or unlink.
|
|
5001
|
+
*/
|
|
5002
|
+
onFileChange(filePath, event) {
|
|
5003
|
+
const normalizedPath = normalizeSlashes(resolve(filePath));
|
|
5004
|
+
const manager = this.findManagerForPath(normalizedPath);
|
|
5005
|
+
if (!manager)
|
|
5006
|
+
return;
|
|
5007
|
+
if (event === 'unlink') {
|
|
5008
|
+
manager.handleUnlink(normalizedPath);
|
|
5009
|
+
}
|
|
5010
|
+
else {
|
|
5011
|
+
manager.fileChanged(normalizedPath);
|
|
3556
5012
|
}
|
|
3557
5013
|
}
|
|
3558
|
-
|
|
5014
|
+
/**
|
|
5015
|
+
* Flush all managers and clean up.
|
|
5016
|
+
*/
|
|
5017
|
+
async stop() {
|
|
5018
|
+
const stopPromises = [];
|
|
5019
|
+
this.managers.forEach((manager) => {
|
|
5020
|
+
stopPromises.push(manager.stop());
|
|
5021
|
+
});
|
|
5022
|
+
await Promise.all(stopPromises);
|
|
5023
|
+
this.logger.info('VcsCoordinator stopped');
|
|
5024
|
+
}
|
|
5025
|
+
/**
|
|
5026
|
+
* Get the sorted root paths (longest-first).
|
|
5027
|
+
*/
|
|
5028
|
+
getRoots() {
|
|
5029
|
+
return this.roots;
|
|
5030
|
+
}
|
|
5031
|
+
/**
|
|
5032
|
+
* Get the VcsManager for a specific root path.
|
|
5033
|
+
*/
|
|
5034
|
+
getManager(root) {
|
|
5035
|
+
return this.managers.get(root);
|
|
5036
|
+
}
|
|
5037
|
+
/**
|
|
5038
|
+
* Get all managers as [root, manager] pairs.
|
|
5039
|
+
*/
|
|
5040
|
+
getAllManagers() {
|
|
5041
|
+
return this.managers;
|
|
5042
|
+
}
|
|
5043
|
+
/**
|
|
5044
|
+
* Find the VcsManager that owns the given normalized file path.
|
|
5045
|
+
* Uses longest-prefix-match against resolved watch roots.
|
|
5046
|
+
*
|
|
5047
|
+
* @param normalizedPath - Normalized absolute path (forward slashes).
|
|
5048
|
+
* @returns The matching VcsManager, or undefined.
|
|
5049
|
+
*/
|
|
5050
|
+
findManagerForPath(normalizedPath) {
|
|
5051
|
+
const root = findRootForPath(this.roots, normalizedPath);
|
|
5052
|
+
return root ? this.managers.get(root) : undefined;
|
|
5053
|
+
}
|
|
3559
5054
|
}
|
|
3560
5055
|
|
|
3561
5056
|
/**
|
|
@@ -3625,10 +5120,10 @@ function createProcessorConfig(config, configDir, customMapLib) {
|
|
|
3625
5120
|
/**
|
|
3626
5121
|
* Create file system watcher with gitignore filtering.
|
|
3627
5122
|
*/
|
|
3628
|
-
function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache) {
|
|
5123
|
+
function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange) {
|
|
3629
5124
|
const respectGitignore = config.watch.respectGitignore ?? true;
|
|
3630
5125
|
const gitignoreFilter = respectGitignore
|
|
3631
|
-
? new GitignoreFilter(config.watch.paths)
|
|
5126
|
+
? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
|
|
3632
5127
|
: undefined;
|
|
3633
5128
|
const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
|
|
3634
5129
|
maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
|
|
@@ -3637,6 +5132,7 @@ function createWatcher(config, factories, queue, processor, logger, runtimeOptio
|
|
|
3637
5132
|
gitignoreFilter,
|
|
3638
5133
|
initialScanTracker,
|
|
3639
5134
|
contentHashCache,
|
|
5135
|
+
onVcsFileChange,
|
|
3640
5136
|
});
|
|
3641
5137
|
return { watcher, gitignoreFilter };
|
|
3642
5138
|
}
|
|
@@ -3708,6 +5204,41 @@ function resolveVersion(referenceUrl) {
|
|
|
3708
5204
|
return 'unknown';
|
|
3709
5205
|
}
|
|
3710
5206
|
}
|
|
5207
|
+
/**
|
|
5208
|
+
* Initialize VCS for all watch roots where VCS is enabled.
|
|
5209
|
+
* Checks git availability, validates stateDir overlap, and initializes repos.
|
|
5210
|
+
*
|
|
5211
|
+
* @param config - The resolved configuration (mutated in place if git unavailable).
|
|
5212
|
+
* @param logger - Logger instance.
|
|
5213
|
+
* @returns The effective VCS enabled state (false if git unavailable).
|
|
5214
|
+
*/
|
|
5215
|
+
async function initVcs(config, logger) {
|
|
5216
|
+
if (!config.vcs?.enabled)
|
|
5217
|
+
return false;
|
|
5218
|
+
const gitAvailable = await checkGitAvailable();
|
|
5219
|
+
if (!gitAvailable) {
|
|
5220
|
+
logger.warn('git not found on PATH — VCS disabled for this session');
|
|
5221
|
+
return false;
|
|
5222
|
+
}
|
|
5223
|
+
const stateDir = config.stateDir ?? '.jeeves-metadata';
|
|
5224
|
+
const pathStrings = extractWatchPathStrings(config.watch.paths);
|
|
5225
|
+
const staticPaths = pathStrings.filter((p) => !/[*?{[]/.test(p));
|
|
5226
|
+
validateStateDirOverlap(stateDir, staticPaths);
|
|
5227
|
+
const normalized = normalizeWatchPaths(config.watch.paths);
|
|
5228
|
+
for (const entry of normalized) {
|
|
5229
|
+
const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
|
|
5230
|
+
if (!rootVcs)
|
|
5231
|
+
continue;
|
|
5232
|
+
if (/[*?{[]/.test(entry.path)) {
|
|
5233
|
+
logger.warn({ path: entry.path }, 'Skipping VCS init for watch path containing glob characters');
|
|
5234
|
+
continue;
|
|
5235
|
+
}
|
|
5236
|
+
await initRepo(entry.path);
|
|
5237
|
+
await ensureGitignore(entry.path);
|
|
5238
|
+
logger.info({ root: entry.path }, 'VCS initialized for watch root');
|
|
5239
|
+
}
|
|
5240
|
+
return true;
|
|
5241
|
+
}
|
|
3711
5242
|
|
|
3712
5243
|
/**
|
|
3713
5244
|
* @module rules/virtualRules
|
|
@@ -4041,7 +5572,7 @@ function createApiServer(options) {
|
|
|
4041
5572
|
logger,
|
|
4042
5573
|
}));
|
|
4043
5574
|
app.post('/walk', createWalkHandler({
|
|
4044
|
-
getWatchPaths: () => getConfig().watch.paths,
|
|
5575
|
+
getWatchPaths: () => extractWatchPathStrings(getConfig().watch.paths),
|
|
4045
5576
|
getFileSystemWatcher,
|
|
4046
5577
|
logger,
|
|
4047
5578
|
}));
|
|
@@ -4136,6 +5667,17 @@ function createApiServer(options) {
|
|
|
4136
5667
|
app.post('/points/delete', createPointsDeleteHandler({ vectorStore, logger }));
|
|
4137
5668
|
app.post('/rules/reapply', createRulesReapplyHandler({ processor, vectorStore, logger }));
|
|
4138
5669
|
}
|
|
5670
|
+
// VCS routes — only registered when a VcsCoordinator is provided
|
|
5671
|
+
if (options.vcsCoordinator) {
|
|
5672
|
+
const coordinator = options.vcsCoordinator;
|
|
5673
|
+
app.get('/vcs/status', createVcsStatusHandler({ coordinator, logger }));
|
|
5674
|
+
app.get('/vcs/history', createVcsHistoryHandler({ coordinator, logger }));
|
|
5675
|
+
app.get('/vcs/show', createVcsShowHandler({ coordinator, logger }));
|
|
5676
|
+
app.get('/vcs/diff', createVcsDiffHandler({ coordinator, logger }));
|
|
5677
|
+
app.get('/vcs/check-exclusion', createVcsCheckExclusionHandler({ coordinator, logger }));
|
|
5678
|
+
app.post('/vcs/revert', createVcsRevertHandler({ coordinator, logger }));
|
|
5679
|
+
app.post('/vcs/exclude', createVcsExcludeHandler({ coordinator, logger }));
|
|
5680
|
+
}
|
|
4139
5681
|
return app;
|
|
4140
5682
|
}
|
|
4141
5683
|
|
|
@@ -4735,49 +6277,6 @@ class ConfigWatcher {
|
|
|
4735
6277
|
}
|
|
4736
6278
|
}
|
|
4737
6279
|
|
|
4738
|
-
/**
|
|
4739
|
-
* @module config/substituteEnvVars
|
|
4740
|
-
*
|
|
4741
|
-
* Deep-walks config objects and replaces `${VAR_NAME}` patterns with environment variable values.
|
|
4742
|
-
*/
|
|
4743
|
-
const ENV_PATTERN = /\$\{([^}]+)\}/g;
|
|
4744
|
-
/**
|
|
4745
|
-
* Replace `${VAR_NAME}` patterns in a string with `process.env.VAR_NAME`.
|
|
4746
|
-
*
|
|
4747
|
-
* @param value - The string to process.
|
|
4748
|
-
* @returns The string with resolved env vars; unresolvable expressions left untouched.
|
|
4749
|
-
*/
|
|
4750
|
-
function substituteString(value) {
|
|
4751
|
-
return value.replace(ENV_PATTERN, (match, varName) => {
|
|
4752
|
-
const envValue = process.env[varName];
|
|
4753
|
-
if (envValue === undefined)
|
|
4754
|
-
return match;
|
|
4755
|
-
return envValue;
|
|
4756
|
-
});
|
|
4757
|
-
}
|
|
4758
|
-
/**
|
|
4759
|
-
* Deep-walk a value and substitute `${VAR_NAME}` patterns in all string values.
|
|
4760
|
-
*
|
|
4761
|
-
* @param value - The value to walk (object, array, or primitive).
|
|
4762
|
-
* @returns A new value with all env var references resolved.
|
|
4763
|
-
*/
|
|
4764
|
-
function substituteEnvVars(value) {
|
|
4765
|
-
if (typeof value === 'string') {
|
|
4766
|
-
return substituteString(value);
|
|
4767
|
-
}
|
|
4768
|
-
if (Array.isArray(value)) {
|
|
4769
|
-
return value.map((item) => substituteEnvVars(item));
|
|
4770
|
-
}
|
|
4771
|
-
if (value !== null && typeof value === 'object') {
|
|
4772
|
-
const result = {};
|
|
4773
|
-
for (const [key, val] of Object.entries(value)) {
|
|
4774
|
-
result[key] = substituteEnvVars(val);
|
|
4775
|
-
}
|
|
4776
|
-
return result;
|
|
4777
|
-
}
|
|
4778
|
-
return value;
|
|
4779
|
-
}
|
|
4780
|
-
|
|
4781
6280
|
const MODULE_NAME = 'jeeves-watcher';
|
|
4782
6281
|
/**
|
|
4783
6282
|
* Merge sensible defaults into a loaded configuration.
|
|
@@ -5413,6 +6912,8 @@ function computeLineOffsets(fullText, chunks) {
|
|
|
5413
6912
|
*/
|
|
5414
6913
|
/** Default number of points to upsert per batch (prevents OOM on large files). */
|
|
5415
6914
|
const DEFAULT_UPSERT_BATCH_SIZE = 50;
|
|
6915
|
+
/** Maximum chunks per Gemini batchEmbedContents request. */
|
|
6916
|
+
const EMBED_BATCH_SIZE = 100;
|
|
5416
6917
|
/**
|
|
5417
6918
|
* Embed text chunks, upsert to vector store, and clean up orphaned chunks.
|
|
5418
6919
|
*
|
|
@@ -5431,8 +6932,14 @@ async function embedAndUpsert(deps, text, filePath, metadata, existingPayload, f
|
|
|
5431
6932
|
const chunks = await splitter.splitText(text);
|
|
5432
6933
|
// Compute line offsets
|
|
5433
6934
|
const offsets = computeLineOffsets(text, chunks);
|
|
5434
|
-
// Embed
|
|
5435
|
-
const
|
|
6935
|
+
// Embed chunks in batches to respect Gemini's 100-doc limit
|
|
6936
|
+
const allVectors = [];
|
|
6937
|
+
for (let start = 0; start < chunks.length; start += EMBED_BATCH_SIZE) {
|
|
6938
|
+
const batch = chunks.slice(start, start + EMBED_BATCH_SIZE);
|
|
6939
|
+
const batchVectors = await embeddingProvider.embed(batch);
|
|
6940
|
+
allVectors.push(...batchVectors);
|
|
6941
|
+
}
|
|
6942
|
+
const vectors = allVectors;
|
|
5436
6943
|
// Build all points
|
|
5437
6944
|
const points = chunks.map((chunk, i) => ({
|
|
5438
6945
|
id: pointId(filePath, i),
|
|
@@ -6112,9 +7619,10 @@ async function ensureTextIndex(client, collectionName, fieldName, log) {
|
|
|
6112
7619
|
* @param limit - Maximum results to return.
|
|
6113
7620
|
* @param textWeight - Weight for text results in RRF (0–1).
|
|
6114
7621
|
* @param filter - Optional Qdrant filter.
|
|
7622
|
+
* @param offset - Optional result offset for pagination.
|
|
6115
7623
|
* @returns An array of search results.
|
|
6116
7624
|
*/
|
|
6117
|
-
async function hybridSearch(client, collectionName, vector, queryText, limit, textWeight, filter) {
|
|
7625
|
+
async function hybridSearch(client, collectionName, vector, queryText, limit, textWeight, filter, offset) {
|
|
6118
7626
|
const prefetchLimit = Math.max(limit * 3, 20);
|
|
6119
7627
|
const vectorWeight = 1 - textWeight;
|
|
6120
7628
|
const textFilter = {
|
|
@@ -6143,6 +7651,7 @@ async function hybridSearch(client, collectionName, vector, queryText, limit, te
|
|
|
6143
7651
|
},
|
|
6144
7652
|
limit,
|
|
6145
7653
|
with_payload: true,
|
|
7654
|
+
...(offset !== undefined ? { offset } : {}),
|
|
6146
7655
|
});
|
|
6147
7656
|
return result.points.map((p) => ({
|
|
6148
7657
|
id: String(p.id),
|
|
@@ -6440,10 +7949,11 @@ class VectorStoreClient {
|
|
|
6440
7949
|
* @param limit - Maximum results to return.
|
|
6441
7950
|
* @param textWeight - Weight for text results in RRF (0–1).
|
|
6442
7951
|
* @param filter - Optional Qdrant filter.
|
|
7952
|
+
* @param offset - Optional result offset for pagination.
|
|
6443
7953
|
* @returns An array of search results.
|
|
6444
7954
|
*/
|
|
6445
|
-
async hybridSearch(vector, queryText, limit, textWeight, filter) {
|
|
6446
|
-
return hybridSearch(this.client, this.collectionName, vector, queryText, limit, textWeight, filter);
|
|
7955
|
+
async hybridSearch(vector, queryText, limit, textWeight, filter, offset) {
|
|
7956
|
+
return hybridSearch(this.client, this.collectionName, vector, queryText, limit, textWeight, filter, offset);
|
|
6447
7957
|
}
|
|
6448
7958
|
/**
|
|
6449
7959
|
* Retrieve points with their vectors by ID.
|
|
@@ -6852,6 +8362,7 @@ class FileSystemWatcher {
|
|
|
6852
8362
|
gitignoreFilter;
|
|
6853
8363
|
initialScanTracker;
|
|
6854
8364
|
contentHashCache;
|
|
8365
|
+
onVcsFileChange;
|
|
6855
8366
|
moveCorrelator;
|
|
6856
8367
|
globMatches;
|
|
6857
8368
|
watcher;
|
|
@@ -6872,6 +8383,7 @@ class FileSystemWatcher {
|
|
|
6872
8383
|
this.gitignoreFilter = options.gitignoreFilter;
|
|
6873
8384
|
this.initialScanTracker = options.initialScanTracker;
|
|
6874
8385
|
this.contentHashCache = options.contentHashCache;
|
|
8386
|
+
this.onVcsFileChange = options.onVcsFileChange;
|
|
6875
8387
|
this.globMatches = () => true;
|
|
6876
8388
|
const healthOptions = {
|
|
6877
8389
|
maxRetries: options.maxRetries,
|
|
@@ -6889,7 +8401,7 @@ class FileSystemWatcher {
|
|
|
6889
8401
|
// Glob patterns are silently treated as literal strings, producing zero
|
|
6890
8402
|
// events. We extract static directory roots for chokidar to watch, then
|
|
6891
8403
|
// filter emitted events against the original globs via picomatch.
|
|
6892
|
-
const { roots, matches } = resolveWatchPaths(this.config.paths);
|
|
8404
|
+
const { roots, matches } = resolveWatchPaths(extractWatchPathStrings(this.config.paths));
|
|
6893
8405
|
this.globMatches = matches;
|
|
6894
8406
|
this.logger.info({ roots }, 'Resolved watch roots from globs');
|
|
6895
8407
|
// Chokidar v5's inline anymatch does exact string equality for string
|
|
@@ -6970,6 +8482,8 @@ class FileSystemWatcher {
|
|
|
6970
8482
|
this.initialScanTracker?.incrementEnqueued();
|
|
6971
8483
|
}
|
|
6972
8484
|
this.logger.debug({ path }, 'File added');
|
|
8485
|
+
if (initialScanComplete)
|
|
8486
|
+
this.onVcsFileChange?.(path, 'add');
|
|
6973
8487
|
if (correlator && initialScanComplete) {
|
|
6974
8488
|
void correlator.handleAdd(path);
|
|
6975
8489
|
}
|
|
@@ -6986,6 +8500,7 @@ class FileSystemWatcher {
|
|
|
6986
8500
|
if (this.isGitignored(path))
|
|
6987
8501
|
return;
|
|
6988
8502
|
this.logger.debug({ path }, 'File changed');
|
|
8503
|
+
this.onVcsFileChange?.(path, 'change');
|
|
6989
8504
|
this.queue.enqueue({ type: 'modify', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.processFile(path)));
|
|
6990
8505
|
});
|
|
6991
8506
|
this.watcher.on('unlink', (path) => {
|
|
@@ -6995,6 +8510,7 @@ class FileSystemWatcher {
|
|
|
6995
8510
|
if (this.isGitignored(path))
|
|
6996
8511
|
return;
|
|
6997
8512
|
this.logger.debug({ path }, 'File removed');
|
|
8513
|
+
this.onVcsFileChange?.(path, 'unlink');
|
|
6998
8514
|
if (correlator) {
|
|
6999
8515
|
correlator.handleUnlink(path);
|
|
7000
8516
|
}
|
|
@@ -7258,6 +8774,7 @@ class JeevesWatcher {
|
|
|
7258
8774
|
gitignoreFilter;
|
|
7259
8775
|
enrichmentStore;
|
|
7260
8776
|
contentHashCache;
|
|
8777
|
+
vcsCoordinator;
|
|
7261
8778
|
initialScanTracker;
|
|
7262
8779
|
version;
|
|
7263
8780
|
/** Create a new JeevesWatcher instance. */
|
|
@@ -7280,6 +8797,7 @@ class JeevesWatcher {
|
|
|
7280
8797
|
async start() {
|
|
7281
8798
|
const logger = this.factories.createLogger(this.config.logging);
|
|
7282
8799
|
this.logger = logger;
|
|
8800
|
+
await initVcs(this.config, logger);
|
|
7283
8801
|
const { embeddingProvider, vectorStore } = await initEmbeddingAndStore(this.config, this.factories, logger);
|
|
7284
8802
|
this.embeddingProvider = embeddingProvider;
|
|
7285
8803
|
this.vectorStore = vectorStore;
|
|
@@ -7316,10 +8834,15 @@ class JeevesWatcher {
|
|
|
7316
8834
|
concurrency: this.config.embedding.concurrency ?? 5,
|
|
7317
8835
|
rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
|
|
7318
8836
|
});
|
|
7319
|
-
const
|
|
8837
|
+
const vcsCoordinator = new VcsCoordinator(this.config, logger);
|
|
8838
|
+
this.vcsCoordinator = vcsCoordinator;
|
|
8839
|
+
const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, processor, logger, this.runtimeOptions, this.initialScanTracker, contentHashCache, (filePath, event) => {
|
|
8840
|
+
vcsCoordinator.onFileChange(filePath, event);
|
|
8841
|
+
});
|
|
7320
8842
|
this.watcher = watcher;
|
|
7321
8843
|
this.gitignoreFilter = gitignoreFilter;
|
|
7322
8844
|
this.server = await this.startApiServer();
|
|
8845
|
+
vcsCoordinator.start();
|
|
7323
8846
|
this.watcher.start();
|
|
7324
8847
|
this.startConfigWatch();
|
|
7325
8848
|
logger.info('jeeves-watcher started');
|
|
@@ -7329,6 +8852,9 @@ class JeevesWatcher {
|
|
|
7329
8852
|
*/
|
|
7330
8853
|
async stop() {
|
|
7331
8854
|
await this.stopConfigWatch();
|
|
8855
|
+
if (this.vcsCoordinator) {
|
|
8856
|
+
await this.vcsCoordinator.stop();
|
|
8857
|
+
}
|
|
7332
8858
|
if (this.watcher) {
|
|
7333
8859
|
await this.watcher.stop();
|
|
7334
8860
|
}
|
|
@@ -7376,6 +8902,7 @@ class JeevesWatcher {
|
|
|
7376
8902
|
fileSystemWatcher: this.watcher,
|
|
7377
8903
|
getFileSystemWatcher: () => this.watcher,
|
|
7378
8904
|
enrichmentStore: this.enrichmentStore,
|
|
8905
|
+
vcsCoordinator: this.vcsCoordinator,
|
|
7379
8906
|
});
|
|
7380
8907
|
await server.listen({
|
|
7381
8908
|
host: this.config.api?.host ?? getBindAddress('watcher'),
|
|
@@ -7725,4 +9252,4 @@ const watcherDescriptor = {
|
|
|
7725
9252
|
},
|
|
7726
9253
|
};
|
|
7727
9254
|
|
|
7728
|
-
export { ContentHashCache, DocumentProcessor, EnrichmentStore, EventQueue, FileSystemWatcher, GitignoreFilter, InitialScanTracker, IssuesManager, JeevesWatcher, ReindexTracker, SystemHealth, TemplateEngine, ValuesManager, VectorStoreClient, VirtualRuleStore, applyRules, buildAttributes, buildTemplateEngine, compileRules, contentHash, createApiServer, createEmbeddingProvider, createHandlebarsInstance, createLogger, extractText, issueRecordSchema, loadConfig, loadCustomHelpers, mergeEnrichment, pointId, registerBuiltinHelpers, resolveTemplateSource, startFromConfig, watcherDescriptor };
|
|
9255
|
+
export { CommitMessageGenerator, ContentHashCache, DocumentProcessor, EnrichmentStore, EventQueue, FileSystemWatcher, GitignoreFilter, InitialScanTracker, IssuesManager, JeevesWatcher, ReindexTracker, SquashManager, SystemHealth, TemplateEngine, ValuesManager, VcsCoordinator, VcsManager, VectorStoreClient, VirtualRuleStore, applyRules, buildAttributes, buildTemplateEngine, compileRules, contentHash, createApiServer, createEmbeddingProvider, createHandlebarsInstance, createLogger, extractText, issueRecordSchema, loadConfig, loadCustomHelpers, mergeEnrichment, pointId, registerBuiltinHelpers, resolveTemplateSource, startFromConfig, watcherDescriptor };
|