@karmaniverous/jeeves-watcher 0.17.11 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { createConfigQueryHandler as createConfigQueryHandler$1, createStatusHandler, createConfigApplyHandler, DEFAULT_PORTS, getBindAddress } from '@karmaniverous/jeeves';
3
- import { a as normalizeSlashes, n as normalizeError, G as GitignoreFilter, e as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, i as isPathWatched, g as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, r as retry, s as sleep, l as loadConfig } from './index.js';
3
+ import { a as normalizeSlashes, n as normalizeError, r as retry, G as GitignoreFilter, e as executeReindex, V as VALID_REINDEX_SCOPES, m as mergeInferenceRules, i as isPathWatched, g as getWatchRootBases, C as CONFIG_WATCH_VALID_SCOPES, s as sleep, l as loadConfig } from './index.js';
4
4
  export { b as startFromConfig } from './index.js';
5
5
  import { mkdirSync, existsSync, readFileSync, writeFileSync, rmSync, renameSync, readdirSync, statSync } from 'node:fs';
6
- import { join, dirname, resolve, extname, basename, isAbsolute } from 'node:path';
6
+ import { join, dirname, resolve, extname, basename, relative, sep, isAbsolute } from 'node:path';
7
7
  import Database from 'better-sqlite3';
8
8
  import { z } from 'zod';
9
9
  import Ajv from 'ajv';
10
10
  import addFormats from 'ajv-formats';
11
11
  import picomatch from 'picomatch';
12
12
  import { deserialize, serialize } from 'node:v8';
13
+ import { normalizeWatchPaths, extractWatchPathStrings, inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
14
+ import { execFile } from 'node:child_process';
15
+ import { promisify } from 'node:util';
16
+ import { homedir } from 'node:os';
17
+ import { access, rm, readFile, writeFile, appendFile, mkdir, stat } from 'node:fs/promises';
13
18
  import { pathToFileURL, fileURLToPath } from 'node:url';
14
19
  import { packageDirectorySync } from 'package-directory';
15
20
  import { JsonMap } from '@karmaniverous/jsonmap';
@@ -24,12 +29,10 @@ import { unified } from 'unified';
24
29
  import yaml from 'js-yaml';
25
30
  import chokidar from 'chokidar';
26
31
  import Fastify from 'fastify';
27
- import { inferenceRuleSchema, jeevesWatcherConfigSchema } from '@karmaniverous/jeeves-watcher-core';
28
32
  import crypto from 'crypto';
29
33
  import https from 'node:https';
30
34
  import { createHash } from 'node:crypto';
31
35
  import pino from 'pino';
32
- import { readFile, stat } from 'node:fs/promises';
33
36
  import { v5 } from 'uuid';
34
37
  import * as cheerio from 'cheerio';
35
38
  import mammoth from 'mammoth';
@@ -744,6 +747,837 @@ class ValuesManager extends BinaryFileStore {
744
747
  }
745
748
  }
746
749
 
750
+ /**
751
+ * @module vcs/CommitMessageGenerator
752
+ * Generates AI-powered commit messages via the Anthropic Messages API.
753
+ */
754
+ /** Maximum characters of diff to send to the AI. */
755
+ const MAX_DIFF_CHARS = 4000;
756
+ /** Maximum length of the generated commit message. */
757
+ const MAX_MESSAGE_LENGTH = 100;
758
+ /** Request timeout in milliseconds. */
759
+ const REQUEST_TIMEOUT_MS = 15000;
760
+ /** System prompt for the commit message generator. */
761
+ 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';
762
+ /**
763
+ * Generates AI commit messages using the Anthropic Messages API.
764
+ */
765
+ class CommitMessageGenerator {
766
+ provider;
767
+ model;
768
+ apiKey;
769
+ logger;
770
+ constructor(provider, model, apiKey, logger) {
771
+ this.provider = provider;
772
+ this.model = model;
773
+ this.apiKey = apiKey;
774
+ this.logger = logger;
775
+ }
776
+ /**
777
+ * Generate a commit message from file changes.
778
+ *
779
+ * @param files - List of changed file paths.
780
+ * @param diffSummary - Combined diff output (stat + patch).
781
+ * @returns A one-line commit message, or null on failure.
782
+ */
783
+ async generate(files, diffSummary) {
784
+ if (!this.apiKey) {
785
+ this.logger.warn('No API key configured for commit message generation');
786
+ return null;
787
+ }
788
+ if (this.provider !== 'anthropic') {
789
+ this.logger.warn({ provider: this.provider }, 'Unsupported commit message provider; only "anthropic" is supported');
790
+ return null;
791
+ }
792
+ const truncatedDiff = diffSummary.slice(0, MAX_DIFF_CHARS);
793
+ const userPrompt = `Files changed:\n${files.join('\n')}\n\nDiff:\n${truncatedDiff}`;
794
+ try {
795
+ const body = JSON.stringify({
796
+ model: this.model,
797
+ max_tokens: 256,
798
+ system: SYSTEM_PROMPT,
799
+ messages: [{ role: 'user', content: userPrompt }],
800
+ });
801
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
802
+ method: 'POST',
803
+ headers: {
804
+ 'Content-Type': 'application/json',
805
+ 'x-api-key': this.apiKey,
806
+ 'anthropic-version': '2023-06-01',
807
+ },
808
+ body,
809
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
810
+ });
811
+ if (!response.ok) {
812
+ const text = await response.text();
813
+ this.logger.warn({ status: response.status, body: text }, 'Anthropic API returned non-OK status');
814
+ return null;
815
+ }
816
+ const data = (await response.json());
817
+ const textBlock = data.content?.find((b) => b.type === 'text');
818
+ if (!textBlock?.text) {
819
+ this.logger.warn('Anthropic API returned no text content');
820
+ return null;
821
+ }
822
+ const message = textBlock.text.trim().slice(0, MAX_MESSAGE_LENGTH);
823
+ this.logger.debug({ message }, 'AI commit message generated');
824
+ return message;
825
+ }
826
+ catch (error) {
827
+ this.logger.warn({ err: normalizeError(error) }, 'Failed to generate AI commit message');
828
+ return null;
829
+ }
830
+ }
831
+ }
832
+
833
+ /**
834
+ * @module vcs/gitExec
835
+ * Shared git execution utilities.
836
+ */
837
+ /** Promisified execFile for git commands. */
838
+ const execFileAsync = promisify(execFile);
839
+ /**
840
+ * Find the longest-prefix-match root for a normalized path.
841
+ * Roots must be sorted longest-first for correct nested matching.
842
+ *
843
+ * @param roots - Root paths sorted longest-first.
844
+ * @param normalizedPath - Normalized absolute path (forward slashes).
845
+ * @returns The matching root, or undefined.
846
+ */
847
+ function findRootForPath(roots, normalizedPath) {
848
+ for (const root of roots) {
849
+ if (normalizedPath === root || normalizedPath.startsWith(root + '/')) {
850
+ return root;
851
+ }
852
+ }
853
+ return undefined;
854
+ }
855
+
856
+ /**
857
+ * @module vcs/resolveCommitMessageApiKey
858
+ * Resolves the API key for AI commit message generation.
859
+ */
860
+ /**
861
+ * Resolve the API key for commit message generation.
862
+ * Checks config first, then falls back to OpenClaw gateway credentials.
863
+ */
864
+ function resolveCommitMessageApiKey(provider, configApiKey, logger) {
865
+ if (configApiKey) {
866
+ logger.debug('Using commit message API key from config');
867
+ return configApiKey;
868
+ }
869
+ try {
870
+ const openclawPath = join(homedir(), '.openclaw', 'openclaw.json');
871
+ const raw = readFileSync(openclawPath, 'utf8');
872
+ const parsed = JSON.parse(raw);
873
+ const gatewayKey = parsed.models?.providers?.[provider]?.apiKey;
874
+ if (gatewayKey) {
875
+ logger.debug({ provider }, 'Using commit message API key from OpenClaw gateway config');
876
+ return gatewayKey;
877
+ }
878
+ logger.warn({ provider }, 'OpenClaw gateway config found but no API key for provider');
879
+ }
880
+ catch {
881
+ logger.warn('No commit message API key in config or OpenClaw gateway');
882
+ }
883
+ return undefined;
884
+ }
885
+
886
+ /**
887
+ * @module vcs/SquashManager
888
+ * Handles scheduled squash retention for git-backed VCS roots.
889
+ */
890
+ /**
891
+ * Parse a standard 5-field cron expression and check if the current time matches.
892
+ * Fields: minute hour dom month dow
893
+ * Supports: numbers, wildcards, lists (1,3,5), ranges (1-5), steps (star/5)
894
+ */
895
+ function cronMatchesNow(cronExpr, now = new Date()) {
896
+ const fields = cronExpr.trim().split(/\s+/);
897
+ if (fields.length !== 5)
898
+ return false;
899
+ const minute = now.getMinutes();
900
+ const hour = now.getHours();
901
+ const dom = now.getDate();
902
+ const month = now.getMonth() + 1; // 1-based
903
+ const dow = now.getDay(); // 0=Sunday
904
+ return (fieldMatches(fields[0], minute, 0, 59) &&
905
+ fieldMatches(fields[1], hour, 0, 23) &&
906
+ fieldMatches(fields[2], dom, 1, 31) &&
907
+ fieldMatches(fields[3], month, 1, 12) &&
908
+ fieldMatches(fields[4], dow, 0, 7) // 7 also means Sunday
909
+ );
910
+ }
911
+ function fieldMatches(field, value, min, max) {
912
+ for (const part of field.split(',')) {
913
+ if (partMatches(part.trim(), value, min, max))
914
+ return true;
915
+ }
916
+ return false;
917
+ }
918
+ function partMatches(part, value, min, max) {
919
+ // Handle step: */N or range/N
920
+ const [rangePart, stepStr] = part.split('/');
921
+ const step = stepStr ? parseInt(stepStr, 10) : 1;
922
+ if (rangePart === '*') {
923
+ return (value - min) % step === 0;
924
+ }
925
+ // Handle range: N-M
926
+ if (rangePart.includes('-')) {
927
+ const [startStr, endStr] = rangePart.split('-');
928
+ const start = parseInt(startStr, 10);
929
+ const end = parseInt(endStr, 10);
930
+ if (value < start || value > end)
931
+ return false;
932
+ return (value - start) % step === 0;
933
+ }
934
+ // Plain number
935
+ const num = parseInt(rangePart, 10);
936
+ if (step === 1) {
937
+ // For dow, treat 7 as 0 (Sunday)
938
+ if (max === 7 && num === 7)
939
+ return value === 0;
940
+ return value === num;
941
+ }
942
+ // Number with step doesn't make sense for single values, treat as exact
943
+ return value === num;
944
+ }
945
+ /**
946
+ * Manages scheduled squash retention for a single VCS root.
947
+ *
948
+ * Runs on a cron schedule (checked every 60s). When triggered, squashes
949
+ * commits older than the retention boundary (the tighter of maxAgeDays
950
+ * and maxVersions) into a single "historical baseline" orphan commit,
951
+ * then cherry-picks retained commits on top. Force-pushes to remote
952
+ * if configured (D13).
953
+ */
954
+ class SquashManager {
955
+ rootPath;
956
+ retention;
957
+ remoteUrl;
958
+ accessToken;
959
+ logger;
960
+ intervalTimer;
961
+ running = false;
962
+ constructor(rootPath, retention, logger, remoteUrl, accessToken) {
963
+ this.rootPath = rootPath;
964
+ this.retention = retention;
965
+ this.logger = logger;
966
+ this.remoteUrl = remoteUrl;
967
+ this.accessToken = accessToken;
968
+ }
969
+ /**
970
+ * Start the cron-based squash scheduler.
971
+ * Checks every 60 seconds if the cron expression matches.
972
+ */
973
+ start() {
974
+ if (this.running)
975
+ return;
976
+ this.running = true;
977
+ this.intervalTimer = setInterval(() => {
978
+ if (cronMatchesNow(this.retention.squashCron)) {
979
+ void this.runSquash();
980
+ }
981
+ }, 60_000);
982
+ this.logger.info({ root: this.rootPath, cron: this.retention.squashCron }, 'SquashManager started');
983
+ }
984
+ /**
985
+ * Stop the cron scheduler.
986
+ */
987
+ stop() {
988
+ this.running = false;
989
+ if (this.intervalTimer !== undefined) {
990
+ clearInterval(this.intervalTimer);
991
+ this.intervalTimer = undefined;
992
+ }
993
+ this.logger.info({ root: this.rootPath }, 'SquashManager stopped');
994
+ }
995
+ /**
996
+ * Run the squash operation. Exposed publicly for testing.
997
+ */
998
+ async runSquash() {
999
+ // Check for index.lock before starting multi-step operation
1000
+ const lockPath = join(this.rootPath, '.git', 'index.lock');
1001
+ try {
1002
+ await access(lockPath);
1003
+ // Lock exists — abort and retry next cycle
1004
+ this.logger.warn({ root: this.rootPath }, 'Squash aborted: index.lock exists, will retry next cycle');
1005
+ return { squashed: false, error: 'index.lock exists' };
1006
+ }
1007
+ catch {
1008
+ // No lock — proceed
1009
+ }
1010
+ try {
1011
+ const commits = await this.getCommitLog();
1012
+ if (commits.length <= 1) {
1013
+ return { squashed: false };
1014
+ }
1015
+ const boundaryIndex = this.calculateRetentionBoundary(commits);
1016
+ if (boundaryIndex <= 0) {
1017
+ // All commits are within retention or only one commit before boundary
1018
+ return { squashed: false };
1019
+ }
1020
+ // Perform the squash
1021
+ await this.performSquash(commits, boundaryIndex);
1022
+ const commitsRemoved = boundaryIndex;
1023
+ const commitsRetained = commits.length - boundaryIndex;
1024
+ this.logger.info({ root: this.rootPath, commitsRemoved, commitsRetained }, 'Squash retention completed');
1025
+ // Force push if remote configured
1026
+ await this.forcePushIfConfigured();
1027
+ return { squashed: true, commitsRemoved, commitsRetained };
1028
+ }
1029
+ catch (error) {
1030
+ const message = normalizeError(error).message;
1031
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash operation failed');
1032
+ return { squashed: false, error: message };
1033
+ }
1034
+ }
1035
+ /**
1036
+ * Get all commits in chronological order (oldest first).
1037
+ */
1038
+ async getCommitLog() {
1039
+ try {
1040
+ const { stdout } = await execFileAsync('git', ['log', '--format=%H %aI', '--reverse'], { cwd: this.rootPath });
1041
+ if (!stdout.trim())
1042
+ return [];
1043
+ return stdout
1044
+ .trim()
1045
+ .split('\n')
1046
+ .map((line) => {
1047
+ const spaceIdx = line.indexOf(' ');
1048
+ return {
1049
+ hash: line.slice(0, spaceIdx),
1050
+ date: new Date(line.slice(spaceIdx + 1)),
1051
+ };
1052
+ });
1053
+ }
1054
+ catch {
1055
+ return [];
1056
+ }
1057
+ }
1058
+ /**
1059
+ * Calculate the retention boundary index.
1060
+ * Returns the index of the oldest commit to KEEP.
1061
+ * All commits before this index will be squashed into a baseline.
1062
+ *
1063
+ * Strategy: find the tighter constraint between age and count.
1064
+ * - Age: keep commits newer than maxAgeDays
1065
+ * - Count: keep the last maxVersions commits
1066
+ * The boundary is the one that retains FEWER old commits (tighter).
1067
+ */
1068
+ calculateRetentionBoundary(commits) {
1069
+ const now = new Date();
1070
+ const maxAgeMs = this.retention.maxAgeDays * 24 * 60 * 60 * 1000;
1071
+ const cutoffDate = new Date(now.getTime() - maxAgeMs);
1072
+ // Find the first commit that is within the age window (to keep)
1073
+ let ageBoundary = commits.length; // Default: all too old
1074
+ for (let i = 0; i < commits.length; i++) {
1075
+ if (commits[i].date >= cutoffDate) {
1076
+ ageBoundary = i;
1077
+ break;
1078
+ }
1079
+ }
1080
+ // Count boundary: keep the last maxVersions commits
1081
+ const countBoundary = Math.max(0, commits.length - this.retention.maxVersions);
1082
+ // The tighter constraint wins (keeps fewer old commits = higher boundary index)
1083
+ return Math.max(ageBoundary, countBoundary);
1084
+ }
1085
+ /**
1086
+ * Perform the squash: create orphan with baseline + cherry-pick retained commits.
1087
+ */
1088
+ async performSquash(commits, boundaryIndex) {
1089
+ // The commit just before boundary is the last one to squash
1090
+ const baselineHash = commits[boundaryIndex - 1].hash;
1091
+ // Get the current branch name
1092
+ const { stdout: branchOut } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: this.rootPath });
1093
+ const currentBranch = branchOut.trim();
1094
+ const orphanBranch = `__squash_orphan_${String(Date.now())}`;
1095
+ try {
1096
+ // 1. Create orphan branch with the tree state at the baseline commit
1097
+ await execFileAsync('git', ['checkout', '--orphan', orphanBranch], {
1098
+ cwd: this.rootPath,
1099
+ });
1100
+ // 2. Reset index to the baseline tree (the last squashed commit's tree)
1101
+ await execFileAsync('git', ['reset', '--hard', baselineHash], {
1102
+ cwd: this.rootPath,
1103
+ });
1104
+ // 3. Create the baseline commit with the same tree
1105
+ // Use commit-tree for a clean orphan commit
1106
+ const { stdout: treeOut } = await execFileAsync('git', ['rev-parse', `${baselineHash}^{tree}`], { cwd: this.rootPath });
1107
+ const treeHash = treeOut.trim();
1108
+ const { stdout: commitOut } = await execFileAsync('git', ['commit-tree', treeHash, '-m', 'historical baseline'], { cwd: this.rootPath });
1109
+ const baselineCommitHash = commitOut.trim();
1110
+ // 4. Reset orphan branch to this new baseline commit
1111
+ await execFileAsync('git', ['reset', '--hard', baselineCommitHash], {
1112
+ cwd: this.rootPath,
1113
+ });
1114
+ // 5. Cherry-pick boundary..HEAD (the retained commits)
1115
+ // Get the list of commits to cherry-pick (from boundary to the end)
1116
+ const commitsToCherry = commits.slice(boundaryIndex).map((c) => c.hash);
1117
+ if (commitsToCherry.length > 0) {
1118
+ await execFileAsync('git', ['cherry-pick', ...commitsToCherry], {
1119
+ cwd: this.rootPath,
1120
+ });
1121
+ }
1122
+ // 6. Force-update the original branch to the orphan
1123
+ const { stdout: newHead } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: this.rootPath });
1124
+ await execFileAsync('git', ['branch', '-f', currentBranch, newHead.trim()], { cwd: this.rootPath });
1125
+ // 7. Switch back to original branch
1126
+ await execFileAsync('git', ['checkout', currentBranch], {
1127
+ cwd: this.rootPath,
1128
+ });
1129
+ // 8. Delete orphan branch
1130
+ await execFileAsync('git', ['branch', '-D', orphanBranch], {
1131
+ cwd: this.rootPath,
1132
+ });
1133
+ }
1134
+ catch (error) {
1135
+ // Attempt cleanup: try to get back to original branch
1136
+ try {
1137
+ await execFileAsync('git', ['checkout', '-f', currentBranch], {
1138
+ cwd: this.rootPath,
1139
+ });
1140
+ await execFileAsync('git', ['branch', '-D', orphanBranch], {
1141
+ cwd: this.rootPath,
1142
+ });
1143
+ }
1144
+ catch {
1145
+ // Cleanup failed — log but re-throw original error
1146
+ }
1147
+ // Remove index.lock if left behind
1148
+ try {
1149
+ const lockFile = join(this.rootPath, '.git', 'index.lock');
1150
+ await rm(lockFile, { force: true });
1151
+ }
1152
+ catch {
1153
+ // ignore
1154
+ }
1155
+ throw error;
1156
+ }
1157
+ }
1158
+ /**
1159
+ * Force push to remote after squash (history rewrite requires force).
1160
+ */
1161
+ async forcePushIfConfigured() {
1162
+ if (!this.remoteUrl)
1163
+ return;
1164
+ try {
1165
+ const pushUrl = this.accessToken
1166
+ ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
1167
+ : this.remoteUrl;
1168
+ await execFileAsync('git', ['push', '--force', pushUrl, 'HEAD'], {
1169
+ cwd: this.rootPath,
1170
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1171
+ });
1172
+ this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'Squash force push succeeded');
1173
+ }
1174
+ catch (error) {
1175
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'Squash force push failed');
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ /**
1181
+ * @module vcs/VcsManager
1182
+ * Per-root VCS manager for git-backed content versioning.
1183
+ */
1184
+ /**
1185
+ * Per-root VCS manager for git-backed content versioning.
1186
+ *
1187
+ * Each VCS-enabled watch root gets its own VcsManager instance, which owns
1188
+ * a debounced commit pipeline: file changes are batched, staged, committed
1189
+ * (with optional AI-generated messages), and optionally pushed to a remote.
1190
+ *
1191
+ * Concurrency: only one commit is in-flight at a time per root. Index.lock
1192
+ * contention is handled with exponential backoff retries (D10).
1193
+ */
1194
+ class VcsManager {
1195
+ config;
1196
+ rootPath;
1197
+ remoteUrl;
1198
+ accessToken;
1199
+ logger;
1200
+ commitMessageGenerator;
1201
+ pending = new Set();
1202
+ pendingReversions = [];
1203
+ _pushErrors = [];
1204
+ squashManager;
1205
+ debounceTimer;
1206
+ commitInFlight = Promise.resolve();
1207
+ started = false;
1208
+ _lastPushTime = null;
1209
+ constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
1210
+ this.rootPath = rootPath;
1211
+ this.config = config;
1212
+ this.logger = logger;
1213
+ this.commitMessageGenerator = commitMessageGenerator;
1214
+ this.remoteUrl = remoteUrl;
1215
+ this.accessToken = accessToken;
1216
+ if (config.retention) {
1217
+ this.squashManager = new SquashManager(rootPath, config.retention, logger, remoteUrl, accessToken);
1218
+ }
1219
+ }
1220
+ get lastPushTime() {
1221
+ return this._lastPushTime;
1222
+ }
1223
+ get pushErrors() {
1224
+ return this._pushErrors;
1225
+ }
1226
+ /**
1227
+ * Begin accepting file changes. Sets up the debounce mechanism.
1228
+ */
1229
+ start() {
1230
+ this.started = true;
1231
+ this.squashManager?.start();
1232
+ this.logger.info({ root: this.rootPath }, 'VcsManager started');
1233
+ }
1234
+ /**
1235
+ * Add a file to the pending set and reset the debounce timer.
1236
+ * If the pending set exceeds maxBatchSize, flush immediately.
1237
+ *
1238
+ * @param filePath - Absolute path of the changed file.
1239
+ */
1240
+ fileChanged(filePath) {
1241
+ if (!this.started)
1242
+ return;
1243
+ this.pending.add(filePath);
1244
+ if (this.pending.size >= this.config.maxBatchSize) {
1245
+ this.clearDebounce();
1246
+ const batch = this.takeBatch(this.config.maxBatchSize);
1247
+ this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
1248
+ if (this.pending.size > 0) {
1249
+ this.resetDebounce();
1250
+ }
1251
+ return;
1252
+ }
1253
+ this.resetDebounce();
1254
+ }
1255
+ /**
1256
+ * Record reversion metadata so the next commit uses a revert-prefixed message.
1257
+ *
1258
+ * @param reversion - The reversion metadata to record.
1259
+ */
1260
+ addPendingReversion(reversion) {
1261
+ this.pendingReversions.push(reversion);
1262
+ }
1263
+ /**
1264
+ * Stage a file deletion and add to pending set.
1265
+ * git add handles deleted files when the file is gone from disk.
1266
+ *
1267
+ * @param filePath - Absolute path of the deleted file.
1268
+ */
1269
+ handleUnlink(filePath) {
1270
+ if (!this.started)
1271
+ return;
1272
+ this.fileChanged(filePath);
1273
+ }
1274
+ /**
1275
+ * Flush all pending files immediately.
1276
+ */
1277
+ async flush() {
1278
+ this.clearDebounce();
1279
+ if (this.pending.size > 0) {
1280
+ const batch = [...this.pending];
1281
+ this.pending.clear();
1282
+ this.commitInFlight = this.commitInFlight.then(() => this.commitBatch(batch));
1283
+ }
1284
+ await this.commitInFlight;
1285
+ }
1286
+ /**
1287
+ * Stop accepting file changes, flush pending, and clean up timers.
1288
+ */
1289
+ async stop() {
1290
+ this.started = false;
1291
+ this.squashManager?.stop();
1292
+ await this.flush();
1293
+ this.logger.info({ root: this.rootPath }, 'VcsManager stopped');
1294
+ }
1295
+ /**
1296
+ * Take up to N items from the pending set.
1297
+ */
1298
+ takeBatch(n) {
1299
+ const batch = [];
1300
+ for (const item of this.pending) {
1301
+ if (batch.length >= n)
1302
+ break;
1303
+ batch.push(item);
1304
+ }
1305
+ for (const item of batch) {
1306
+ this.pending.delete(item);
1307
+ }
1308
+ return batch;
1309
+ }
1310
+ /**
1311
+ * Clear the debounce timer.
1312
+ */
1313
+ clearDebounce() {
1314
+ if (this.debounceTimer !== undefined) {
1315
+ clearTimeout(this.debounceTimer);
1316
+ this.debounceTimer = undefined;
1317
+ }
1318
+ }
1319
+ /**
1320
+ * Reset the debounce timer to fire flush() after commitDebounceMs.
1321
+ */
1322
+ resetDebounce() {
1323
+ this.clearDebounce();
1324
+ this.debounceTimer = setTimeout(() => {
1325
+ this.debounceTimer = undefined;
1326
+ void this.flush();
1327
+ }, this.config.commitDebounceMs);
1328
+ }
1329
+ /**
1330
+ * Get the cached diff for staged files, for AI context.
1331
+ */
1332
+ async getStagedDiff() {
1333
+ try {
1334
+ const { stdout: stat } = await execFileAsync('git', ['diff', '--cached', '--stat'], { cwd: this.rootPath });
1335
+ const { stdout: patch } = await execFileAsync('git', ['diff', '--cached'], { cwd: this.rootPath });
1336
+ return `${stat}\n${patch}`;
1337
+ }
1338
+ catch {
1339
+ return '';
1340
+ }
1341
+ }
1342
+ /**
1343
+ * Build the template commit message (no AI).
1344
+ */
1345
+ buildTemplateMessage(fileCount) {
1346
+ if (this.pendingReversions.length === 0) {
1347
+ const timestamp = new Date().toISOString();
1348
+ return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
1349
+ }
1350
+ // Count total reverted files across all pending reversions
1351
+ const revertedPaths = new Set(this.pendingReversions.flatMap((r) => r.paths));
1352
+ const revertedCount = revertedPaths.size;
1353
+ const otherCount = fileCount - revertedCount;
1354
+ // Use the first reversion's glob and commit for the message
1355
+ const { glob, commit } = this.pendingReversions[0];
1356
+ const shortCommit = commit.slice(0, 7);
1357
+ let message = `revert: ${glob} to ${shortCommit} — restored ${String(revertedCount)} files`;
1358
+ if (otherCount > 0) {
1359
+ message += ` (+ ${String(otherCount)} other changes)`;
1360
+ }
1361
+ return message;
1362
+ }
1363
+ /**
1364
+ * Build the commit message, using AI when available with template fallback.
1365
+ */
1366
+ async buildCommitMessage(files) {
1367
+ const fileCount = files.length;
1368
+ const templateMessage = this.buildTemplateMessage(fileCount);
1369
+ if (!this.commitMessageGenerator) {
1370
+ return templateMessage;
1371
+ }
1372
+ try {
1373
+ const diffSummary = await this.getStagedDiff();
1374
+ const aiMessage = await this.commitMessageGenerator.generate(files, diffSummary);
1375
+ if (!aiMessage) {
1376
+ this.logger.warn('AI commit message generation returned null; using template');
1377
+ return templateMessage;
1378
+ }
1379
+ // For reversions: prefix with revert info + AI description
1380
+ if (this.pendingReversions.length > 0) {
1381
+ const { glob, commit } = this.pendingReversions[0];
1382
+ const shortCommit = commit.slice(0, 7);
1383
+ return `revert: ${glob} to ${shortCommit} — ${aiMessage}`;
1384
+ }
1385
+ return aiMessage;
1386
+ }
1387
+ catch (error) {
1388
+ this.logger.warn({ err: normalizeError(error) }, 'AI commit message generation failed; using template');
1389
+ return templateMessage;
1390
+ }
1391
+ }
1392
+ /**
1393
+ * Commit a batch of files to the git repo with index.lock retry.
1394
+ */
1395
+ async commitBatch(files) {
1396
+ if (files.length === 0)
1397
+ return;
1398
+ try {
1399
+ await retry(async (attempt) => {
1400
+ await execFileAsync('git', ['add', '--', ...files], {
1401
+ cwd: this.rootPath,
1402
+ });
1403
+ // Build message after staging so getStagedDiff can see the changes
1404
+ const message = attempt === 1
1405
+ ? await this.buildCommitMessage(files)
1406
+ : this.buildTemplateMessage(files.length);
1407
+ this.pendingReversions.length = 0;
1408
+ await execFileAsync('git', ['commit', '-m', message], {
1409
+ cwd: this.rootPath,
1410
+ });
1411
+ const { stdout: hashOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], { cwd: this.rootPath });
1412
+ const hash = hashOut.trim();
1413
+ this.logger.info({ root: this.rootPath, hash, fileCount: files.length }, 'VCS commit created');
1414
+ await this.pushIfConfigured();
1415
+ }, {
1416
+ attempts: 4,
1417
+ baseDelayMs: 500,
1418
+ maxDelayMs: 2000,
1419
+ onRetry: ({ attempt, delayMs }) => {
1420
+ this.logger.warn({ root: this.rootPath, attempt, delay: delayMs }, 'index.lock contention, retrying');
1421
+ },
1422
+ });
1423
+ }
1424
+ catch (error) {
1425
+ for (const f of files) {
1426
+ this.pending.add(f);
1427
+ }
1428
+ this.logger.warn({ root: this.rootPath, fileCount: files.length }, 'Re-queued files after commit failure');
1429
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS commit failed');
1430
+ }
1431
+ }
1432
+ /**
1433
+ * Push to the configured remote if remoteUrl is set.
1434
+ * On failure: logs the error and records it in pushErrors; never throws.
1435
+ */
1436
+ async pushIfConfigured() {
1437
+ if (!this.remoteUrl)
1438
+ return;
1439
+ try {
1440
+ // Build the authenticated URL if a token is available
1441
+ const pushUrl = this.accessToken
1442
+ ? this.remoteUrl.replace(/^https:\/\//, `https://${encodeURIComponent(this.accessToken)}@`)
1443
+ : this.remoteUrl;
1444
+ await execFileAsync('git', ['push', pushUrl, 'HEAD'], {
1445
+ cwd: this.rootPath,
1446
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1447
+ });
1448
+ this._lastPushTime = new Date().toISOString();
1449
+ this.logger.info({ root: this.rootPath, remote: this.remoteUrl }, 'VCS push succeeded');
1450
+ }
1451
+ catch (error) {
1452
+ const pushError = {
1453
+ timestamp: new Date().toISOString(),
1454
+ message: normalizeError(error).message,
1455
+ };
1456
+ this._pushErrors.push(pushError);
1457
+ this.logger.error({ root: this.rootPath, err: normalizeError(error) }, 'VCS push failed');
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ /**
1463
+ * @module vcs/VcsCoordinator
1464
+ * Orchestrates VCS across all watch roots. Routes file events to the correct
1465
+ * root's VcsManager instance.
1466
+ */
1467
+ /**
1468
+ * Orchestrates VCS across all VCS-enabled watch roots.
1469
+ */
1470
+ class VcsCoordinator {
1471
+ managers = new Map();
1472
+ roots = [];
1473
+ logger;
1474
+ constructor(config, logger) {
1475
+ this.logger = logger;
1476
+ if (!config.vcs?.enabled)
1477
+ return;
1478
+ const normalized = normalizeWatchPaths(config.watch.paths);
1479
+ for (const entry of normalized) {
1480
+ const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
1481
+ if (!rootVcs)
1482
+ continue;
1483
+ if (/[*?{[]/.test(entry.path)) {
1484
+ logger.warn({ path: entry.path }, 'Skipping VCS for watch path containing glob characters');
1485
+ continue;
1486
+ }
1487
+ const resolvedRoot = normalizeSlashes(resolve(entry.path));
1488
+ const mergedConfig = {
1489
+ ...config.vcs,
1490
+ ...entry.vcs,
1491
+ enabled: true,
1492
+ };
1493
+ // Create CommitMessageGenerator if AI commit messages are configured
1494
+ const cmConfig = mergedConfig.commitMessage;
1495
+ const rootLogger = logger.child({ vcsRoot: resolvedRoot });
1496
+ const resolvedApiKey = cmConfig?.enabled !== false
1497
+ ? resolveCommitMessageApiKey(cmConfig?.provider ?? 'anthropic', cmConfig?.apiKey, rootLogger)
1498
+ : undefined;
1499
+ const generator = cmConfig?.enabled !== false && resolvedApiKey
1500
+ ? new CommitMessageGenerator(cmConfig?.provider ?? 'anthropic', cmConfig?.model ?? 'claude-haiku-4-0', resolvedApiKey, rootLogger)
1501
+ : undefined;
1502
+ // Resolve remote config: per-root overrides fall back to root-level defaults
1503
+ const remoteUrl = entry.vcs?.remote;
1504
+ const accessToken = entry.vcs?.accessToken ?? config.vcs.defaultAccessToken;
1505
+ const manager = new VcsManager(resolvedRoot, mergedConfig, logger.child({ vcsRoot: resolvedRoot }), generator, remoteUrl, accessToken);
1506
+ this.managers.set(resolvedRoot, manager);
1507
+ this.roots.push(resolvedRoot);
1508
+ }
1509
+ // Sort roots longest-first so nested paths match before parents.
1510
+ this.roots.sort((a, b) => b.length - a.length);
1511
+ }
1512
+ /**
1513
+ * Start all VcsManager instances.
1514
+ */
1515
+ start() {
1516
+ this.managers.forEach((manager) => {
1517
+ manager.start();
1518
+ });
1519
+ this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
1520
+ }
1521
+ /**
1522
+ * Route a file event to the correct root's VcsManager.
1523
+ *
1524
+ * @param filePath - Absolute path of the changed file.
1525
+ * @param event - The event type: add, change, or unlink.
1526
+ */
1527
+ onFileChange(filePath, event) {
1528
+ const normalizedPath = normalizeSlashes(resolve(filePath));
1529
+ const manager = this.findManagerForPath(normalizedPath);
1530
+ if (!manager)
1531
+ return;
1532
+ if (event === 'unlink') {
1533
+ manager.handleUnlink(normalizedPath);
1534
+ }
1535
+ else {
1536
+ manager.fileChanged(normalizedPath);
1537
+ }
1538
+ }
1539
+ /**
1540
+ * Flush all managers and clean up.
1541
+ */
1542
+ async stop() {
1543
+ const stopPromises = [];
1544
+ this.managers.forEach((manager) => {
1545
+ stopPromises.push(manager.stop());
1546
+ });
1547
+ await Promise.all(stopPromises);
1548
+ this.logger.info('VcsCoordinator stopped');
1549
+ }
1550
+ /**
1551
+ * Get the sorted root paths (longest-first).
1552
+ */
1553
+ getRoots() {
1554
+ return this.roots;
1555
+ }
1556
+ /**
1557
+ * Get the VcsManager for a specific root path.
1558
+ */
1559
+ getManager(root) {
1560
+ return this.managers.get(root);
1561
+ }
1562
+ /**
1563
+ * Get all managers as [root, manager] pairs.
1564
+ */
1565
+ getAllManagers() {
1566
+ return this.managers;
1567
+ }
1568
+ /**
1569
+ * Find the VcsManager that owns the given normalized file path.
1570
+ * Uses longest-prefix-match against resolved watch roots.
1571
+ *
1572
+ * @param normalizedPath - Normalized absolute path (forward slashes).
1573
+ * @returns The matching VcsManager, or undefined.
1574
+ */
1575
+ findManagerForPath(normalizedPath) {
1576
+ const root = findRootForPath(this.roots, normalizedPath);
1577
+ return root ? this.managers.get(root) : undefined;
1578
+ }
1579
+ }
1580
+
747
1581
  /**
748
1582
  * @module helpers/loadModule
749
1583
  * Shared utility for loading and namespace-prefixing exports from helper modules.
@@ -1925,7 +2759,164 @@ async function applyRules(compiledRules, attributes, options = {}) {
1925
2759
  }
1926
2760
  }
1927
2761
  }
1928
- return { metadata: merged, renderedContent, matchedRules, renderAs };
2762
+ return { metadata: merged, renderedContent, matchedRules, renderAs };
2763
+ }
2764
+
2765
+ /**
2766
+ * @module vcs/resolveWatchRoot
2767
+ * Resolves which VCS-enabled watch root(s) own a given absolute path or glob.
2768
+ */
2769
+ /**
2770
+ * Resolve which VCS-enabled watch root owns an absolute path.
2771
+ *
2772
+ * @param coordinator - The VcsCoordinator with root information.
2773
+ * @param absolutePath - An absolute filesystem path.
2774
+ * @returns The matching root and relative path, or undefined if no root matches.
2775
+ */
2776
+ function resolveWatchRoot(coordinator, absolutePath) {
2777
+ const normalized = normalizeSlashes(resolve(absolutePath));
2778
+ const root = findRootForPath(coordinator.getRoots(), normalized);
2779
+ if (!root)
2780
+ return undefined;
2781
+ return {
2782
+ root,
2783
+ relativePath: normalizeSlashes(relative(root, normalized)),
2784
+ };
2785
+ }
2786
+ /**
2787
+ * Resolve which VCS-enabled watch roots match a glob pattern.
2788
+ * For globs that could span multiple roots, returns all matching roots.
2789
+ *
2790
+ * @param coordinator - The VcsCoordinator with root information.
2791
+ * @param glob - An absolute glob pattern.
2792
+ * @returns Array of matching roots with relative glob patterns.
2793
+ */
2794
+ function resolveWatchRootsForGlob(coordinator, glob) {
2795
+ const normalizedGlob = normalizeSlashes(glob);
2796
+ const roots = coordinator.getRoots();
2797
+ const results = [];
2798
+ for (const root of roots) {
2799
+ if (normalizedGlob.startsWith(root + '/') || normalizedGlob === root) {
2800
+ // Glob is under this root
2801
+ results.push({
2802
+ root,
2803
+ relativePath: normalizeSlashes(relative(root, normalizedGlob)),
2804
+ });
2805
+ }
2806
+ else if (root.startsWith(normalizedGlob.replace(/[*?[\]{}]/g, ''))) {
2807
+ // Root is under the glob's base path — include with root-relative glob
2808
+ results.push({
2809
+ root,
2810
+ relativePath: '.',
2811
+ });
2812
+ }
2813
+ }
2814
+ return results;
2815
+ }
2816
+
2817
+ /**
2818
+ * @module vcs/validateStateDirOverlap
2819
+ * Validates that stateDir does not overlap with any watch path.
2820
+ */
2821
+ /**
2822
+ * Validate that stateDir does not overlap with any watch path.
2823
+ * Overlap means stateDir is a parent, child, or equal to a watch path.
2824
+ *
2825
+ * @param stateDir - The state directory path.
2826
+ * @param watchPaths - Array of watch path strings.
2827
+ * @throws Error if stateDir overlaps with any watch path.
2828
+ */
2829
+ function validateStateDirOverlap(stateDir, watchPaths) {
2830
+ const resolvedStateDir = resolve(stateDir);
2831
+ for (const watchPath of watchPaths) {
2832
+ const resolvedWatch = resolve(watchPath);
2833
+ if (resolvedStateDir === resolvedWatch) {
2834
+ throw new Error(`stateDir "${stateDir}" is the same as watch path "${watchPath}". They must not overlap.`);
2835
+ }
2836
+ const stateDirWithSep = resolvedStateDir.endsWith(sep)
2837
+ ? resolvedStateDir
2838
+ : resolvedStateDir + sep;
2839
+ const watchWithSep = resolvedWatch.endsWith(sep)
2840
+ ? resolvedWatch
2841
+ : resolvedWatch + sep;
2842
+ if (resolvedWatch.startsWith(stateDirWithSep)) {
2843
+ throw new Error(`Watch path "${watchPath}" is inside stateDir "${stateDir}". They must not overlap.`);
2844
+ }
2845
+ if (resolvedStateDir.startsWith(watchWithSep)) {
2846
+ throw new Error(`stateDir "${stateDir}" is inside watch path "${watchPath}". They must not overlap.`);
2847
+ }
2848
+ }
2849
+ }
2850
+
2851
+ /**
2852
+ * @module vcs/vcsBootstrap
2853
+ * Static bootstrap utilities for VCS-managed watch roots.
2854
+ */
2855
+ /** Always-on .gitignore entries for VCS-managed watch roots. */
2856
+ const ALWAYS_GITIGNORE_ENTRIES = [
2857
+ '.git/',
2858
+ 'node_modules/',
2859
+ '.jeeves-watcher/',
2860
+ '.jeeves-metadata/',
2861
+ ];
2862
+ /**
2863
+ * Check whether git is available on the system PATH.
2864
+ *
2865
+ * @returns true if git is available, false otherwise.
2866
+ */
2867
+ async function checkGitAvailable() {
2868
+ try {
2869
+ await execFileAsync('git', ['--version']);
2870
+ return true;
2871
+ }
2872
+ catch {
2873
+ return false;
2874
+ }
2875
+ }
2876
+ /**
2877
+ * Initialize a git repository at the given path if none exists.
2878
+ * Idempotent — does nothing if .git/ already exists.
2879
+ *
2880
+ * @param rootPath - Directory to initialize as a git repo.
2881
+ */
2882
+ async function initRepo(rootPath) {
2883
+ const gitDir = join(rootPath, '.git');
2884
+ try {
2885
+ await access(gitDir);
2886
+ // .git exists, nothing to do
2887
+ }
2888
+ catch {
2889
+ await execFileAsync('git', ['init'], { cwd: rootPath });
2890
+ }
2891
+ }
2892
+ /**
2893
+ * Ensure a .gitignore file exists at rootPath with all always-on entries.
2894
+ * Creates the file if missing. Appends only entries not already present.
2895
+ *
2896
+ * @param rootPath - Directory containing the .gitignore.
2897
+ * @param alwaysOnEntries - Additional always-on entries beyond the defaults.
2898
+ */
2899
+ async function ensureGitignore(rootPath, alwaysOnEntries = []) {
2900
+ const gitignorePath = join(rootPath, '.gitignore');
2901
+ const requiredEntries = [...ALWAYS_GITIGNORE_ENTRIES, ...alwaysOnEntries];
2902
+ let existingContent = '';
2903
+ try {
2904
+ existingContent = await readFile(gitignorePath, 'utf8');
2905
+ }
2906
+ catch {
2907
+ // File doesn't exist yet
2908
+ }
2909
+ const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
2910
+ const missing = requiredEntries.filter((entry) => !existingLines.has(entry));
2911
+ if (missing.length === 0)
2912
+ return;
2913
+ if (existingContent.length === 0) {
2914
+ await writeFile(gitignorePath, missing.join('\n') + '\n', 'utf8');
2915
+ }
2916
+ else {
2917
+ const prefix = existingContent.endsWith('\n') ? '' : '\n';
2918
+ await appendFile(gitignorePath, prefix + missing.join('\n') + '\n', 'utf8');
2919
+ }
1929
2920
  }
1930
2921
 
1931
2922
  /**
@@ -1995,10 +2986,10 @@ function createProcessorConfig(config, configDir, customMapLib) {
1995
2986
  /**
1996
2987
  * Create file system watcher with gitignore filtering.
1997
2988
  */
1998
- function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache) {
2989
+ function createWatcher(config, factories, queue, processor, logger, runtimeOptions, initialScanTracker, contentHashCache, onVcsFileChange) {
1999
2990
  const respectGitignore = config.watch.respectGitignore ?? true;
2000
2991
  const gitignoreFilter = respectGitignore
2001
- ? new GitignoreFilter(config.watch.paths)
2992
+ ? new GitignoreFilter(extractWatchPathStrings(config.watch.paths))
2002
2993
  : undefined;
2003
2994
  const watcher = factories.createFileSystemWatcher(config.watch, queue, processor, logger, {
2004
2995
  maxRetries: config.maxRetries ?? runtimeOptions.maxRetries,
@@ -2007,6 +2998,7 @@ function createWatcher(config, factories, queue, processor, logger, runtimeOptio
2007
2998
  gitignoreFilter,
2008
2999
  initialScanTracker,
2009
3000
  contentHashCache,
3001
+ onVcsFileChange,
2010
3002
  });
2011
3003
  return { watcher, gitignoreFilter };
2012
3004
  }
@@ -2078,6 +3070,41 @@ function resolveVersion(referenceUrl) {
2078
3070
  return 'unknown';
2079
3071
  }
2080
3072
  }
3073
+ /**
3074
+ * Initialize VCS for all watch roots where VCS is enabled.
3075
+ * Checks git availability, validates stateDir overlap, and initializes repos.
3076
+ *
3077
+ * @param config - The resolved configuration (mutated in place if git unavailable).
3078
+ * @param logger - Logger instance.
3079
+ * @returns The effective VCS enabled state (false if git unavailable).
3080
+ */
3081
+ async function initVcs(config, logger) {
3082
+ if (!config.vcs?.enabled)
3083
+ return false;
3084
+ const gitAvailable = await checkGitAvailable();
3085
+ if (!gitAvailable) {
3086
+ logger.warn('git not found on PATH — VCS disabled for this session');
3087
+ return false;
3088
+ }
3089
+ const stateDir = config.stateDir ?? '.jeeves-metadata';
3090
+ const pathStrings = extractWatchPathStrings(config.watch.paths);
3091
+ const staticPaths = pathStrings.filter((p) => !/[*?{[]/.test(p));
3092
+ validateStateDirOverlap(stateDir, staticPaths);
3093
+ const normalized = normalizeWatchPaths(config.watch.paths);
3094
+ for (const entry of normalized) {
3095
+ const rootVcs = entry.vcs?.enabled ?? config.vcs.enabled;
3096
+ if (!rootVcs)
3097
+ continue;
3098
+ if (/[*?{[]/.test(entry.path)) {
3099
+ logger.warn({ path: entry.path }, 'Skipping VCS init for watch path containing glob characters');
3100
+ continue;
3101
+ }
3102
+ await initRepo(entry.path);
3103
+ await ensureGitignore(entry.path);
3104
+ logger.info({ root: entry.path }, 'VCS initialized for watch root');
3105
+ }
3106
+ return true;
3107
+ }
2081
3108
 
2082
3109
  /**
2083
3110
  * @module app/configReload
@@ -2283,7 +3310,9 @@ function createConfigMatchHandler(options) {
2283
3310
  const config = getConfig();
2284
3311
  if (config !== cachedConfig) {
2285
3312
  compiledRules = compileRules(config.inferenceRules ?? []);
2286
- watchMatcher = picomatch(config.watch.paths, { dot: true });
3313
+ watchMatcher = picomatch(extractWatchPathStrings(config.watch.paths), {
3314
+ dot: true,
3315
+ });
2287
3316
  ignoreMatcher = config.watch.ignored.length
2288
3317
  ? picomatch(config.watch.ignored, { dot: true })
2289
3318
  : undefined;
@@ -3180,7 +4209,7 @@ function createRenderHandler(deps) {
3180
4209
  }
3181
4210
  // Validate path is within watched scope (live config)
3182
4211
  const watch = getWatch();
3183
- if (!isPathWatched(filePath, watch.paths, watch.ignored)) {
4212
+ if (!isPathWatched(filePath, extractWatchPathStrings(watch.paths), watch.ignored)) {
3184
4213
  return reply.status(403).send({ error: 'Path is outside watched scope' });
3185
4214
  }
3186
4215
  try {
@@ -3373,6 +4402,504 @@ function createSearchHandler(deps) {
3373
4402
  }, deps.logger, 'Search');
3374
4403
  }
3375
4404
 
4405
+ /**
4406
+ * @module api/handlers/vcs/vcsCheckExclusion
4407
+ * Fastify route handler for GET /vcs/check-exclusion. Checks gitignore status of a path.
4408
+ */
4409
+ /**
4410
+ * Create handler for GET /vcs/check-exclusion.
4411
+ */
4412
+ function createVcsCheckExclusionHandler(deps) {
4413
+ return wrapHandler(async (request, reply) => {
4414
+ const { path } = request.query;
4415
+ if (!path) {
4416
+ void reply
4417
+ .status(400)
4418
+ .send({ error: 'Missing required query parameter: path' });
4419
+ return;
4420
+ }
4421
+ const resolved = resolveWatchRoot(deps.coordinator, path);
4422
+ if (!resolved) {
4423
+ void reply
4424
+ .status(404)
4425
+ .send({ error: 'Path does not match any VCS-enabled watch root' });
4426
+ return;
4427
+ }
4428
+ try {
4429
+ const { stdout } = await execFileAsync('git', ['check-ignore', '-v', resolved.relativePath], { cwd: resolved.root });
4430
+ // Output format: <source>:<linenum>:<pattern>\t<pathname>
4431
+ const trimmed = stdout.trim();
4432
+ const match = /^(.+):(\d+):(.+)\t/.exec(trimmed);
4433
+ if (match) {
4434
+ return {
4435
+ excluded: true,
4436
+ rule: match[3].trim(),
4437
+ source: match[1],
4438
+ };
4439
+ }
4440
+ return { excluded: true };
4441
+ }
4442
+ catch {
4443
+ // Exit code 1 means the path is NOT ignored
4444
+ return { excluded: false };
4445
+ }
4446
+ }, deps.logger, 'VcsCheckExclusion');
4447
+ }
4448
+
4449
+ /**
4450
+ * @module api/handlers/vcs/vcsDiff
4451
+ * Fastify route handler for GET /vcs/diff. Returns diff text for a glob between commits.
4452
+ */
4453
+ /**
4454
+ * Create handler for GET /vcs/diff.
4455
+ */
4456
+ function createVcsDiffHandler(deps) {
4457
+ return wrapHandler(async (request, reply) => {
4458
+ const { glob, commit, commitEnd } = request.query;
4459
+ if (!glob || !commit) {
4460
+ void reply
4461
+ .status(400)
4462
+ .send({ error: 'Missing required query parameters: glob, commit' });
4463
+ return;
4464
+ }
4465
+ const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
4466
+ if (resolved.length === 0) {
4467
+ void reply
4468
+ .status(404)
4469
+ .send({ error: 'No VCS-enabled watch root matches the given glob' });
4470
+ return;
4471
+ }
4472
+ const diffs = [];
4473
+ for (const { root, relativePath } of resolved) {
4474
+ const range = commitEnd ? `${commit}..${commitEnd}` : `${commit}..HEAD`;
4475
+ const args = ['diff', range, '--', relativePath || '.'];
4476
+ try {
4477
+ const { stdout } = await execFileAsync('git', args, { cwd: root });
4478
+ if (stdout.trim())
4479
+ diffs.push(stdout);
4480
+ }
4481
+ catch {
4482
+ // Invalid commit or path — skip
4483
+ }
4484
+ }
4485
+ void reply.header('content-type', 'text/plain').send(diffs.join('\n'));
4486
+ }, deps.logger, 'VcsDiff');
4487
+ }
4488
+
4489
+ /**
4490
+ * @module api/handlers/vcs/vcsExclude
4491
+ * Fastify route handler for POST /vcs/exclude. Manages .gitignore exclusions
4492
+ * with locality-based placement.
4493
+ */
4494
+ /**
4495
+ * Determine the deepest directory for .gitignore placement and the pattern
4496
+ * to add. Splits the glob at the first wildcard character.
4497
+ *
4498
+ * @param relativeGlob - Glob relative to the watch root.
4499
+ * @returns The directory (relative to root) and the pattern for .gitignore.
4500
+ */
4501
+ function splitGlobForLocality(relativeGlob) {
4502
+ // Find the first path segment containing a wildcard
4503
+ const segments = relativeGlob.split('/');
4504
+ const dirSegments = [];
4505
+ for (const segment of segments) {
4506
+ if (/[*?[\]{}]/.test(segment))
4507
+ break;
4508
+ dirSegments.push(segment);
4509
+ }
4510
+ // Last non-wildcard segment might be a filename, not a directory.
4511
+ // If no wildcards at all, the directory is the parent of the file.
4512
+ if (dirSegments.length === segments.length) {
4513
+ // No wildcards — the glob is a literal path, directory is parent
4514
+ const dir = dirSegments.slice(0, -1).join('/') || '.';
4515
+ const pattern = dirSegments[dirSegments.length - 1];
4516
+ return { dir, pattern };
4517
+ }
4518
+ const dir = dirSegments.join('/') || '.';
4519
+ const patternSegments = segments.slice(dirSegments.length);
4520
+ const pattern = patternSegments.join('/');
4521
+ return { dir, pattern };
4522
+ }
4523
+ /**
4524
+ * Create handler for POST /vcs/exclude.
4525
+ */
4526
+ function createVcsExcludeHandler(deps) {
4527
+ return wrapHandler(async (request, reply) => {
4528
+ const { glob, root: requestedRoot, remove } = request.body;
4529
+ if (!glob) {
4530
+ void reply
4531
+ .status(400)
4532
+ .send({ error: 'Missing required body parameter: glob' });
4533
+ return;
4534
+ }
4535
+ // Resolve the root
4536
+ let root;
4537
+ if (requestedRoot) {
4538
+ const normalizedRoot = normalizeSlashes(requestedRoot);
4539
+ const manager = deps.coordinator.getManager(normalizedRoot);
4540
+ if (!manager) {
4541
+ void reply
4542
+ .status(404)
4543
+ .send({ error: 'Root is not a VCS-enabled watch root' });
4544
+ return;
4545
+ }
4546
+ root = normalizedRoot;
4547
+ }
4548
+ else {
4549
+ // Resolve root from the glob path
4550
+ const normalizedGlob = normalizeSlashes(glob);
4551
+ const basePath = normalizedGlob.replace(/[*?[\]{}].*/g, '');
4552
+ const matchedRoot = findRootForPath(deps.coordinator.getRoots(), basePath);
4553
+ if (!matchedRoot) {
4554
+ void reply
4555
+ .status(404)
4556
+ .send({ error: 'Glob does not match any VCS-enabled watch root' });
4557
+ return;
4558
+ }
4559
+ root = matchedRoot;
4560
+ }
4561
+ // Compute relative glob from root
4562
+ const normalizedGlob = normalizeSlashes(glob);
4563
+ let relativeGlob;
4564
+ if (normalizedGlob.startsWith(root + '/')) {
4565
+ relativeGlob = normalizedGlob.slice(root.length + 1);
4566
+ }
4567
+ else {
4568
+ // Glob is the root itself or doesn't start with root — use as-is
4569
+ relativeGlob = normalizedGlob;
4570
+ }
4571
+ // Determine .gitignore directory and pattern using locality principle
4572
+ const { dir, pattern } = splitGlobForLocality(relativeGlob);
4573
+ const gitignoreDir = dir === '.' ? root : normalizeSlashes(join(root, dir));
4574
+ const gitignorePath = normalizeSlashes(join(gitignoreDir, '.gitignore'));
4575
+ if (remove) {
4576
+ // Remove the pattern from .gitignore
4577
+ let content;
4578
+ try {
4579
+ content = await readFile(gitignorePath, 'utf8');
4580
+ }
4581
+ catch {
4582
+ void reply
4583
+ .status(404)
4584
+ .send({ error: 'No .gitignore found at the expected location' });
4585
+ return;
4586
+ }
4587
+ const lines = content.split('\n');
4588
+ const filtered = lines.filter((line) => line.trim() !== pattern);
4589
+ if (filtered.length === lines.length) {
4590
+ void reply
4591
+ .status(404)
4592
+ .send({ error: 'Pattern not found in .gitignore' });
4593
+ return;
4594
+ }
4595
+ await writeFile(gitignorePath, filtered.join('\n'), 'utf8');
4596
+ return { ok: true, gitignorePath, action: 'removed' };
4597
+ }
4598
+ // Add the pattern to .gitignore
4599
+ await mkdir(gitignoreDir, { recursive: true });
4600
+ let existingContent = '';
4601
+ try {
4602
+ existingContent = await readFile(gitignorePath, 'utf8');
4603
+ }
4604
+ catch {
4605
+ // File doesn't exist yet
4606
+ }
4607
+ // Check if pattern already exists
4608
+ const existingLines = new Set(existingContent.split('\n').map((line) => line.trim()));
4609
+ if (existingLines.has(pattern)) {
4610
+ return { ok: true, gitignorePath, action: 'added' };
4611
+ }
4612
+ if (existingContent.length === 0) {
4613
+ await writeFile(gitignorePath, pattern + '\n', 'utf8');
4614
+ }
4615
+ else {
4616
+ const prefix = existingContent.endsWith('\n') ? '' : '\n';
4617
+ await appendFile(gitignorePath, prefix + pattern + '\n', 'utf8');
4618
+ }
4619
+ return { ok: true, gitignorePath, action: 'added' };
4620
+ }, deps.logger, 'VcsExclude');
4621
+ }
4622
+
4623
+ /**
4624
+ * @module api/handlers/vcs/vcsHistory
4625
+ * Fastify route handler for GET /vcs/history. Returns commit history for a glob.
4626
+ */
4627
+ /** Matches a commit header line: 40-hex-char hash, pipe, message, pipe, ISO timestamp. */
4628
+ const HEADER_RE = /^([0-9a-f]{40})\|(.+)\|(\d{4}-\d{2}-\d{2}T.+)$/;
4629
+ /**
4630
+ * Parse git log --format='%H|%s|%aI' --name-only output into structured entries.
4631
+ *
4632
+ * Output format per commit:
4633
+ * hash|subject|ISO-date
4634
+ * (empty line)
4635
+ * file1
4636
+ * file2
4637
+ * (empty line)
4638
+ */
4639
+ function parseGitLog(stdout) {
4640
+ const entries = [];
4641
+ const lines = stdout.split('\n');
4642
+ let current;
4643
+ for (const line of lines) {
4644
+ const trimmed = line.trim();
4645
+ const headerMatch = HEADER_RE.exec(trimmed);
4646
+ if (headerMatch) {
4647
+ // Start of a new commit
4648
+ current = {
4649
+ commit: headerMatch[1],
4650
+ message: headerMatch[2],
4651
+ timestamp: headerMatch[3],
4652
+ files: [],
4653
+ };
4654
+ entries.push(current);
4655
+ }
4656
+ else if (trimmed.length > 0 && current) {
4657
+ current.files.push(trimmed);
4658
+ }
4659
+ }
4660
+ return entries;
4661
+ }
4662
+ /**
4663
+ * Create handler for GET /vcs/history.
4664
+ */
4665
+ function createVcsHistoryHandler(deps) {
4666
+ return wrapHandler(async (request, reply) => {
4667
+ const { glob, since, until, limit: limitStr } = request.query;
4668
+ if (!glob) {
4669
+ void reply
4670
+ .status(400)
4671
+ .send({ error: 'Missing required query parameter: glob' });
4672
+ return;
4673
+ }
4674
+ const limit = limitStr ? parseInt(limitStr, 10) : 20;
4675
+ const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
4676
+ if (resolved.length === 0) {
4677
+ void reply
4678
+ .status(404)
4679
+ .send({ error: 'No VCS-enabled watch root matches the given glob' });
4680
+ return;
4681
+ }
4682
+ const allEntries = [];
4683
+ for (const { root, relativePath } of resolved) {
4684
+ const args = ['log', '--format=%H|%s|%aI', '--name-only'];
4685
+ if (since)
4686
+ args.push(`--since=${since}`);
4687
+ if (until)
4688
+ args.push(`--until=${until}`);
4689
+ args.push('--', relativePath || '.');
4690
+ try {
4691
+ const { stdout } = await execFileAsync('git', args, { cwd: root });
4692
+ allEntries.push(...parseGitLog(stdout));
4693
+ }
4694
+ catch {
4695
+ // No commits or invalid path — skip
4696
+ }
4697
+ }
4698
+ // Sort by timestamp descending
4699
+ allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
4700
+ return allEntries.slice(0, limit);
4701
+ }, deps.logger, 'VcsHistory');
4702
+ }
4703
+
4704
+ /**
4705
+ * @module api/handlers/vcs/vcsRevert
4706
+ * Fastify route handler for POST /vcs/revert. Restores files from a past commit.
4707
+ */
4708
+ /**
4709
+ * Create handler for POST /vcs/revert.
4710
+ */
4711
+ function createVcsRevertHandler(deps) {
4712
+ return wrapHandler(async (request, reply) => {
4713
+ const { glob, commit, existingOnly } = request.body;
4714
+ if (!glob || !commit) {
4715
+ void reply
4716
+ .status(400)
4717
+ .send({ error: 'Missing required body parameters: glob, commit' });
4718
+ return;
4719
+ }
4720
+ const resolved = resolveWatchRootsForGlob(deps.coordinator, glob);
4721
+ if (resolved.length === 0) {
4722
+ void reply
4723
+ .status(404)
4724
+ .send({ error: 'Glob does not match any VCS-enabled watch root' });
4725
+ return;
4726
+ }
4727
+ const allFiles = [];
4728
+ for (const { root, relativePath } of resolved) {
4729
+ const manager = deps.coordinator.getManager(root);
4730
+ if (!manager)
4731
+ continue;
4732
+ // List files matching the glob at the target commit
4733
+ let fileList;
4734
+ try {
4735
+ const args = relativePath === '' || relativePath === '.'
4736
+ ? ['ls-tree', '-r', '--name-only', commit]
4737
+ : ['ls-tree', '-r', '--name-only', commit, '--', relativePath];
4738
+ const { stdout } = await execFileAsync('git', args, { cwd: root });
4739
+ fileList = stdout
4740
+ .trim()
4741
+ .split('\n')
4742
+ .filter((line) => line.length > 0);
4743
+ }
4744
+ catch {
4745
+ continue;
4746
+ }
4747
+ const restoredPaths = [];
4748
+ for (const relFile of fileList) {
4749
+ const absPath = normalizeSlashes(join(root, relFile));
4750
+ // If existingOnly, skip files that don't exist on disk
4751
+ if (existingOnly) {
4752
+ try {
4753
+ await access(absPath);
4754
+ }
4755
+ catch {
4756
+ continue;
4757
+ }
4758
+ }
4759
+ // Get file content at the target commit
4760
+ let content;
4761
+ try {
4762
+ const { stdout } = await execFileAsync('git', ['show', `${commit}:${relFile}`], { cwd: root });
4763
+ content = stdout;
4764
+ }
4765
+ catch {
4766
+ continue;
4767
+ }
4768
+ // Ensure parent directory exists
4769
+ const dir = dirname(absPath);
4770
+ await mkdir(dir, { recursive: true });
4771
+ // Write the file
4772
+ await writeFile(absPath, content, 'utf8');
4773
+ restoredPaths.push(absPath);
4774
+ // Notify VcsManager of the change (enters debounce cycle)
4775
+ manager.fileChanged(absPath);
4776
+ }
4777
+ if (restoredPaths.length > 0) {
4778
+ manager.addPendingReversion({
4779
+ glob,
4780
+ commit,
4781
+ paths: restoredPaths,
4782
+ });
4783
+ allFiles.push(...restoredPaths);
4784
+ }
4785
+ }
4786
+ return { restored: allFiles.length, files: allFiles };
4787
+ }, deps.logger, 'VcsRevert');
4788
+ }
4789
+
4790
+ /**
4791
+ * @module api/handlers/vcs/vcsShow
4792
+ * Fastify route handler for GET /vcs/show. Returns file content at a specific commit.
4793
+ */
4794
+ const CONTENT_TYPES = {
4795
+ '.txt': 'text/plain',
4796
+ '.md': 'text/markdown',
4797
+ '.json': 'application/json',
4798
+ '.xml': 'application/xml',
4799
+ '.html': 'text/html',
4800
+ '.css': 'text/css',
4801
+ '.js': 'application/javascript',
4802
+ '.ts': 'text/plain',
4803
+ '.yaml': 'text/yaml',
4804
+ '.yml': 'text/yaml',
4805
+ '.csv': 'text/csv',
4806
+ };
4807
+ /**
4808
+ * Create handler for GET /vcs/show.
4809
+ */
4810
+ function createVcsShowHandler(deps) {
4811
+ return wrapHandler(async (request, reply) => {
4812
+ const { path, commit } = request.query;
4813
+ if (!path || !commit) {
4814
+ void reply
4815
+ .status(400)
4816
+ .send({ error: 'Missing required query parameters: path, commit' });
4817
+ return;
4818
+ }
4819
+ const resolved = resolveWatchRoot(deps.coordinator, path);
4820
+ if (!resolved) {
4821
+ void reply
4822
+ .status(404)
4823
+ .send({ error: 'Path does not match any VCS-enabled watch root' });
4824
+ return;
4825
+ }
4826
+ try {
4827
+ const { stdout } = await execFileAsync('git', ['show', `${commit}:${resolved.relativePath}`], { cwd: resolved.root });
4828
+ const ext = extname(path).toLowerCase();
4829
+ const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
4830
+ void reply.header('content-type', contentType).send(stdout);
4831
+ }
4832
+ catch {
4833
+ void reply
4834
+ .status(404)
4835
+ .send({ error: 'File not found at the specified commit' });
4836
+ }
4837
+ }, deps.logger, 'VcsShow');
4838
+ }
4839
+
4840
+ /**
4841
+ * @module api/handlers/vcs/vcsStatus
4842
+ * Fastify route handler for GET /vcs/status. Returns VCS state for all roots.
4843
+ */
4844
+ async function getLastCommit(cwd) {
4845
+ try {
4846
+ const { stdout } = await execFileAsync('git', ['log', '-1', '--format=%H|%s|%aI'], { cwd });
4847
+ const trimmed = stdout.trim();
4848
+ if (!trimmed)
4849
+ return null;
4850
+ const [hash, message, timestamp] = trimmed.split('|');
4851
+ return { hash, message, timestamp };
4852
+ }
4853
+ catch {
4854
+ return null;
4855
+ }
4856
+ }
4857
+ async function getTrackedCount(cwd) {
4858
+ try {
4859
+ const { stdout } = await execFileAsync('git', ['rev-list', '--count', 'HEAD'], { cwd });
4860
+ return parseInt(stdout.trim(), 10);
4861
+ }
4862
+ catch {
4863
+ return 0;
4864
+ }
4865
+ }
4866
+ async function getRemoteUrl(cwd) {
4867
+ try {
4868
+ const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], { cwd });
4869
+ const url = stdout.trim();
4870
+ return url || null;
4871
+ }
4872
+ catch {
4873
+ return null;
4874
+ }
4875
+ }
4876
+ /**
4877
+ * Create handler for GET /vcs/status.
4878
+ */
4879
+ function createVcsStatusHandler(deps) {
4880
+ return wrapHandler(async () => {
4881
+ const roots = deps.coordinator.getRoots();
4882
+ const enabled = roots.length > 0;
4883
+ const rootStatuses = await Promise.all(roots.map(async (root) => {
4884
+ const manager = deps.coordinator.getManager(root);
4885
+ const [tracked, lastCommit, remoteUrl] = await Promise.all([
4886
+ getTrackedCount(root),
4887
+ getLastCommit(root),
4888
+ getRemoteUrl(root),
4889
+ ]);
4890
+ return {
4891
+ path: root,
4892
+ tracked,
4893
+ lastCommit,
4894
+ remoteUrl: manager?.remoteUrl ?? remoteUrl,
4895
+ lastPush: manager?.lastPushTime ?? null,
4896
+ pushErrors: manager?.pushErrors ?? [],
4897
+ };
4898
+ }));
4899
+ return { enabled, roots: rootStatuses };
4900
+ }, deps.logger, 'VcsStatus');
4901
+ }
4902
+
3376
4903
  /**
3377
4904
  * @module api/handlers/walk
3378
4905
  * Fastify route handler for POST /walk. Enumerates watched files matching caller-provided globs
@@ -3681,7 +5208,7 @@ function createApiServer(options) {
3681
5208
  logger,
3682
5209
  }));
3683
5210
  app.post('/walk', createWalkHandler({
3684
- getWatchPaths: () => getConfig().watch.paths,
5211
+ getWatchPaths: () => extractWatchPathStrings(getConfig().watch.paths),
3685
5212
  getFileSystemWatcher,
3686
5213
  logger,
3687
5214
  }));
@@ -3776,6 +5303,17 @@ function createApiServer(options) {
3776
5303
  app.post('/points/delete', createPointsDeleteHandler({ vectorStore, logger }));
3777
5304
  app.post('/rules/reapply', createRulesReapplyHandler({ processor, vectorStore, logger }));
3778
5305
  }
5306
+ // VCS routes — only registered when a VcsCoordinator is provided
5307
+ if (options.vcsCoordinator) {
5308
+ const coordinator = options.vcsCoordinator;
5309
+ app.get('/vcs/status', createVcsStatusHandler({ coordinator, logger }));
5310
+ app.get('/vcs/history', createVcsHistoryHandler({ coordinator, logger }));
5311
+ app.get('/vcs/show', createVcsShowHandler({ coordinator, logger }));
5312
+ app.get('/vcs/diff', createVcsDiffHandler({ coordinator, logger }));
5313
+ app.get('/vcs/check-exclusion', createVcsCheckExclusionHandler({ coordinator, logger }));
5314
+ app.post('/vcs/revert', createVcsRevertHandler({ coordinator, logger }));
5315
+ app.post('/vcs/exclude', createVcsExcludeHandler({ coordinator, logger }));
5316
+ }
3779
5317
  return app;
3780
5318
  }
3781
5319
 
@@ -5787,6 +7325,7 @@ class FileSystemWatcher {
5787
7325
  gitignoreFilter;
5788
7326
  initialScanTracker;
5789
7327
  contentHashCache;
7328
+ onVcsFileChange;
5790
7329
  moveCorrelator;
5791
7330
  globMatches;
5792
7331
  watcher;
@@ -5807,6 +7346,7 @@ class FileSystemWatcher {
5807
7346
  this.gitignoreFilter = options.gitignoreFilter;
5808
7347
  this.initialScanTracker = options.initialScanTracker;
5809
7348
  this.contentHashCache = options.contentHashCache;
7349
+ this.onVcsFileChange = options.onVcsFileChange;
5810
7350
  this.globMatches = () => true;
5811
7351
  const healthOptions = {
5812
7352
  maxRetries: options.maxRetries,
@@ -5824,7 +7364,7 @@ class FileSystemWatcher {
5824
7364
  // Glob patterns are silently treated as literal strings, producing zero
5825
7365
  // events. We extract static directory roots for chokidar to watch, then
5826
7366
  // filter emitted events against the original globs via picomatch.
5827
- const { roots, matches } = resolveWatchPaths(this.config.paths);
7367
+ const { roots, matches } = resolveWatchPaths(extractWatchPathStrings(this.config.paths));
5828
7368
  this.globMatches = matches;
5829
7369
  this.logger.info({ roots }, 'Resolved watch roots from globs');
5830
7370
  // Chokidar v5's inline anymatch does exact string equality for string
@@ -5905,6 +7445,8 @@ class FileSystemWatcher {
5905
7445
  this.initialScanTracker?.incrementEnqueued();
5906
7446
  }
5907
7447
  this.logger.debug({ path }, 'File added');
7448
+ if (initialScanComplete)
7449
+ this.onVcsFileChange?.(path, 'add');
5908
7450
  if (correlator && initialScanComplete) {
5909
7451
  void correlator.handleAdd(path);
5910
7452
  }
@@ -5921,6 +7463,7 @@ class FileSystemWatcher {
5921
7463
  if (this.isGitignored(path))
5922
7464
  return;
5923
7465
  this.logger.debug({ path }, 'File changed');
7466
+ this.onVcsFileChange?.(path, 'change');
5924
7467
  this.queue.enqueue({ type: 'modify', path, priority: 'normal' }, () => this.wrapProcessing(() => this.processor.processFile(path)));
5925
7468
  });
5926
7469
  this.watcher.on('unlink', (path) => {
@@ -5930,6 +7473,7 @@ class FileSystemWatcher {
5930
7473
  if (this.isGitignored(path))
5931
7474
  return;
5932
7475
  this.logger.debug({ path }, 'File removed');
7476
+ this.onVcsFileChange?.(path, 'unlink');
5933
7477
  if (correlator) {
5934
7478
  correlator.handleUnlink(path);
5935
7479
  }
@@ -6081,6 +7625,7 @@ class JeevesWatcher {
6081
7625
  gitignoreFilter;
6082
7626
  enrichmentStore;
6083
7627
  contentHashCache;
7628
+ vcsCoordinator;
6084
7629
  initialScanTracker;
6085
7630
  version;
6086
7631
  /** Create a new JeevesWatcher instance. */
@@ -6103,6 +7648,7 @@ class JeevesWatcher {
6103
7648
  async start() {
6104
7649
  const logger = this.factories.createLogger(this.config.logging);
6105
7650
  this.logger = logger;
7651
+ await initVcs(this.config, logger);
6106
7652
  const { embeddingProvider, vectorStore } = await initEmbeddingAndStore(this.config, this.factories, logger);
6107
7653
  this.embeddingProvider = embeddingProvider;
6108
7654
  this.vectorStore = vectorStore;
@@ -6139,10 +7685,15 @@ class JeevesWatcher {
6139
7685
  concurrency: this.config.embedding.concurrency ?? 5,
6140
7686
  rateLimitPerMinute: this.config.embedding.rateLimitPerMinute,
6141
7687
  });
6142
- const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, processor, logger, this.runtimeOptions, this.initialScanTracker, contentHashCache);
7688
+ const vcsCoordinator = new VcsCoordinator(this.config, logger);
7689
+ this.vcsCoordinator = vcsCoordinator;
7690
+ const { watcher, gitignoreFilter } = createWatcher(this.config, this.factories, this.queue, processor, logger, this.runtimeOptions, this.initialScanTracker, contentHashCache, (filePath, event) => {
7691
+ vcsCoordinator.onFileChange(filePath, event);
7692
+ });
6143
7693
  this.watcher = watcher;
6144
7694
  this.gitignoreFilter = gitignoreFilter;
6145
7695
  this.server = await this.startApiServer();
7696
+ vcsCoordinator.start();
6146
7697
  this.watcher.start();
6147
7698
  this.startConfigWatch();
6148
7699
  logger.info('jeeves-watcher started');
@@ -6152,6 +7703,9 @@ class JeevesWatcher {
6152
7703
  */
6153
7704
  async stop() {
6154
7705
  await this.stopConfigWatch();
7706
+ if (this.vcsCoordinator) {
7707
+ await this.vcsCoordinator.stop();
7708
+ }
6155
7709
  if (this.watcher) {
6156
7710
  await this.watcher.stop();
6157
7711
  }
@@ -6199,6 +7753,7 @@ class JeevesWatcher {
6199
7753
  fileSystemWatcher: this.watcher,
6200
7754
  getFileSystemWatcher: () => this.watcher,
6201
7755
  enrichmentStore: this.enrichmentStore,
7756
+ vcsCoordinator: this.vcsCoordinator,
6202
7757
  });
6203
7758
  await server.listen({
6204
7759
  host: this.config.api?.host ?? getBindAddress('watcher'),