@karmaniverous/jeeves-watcher 0.18.3 → 0.18.4

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.
@@ -934,17 +934,32 @@ class CommitMessageGenerator {
934
934
  */
935
935
  /** Promisified execFile for git commands. */
936
936
  const execFileAsync = promisify(execFile);
937
+ /**
938
+ * Normalize a path for case-insensitive comparison on Windows.
939
+ * On Windows, lowercases the entire path; on other platforms, returns as-is.
940
+ *
941
+ * @param p - The path string to normalize.
942
+ * @param platform - The platform to check against (default: `process.platform`).
943
+ * @returns The normalized path.
944
+ */
945
+ function normalizePathCase(p, platform = process.platform) {
946
+ return platform === 'win32' ? p.toLowerCase() : p;
947
+ }
937
948
  /**
938
949
  * Find the longest-prefix-match root for a normalized path.
939
950
  * Roots must be sorted longest-first for correct nested matching.
940
951
  *
941
952
  * @param roots - Root paths sorted longest-first.
942
953
  * @param normalizedPath - Normalized absolute path (forward slashes).
954
+ * @param platform - The platform to check against (default: `process.platform`).
943
955
  * @returns The matching root, or undefined.
944
956
  */
945
- function findRootForPath(roots, normalizedPath) {
957
+ function findRootForPath(roots, normalizedPath, platform = process.platform) {
958
+ const comparePath = normalizePathCase(normalizedPath, platform);
946
959
  for (const root of roots) {
947
- if (normalizedPath === root || normalizedPath.startsWith(root + '/')) {
960
+ const compareRoot = normalizePathCase(root, platform);
961
+ if (comparePath === compareRoot ||
962
+ comparePath.startsWith(compareRoot + '/')) {
948
963
  return root;
949
964
  }
950
965
  }
@@ -1303,6 +1318,7 @@ class VcsManager {
1303
1318
  debounceTimer;
1304
1319
  commitInFlight = Promise.resolve();
1305
1320
  started = false;
1321
+ isBaseline = false;
1306
1322
  _lastPushTime = null;
1307
1323
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
1308
1324
  this.rootPath = rootPath;
@@ -1323,11 +1339,47 @@ class VcsManager {
1323
1339
  }
1324
1340
  /**
1325
1341
  * Begin accepting file changes. Sets up the debounce mechanism.
1342
+ * Enqueues a baseline commit for pre-existing files if the repo has no commits.
1326
1343
  */
1327
- start() {
1344
+ async start() {
1328
1345
  this.started = true;
1329
1346
  this.squashManager?.start();
1330
1347
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
1348
+ await this.enqueueBaselineIfNeeded();
1349
+ }
1350
+ /**
1351
+ * If the repo has no commits, enumerate untracked files and feed them
1352
+ * through fileChanged() to create a baseline commit.
1353
+ */
1354
+ async enqueueBaselineIfNeeded() {
1355
+ try {
1356
+ await execFileAsync('git', ['rev-parse', 'HEAD'], {
1357
+ cwd: this.rootPath,
1358
+ });
1359
+ // HEAD exists — repo already has commits
1360
+ return;
1361
+ }
1362
+ catch {
1363
+ // No commits yet — proceed with baseline
1364
+ }
1365
+ try {
1366
+ const { stdout } = await execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: this.rootPath });
1367
+ const files = stdout
1368
+ .split('\0')
1369
+ .map((line) => line.trim())
1370
+ .filter((line) => line.length > 0);
1371
+ if (files.length === 0)
1372
+ return;
1373
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'Enqueuing baseline commit for pre-existing files');
1374
+ this.isBaseline = true;
1375
+ const absolutePaths = files.map((f) => this.rootPath + '/' + f);
1376
+ for (const filePath of absolutePaths) {
1377
+ this.fileChanged(filePath);
1378
+ }
1379
+ }
1380
+ catch (error) {
1381
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Failed to enumerate untracked files for baseline commit');
1382
+ }
1331
1383
  }
1332
1384
  /**
1333
1385
  * Add a file to the pending set and reset the debounce timer.
@@ -1442,6 +1494,9 @@ class VcsManager {
1442
1494
  */
1443
1495
  buildTemplateMessage(fileCount) {
1444
1496
  if (this.pendingReversions.length === 0) {
1497
+ if (this.isBaseline) {
1498
+ return `baseline: batch (${String(fileCount)} files)`;
1499
+ }
1445
1500
  const timestamp = new Date().toISOString();
1446
1501
  return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
1447
1502
  }
@@ -1499,10 +1554,15 @@ class VcsManager {
1499
1554
  cwd: this.rootPath,
1500
1555
  });
1501
1556
  // Build message after staging so getStagedDiff can see the changes
1502
- const message = attempt === 1
1503
- ? await this.buildCommitMessage(files)
1504
- : this.buildTemplateMessage(files.length);
1557
+ // Skip AI for baselines and retries — use template directly
1558
+ const message = this.isBaseline || attempt > 1
1559
+ ? this.buildTemplateMessage(files.length)
1560
+ : await this.buildCommitMessage(files);
1505
1561
  this.pendingReversions.length = 0;
1562
+ // Only clear baseline flag when all baseline files have been committed
1563
+ if (this.pending.size === 0) {
1564
+ this.isBaseline = false;
1565
+ }
1506
1566
  await execFileAsync('git', ['commit', '-m', message], {
1507
1567
  cwd: this.rootPath,
1508
1568
  });
@@ -1579,8 +1639,9 @@ class VcsCoordinator {
1579
1639
  if (!rootVcs)
1580
1640
  continue;
1581
1641
  const resolvedRoot = normalizeSlashes(resolve(globRoot(entry.path)));
1642
+ const rootKey = normalizePathCase(resolvedRoot);
1582
1643
  // Deduplicate: skip if another glob already resolved to this root.
1583
- if (this.managers.has(resolvedRoot))
1644
+ if (this.managers.has(rootKey))
1584
1645
  continue;
1585
1646
  const mergedConfig = {
1586
1647
  ...config.vcs,
@@ -1600,7 +1661,7 @@ class VcsCoordinator {
1600
1661
  const remoteUrl = entry.vcs?.remote;
1601
1662
  const accessToken = entry.vcs?.accessToken ?? config.vcs.defaultAccessToken;
1602
1663
  const manager = new VcsManager(resolvedRoot, mergedConfig, logger.child({ vcsRoot: resolvedRoot }), generator, remoteUrl, accessToken);
1603
- this.managers.set(resolvedRoot, manager);
1664
+ this.managers.set(rootKey, manager);
1604
1665
  this.roots.push(resolvedRoot);
1605
1666
  }
1606
1667
  // Sort roots longest-first so nested paths match before parents.
@@ -1609,10 +1670,12 @@ class VcsCoordinator {
1609
1670
  /**
1610
1671
  * Start all VcsManager instances.
1611
1672
  */
1612
- start() {
1673
+ async start() {
1674
+ const startPromises = [];
1613
1675
  this.managers.forEach((manager) => {
1614
- manager.start();
1676
+ startPromises.push(manager.start());
1615
1677
  });
1678
+ await Promise.all(startPromises);
1616
1679
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
1617
1680
  }
1618
1681
  /**
@@ -1654,7 +1717,7 @@ class VcsCoordinator {
1654
1717
  * Get the VcsManager for a specific root path.
1655
1718
  */
1656
1719
  getManager(root) {
1657
- return this.managers.get(root);
1720
+ return this.managers.get(normalizePathCase(root));
1658
1721
  }
1659
1722
  /**
1660
1723
  * Get all managers as [root, manager] pairs.
@@ -1671,7 +1734,7 @@ class VcsCoordinator {
1671
1734
  */
1672
1735
  findManagerForPath(normalizedPath) {
1673
1736
  const root = findRootForPath(this.roots, normalizedPath);
1674
- return root ? this.managers.get(root) : undefined;
1737
+ return root ? this.managers.get(normalizePathCase(root)) : undefined;
1675
1738
  }
1676
1739
  }
1677
1740
 
@@ -2892,15 +2955,18 @@ function resolveWatchRootsForGlob(coordinator, glob) {
2892
2955
  const normalizedGlob = normalizeSlashes(glob);
2893
2956
  const roots = coordinator.getRoots();
2894
2957
  const results = [];
2958
+ const compareGlob = normalizePathCase(normalizedGlob);
2895
2959
  for (const root of roots) {
2896
- if (normalizedGlob.startsWith(root + '/') || normalizedGlob === root) {
2960
+ const compareRoot = normalizePathCase(root);
2961
+ if (compareGlob.startsWith(compareRoot + '/') ||
2962
+ compareGlob === compareRoot) {
2897
2963
  // Glob is under this root
2898
2964
  results.push({
2899
2965
  root,
2900
2966
  relativePath: normalizeSlashes(relative(root, normalizedGlob)),
2901
2967
  });
2902
2968
  }
2903
- else if (root.startsWith(normalizedGlob.replace(/[*?[\]{}]/g, ''))) {
2969
+ else if (compareRoot.startsWith(compareGlob.replace(/[*?[\]{}]/g, ''))) {
2904
2970
  // Root is under the glob's base path — include with root-relative glob
2905
2971
  results.push({
2906
2972
  root,
@@ -7694,7 +7760,7 @@ class JeevesWatcher {
7694
7760
  this.watcher = watcher;
7695
7761
  this.gitignoreFilter = gitignoreFilter;
7696
7762
  this.server = await this.startApiServer();
7697
- vcsCoordinator.start();
7763
+ await vcsCoordinator.start();
7698
7764
  this.watcher.start();
7699
7765
  this.startConfigWatch();
7700
7766
  logger.info('jeeves-watcher started');
@@ -224,7 +224,7 @@ async function startFromConfig(configPath, descriptor) {
224
224
  const config = await loadConfig(resolvedPath);
225
225
  // Dynamic import breaks the circular dependency between this module
226
226
  // and app/index.ts (which defines JeevesWatcher and re-exports startFromConfig).
227
- const { JeevesWatcher } = await import('./index-Dk_WrV0T.js');
227
+ const { JeevesWatcher } = await import('./index-ChYxOGtD.js');
228
228
  const app = new JeevesWatcher(config, resolvedPath, descriptor);
229
229
  installShutdownHandlers(() => app.stop());
230
230
  await app.start();
package/dist/index.d.ts CHANGED
@@ -1291,14 +1291,21 @@ declare class VcsManager {
1291
1291
  private debounceTimer;
1292
1292
  private commitInFlight;
1293
1293
  private started;
1294
+ private isBaseline;
1294
1295
  private _lastPushTime;
1295
1296
  constructor(rootPath: string, config: VcsConfig, logger: pino.Logger, commitMessageGenerator?: CommitMessageGenerator, remoteUrl?: string, accessToken?: string);
1296
1297
  get lastPushTime(): string | null;
1297
1298
  get pushErrors(): readonly PushError[];
1298
1299
  /**
1299
1300
  * Begin accepting file changes. Sets up the debounce mechanism.
1301
+ * Enqueues a baseline commit for pre-existing files if the repo has no commits.
1300
1302
  */
1301
- start(): void;
1303
+ start(): Promise<void>;
1304
+ /**
1305
+ * If the repo has no commits, enumerate untracked files and feed them
1306
+ * through fileChanged() to create a baseline commit.
1307
+ */
1308
+ private enqueueBaselineIfNeeded;
1302
1309
  /**
1303
1310
  * Add a file to the pending set and reset the debounce timer.
1304
1311
  * If the pending set exceeds maxBatchSize, flush immediately.
@@ -1379,7 +1386,7 @@ declare class VcsCoordinator {
1379
1386
  /**
1380
1387
  * Start all VcsManager instances.
1381
1388
  */
1382
- start(): void;
1389
+ start(): Promise<void>;
1383
1390
  /**
1384
1391
  * Route a file event to the correct root's VcsManager.
1385
1392
  *
package/dist/index.js CHANGED
@@ -3375,17 +3375,32 @@ function createSearchHandler(deps) {
3375
3375
  */
3376
3376
  /** Promisified execFile for git commands. */
3377
3377
  const execFileAsync = promisify(execFile);
3378
+ /**
3379
+ * Normalize a path for case-insensitive comparison on Windows.
3380
+ * On Windows, lowercases the entire path; on other platforms, returns as-is.
3381
+ *
3382
+ * @param p - The path string to normalize.
3383
+ * @param platform - The platform to check against (default: `process.platform`).
3384
+ * @returns The normalized path.
3385
+ */
3386
+ function normalizePathCase(p, platform = process.platform) {
3387
+ return platform === 'win32' ? p.toLowerCase() : p;
3388
+ }
3378
3389
  /**
3379
3390
  * Find the longest-prefix-match root for a normalized path.
3380
3391
  * Roots must be sorted longest-first for correct nested matching.
3381
3392
  *
3382
3393
  * @param roots - Root paths sorted longest-first.
3383
3394
  * @param normalizedPath - Normalized absolute path (forward slashes).
3395
+ * @param platform - The platform to check against (default: `process.platform`).
3384
3396
  * @returns The matching root, or undefined.
3385
3397
  */
3386
- function findRootForPath(roots, normalizedPath) {
3398
+ function findRootForPath(roots, normalizedPath, platform = process.platform) {
3399
+ const comparePath = normalizePathCase(normalizedPath, platform);
3387
3400
  for (const root of roots) {
3388
- if (normalizedPath === root || normalizedPath.startsWith(root + '/')) {
3401
+ const compareRoot = normalizePathCase(root, platform);
3402
+ if (comparePath === compareRoot ||
3403
+ comparePath.startsWith(compareRoot + '/')) {
3389
3404
  return root;
3390
3405
  }
3391
3406
  }
@@ -3425,15 +3440,18 @@ function resolveWatchRootsForGlob(coordinator, glob) {
3425
3440
  const normalizedGlob = normalizeSlashes(glob);
3426
3441
  const roots = coordinator.getRoots();
3427
3442
  const results = [];
3443
+ const compareGlob = normalizePathCase(normalizedGlob);
3428
3444
  for (const root of roots) {
3429
- if (normalizedGlob.startsWith(root + '/') || normalizedGlob === root) {
3445
+ const compareRoot = normalizePathCase(root);
3446
+ if (compareGlob.startsWith(compareRoot + '/') ||
3447
+ compareGlob === compareRoot) {
3430
3448
  // Glob is under this root
3431
3449
  results.push({
3432
3450
  root,
3433
3451
  relativePath: normalizeSlashes(relative(root, normalizedGlob)),
3434
3452
  });
3435
3453
  }
3436
- else if (root.startsWith(normalizedGlob.replace(/[*?[\]{}]/g, ''))) {
3454
+ else if (compareRoot.startsWith(compareGlob.replace(/[*?[\]{}]/g, ''))) {
3437
3455
  // Root is under the glob's base path — include with root-relative glob
3438
3456
  results.push({
3439
3457
  root,
@@ -4780,6 +4798,7 @@ class VcsManager {
4780
4798
  debounceTimer;
4781
4799
  commitInFlight = Promise.resolve();
4782
4800
  started = false;
4801
+ isBaseline = false;
4783
4802
  _lastPushTime = null;
4784
4803
  constructor(rootPath, config, logger, commitMessageGenerator, remoteUrl, accessToken) {
4785
4804
  this.rootPath = rootPath;
@@ -4800,11 +4819,47 @@ class VcsManager {
4800
4819
  }
4801
4820
  /**
4802
4821
  * Begin accepting file changes. Sets up the debounce mechanism.
4822
+ * Enqueues a baseline commit for pre-existing files if the repo has no commits.
4803
4823
  */
4804
- start() {
4824
+ async start() {
4805
4825
  this.started = true;
4806
4826
  this.squashManager?.start();
4807
4827
  this.logger.info({ root: this.rootPath }, 'VcsManager started');
4828
+ await this.enqueueBaselineIfNeeded();
4829
+ }
4830
+ /**
4831
+ * If the repo has no commits, enumerate untracked files and feed them
4832
+ * through fileChanged() to create a baseline commit.
4833
+ */
4834
+ async enqueueBaselineIfNeeded() {
4835
+ try {
4836
+ await execFileAsync('git', ['rev-parse', 'HEAD'], {
4837
+ cwd: this.rootPath,
4838
+ });
4839
+ // HEAD exists — repo already has commits
4840
+ return;
4841
+ }
4842
+ catch {
4843
+ // No commits yet — proceed with baseline
4844
+ }
4845
+ try {
4846
+ const { stdout } = await execFileAsync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: this.rootPath });
4847
+ const files = stdout
4848
+ .split('\0')
4849
+ .map((line) => line.trim())
4850
+ .filter((line) => line.length > 0);
4851
+ if (files.length === 0)
4852
+ return;
4853
+ this.logger.info({ root: this.rootPath, fileCount: files.length }, 'Enqueuing baseline commit for pre-existing files');
4854
+ this.isBaseline = true;
4855
+ const absolutePaths = files.map((f) => this.rootPath + '/' + f);
4856
+ for (const filePath of absolutePaths) {
4857
+ this.fileChanged(filePath);
4858
+ }
4859
+ }
4860
+ catch (error) {
4861
+ this.logger.warn({ root: this.rootPath, err: normalizeError(error) }, 'Failed to enumerate untracked files for baseline commit');
4862
+ }
4808
4863
  }
4809
4864
  /**
4810
4865
  * Add a file to the pending set and reset the debounce timer.
@@ -4919,6 +4974,9 @@ class VcsManager {
4919
4974
  */
4920
4975
  buildTemplateMessage(fileCount) {
4921
4976
  if (this.pendingReversions.length === 0) {
4977
+ if (this.isBaseline) {
4978
+ return `baseline: batch (${String(fileCount)} files)`;
4979
+ }
4922
4980
  const timestamp = new Date().toISOString();
4923
4981
  return `watcher: batch ${timestamp} (${String(fileCount)} files)`;
4924
4982
  }
@@ -4976,10 +5034,15 @@ class VcsManager {
4976
5034
  cwd: this.rootPath,
4977
5035
  });
4978
5036
  // Build message after staging so getStagedDiff can see the changes
4979
- const message = attempt === 1
4980
- ? await this.buildCommitMessage(files)
4981
- : this.buildTemplateMessage(files.length);
5037
+ // Skip AI for baselines and retries — use template directly
5038
+ const message = this.isBaseline || attempt > 1
5039
+ ? this.buildTemplateMessage(files.length)
5040
+ : await this.buildCommitMessage(files);
4982
5041
  this.pendingReversions.length = 0;
5042
+ // Only clear baseline flag when all baseline files have been committed
5043
+ if (this.pending.size === 0) {
5044
+ this.isBaseline = false;
5045
+ }
4983
5046
  await execFileAsync('git', ['commit', '-m', message], {
4984
5047
  cwd: this.rootPath,
4985
5048
  });
@@ -5056,8 +5119,9 @@ class VcsCoordinator {
5056
5119
  if (!rootVcs)
5057
5120
  continue;
5058
5121
  const resolvedRoot = normalizeSlashes(resolve(globRoot(entry.path)));
5122
+ const rootKey = normalizePathCase(resolvedRoot);
5059
5123
  // Deduplicate: skip if another glob already resolved to this root.
5060
- if (this.managers.has(resolvedRoot))
5124
+ if (this.managers.has(rootKey))
5061
5125
  continue;
5062
5126
  const mergedConfig = {
5063
5127
  ...config.vcs,
@@ -5077,7 +5141,7 @@ class VcsCoordinator {
5077
5141
  const remoteUrl = entry.vcs?.remote;
5078
5142
  const accessToken = entry.vcs?.accessToken ?? config.vcs.defaultAccessToken;
5079
5143
  const manager = new VcsManager(resolvedRoot, mergedConfig, logger.child({ vcsRoot: resolvedRoot }), generator, remoteUrl, accessToken);
5080
- this.managers.set(resolvedRoot, manager);
5144
+ this.managers.set(rootKey, manager);
5081
5145
  this.roots.push(resolvedRoot);
5082
5146
  }
5083
5147
  // Sort roots longest-first so nested paths match before parents.
@@ -5086,10 +5150,12 @@ class VcsCoordinator {
5086
5150
  /**
5087
5151
  * Start all VcsManager instances.
5088
5152
  */
5089
- start() {
5153
+ async start() {
5154
+ const startPromises = [];
5090
5155
  this.managers.forEach((manager) => {
5091
- manager.start();
5156
+ startPromises.push(manager.start());
5092
5157
  });
5158
+ await Promise.all(startPromises);
5093
5159
  this.logger.info({ rootCount: this.managers.size }, 'VcsCoordinator started');
5094
5160
  }
5095
5161
  /**
@@ -5131,7 +5197,7 @@ class VcsCoordinator {
5131
5197
  * Get the VcsManager for a specific root path.
5132
5198
  */
5133
5199
  getManager(root) {
5134
- return this.managers.get(root);
5200
+ return this.managers.get(normalizePathCase(root));
5135
5201
  }
5136
5202
  /**
5137
5203
  * Get all managers as [root, manager] pairs.
@@ -5148,7 +5214,7 @@ class VcsCoordinator {
5148
5214
  */
5149
5215
  findManagerForPath(normalizedPath) {
5150
5216
  const root = findRootForPath(this.roots, normalizedPath);
5151
- return root ? this.managers.get(root) : undefined;
5217
+ return root ? this.managers.get(normalizePathCase(root)) : undefined;
5152
5218
  }
5153
5219
  }
5154
5220
 
@@ -8845,7 +8911,7 @@ class JeevesWatcher {
8845
8911
  this.watcher = watcher;
8846
8912
  this.gitignoreFilter = gitignoreFilter;
8847
8913
  this.server = await this.startApiServer();
8848
- vcsCoordinator.start();
8914
+ await vcsCoordinator.start();
8849
8915
  this.watcher.start();
8850
8916
  this.startConfigWatch();
8851
8917
  logger.info('jeeves-watcher started');
package/package.json CHANGED
@@ -149,5 +149,5 @@
149
149
  },
150
150
  "type": "module",
151
151
  "types": "dist/index.d.ts",
152
- "version": "0.18.3"
152
+ "version": "0.18.4"
153
153
  }