@in-the-loop-labs/pair-review 3.3.7 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.3.7",
3
+ "version": "3.4.1",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -68,6 +68,7 @@
68
68
  "glob": "^13.0.6",
69
69
  "markdown-it": "^13.0.2",
70
70
  "open": "^9.1.0",
71
+ "semver": "^7.7.4",
71
72
  "simple-git": "^3.19.1",
72
73
  "update-notifier": "^5.1.0",
73
74
  "uuid": "^11.1.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.3.7",
3
+ "version": "3.4.1",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.3.7",
3
+ "version": "3.4.1",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -31,7 +31,13 @@
31
31
  --color-warning-bg: #fffae6;
32
32
  --color-warning-border: #ffc552;
33
33
  --color-warning-text: #7d4e00;
34
-
34
+
35
+ --color-info-bg: #eff6ff;
36
+ --color-info-border: #bfdbfe;
37
+ --color-info-accent: #3b82f6;
38
+ --color-info-text: #1e3a8a;
39
+ --color-info-text-muted: #3b5998;
40
+
35
41
  --color-selection: #fff5b1;
36
42
  --color-selection-num: #ffeb3b;
37
43
 
@@ -72,7 +78,13 @@
72
78
  --color-warning-bg: #3d2e00;
73
79
  --color-warning-border: #9e6a03;
74
80
  --color-warning-text: #e3b341;
75
-
81
+
82
+ --color-info-bg: #152033;
83
+ --color-info-border: #2d4a7a;
84
+ --color-info-accent: #4a90e2;
85
+ --color-info-text: #cfe0f5;
86
+ --color-info-text-muted: #8aa9cf;
87
+
76
88
  --color-selection: #3d3300;
77
89
  --color-selection-num: #4d4000;
78
90
 
package/public/index.html CHANGED
@@ -1488,6 +1488,7 @@
1488
1488
 
1489
1489
  <script src="/js/ws-client.js"></script>
1490
1490
  <script src="/js/components/Toast.js"></script>
1491
+ <script src="/js/components/UpdateBanner.js"></script>
1491
1492
  <script src="/js/index.js"></script>
1492
1493
  </body>
1493
1494
  </html>
@@ -0,0 +1,143 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * UpdateBanner Component
4
+ * Shows a persistent, dismissible corner-card notification when a newer
5
+ * version of pair-review is available. Single delivery path: on construction,
6
+ * fetch /api/config and show the banner if a pending_update exists.
7
+ */
8
+
9
+ const DISMISS_KEY = 'update-banner-dismissed';
10
+
11
+ class UpdateBanner {
12
+ constructor() {
13
+ this._banner = null;
14
+ this._dismissBtn = null;
15
+ this._version = null;
16
+
17
+ // Single path: fetch current config at construction; show banner if a
18
+ // pending update exists. No event listener, no WebSocket coupling.
19
+ // `fetch()` returns a Promise; `.then()` chains async steps; the final
20
+ // `.catch()` swallows network errors because the banner is non-critical.
21
+ fetch('/api/config')
22
+ .then(r => (r.ok ? r.json() : null))
23
+ .then(config => {
24
+ if (config && config.pending_update) this.show(config.pending_update);
25
+ })
26
+ .catch(() => { /* non-critical */ });
27
+ }
28
+
29
+ /**
30
+ * Show the update banner for the given version.
31
+ * No-op if already showing or dismissed for this version.
32
+ * @param {string} version
33
+ */
34
+ show(version) {
35
+ if (!version) return;
36
+
37
+ // Already dismissed for this version in this session
38
+ if (sessionStorage.getItem(DISMISS_KEY) === version) return;
39
+
40
+ // Already showing this version
41
+ if (this._banner && this._version === version) return;
42
+
43
+ // Remove any existing banner (e.g., for an older version)
44
+ this._remove();
45
+
46
+ this._version = version;
47
+
48
+ // Theme-aware colors come from CSS custom properties (set in styles.css
49
+ // under :root and [data-theme="dark"]). The inline `var(..., fallback)`
50
+ // form keeps the banner readable even if the stylesheet hasn't loaded
51
+ // yet. No MutationObserver needed — CSS handles the theme switch.
52
+ const banner = document.createElement('div');
53
+ banner.setAttribute('data-update-banner', '');
54
+ Object.assign(banner.style, {
55
+ position: 'fixed',
56
+ top: '16px',
57
+ left: '16px',
58
+ zIndex: '1000',
59
+ maxWidth: '360px',
60
+ background: 'var(--color-info-bg, #eff6ff)',
61
+ border: '1px solid var(--color-info-border, #bfdbfe)',
62
+ borderLeft: '4px solid var(--color-info-accent, #3b82f6)',
63
+ borderRadius: '8px',
64
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
65
+ padding: '12px 14px',
66
+ fontSize: '13px',
67
+ lineHeight: '1.4',
68
+ color: 'var(--color-info-text, #1e3a8a)',
69
+ display: 'flex',
70
+ alignItems: 'flex-start',
71
+ gap: '10px'
72
+ });
73
+
74
+ // Two-line layout: headline + restart instruction on its own line.
75
+ const text = document.createElement('div');
76
+ text.style.flex = '1';
77
+
78
+ const headline = document.createElement('div');
79
+ headline.textContent = `pair-review v${version} is available.`;
80
+
81
+ const instruction = document.createElement('div');
82
+ instruction.textContent = 'Restart the server to update.';
83
+ instruction.style.marginTop = '2px';
84
+
85
+ text.appendChild(headline);
86
+ text.appendChild(instruction);
87
+
88
+ const dismissBtn = document.createElement('button');
89
+ dismissBtn.textContent = '\u00d7';
90
+ dismissBtn.setAttribute('aria-label', 'Dismiss');
91
+ Object.assign(dismissBtn.style, {
92
+ background: 'none',
93
+ border: 'none',
94
+ color: 'var(--color-info-text-muted, #3b5998)',
95
+ cursor: 'pointer',
96
+ fontSize: '18px',
97
+ padding: '0',
98
+ lineHeight: '1',
99
+ flexShrink: '0',
100
+ opacity: '0.8'
101
+ });
102
+ dismissBtn.addEventListener('click', () => this.dismiss());
103
+
104
+ banner.appendChild(text);
105
+ banner.appendChild(dismissBtn);
106
+ document.body.appendChild(banner);
107
+ this._banner = banner;
108
+ this._dismissBtn = dismissBtn;
109
+ }
110
+
111
+ /** Dismiss the banner and remember the choice for this session. */
112
+ dismiss() {
113
+ if (this._version) {
114
+ sessionStorage.setItem(DISMISS_KEY, this._version);
115
+ }
116
+ this._remove();
117
+ }
118
+
119
+ /** @private */
120
+ _remove() {
121
+ if (this._banner && this._banner.parentNode) {
122
+ this._banner.parentNode.removeChild(this._banner);
123
+ }
124
+ this._banner = null;
125
+ this._dismissBtn = null;
126
+ }
127
+ }
128
+
129
+ // Singleton init (browser only)
130
+ if (typeof window !== 'undefined' && !window.updateBanner) {
131
+ if (document.readyState === 'loading') {
132
+ document.addEventListener('DOMContentLoaded', () => {
133
+ window.updateBanner = new UpdateBanner();
134
+ });
135
+ } else {
136
+ window.updateBanner = new UpdateBanner();
137
+ }
138
+ }
139
+
140
+ // CommonJS export for unit tests
141
+ if (typeof module !== 'undefined' && module.exports) {
142
+ module.exports = { UpdateBanner };
143
+ }
@@ -133,6 +133,20 @@
133
133
  return div.innerHTML;
134
134
  }
135
135
 
136
+ const LOCAL_REVIEW_PATH_URL_ERROR = 'Local reviews require a filesystem path, not a URL. Pass GitHub or Graphite URLs as PR review inputs instead.';
137
+
138
+ function isUrlLikeLocalReviewPath(value) {
139
+ if (!value || typeof value !== 'string') return false;
140
+ const trimmed = value.trim();
141
+ if (!trimmed) return false;
142
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return true;
143
+ if (/^(?:github\.com|app\.graphite\.(?:dev|com))\//i.test(trimmed)) return true;
144
+ // Keep this aligned with src/utils/local-path-input.js: only a leading
145
+ // user@host:path token is treated as an SSH-style remote.
146
+ if (/^[^@/\\\s]+@[^:/\\\s]+:[^\s]+$/.test(trimmed)) return true;
147
+ return false;
148
+ }
149
+
136
150
  /**
137
151
  * Set loading state for a tab's form
138
152
  * @param {string} tab - 'pr' or 'local'
@@ -710,6 +724,12 @@
710
724
  return;
711
725
  }
712
726
 
727
+ if (isUrlLikeLocalReviewPath(pathValue)) {
728
+ showError('local', LOCAL_REVIEW_PATH_URL_ERROR);
729
+ input.focus();
730
+ return;
731
+ }
732
+
713
733
  // Navigate to the setup page which shows step-by-step progress
714
734
  // The /local?path= route serves setup.html which handles the full setup flow
715
735
  let href = '/local?path=' + encodeURIComponent(pathValue);
@@ -717,6 +737,21 @@
717
737
  window.location.href = href;
718
738
  }
719
739
 
740
+ function handleLocalPathInput(event) {
741
+ const input = event && event.target ? event.target : document.getElementById('local-path-input');
742
+ const errorEl = document.getElementById('start-review-error-local');
743
+ if (!input || !errorEl) return;
744
+
745
+ if (isUrlLikeLocalReviewPath(input.value)) {
746
+ showError('local', LOCAL_REVIEW_PATH_URL_ERROR);
747
+ return;
748
+ }
749
+
750
+ if (errorEl.textContent === LOCAL_REVIEW_PATH_URL_ERROR) {
751
+ errorEl.classList.remove('visible', 'info');
752
+ }
753
+ }
754
+
720
755
  // ─── Browse Directory ──────────────────────────────────────────────────────
721
756
 
722
757
  /**
@@ -746,6 +781,9 @@
746
781
 
747
782
  if (!data.cancelled && data.path) {
748
783
  input.value = data.path;
784
+ // Setting .value in JavaScript does not fire an input event, so run the
785
+ // same handler used for typing to clear any stale URL-specific error.
786
+ handleLocalPathInput({ target: input });
749
787
  input.focus();
750
788
  }
751
789
 
@@ -1873,6 +1911,11 @@
1873
1911
  localForm.addEventListener('submit', handleStartLocal);
1874
1912
  }
1875
1913
 
1914
+ const localPathInput = document.getElementById('local-path-input');
1915
+ if (localPathInput) {
1916
+ localPathInput.addEventListener('input', handleLocalPathInput);
1917
+ }
1918
+
1876
1919
  // Set up browse button handler
1877
1920
  const browseBtn = document.getElementById('browse-local-btn');
1878
1921
  if (browseBtn) {
@@ -64,6 +64,9 @@ class DiffRenderer {
64
64
  // Ruby
65
65
  'rb': 'ruby',
66
66
  'erb': 'erb',
67
+ // Elixir
68
+ 'ex': 'elixir',
69
+ 'exs': 'elixir',
67
70
  // PHP
68
71
  'php': 'php',
69
72
  // Java/Kotlin/Scala
package/public/local.html CHANGED
@@ -562,6 +562,7 @@
562
562
 
563
563
  <!-- Highlight.js for syntax highlighting -->
564
564
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
565
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/elixir.min.js"></script>
565
566
 
566
567
  <!-- Markdown-it Library for rendering markdown in comments -->
567
568
  <script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.2/dist/markdown-it.min.js"></script>
@@ -591,6 +592,7 @@
591
592
  <!-- Components -->
592
593
  <script src="/js/components/TabTitle.js"></script>
593
594
  <script src="/js/components/Toast.js"></script>
595
+ <script src="/js/components/UpdateBanner.js"></script>
594
596
  <script src="/js/components/ConfirmDialog.js"></script>
595
597
  <script src="/js/components/TextInputDialog.js"></script>
596
598
  <script src="/js/components/AnalysisConfigModal.js"></script>
package/public/pr.html CHANGED
@@ -358,6 +358,7 @@
358
358
 
359
359
  <!-- Highlight.js for syntax highlighting -->
360
360
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
361
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/elixir.min.js"></script>
361
362
 
362
363
  <!-- Markdown-it Library for rendering markdown in comments -->
363
364
  <script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.2/dist/markdown-it.min.js"></script>
@@ -387,6 +388,7 @@
387
388
  <!-- Components -->
388
389
  <script src="/js/components/TabTitle.js"></script>
389
390
  <script src="/js/components/Toast.js"></script>
391
+ <script src="/js/components/UpdateBanner.js"></script>
390
392
  <script src="/js/components/ConfirmDialog.js"></script>
391
393
  <script src="/js/components/TextInputDialog.js"></script>
392
394
  <script src="/js/components/AnalysisConfigModal.js"></script>
package/public/setup.html CHANGED
@@ -528,6 +528,7 @@
528
528
 
529
529
  <!-- WebSocket client -->
530
530
  <script src="/js/ws-client.js"></script>
531
+ <script src="/js/components/UpdateBanner.js"></script>
531
532
  <script src="/js/utils/notification-sounds.js"></script>
532
533
 
533
534
  <script>
package/src/config.js CHANGED
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG = {
19
19
  github_token: "",
20
20
  github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
21
21
  port: 7247,
22
+ single_port: true, // When true, reuse a single server on the configured port; new invocations delegate to the running server
22
23
  theme: "light",
23
24
  default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
24
25
  default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
@@ -128,7 +129,7 @@ async function copyExampleConfig() {
128
129
  try {
129
130
  await fs.access(sourceExample);
130
131
  await fs.copyFile(sourceExample, CONFIG_EXAMPLE_FILE);
131
- console.log(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
132
+ logger.info(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
132
133
  return true;
133
134
  } catch (error) {
134
135
  if (error.code === 'ENOENT') {
@@ -154,13 +155,13 @@ async function ensureConfigDir() {
154
155
  if (error.code === 'ENOENT') {
155
156
  try {
156
157
  await fs.mkdir(CONFIG_DIR, { recursive: true });
157
- console.log(`Created config directory: ${CONFIG_DIR}`);
158
+ logger.info(`Created config directory: ${CONFIG_DIR}`);
158
159
  // Copy example config to new directory
159
160
  await copyExampleConfig();
160
161
  return true; // Directory was newly created
161
162
  } catch (mkdirError) {
162
163
  if (mkdirError.code === 'EACCES' || mkdirError.code === 'EPERM') {
163
- console.error(`Cannot create configuration directory at ~/.pair-review/`);
164
+ logger.error(`Cannot create configuration directory at ~/.pair-review/`);
164
165
  process.exit(1);
165
166
  }
166
167
  throw mkdirError;
@@ -211,7 +212,7 @@ async function loadConfig() {
211
212
  // Optional files or managed-config-present: skip silently
212
213
  } else if (error instanceof SyntaxError) {
213
214
  if (source.required) {
214
- console.error(`Invalid configuration file at ~/.pair-review/config.json`);
215
+ logger.error(`Invalid configuration file at ~/.pair-review/config.json`);
215
216
  process.exit(1);
216
217
  }
217
218
  logger.warn(`Malformed config at ${source.label}, skipping`);
@@ -221,23 +222,33 @@ async function loadConfig() {
221
222
  }
222
223
  }
223
224
 
224
- // Normalize legacy monorepos key into repos (monorepos values are overridden by repos)
225
- if (mergedConfig.monorepos) {
226
- mergedConfig.repos = deepMerge(mergedConfig.monorepos, mergedConfig.repos);
227
- }
228
-
229
- // Normalize repo keys to lowercase to match the database's COLLATE NOCASE identity
230
- if (mergedConfig.repos) {
231
- const normalized = {};
232
- for (const [key, value] of Object.entries(mergedConfig.repos)) {
233
- normalized[key.toLowerCase()] = value;
225
+ // Normalize legacy monorepos into one canonical repos map. Lowercase both
226
+ // sides before merging so JS object identity matches DB COLLATE NOCASE.
227
+ const lowercaseKeys = (obj) => {
228
+ const out = {};
229
+ for (const [key, value] of Object.entries(obj || {})) {
230
+ out[key.toLowerCase()] = value;
234
231
  }
235
- mergedConfig.repos = normalized;
232
+ return out;
233
+ };
234
+ const lowerMonorepos = lowercaseKeys(mergedConfig.monorepos);
235
+ const lowerRepos = lowercaseKeys(mergedConfig.repos);
236
+ mergedConfig.repos = deepMerge(lowerMonorepos, lowerRepos);
237
+ delete mergedConfig.monorepos;
238
+
239
+ // PORT env var overrides all config layers (used by Preview and similar harnesses)
240
+ if (process.env.PORT) {
241
+ const envPort = Number(process.env.PORT);
242
+ if (!validatePort(envPort)) {
243
+ logger.error(`Invalid PORT env var "${process.env.PORT}" (must be an integer between 1024 and 65535)`);
244
+ process.exit(1);
245
+ }
246
+ mergedConfig.port = envPort;
236
247
  }
237
248
 
238
249
  // Validate port
239
250
  if (!validatePort(mergedConfig.port)) {
240
- console.error(`Invalid port number ${mergedConfig.port}`);
251
+ logger.error(`Invalid port number ${mergedConfig.port}`);
241
252
  process.exit(1);
242
253
  }
243
254
 
@@ -269,7 +280,7 @@ async function saveConfig(config) {
269
280
  await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
270
281
  } catch (error) {
271
282
  if (error.code === 'EACCES' || error.code === 'EPERM') {
272
- console.error(`Cannot create configuration directory at ~/.pair-review/`);
283
+ logger.error(`Cannot create configuration directory at ~/.pair-review/`);
273
284
  process.exit(1);
274
285
  }
275
286
  throw error;
@@ -413,12 +424,15 @@ function expandPath(p) {
413
424
  * @returns {object|null}
414
425
  */
415
426
  function getRepoConfig(config, repository) {
427
+ const key = String(repository).toLowerCase();
416
428
  const reposSection = config.repos || {};
417
- const entry = reposSection[repository];
418
- if (entry) return entry;
429
+ const repoEntry = reposSection[key] || reposSection[repository] || Object.entries(reposSection)
430
+ .find(([repoName]) => repoName.toLowerCase() === key)?.[1];
431
+ if (repoEntry) return repoEntry;
419
432
 
420
433
  const legacySection = config.monorepos || {};
421
- return legacySection[repository] || null;
434
+ return legacySection[key] || legacySection[repository] || Object.entries(legacySection)
435
+ .find(([repoName]) => repoName.toLowerCase() === key)?.[1] || null;
422
436
  }
423
437
 
424
438
  /**
@@ -773,4 +787,4 @@ module.exports = {
773
787
  shouldSkipUpdateNotifier,
774
788
  _resetTokenCache,
775
789
  DEFAULT_CHECKOUT_TIMEOUT_MS
776
- };
790
+ };
package/src/database.js CHANGED
@@ -3001,6 +3001,20 @@ class RepoSettingsRepository {
3001
3001
  await run(this.db, `UPDATE repo_settings SET pool_fetch_finished_at = ? WHERE repository = ?`, [now, repository]);
3002
3002
  }
3003
3003
 
3004
+ /**
3005
+ * List repositories with pool settings stored in the database.
3006
+ * Includes rows with a fetch interval only so callers can resolve complete
3007
+ * pool configuration with file fallback through resolvePoolConfig().
3008
+ * @returns {Promise<Array<{repository: string, pool_size: number|null, pool_fetch_interval_minutes: number|null}>>}
3009
+ */
3010
+ async findPoolConfiguredRepoSettings() {
3011
+ return await query(this.db, `
3012
+ SELECT repository, pool_size, pool_fetch_interval_minutes
3013
+ FROM repo_settings
3014
+ WHERE pool_size IS NOT NULL OR pool_fetch_interval_minutes IS NOT NULL
3015
+ `);
3016
+ }
3017
+
3004
3018
  /**
3005
3019
  * Delete settings for a repository
3006
3020
  * @param {string} repository - Repository in owner/repo format
@@ -0,0 +1,29 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Fetch from a remote without auto-following tags reachable from the fetched
5
+ * commits. Large monorepos can have very large tag namespaces, and pair-review
6
+ * only needs commits/refs for review setup.
7
+ * @param {Object} git - simple-git instance
8
+ * @param {string[]} args - Arguments after `git fetch --no-tags`
9
+ * @returns {Promise<*>}
10
+ */
11
+ async function fetchNoTags(git, args) {
12
+ return git.fetch(['--no-tags', ...args]);
13
+ }
14
+
15
+ /**
16
+ * Raw `git fetch --no-tags` wrapper for fetch forms not exposed cleanly by
17
+ * simple-git helpers.
18
+ * @param {Object} git - simple-git instance
19
+ * @param {string[]} args - Arguments after `git fetch --no-tags`
20
+ * @returns {Promise<*>}
21
+ */
22
+ async function rawFetchNoTags(git, args) {
23
+ return git.raw(['fetch', '--no-tags', ...args]);
24
+ }
25
+
26
+ module.exports = {
27
+ fetchNoTags,
28
+ rawFetchNoTags,
29
+ };
@@ -3,11 +3,11 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const logger = require('../utils/logger');
6
- const { WorktreePoolRepository, WorktreeRepository, generateWorktreeId } = require('../database');
6
+ const { WorktreePoolRepository, WorktreeRepository, RepoSettingsRepository, generateWorktreeId } = require('../database');
7
7
  const { GitWorktreeManager } = require('./worktree');
8
8
  const { WorktreePoolUsageTracker } = require('./worktree-pool-usage');
9
9
  const { normalizeRepository } = require('../utils/paths');
10
- const { getRepoPoolSize } = require('../config');
10
+ const { resolvePoolConfig } = require('../config');
11
11
 
12
12
  /**
13
13
  * Consolidates the worktree pool state machine: absorbs WorktreePoolManager
@@ -25,6 +25,7 @@ class WorktreePoolLifecycle {
25
25
  const defaults = {
26
26
  poolRepo: new WorktreePoolRepository(db),
27
27
  worktreeRepo: new WorktreeRepository(db),
28
+ repoSettingsRepo: new RepoSettingsRepository(db),
28
29
  usageTracker: new WorktreePoolUsageTracker(),
29
30
  fs: fs,
30
31
  simpleGit: require('simple-git'),
@@ -36,6 +37,7 @@ class WorktreePoolLifecycle {
36
37
  this.config = config;
37
38
  this._poolRepo = deps.poolRepo;
38
39
  this._worktreeRepo = deps.worktreeRepo;
40
+ this._repoSettingsRepo = deps.repoSettingsRepo;
39
41
  this._usageTracker = deps.usageTracker;
40
42
  this._fs = deps.fs;
41
43
  this._simpleGit = deps.simpleGit;
@@ -628,11 +630,20 @@ class WorktreePoolLifecycle {
628
630
  * @private
629
631
  */
630
632
  async _adoptExistingWorktrees() {
631
- const repos = this.config.repos || {};
633
+ const config = this.config || {};
634
+ const repoSettingsRows = await this._repoSettingsRepo.findPoolConfiguredRepoSettings();
635
+ const settingsByRepo = new Map(
636
+ repoSettingsRows.map(row => [String(row.repository).toLowerCase(), row])
637
+ );
638
+ const repoNames = new Set(Object.keys(config.repos || {}));
639
+ for (const row of repoSettingsRows) {
640
+ repoNames.add(String(row.repository).toLowerCase());
641
+ }
632
642
  const adoptedInUse = [];
633
643
 
634
- for (const repoName of Object.keys(repos)) {
635
- const poolSize = getRepoPoolSize(this.config, repoName);
644
+ for (const repoName of repoNames) {
645
+ const repoSettings = settingsByRepo.get(String(repoName).toLowerCase()) || null;
646
+ const { poolSize } = resolvePoolConfig(config, repoName, repoSettings);
636
647
  if (!poolSize) continue;
637
648
 
638
649
  // Count existing pool entries for this repo
@@ -8,6 +8,7 @@ const { WorktreeRepository, generateWorktreeId } = require('../database');
8
8
  const { getGeneratedFilePatterns } = require('./gitattributes');
9
9
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
10
10
  const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./diff-flags');
11
+ const { fetchNoTags, rawFetchNoTags } = require('./fetch-helpers');
11
12
  const { spawn, execSync } = require('child_process');
12
13
 
13
14
  const MISSING_COMMIT_ERROR_CODE = 'PAIR_REVIEW_MISSING_COMMIT';
@@ -250,7 +251,7 @@ class GitWorktreeManager {
250
251
 
251
252
  let fetchError = null;
252
253
  try {
253
- await git.raw(['fetch', remote, sha]);
254
+ await rawFetchNoTags(git, [remote, sha]);
254
255
  } catch (error) {
255
256
  fetchError = error;
256
257
  }
@@ -370,7 +371,7 @@ class GitWorktreeManager {
370
371
  const prTrackingRef = `refs/remotes/${baseRemote}/pr-${prNumber}`;
371
372
 
372
373
  try {
373
- await git.fetch([baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
374
+ await fetchNoTags(git, [baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
374
375
  return {
375
376
  remote: baseRemote,
376
377
  trackingRef: prTrackingRef,
@@ -387,7 +388,7 @@ class GitWorktreeManager {
387
388
  throw prRefError;
388
389
  }
389
390
 
390
- await git.raw(['fetch', baseRemote, headSha]);
391
+ await rawFetchNoTags(git, [baseRemote, headSha]);
391
392
  return {
392
393
  remote: baseRemote,
393
394
  trackingRef: null,
@@ -591,13 +592,13 @@ class GitWorktreeManager {
591
592
  // Fetch only the specific base branch we need, with error handling for ref conflicts
592
593
  console.log(`Fetching base branch ${prData.base_branch} from ${remote}...`);
593
594
  try {
594
- await git.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
595
+ await fetchNoTags(git, [remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
595
596
  } catch (fetchError) {
596
597
  // If fetch fails due to ref conflicts, try alternative approaches
597
598
  console.log(`Standard fetch failed, trying alternative: ${fetchError.message}`);
598
599
  try {
599
600
  // Try fetching with force flag to overwrite conflicting refs
600
- await git.raw(['fetch', remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`, '--force']);
601
+ await rawFetchNoTags(git, ['--force', remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
601
602
  } catch (altFetchError) {
602
603
  console.warn(`Could not fetch base branch ${prData.base_branch}, will try to use existing ref`);
603
604
  // Continue anyway - the branch might already be available locally
@@ -658,7 +659,7 @@ class GitWorktreeManager {
658
659
  if (headBranch) {
659
660
  try {
660
661
  console.log(`Fetching head branch ${headBranch}...`);
661
- await worktreeGit.fetch([remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
662
+ await fetchNoTags(worktreeGit, [remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
662
663
  // Create/update a local branch pointing to the fetched ref so tooling can reference it by name
663
664
  await worktreeGit.branch(['-f', headBranch, `${remote}/${headBranch}`]);
664
665
  } catch (branchFetchError) {
@@ -769,14 +770,14 @@ class GitWorktreeManager {
769
770
  // This mirrors the targeted fetch used in createWorktreeForPR.
770
771
  if (prData?.base_branch) {
771
772
  try {
772
- await worktreeGit.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
773
+ await fetchNoTags(worktreeGit, [remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
773
774
  } catch (fetchError) {
774
775
  console.warn(`Targeted base-branch fetch failed, will rely on existing refs: ${fetchError.message}`);
775
776
  }
776
777
  }
777
778
  } else {
778
779
  console.log(`Fetching latest changes from ${remote}...`);
779
- await worktreeGit.fetch([remote, '--prune']);
780
+ await fetchNoTags(worktreeGit, ['--prune', remote]);
780
781
  }
781
782
 
782
783
  await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const fs = require('fs').promises;
7
7
  const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken } = require('./config');
8
8
  const logger = require('./utils/logger');
9
+ const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
9
10
  const { fireHooks, hasHooks } = require('./hooks/hook-runner');
10
11
  const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
11
12
 
@@ -699,6 +700,8 @@ async function handleLocalReview(targetPath, flags = {}) {
699
700
  let db = null;
700
701
 
701
702
  try {
703
+ rejectUrlLikeLocalReviewPath(targetPath);
704
+
702
705
  // Resolve target path
703
706
  const resolvedPath = path.resolve(targetPath || process.cwd());
704
707