gitnexus 1.6.12-rc.10 → 1.6.12-rc.11

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.
@@ -41,8 +41,8 @@ export declare class GitNexusRcError extends Error {
41
41
  }
42
42
  /**
43
43
  * Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
44
- * trimmed name or throws {@link GitNexusRcError}. Rules live in
45
- * `core/git-ref.ts`; this wrapper keeps the CLI / `.gitnexusrc` error type.
44
+ * trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
45
+ * shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
46
46
  */
47
47
  export declare function validateBranchName(value: string, source: string): string;
48
48
  /**
@@ -30,10 +30,11 @@
30
30
  import fs from 'node:fs';
31
31
  import path from 'node:path';
32
32
  import { readRepoControlFile } from '../config/repo-control-file.js';
33
- import { InvalidBranchError, validateBranchName as validateBranchNameCore, } from '../core/git-ref.js';
34
33
  export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
35
34
  /** Final fallback when no branch is configured or detectable. */
36
35
  export const DEFAULT_BRANCH_FALLBACK = 'main';
36
+ /** Git refs longer than this are almost certainly a mistake / injection attempt. */
37
+ const BRANCH_MAX_LENGTH = 255;
37
38
  /**
38
39
  * Thrown for any `.gitnexusrc` problem (missing-file is NOT an error — it
39
40
  * returns `undefined`). The message is user-facing and names the file so the
@@ -125,19 +126,41 @@ const assertNoHiddenChars = (value, source) => {
125
126
  };
126
127
  /**
127
128
  * Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
128
- * trimmed name or throws {@link GitNexusRcError}. Rules live in
129
- * `core/git-ref.ts`; this wrapper keeps the CLI / `.gitnexusrc` error type.
129
+ * trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
130
+ * shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
130
131
  */
131
132
  export function validateBranchName(value, source) {
132
- try {
133
- return validateBranchNameCore(value, source);
133
+ const trimmed = value.trim();
134
+ if (!trimmed) {
135
+ throw new GitNexusRcError(`${source}: branch name must not be empty.`);
134
136
  }
135
- catch (err) {
136
- if (err instanceof InvalidBranchError) {
137
- throw new GitNexusRcError(err.message);
138
- }
139
- throw err;
137
+ if (trimmed.length > BRANCH_MAX_LENGTH) {
138
+ throw new GitNexusRcError(`${source}: branch name is too long (max ${BRANCH_MAX_LENGTH}).`);
139
+ }
140
+ assertNoHiddenChars(trimmed, source);
141
+ if (/\s/.test(trimmed)) {
142
+ throw new GitNexusRcError(`${source}: branch name must not contain whitespace.`);
143
+ }
144
+ // git ref-name rules (subset): reject characters git itself forbids in refs.
145
+ if (/[~^:?*[\\]/.test(trimmed)) {
146
+ throw new GitNexusRcError(`${source}: branch name contains characters not allowed in a git ref (~ ^ : ? * [ \\).`);
147
+ }
148
+ if (trimmed.startsWith('-')) {
149
+ throw new GitNexusRcError(`${source}: branch name must not start with "-".`);
150
+ }
151
+ if (trimmed.includes('..')) {
152
+ throw new GitNexusRcError(`${source}: branch name must not contain "..".`);
153
+ }
154
+ // Git permits a backtick in a ref, but the branch is embedded inside a
155
+ // Markdown inline-code span in the generated AGENTS.md/CLAUDE.md regression
156
+ // example, where a backtick would close the span early and let the rest of
157
+ // the template render as instruction text. Reject it at this single
158
+ // chokepoint so all three tiers (CLI flag, .gitnexusrc, auto-detect via
159
+ // sanitizeDetectedBranch) are covered (#1996 tri-review P1).
160
+ if (trimmed.includes('`')) {
161
+ throw new GitNexusRcError(`${source}: branch name must not contain a backtick (it would break the generated Markdown).`);
140
162
  }
163
+ return trimmed;
141
164
  }
142
165
  /**
143
166
  * Best-effort validation for an auto-detected branch (from git). Never throws —
@@ -53,13 +53,6 @@ export interface AnalyzeJob {
53
53
  repoUrl?: string;
54
54
  repoPath?: string;
55
55
  repoName?: string;
56
- /**
57
- * Index-branch selector this job was started with, part of the job's dedup
58
- * identity. A repo is not "the same repo" for reuse purposes when a different
59
- * branch was asked for — reusing across branches would hand the caller a 202
60
- * for a job indexing something else.
61
- */
62
- branch?: string;
63
56
  progress: AnalyzeJobProgress;
64
57
  error?: string;
65
58
  /** Set only when a terminal `failed` job still persisted usable work. */
@@ -77,21 +70,10 @@ export declare class JobManager {
77
70
  private emitter;
78
71
  private cleanupTimer;
79
72
  constructor();
80
- /**
81
- * Create a new job, or return the existing active job for the same repo AND
82
- * the same branch.
83
- *
84
- * Branch is part of the identity deliberately. Deduping on repo alone would
85
- * return the in-flight job for branch A to a caller that asked for branch B,
86
- * and that caller would read the resulting 202/`complete` as "B is indexed"
87
- * — the same silent wrong-branch outcome that made `branch` worth honoring in
88
- * the first place. Falling through instead lets the single-slot guard below
89
- * reject the request outright, which is a truthful answer.
90
- */
73
+ /** Create a new job, or return existing active job for the same repo. */
91
74
  createJob(params: {
92
75
  repoUrl?: string;
93
76
  repoPath?: string;
94
- branch?: string;
95
77
  }): AnalyzeJob;
96
78
  getJob(id: string): AnalyzeJob | undefined;
97
79
  /** Return a snapshot of all tracked jobs for inspection. */
@@ -34,24 +34,14 @@ export class JobManager {
34
34
  constructor() {
35
35
  this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
36
36
  }
37
- /**
38
- * Create a new job, or return the existing active job for the same repo AND
39
- * the same branch.
40
- *
41
- * Branch is part of the identity deliberately. Deduping on repo alone would
42
- * return the in-flight job for branch A to a caller that asked for branch B,
43
- * and that caller would read the resulting 202/`complete` as "B is indexed"
44
- * — the same silent wrong-branch outcome that made `branch` worth honoring in
45
- * the first place. Falling through instead lets the single-slot guard below
46
- * reject the request outright, which is a truthful answer.
47
- */
37
+ /** Create a new job, or return existing active job for the same repo. */
48
38
  createJob(params) {
49
- // Dedup: return existing active job for the same repo (by URL or path) and branch
39
+ // Dedup: return existing active job for the same repo (by URL or path)
50
40
  for (const job of this.jobs.values()) {
51
41
  if (!this.isTerminal(job.status)) {
52
42
  const isSameRepo = (params.repoUrl && job.repoUrl === params.repoUrl) ||
53
43
  (params.repoPath && job.repoPath === params.repoPath);
54
- if (isSameRepo && job.branch === params.branch) {
44
+ if (isSameRepo) {
55
45
  return job;
56
46
  }
57
47
  }
@@ -67,7 +57,6 @@ export class JobManager {
67
57
  status: 'queued',
68
58
  repoUrl: params.repoUrl,
69
59
  repoPath: params.repoPath,
70
- branch: params.branch,
71
60
  progress: { phase: 'queued', percent: 0, message: 'Waiting to start...' },
72
61
  startedAt: Date.now(),
73
62
  retryCount: 0,
@@ -31,20 +31,6 @@ export interface LaunchOptions {
31
31
  springActuatorPath?: string;
32
32
  asyncApiSpecPath?: string;
33
33
  registryName?: string;
34
- /**
35
- * Index-branch selector, forwarded to `AnalyzeOptions.branch`.
36
- *
37
- * Setting it does not by itself mean a `branches/<slug>/` sub-directory:
38
- * `resolveBranchPlacement` (storage/branch-index.ts) keeps the run on the flat
39
- * slot when that slot has no recorded owner, or when its owner already IS this
40
- * label. Only a label that differs from the flat slot's owner gets its own
41
- * sub-directory.
42
- *
43
- * The caller is responsible for having the branch checked out —
44
- * `resolveWriteTarget` in core refuses a label that disagrees with the working
45
- * tree, which is what keeps one branch's content out of another's slot (#2106).
46
- */
47
- branch?: string;
48
34
  }
49
35
  export declare function createLaunchAnalysisWorker(deps: LaunchDeps): (job: {
50
36
  id: string;
@@ -15,7 +15,6 @@ import { fork } from 'child_process';
15
15
  import { fileURLToPath, pathToFileURL } from 'url';
16
16
  import { createRequire } from 'node:module';
17
17
  import { canonicalizePath, getStoragePath, INDEX_METADATA_FILE, listRegisteredRepos, registryPathEquals, } from '../storage/repo-manager.js';
18
- import { BRANCHES_DIR, branchSlug } from '../storage/branch-index.js';
19
18
  import { logger } from '../core/logger.js';
20
19
  import { autoHeapCapMb } from '../core/ingestion/utils/effective-ram.js';
21
20
  import { isTerminalJobStatus } from './analyze-job.js';
@@ -33,6 +32,17 @@ const MAX_WORKER_RETRIES = 2;
33
32
  */
34
33
  const FINALIZE_SETTLE_TIMEOUT_MS = 60_000;
35
34
  const FINALIZE_SETTLE_POLL_MS = 200;
35
+ /**
36
+ * Resolve once the analyzed repo's index is settled at `storagePath`: the
37
+ * LadybugDB file and metadata both exist AND were (re)written by THIS job
38
+ * (mtime >= jobStartMs — bare existence is not enough, a re-analysis leaves
39
+ * the previous index in place while it works), and no transient WAL/shadow/
40
+ * checkpoint sidecars remain (the worker's native close has finished).
41
+ *
42
+ * Never rejects. Timing out logs and proceeds (pre-gate behavior) rather
43
+ * than failing a job whose analysis genuinely succeeded — e.g. a no-op
44
+ * non-force analyze legitimately rewrites nothing.
45
+ */
36
46
  /**
37
47
  * Look up the analyzed repo's registered storage path. The request's
38
48
  * user-provided path is used only as a comparison key; the filesystem probes
@@ -46,36 +56,7 @@ const registeredStoragePath = async (targetPath) => {
46
56
  const entry = entries.find((e) => registryPathEquals(canonicalizePath(e.path), target));
47
57
  return entry?.storagePath ?? null;
48
58
  };
49
- /**
50
- * Resolve the directory this run's index actually landed in.
51
- *
52
- * `registerRepo` always records the FLAT `.gitnexus` as `entry.storagePath`,
53
- * but a pinned `--branch` run whose label differs from the flat slot's owner
54
- * writes `lbug`/`gitnexus.json` under `branches/<slug>/` instead. Probing the
55
- * flat path for such a run watches files it never rewrote, so the gate below
56
- * would spin to its timeout on a perfectly successful analysis (#3199 review).
57
- *
58
- * `isPrimaryBranch` is the worker's own report of `!placement.branch`, so this
59
- * follows the placement core actually chose rather than recomputing it here
60
- * (the flat slot's recorded owner can be adopted mid-run, which would make a
61
- * recomputation race the thing it is trying to observe).
62
- */
63
- const settleDirFor = (registryStoragePath, branch, isPrimaryBranch) => branch && isPrimaryBranch === false
64
- ? path.join(registryStoragePath, BRANCHES_DIR, branchSlug(branch))
65
- : registryStoragePath;
66
- /**
67
- * Resolve once the analyzed repo's index is settled at `storagePath`: the
68
- * LadybugDB file and metadata both exist AND were (re)written by THIS job
69
- * (mtime >= jobStartMs — bare existence is not enough, a re-analysis leaves
70
- * the previous index in place while it works), and no transient WAL/shadow/
71
- * checkpoint sidecars remain (the worker's native close has finished).
72
- *
73
- * Never rejects. Timing out logs and proceeds (pre-gate behavior) rather
74
- * than failing a job whose analysis genuinely succeeded. The `alreadyUpToDate`
75
- * fast path never rewrites `lbug` (see `run-analyze.ts`) and skips this wait
76
- * at the `complete` handler so it does not hold the analyze slot for 60s.
77
- */
78
- const waitForSettledIndex = async (targetPath, jobStartMs, branch, isPrimaryBranch) => {
59
+ const waitForSettledIndex = async (targetPath, jobStartMs) => {
79
60
  const settled = (storagePath) => {
80
61
  try {
81
62
  const lbugStat = statSync(path.join(storagePath, 'lbug'));
@@ -93,7 +74,7 @@ const waitForSettledIndex = async (targetPath, jobStartMs, branch, isPrimaryBran
93
74
  // Re-resolved each round: the worker registers the repo as part of the
94
75
  // finalization this gate is waiting out.
95
76
  const storagePath = await registeredStoragePath(targetPath);
96
- if (storagePath && settled(settleDirFor(storagePath, branch, isPrimaryBranch)))
77
+ if (storagePath && settled(storagePath))
97
78
  return;
98
79
  if (Date.now() > deadline) {
99
80
  logger.warn({ targetPath }, 'analyze finalization not visible after timeout; completing job anyway');
@@ -140,13 +121,6 @@ export function createLaunchAnalysisWorker(deps) {
140
121
  });
141
122
  // Capture stderr for crash diagnostics
142
123
  let stderrChunks = '';
143
- // A terminal IPC message (`complete`/`error`) means the worker finished
144
- // and is now winding down — it calls process.exit(0) ~500ms later. The
145
- // job is deliberately still non-terminal at that point because the
146
- // finalization gate is running, so without this flag the exit handler
147
- // below reads that clean exit as a crash and retries a SUCCESSFUL
148
- // analysis, three times, before failing it (#3199 review).
149
- let terminalIpcSeen = false;
150
124
  child.stderr?.on('data', (chunk) => {
151
125
  stderrChunks += chunk.toString();
152
126
  if (stderrChunks.length > 4096)
@@ -160,8 +134,6 @@ export function createLaunchAnalysisWorker(deps) {
160
134
  const current = jobManager.getJob(job.id);
161
135
  if (!current || isTerminalJobStatus(current.status))
162
136
  return;
163
- if (msg.type === 'complete' || msg.type === 'error')
164
- terminalIpcSeen = true;
165
137
  if (msg.type === 'progress') {
166
138
  jobManager.updateJob(job.id, {
167
139
  status: 'analyzing',
@@ -179,16 +151,7 @@ export function createLaunchAnalysisWorker(deps) {
179
151
  // below true in practice: the repo is actually queryable when the
180
152
  // client receives the SSE complete event, and an index this run knows
181
153
  // to be incomplete is never published at all.
182
- //
183
- // alreadyUpToDate never opens LadybugDB and never rewrites `lbug`
184
- // (run-analyze.ts early-return; CLI notes the same). The mtime gate
185
- // would spin the full 60s and hold the single global analyze slot.
186
- // ftsRepairedOnly DOES rewrite `lbug` (initLbug + createSearchFTSIndexes)
187
- // so it still waits.
188
- const settle = msg.result.alreadyUpToDate
189
- ? Promise.resolve()
190
- : waitForSettledIndex(targetPath, jobStartMs, opts.branch, msg.result.isPrimaryBranch);
191
- settle
154
+ waitForSettledIndex(targetPath, jobStartMs)
192
155
  .then(() => closeDbHandle())
193
156
  .catch(() => { }) // best-effort: eviction failure must not fail the job
194
157
  .then(() => {
@@ -280,13 +243,6 @@ export function createLaunchAnalysisWorker(deps) {
280
243
  const j = jobManager.getJob(job.id);
281
244
  if (!j || isTerminalJobStatus(j.status))
282
245
  return;
283
- // The worker already reported a terminal outcome; this exit is it
284
- // winding down, not dying. The job is still non-terminal only because
285
- // the finalization gate above has not resolved yet, and that gate owns
286
- // the outcome — retrying here would fork a second worker over a
287
- // finished, successful analysis.
288
- if (terminalIpcSeen)
289
- return;
290
246
  // Worker crashed — attempt retry if under the limit
291
247
  if (j.retryCount < MAX_WORKER_RETRIES) {
292
248
  j.retryCount++;
@@ -327,7 +283,6 @@ export function createLaunchAnalysisWorker(deps) {
327
283
  ...(opts.springActuatorPath ? { springActuatorPath: opts.springActuatorPath } : {}),
328
284
  ...(opts.asyncApiSpecPath ? { asyncApiSpecPath: opts.asyncApiSpecPath } : {}),
329
285
  ...(opts.registryName ? { registryName: opts.registryName } : {}),
330
- ...(opts.branch ? { branch: opts.branch } : {}),
331
286
  },
332
287
  });
333
288
  };
@@ -43,13 +43,12 @@ import type { AnalyzeResult } from '../core/run-analyze.js';
43
43
  * ones (e.g. `isPrimaryBranch?`), so an optional non-serializable field could be
44
44
  * advertised by the type yet silently dropped by the runtime allowlist.
45
45
  *
46
- * `isPrimaryBranch` IS on the wire, under exactly the rule this comment used to
47
- * cite for excluding it: a server-side consumer now needs it. `analyze-launch.ts`
48
- * settles the index the run actually wrote, and only the worker knows whether
49
- * core chose the flat slot or a `branches/<slug>/` sub-slot. It is a boolean, so
50
- * it is JSON-safe by construction.
46
+ * `isPrimaryBranch` is intentionally excluded: the parent (`api.ts`) reads only
47
+ * `repoName`, and nothing consumes `isPrimaryBranch` across this fork (its CLI
48
+ * consumer calls `runFullAnalysis` in-process). Add a field here only when a
49
+ * server-side IPC consumer actually needs it — and only if it is JSON-safe.
51
50
  */
52
- export type AnalyzeResultIpc = Pick<AnalyzeResult, 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' | 'graphWriteCollapsed' | 'isPrimaryBranch'>;
51
+ export type AnalyzeResultIpc = Pick<AnalyzeResult, 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' | 'graphWriteCollapsed'>;
53
52
  /**
54
53
  * Project an `AnalyzeResult` down to the JSON-safe fields the parent consumes,
55
54
  * dropping `pipelineResult` (the live `KnowledgeGraph`) and any other field not
@@ -16,9 +16,5 @@ export function projectAnalyzeResultForIpc(result) {
16
16
  // outcome the CLI does; without it the worker reports a clean `complete`
17
17
  // for a run whose edges are mostly missing.
18
18
  graphWriteCollapsed: result.graphWriteCollapsed,
19
- // Tells the parent which slot this run wrote — the flat `.gitnexus` or a
20
- // `branches/<slug>/` sub-slot — so its finalization gate watches the files
21
- // this job actually rewrote (#3199 review).
22
- isPrimaryBranch: result.isPrimaryBranch,
23
19
  };
24
20
  }
@@ -29,12 +29,8 @@ import { measurePersistedEmbeddingCount, persistedEmbeddingCountOrUndefined, } f
29
29
  import { assertString, BadRequestError, createRouteLimiter } from './validation.js';
30
30
  import { parseGrepQuery, GREP_TIME_BUDGET_MS } from './grep-params.js';
31
31
  import { runGrepScanInWorker } from './grep-scan.js';
32
- import { analyzeCloneOptions, extractWebRepoName, getCloneDir, cloneOrPull, warnIfInsecureAzureConfig, GITHUB_TOKEN_HOSTS, } from './git-clone.js';
32
+ import { extractWebRepoName, getCloneDir, cloneOrPull, warnIfInsecureAzureConfig, GITHUB_TOKEN_HOSTS, } from './git-clone.js';
33
33
  import { createAnalyzeUploadHandler } from './analyze-upload.js';
34
- // Shared with the CLI's `--branch` (via the analyze-config wrapper) so both
35
- // entry points accept the same refs. Imported from core — not cli/ — so
36
- // createServer does not close a cycle with cli/serve.ts.
37
- import { InvalidBranchError, validateBranchName } from '../core/git-ref.js';
38
34
  import { assertServeAuthForPublicOrigin, createPublicOriginMatcher, createWriteOriginGuard, logOriginPolicy, PUBLIC_ORIGIN_ENV, resolveTrustProxy, TRUST_PROXY_ENV, warnIfRateLimitKeysCollapse, } from './middleware.js';
39
35
  import { createLaunchAnalysisWorker } from './analyze-launch.js';
40
36
  import { UPLOAD_ROOT } from './upload-paths.js';
@@ -1268,7 +1264,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
1268
1264
  // POST /api/analyze — start a new analysis job
1269
1265
  app.post('/api/analyze', createRouteLimiter({ limit: 10 }), requireTrustedOrigin, async (req, res) => {
1270
1266
  try {
1271
- const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings, springActuatorPath, asyncApiSpecPath, token: repoToken, branch: repoBranch, } = req.body;
1267
+ const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings, springActuatorPath, asyncApiSpecPath, token: repoToken, } = req.body;
1272
1268
  // Input type validation
1273
1269
  if (repoUrl !== undefined && typeof repoUrl !== 'string') {
1274
1270
  res.status(400).json({ error: '"url" must be a string' });
@@ -1292,27 +1288,6 @@ export const createServer = async (port, host = '127.0.0.1') => {
1292
1288
  res.status(400).json({ error: 'Provide "url" (git URL) or "path" (local path)' });
1293
1289
  return;
1294
1290
  }
1295
- // Branch: optional index-branch selector, validated with the same rules
1296
- // as the CLI's `--branch` so both entry points accept the same refs.
1297
- // Rejecting here (rather than letting the clone fail) keeps a malformed
1298
- // ref from ever reaching `git`.
1299
- if (repoBranch !== undefined && typeof repoBranch !== 'string') {
1300
- res.status(400).json({ error: '"branch" must be a string' });
1301
- return;
1302
- }
1303
- let analyzeBranch;
1304
- if (repoBranch !== undefined) {
1305
- try {
1306
- analyzeBranch = validateBranchName(repoBranch, '"branch"');
1307
- }
1308
- catch (err) {
1309
- if (err instanceof InvalidBranchError) {
1310
- res.status(400).json({ error: err.message });
1311
- return;
1312
- }
1313
- throw err;
1314
- }
1315
- }
1316
1291
  // Token: optional, restricted charset to prevent header smuggling
1317
1292
  // (CRLF), bound length, and bound to github.com (see validateAnalyzeToken).
1318
1293
  const tokenError = validateAnalyzeToken(repoToken, repoUrl);
@@ -1336,11 +1311,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
1336
1311
  res.status(400).json({ error: '"path" must be an absolute path' });
1337
1312
  return;
1338
1313
  }
1339
- const job = jobManager.createJob({
1340
- repoUrl,
1341
- repoPath: repoLocalPath,
1342
- branch: analyzeBranch,
1343
- });
1314
+ const job = jobManager.createJob({ repoUrl, repoPath: repoLocalPath });
1344
1315
  // If job was already running (dedup), just return its id. The token is
1345
1316
  // not part of the dedup identity and is never stored on the job, so a
1346
1317
  // token on THIS request had no effect — the existing job already
@@ -1366,21 +1337,17 @@ export const createServer = async (port, host = '127.0.0.1') => {
1366
1337
  // Clone if URL provided
1367
1338
  if (repoUrl && !repoLocalPath) {
1368
1339
  const repoName = extractWebRepoName(repoUrl);
1369
- // Branch-pinned runs get their own clone dir, so they never share
1370
- // a working tree with the unpinned one (see getCloneDir).
1371
- targetPath = getCloneDir(repoName, analyzeBranch);
1340
+ targetPath = getCloneDir(repoName);
1372
1341
  jobManager.updateJob(job.id, {
1373
1342
  status: 'cloning',
1374
- // url+branch: same value as registryName (dir basename), not
1375
- // the extractWebRepoName stem used only as getCloneDir's first arg.
1376
- repoName: analyzeBranch ? path.basename(targetPath) : repoName,
1343
+ repoName,
1377
1344
  progress: { phase: 'cloning', percent: 0, message: `Cloning ${repoUrl}...` },
1378
1345
  });
1379
1346
  await cloneOrPull(repoUrl, targetPath, (progress) => {
1380
1347
  jobManager.updateJob(job.id, {
1381
1348
  progress: { phase: progress.phase, percent: 5, message: progress.message },
1382
1349
  });
1383
- }, analyzeCloneOptions(repoToken, analyzeBranch));
1350
+ }, repoToken ? { token: repoToken } : undefined);
1384
1351
  }
1385
1352
  if (!targetPath) {
1386
1353
  throw new Error('No target path resolved');
@@ -1391,20 +1358,6 @@ export const createServer = async (port, host = '127.0.0.1') => {
1391
1358
  dropEmbeddings,
1392
1359
  springActuatorPath,
1393
1360
  asyncApiSpecPath,
1394
- branch: analyzeBranch,
1395
- // Both clone dirs share an `origin`, so the name `registerRepo`
1396
- // infers from the remote would be identical and the second one
1397
- // would fail with RegistryNameCollisionError. Register the pinned
1398
- // clone under its directory name instead: unique per branch, and
1399
- // it re-derives through getCloneDir for DELETE /api/repo.
1400
- //
1401
- // Gated on the SAME condition as the clone above: when a caller
1402
- // supplies both `url` and `path` nothing is cloned, and renaming
1403
- // the operator's own local repo after its directory would be a
1404
- // surprise unrelated to branch pinning.
1405
- ...(analyzeBranch && repoUrl && !repoLocalPath
1406
- ? { registryName: path.basename(targetPath) }
1407
- : {}),
1408
1361
  });
1409
1362
  }
1410
1363
  catch (err) {
@@ -25,25 +25,12 @@ export declare function extractRepoName(url: string): string;
25
25
  * `extractRepoName()` strict for internal/security-sensitive callers.
26
26
  */
27
27
  export declare function extractWebRepoName(url: string): string;
28
- /** Get the clone target directory for a repo name, optionally pinned to a branch. */
29
- export declare function getCloneDir(repoName: string, branch?: string): string;
28
+ /** Get the clone target directory for a repo name. */
29
+ export declare function getCloneDir(repoName: string): string;
30
30
  export interface CloneProgress {
31
31
  phase: 'cloning' | 'pulling';
32
32
  message: string;
33
33
  }
34
- /**
35
- * Build the `cloneOrPull` options for an `/api/analyze` request.
36
- *
37
- * Extracted from the route so the token/branch combination is unit-testable.
38
- * Inline, the branch-only case was the one nothing asserted: every existing
39
- * test still passed if `branch` were dropped whenever no token was supplied —
40
- * i.e. silently cloning the default branch for every public URL, which is the
41
- * exact behavior #3198 is about (#3199 review).
42
- *
43
- * Returns `undefined` rather than `{}` when neither is set, because that is
44
- * what `cloneOrPull` treats as "no options" at its own call sites.
45
- */
46
- export declare function analyzeCloneOptions(token?: string, branch?: string): Pick<CloneOrPullOptions, 'token' | 'branch'> | undefined;
47
34
  export interface CloneOrPullOptions {
48
35
  token?: string;
49
36
  allowedCloneRoot?: string;
@@ -132,29 +119,9 @@ export declare function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Pro
132
119
  export declare function assertRemoteMatchesRequestedUrl(targetDir: string, requestedUrl: string, timeoutMs?: number): Promise<void>;
133
120
  /**
134
121
  * Clone or pull a git repository.
135
- *
136
- * If targetDir doesn't exist: git clone --depth 1, adding `--branch <branch>`
137
- * when one is requested.
138
- *
139
- * If targetDir exists with .git, its remote.origin is verified against the
140
- * requested URL first, and then the branch decides the update:
141
- * - no `options.branch`: git pull --ff-only, which updates the current
142
- * branch in place via its configured upstream. Nothing moves, so no
143
- * dirty-tree check applies.
144
- * - a `options.branch` that is ALREADY the current named branch: restore
145
- * GitNexus overlays, then fetch via
146
- * `+refs/heads/<branch>:refs/remotes/origin/<branch>` and
147
- * `checkout -B <branch> origin/<branch>` (shallow clones cannot
148
- * `merge --ff-only` across a 2+ commit move). Never a raw
149
- * `origin <branch>` pull refspec. No porcelain refuse.
150
- * - a detached HEAD whose SHA already matches the requested ref (tag /
151
- * SHA pin): restore overlays only. Do not fetch/merge — a same-named
152
- * branch could otherwise fast-forward the pin past the tag.
153
- * - a `options.branch` that DIFFERS from the current pin: fetch that ref,
154
- * then `checkout -B <branch> origin/<branch>` — so the requested branch,
155
- * not the one already checked out, is what ends up in the working tree.
156
- * This is the switching case, and it refuses a dirty tree unless
157
- * `overwriteLocalChanges` is set.
122
+ * If targetDir doesn't exist: git clone --depth 1
123
+ * If targetDir exists with .git: git pull --ff-only (after verifying the
124
+ * existing clone's remote.origin matches the requested URL).
158
125
  *
159
126
  * Security:
160
127
  * - targetDir must resolve inside CLONE_ROOT (~/.gitnexus/repos/). The
@@ -10,7 +10,6 @@ import fs from 'fs/promises';
10
10
  import os from 'node:os';
11
11
  import { logger } from '../core/logger.js';
12
12
  import { getGlobalDir } from '../storage/repo-manager.js';
13
- import { branchSlug } from '../storage/branch-index.js';
14
13
  import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js';
15
14
  import { validateGitUrl } from '../core/net/url-guard.js';
16
15
  import { assertDirectoryOwnerAndPermissions, quarantineAutoSyncPartial, } from '../core/auto-sync/path-security.js';
@@ -75,83 +74,14 @@ export function extractWebRepoName(url) {
75
74
  }
76
75
  return safeName;
77
76
  }
78
- /**
79
- * Longest single path component the supported filesystems accept (ext4, APFS,
80
- * NTFS all cap at 255). Compared against `.length`, which equals the byte
81
- * count here because every name this guards is ASCII by construction
82
- * (REPO_NAME_PATTERN and sanitizeRepoName both restrict to `[a-zA-Z0-9._-]`).
83
- */
84
- const MAX_PATH_COMPONENT_BYTES = 255;
85
- /**
86
- * `branchSlug` for a clone-directory name, trimmed to fit one path component.
87
- *
88
- * `validateBranchName` allows a ref up to 255 characters and `branchSlug`
89
- * appends `-` plus 8 hash characters, so `<repo>__<slug>` can reach 267 — past
90
- * the filesystem limit, and the clone would then fail to create its target
91
- * directory (#3199 review).
92
- *
93
- * Only the READABLE half is trimmed; the 8-character hash is always kept, and
94
- * it is a digest of the full ref, so two long branches that share a prefix
95
- * still get different directories. The slug is not trimmed inside
96
- * `branchSlug` itself because the per-branch *index* slots already use those
97
- * names on disk — shortening them there would orphan existing indexes.
98
- */
99
- const boundedBranchSegment = (repoName, branch) => {
100
- const slug = branchSlug(branch);
101
- if (`${repoName}__${slug}`.length <= MAX_PATH_COMPONENT_BYTES)
102
- return slug;
103
- const hash = slug.slice(slug.lastIndexOf('-')); // "-" + 8 hex
104
- const budget = MAX_PATH_COMPONENT_BYTES - repoName.length - '__'.length - hash.length;
105
- // A repo name long enough to leave no budget falls through to the caller's
106
- // length check, which rejects it rather than building an unusable path.
107
- return `${slug.slice(0, Math.max(0, budget))}${hash}`;
108
- };
109
- /** Get the clone target directory for a repo name, optionally pinned to a branch. */
110
- export function getCloneDir(repoName, branch) {
77
+ /** Get the clone target directory for a repo name. */
78
+ export function getCloneDir(repoName) {
111
79
  // Re-validate at the boundary even though extractRepoName already checked —
112
80
  // callers may pass a repoName from another source (test fixtures, scripts).
113
81
  if (!repoName || repoName === '.' || repoName === '..' || !REPO_NAME_PATTERN.test(repoName)) {
114
82
  throw new Error('Invalid repository name');
115
83
  }
116
- // A branch-pinned analyze gets its OWN working tree.
117
- //
118
- // Sharing one checkout per repo made `branch` unusable in practice: the tree
119
- // is dirty after any analyze (generated AGENTS.md / CLAUDE.md / .claude/), so
120
- // a pinned request hit `cloneOrPull`'s porcelain refusal; and a later request
121
- // that OMITTED `branch` would pull whatever branch the last pin left checked
122
- // out and index it as the default (#3199 review). Separate directories remove
123
- // both, because the two requests no longer share a tree.
124
- //
125
- // `branchSlug` is the same helper the per-branch index slots use, so the two
126
- // layouts agree on how a ref becomes a path segment. It emits only
127
- // `[a-zA-Z0-9._-]`, so the composed name still satisfies REPO_NAME_PATTERN and
128
- // round-trips through this function — which is how DELETE /api/repo re-derives
129
- // the directory from the registry name.
130
- const dirName = branch ? `${repoName}__${boundedBranchSegment(repoName, branch)}` : repoName;
131
- if (!REPO_NAME_PATTERN.test(dirName) || dirName.length > MAX_PATH_COMPONENT_BYTES) {
132
- throw new Error('Invalid repository name');
133
- }
134
- return path.join(CLONE_ROOT, dirName);
135
- }
136
- /**
137
- * Build the `cloneOrPull` options for an `/api/analyze` request.
138
- *
139
- * Extracted from the route so the token/branch combination is unit-testable.
140
- * Inline, the branch-only case was the one nothing asserted: every existing
141
- * test still passed if `branch` were dropped whenever no token was supplied —
142
- * i.e. silently cloning the default branch for every public URL, which is the
143
- * exact behavior #3198 is about (#3199 review).
144
- *
145
- * Returns `undefined` rather than `{}` when neither is set, because that is
146
- * what `cloneOrPull` treats as "no options" at its own call sites.
147
- */
148
- export function analyzeCloneOptions(token, branch) {
149
- if (!token && !branch)
150
- return undefined;
151
- return {
152
- ...(token ? { token } : {}),
153
- ...(branch ? { branch } : {}),
154
- };
84
+ return path.join(CLONE_ROOT, repoName);
155
85
  }
156
86
  /**
157
87
  * Build the `git clone` argument list for a given URL and target directory.
@@ -318,110 +248,11 @@ export async function assertRemoteMatchesRequestedUrl(targetDir, requestedUrl, t
318
248
  `not the requested URL ${stripUrlCredentials(requestedUrl)}`);
319
249
  }
320
250
  }
321
- /**
322
- * Fetch refspec that updates `origin/<branch>` from `refs/heads/<branch>`.
323
- *
324
- * The leading `+` is git's dest-update prefix (`+refs/heads/*:refs/remotes/origin/*`
325
- * is what `git clone` writes into `.git/config`). Without it, `fetch --depth 1`
326
- * refuses to move `origin/<branch>` when the shallow history cannot prove a
327
- * fast-forward — so a same-branch re-index stays stuck on the old tip.
328
- *
329
- * The user string is interpolated inside `refs/heads/…`, never as a raw pull
330
- * dest. A branch named `+develop` becomes `+refs/heads/+develop:…`, not a
331
- * force-update of `develop`.
332
- */
333
- function branchFetchRefspec(branch) {
334
- return `+refs/heads/${branch}:refs/remotes/origin/${branch}`;
335
- }
336
- /** Overlays `analyze` writes into a clone; they must not block a same-ref update. */
337
- const GITNEXUS_GENERATED_OVERLAYS = ['./AGENTS.md', './CLAUDE.md', './.claude'];
338
- /**
339
- * Restore only GitNexus-generated overlays so a same-ref update is not
340
- * blocked by analyze dirt. Path-limited and root-anchored (`./`): tracked
341
- * files are checked out from HEAD; untracked overlays (including gitignored
342
- * ones — `AGENTS.md` / `.claude/` are commonly ignored) are `git clean -fdx`'d.
343
- * A slash-free `AGENTS.md` would also hit `docs/AGENTS.md`. Never a
344
- * whole-clone `git clean --force -d`.
345
- */
346
- async function restoreGitNexusGeneratedOverlays(runGitImpl, cwd, gitOpts) {
347
- for (const overlay of GITNEXUS_GENERATED_OVERLAYS) {
348
- const listed = (await runGitImpl(['ls-files', '--', overlay], cwd, gitOpts)).trim();
349
- if (!listed)
350
- continue;
351
- await runGitImpl(['checkout', 'HEAD', '--', overlay], cwd, gitOpts);
352
- }
353
- // Path-limited: untracked analyze output still blocks checkout when the
354
- // incoming tree has the same path, and otherwise leaves a dirty tree to
355
- // index. `-x` is required because these overlays are often gitignored.
356
- // Never a whole-clone `git clean --force -d`.
357
- await runGitImpl(['clean', '-fdx', '--', ...GITNEXUS_GENERATED_OVERLAYS], cwd, gitOpts);
358
- }
359
- /**
360
- * True when the working tree is already at the requested pin: either HEAD is
361
- * that named branch, or HEAD is detached at the same SHA as `branch` /
362
- * `origin/<branch>`. A missing ref falls through to the switch path.
363
- */
364
- async function matchRequestedRef(runGitImpl, cwd, branch, gitOpts) {
365
- const abbrev = (await runGitImpl(['rev-parse', '--abbrev-ref', 'HEAD'], cwd, gitOpts)).trim();
366
- if (abbrev === branch)
367
- return 'branch';
368
- // Detached HEAD reports `HEAD`; compare SHAs so a tag/SHA pin is not a switch.
369
- if (abbrev !== 'HEAD')
370
- return undefined;
371
- let headSha;
372
- try {
373
- headSha = (await runGitImpl(['rev-parse', 'HEAD'], cwd, gitOpts)).trim();
374
- }
375
- catch {
376
- return undefined;
377
- }
378
- for (const candidate of [branch, `origin/${branch}`]) {
379
- try {
380
- // Peel annotated tags (`v1.0` is a tag object; HEAD is the commit).
381
- const requestedSha = (await runGitImpl(['rev-parse', `${candidate}^{commit}`], cwd, gitOpts)).trim();
382
- if (requestedSha && requestedSha === headSha)
383
- return 'sha';
384
- }
385
- catch {
386
- // Ref missing — try origin/<branch>, then the switch path.
387
- }
388
- }
389
- return undefined;
390
- }
391
- async function fetchAndCheckoutRequestedBranch(runGitImpl, cwd, branch, gitOpts) {
392
- // Analyze clones are `--depth 1`. `merge --ff-only` cannot walk O→N when
393
- // the remote moved 2+ commits (the merge-base is not in the shallow
394
- // history). `checkout -B` points the local branch at the fetched tip —
395
- // same as the switch path, no ancestry walk. No `--force`: leftover
396
- // non-overlay dirt still refuses.
397
- await runGitImpl(['fetch', '--depth', '1', 'origin', branchFetchRefspec(branch)], cwd, gitOpts);
398
- await runGitImpl(['checkout', '-B', branch, `origin/${branch}`], cwd, gitOpts);
399
- }
400
251
  /**
401
252
  * Clone or pull a git repository.
402
- *
403
- * If targetDir doesn't exist: git clone --depth 1, adding `--branch <branch>`
404
- * when one is requested.
405
- *
406
- * If targetDir exists with .git, its remote.origin is verified against the
407
- * requested URL first, and then the branch decides the update:
408
- * - no `options.branch`: git pull --ff-only, which updates the current
409
- * branch in place via its configured upstream. Nothing moves, so no
410
- * dirty-tree check applies.
411
- * - a `options.branch` that is ALREADY the current named branch: restore
412
- * GitNexus overlays, then fetch via
413
- * `+refs/heads/<branch>:refs/remotes/origin/<branch>` and
414
- * `checkout -B <branch> origin/<branch>` (shallow clones cannot
415
- * `merge --ff-only` across a 2+ commit move). Never a raw
416
- * `origin <branch>` pull refspec. No porcelain refuse.
417
- * - a detached HEAD whose SHA already matches the requested ref (tag /
418
- * SHA pin): restore overlays only. Do not fetch/merge — a same-named
419
- * branch could otherwise fast-forward the pin past the tag.
420
- * - a `options.branch` that DIFFERS from the current pin: fetch that ref,
421
- * then `checkout -B <branch> origin/<branch>` — so the requested branch,
422
- * not the one already checked out, is what ends up in the working tree.
423
- * This is the switching case, and it refuses a dirty tree unless
424
- * `overwriteLocalChanges` is set.
253
+ * If targetDir doesn't exist: git clone --depth 1
254
+ * If targetDir exists with .git: git pull --ff-only (after verifying the
255
+ * existing clone's remote.origin matches the requested URL).
425
256
  *
426
257
  * Security:
427
258
  * - targetDir must resolve inside CLONE_ROOT (~/.gitnexus/repos/). The
@@ -490,47 +321,39 @@ export async function cloneOrPull(url, targetDir, onProgress, options) {
490
321
  await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs);
491
322
  onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
492
323
  const runGitImpl = options?.runGitForTest ?? runGit;
493
- const gitOpts = {
494
- token: options?.token,
495
- url,
496
- timeoutMs: options?.timeoutMs,
497
- };
498
- // Already at the requested pin? Then there is no switch to make, so do
499
- // not take the checkout path below — that would run the porcelain check
500
- // against a tree ANALYZE ITSELF dirtied (it writes AGENTS.md / CLAUDE.md /
501
- // .claude/ into the clone), which made a pinned RE-index impossible: the
502
- // first pin succeeded and every later one failed asking for
503
- // `overwrite_local_changes`, a flag this route deliberately does not pass
504
- // because it would `git clean --force -d` the directory (#3199 review).
505
- //
506
- // "Already there" is a named-branch match OR a detached HEAD whose SHA
507
- // equals `branch` / `origin/<branch>` (tag / SHA pin). A missing ref
508
- // falls through to the switch path, which still refuses a dirty tree.
509
- //
510
- // Same-named-branch update uses the heads/ → remotes/ fetch refspec plus
511
- // `checkout -B <branch> origin/<branch>`, never `pull origin <user-string>`
512
- // (a leading `+` would otherwise be a force-fetch). Only
513
- // `remote.origin.url` is verified above; `branch.<name>.remote` /
514
- // `.merge` are not, so an implicit-upstream pull can update a different
515
- // ref while the job still carries this branch (#3199 review).
516
- const requestedRefMatch = options?.branch
517
- ? await matchRequestedRef(runGitImpl, safeTarget, options.branch, gitOpts)
518
- : undefined;
519
- if (options?.branch && !requestedRefMatch) {
324
+ if (options?.branch) {
520
325
  if (!options.overwriteLocalChanges) {
521
- const status = await runGitImpl(['status', '--porcelain'], safeTarget, gitOpts);
326
+ const status = await runGitImpl(['status', '--porcelain'], safeTarget, {
327
+ token: options?.token,
328
+ url,
329
+ timeoutMs: options?.timeoutMs,
330
+ });
522
331
  if (status.trim()) {
523
332
  throw new Error(`Refusing to update ${safeTarget}: local changes detected. Set overwrite_local_changes: true to overwrite them.`);
524
333
  }
525
334
  }
526
- await runGitImpl(['fetch', '--depth', '1', 'origin', branchFetchRefspec(options.branch)], safeTarget, gitOpts);
335
+ await runGitImpl([
336
+ 'fetch',
337
+ '--depth',
338
+ '1',
339
+ 'origin',
340
+ `refs/heads/${options.branch}:refs/remotes/origin/${options.branch}`,
341
+ ], safeTarget, {
342
+ token: options?.token,
343
+ url,
344
+ timeoutMs: options?.timeoutMs,
345
+ });
527
346
  await runGitImpl([
528
347
  'checkout',
529
348
  ...(options.overwriteLocalChanges ? ['--force'] : []),
530
349
  '-B',
531
350
  options.branch,
532
351
  `origin/${options.branch}`,
533
- ], safeTarget, gitOpts);
352
+ ], safeTarget, {
353
+ token: options?.token,
354
+ url,
355
+ timeoutMs: options?.timeoutMs,
356
+ });
534
357
  if (options.overwriteLocalChanges) {
535
358
  // `checkout --force` rewrites tracked files only, so untracked sources
536
359
  // left by an operator or an earlier branch survive and then get indexed
@@ -538,20 +361,19 @@ export async function cloneOrPull(url, targetDir, onProgress, options) {
538
361
  // ignored paths must survive, and `-e /.gitnexus` is belt-and-braces
539
362
  // because `.git/info/exclude` is skipped on a read-only storage mount
540
363
  // and a freshly cloned repo may not have been analyzed yet at all.
541
- await runGitImpl(['clean', '--force', '-d', '-e', '/.gitnexus'], safeTarget, gitOpts);
364
+ await runGitImpl(['clean', '--force', '-d', '-e', '/.gitnexus'], safeTarget, {
365
+ token: options?.token,
366
+ url,
367
+ timeoutMs: options?.timeoutMs,
368
+ });
542
369
  }
543
370
  }
544
- else if (options?.branch && requestedRefMatch === 'branch') {
545
- await restoreGitNexusGeneratedOverlays(runGitImpl, safeTarget, gitOpts);
546
- await fetchAndCheckoutRequestedBranch(runGitImpl, safeTarget, options.branch, gitOpts);
547
- }
548
- else if (options?.branch && requestedRefMatch === 'sha') {
549
- // Tag / SHA pin: already at the requested commit. Fetching
550
- // `refs/heads/<name>` would follow a same-named branch past the pin.
551
- await restoreGitNexusGeneratedOverlays(runGitImpl, safeTarget, gitOpts);
552
- }
553
371
  else {
554
- await runGitImpl(['pull', '--ff-only'], safeTarget, gitOpts);
372
+ await runGitImpl(['pull', '--ff-only'], safeTarget, {
373
+ token: options?.token,
374
+ url,
375
+ timeoutMs: options?.timeoutMs,
376
+ });
555
377
  }
556
378
  }
557
379
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.10",
3
+ "version": "1.6.12-rc.11",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -1,22 +0,0 @@
1
- /**
2
- * Git ref-name validation used by both the CLI and the HTTP analyze route.
3
- *
4
- * Lives in `core/` so `server/api.ts` does not import `cli/analyze-config`
5
- * (that import closed a cli → server → cli cycle: `cli/serve.ts` already
6
- * imports `createServer`). The CLI keeps a thin wrapper that rethrows
7
- * {@link InvalidBranchError} as `GitNexusRcError`.
8
- */
9
- /**
10
- * Thrown when a user-supplied branch name fails {@link validateBranchName}.
11
- * Callers at a product boundary map this to their own error type (CLI:
12
- * `GitNexusRcError`; HTTP: 400).
13
- */
14
- export declare class InvalidBranchError extends Error {
15
- constructor(message: string);
16
- }
17
- /**
18
- * Validate a user-supplied branch name. Returns the trimmed name or throws
19
- * {@link InvalidBranchError}. Conservative but accepts the shapes real
20
- * branches use (`feature/foo-bar`, `release/1.2`, `develop`).
21
- */
22
- export declare function validateBranchName(value: string, source: string): string;
@@ -1,114 +0,0 @@
1
- /**
2
- * Git ref-name validation used by both the CLI and the HTTP analyze route.
3
- *
4
- * Lives in `core/` so `server/api.ts` does not import `cli/analyze-config`
5
- * (that import closed a cli → server → cli cycle: `cli/serve.ts` already
6
- * imports `createServer`). The CLI keeps a thin wrapper that rethrows
7
- * {@link InvalidBranchError} as `GitNexusRcError`.
8
- */
9
- /** Git refs longer than this are almost certainly a mistake / injection attempt. */
10
- const BRANCH_MAX_LENGTH = 255;
11
- /**
12
- * Thrown when a user-supplied branch name fails {@link validateBranchName}.
13
- * Callers at a product boundary map this to their own error type (CLI:
14
- * `GitNexusRcError`; HTTP: 400).
15
- */
16
- export class InvalidBranchError extends Error {
17
- constructor(message) {
18
- super(message);
19
- this.name = 'InvalidBranchError';
20
- }
21
- }
22
- /**
23
- * Reject control characters and hidden / bidirectional Unicode in a string
24
- * value. These have no legitimate place in a branch name and would otherwise
25
- * let a committed config or HTTP body smuggle invisible controls into
26
- * generated AGENTS.md / CLAUDE.md content.
27
- */
28
- const isHiddenOrControl = (codePoint) => codePoint < 0x20 ||
29
- codePoint === 0x7f ||
30
- (codePoint >= 0x200b && codePoint <= 0x200f) || // zero-width + LRM/RLM
31
- (codePoint >= 0x202a && codePoint <= 0x202e) || // bidi embeddings/overrides
32
- (codePoint >= 0x2060 && codePoint <= 0x2064) || // word-joiner + invisible math
33
- (codePoint >= 0x2066 && codePoint <= 0x206f) || // bidi isolates + deprecated
34
- codePoint === 0xfeff; // BOM / zero-width no-break space
35
- const assertNoHiddenChars = (value, source) => {
36
- for (const ch of value) {
37
- const cp = ch.codePointAt(0);
38
- if (cp !== undefined && isHiddenOrControl(cp)) {
39
- throw new InvalidBranchError(`${source}: value contains control or hidden/bidirectional characters, which are not allowed.`);
40
- }
41
- }
42
- };
43
- /**
44
- * Validate a user-supplied branch name. Returns the trimmed name or throws
45
- * {@link InvalidBranchError}. Conservative but accepts the shapes real
46
- * branches use (`feature/foo-bar`, `release/1.2`, `develop`).
47
- */
48
- export function validateBranchName(value, source) {
49
- const trimmed = value.trim();
50
- if (!trimmed) {
51
- throw new InvalidBranchError(`${source}: branch name must not be empty.`);
52
- }
53
- if (trimmed.length > BRANCH_MAX_LENGTH) {
54
- throw new InvalidBranchError(`${source}: branch name is too long (max ${BRANCH_MAX_LENGTH}).`);
55
- }
56
- assertNoHiddenChars(trimmed, source);
57
- if (/\s/.test(trimmed)) {
58
- throw new InvalidBranchError(`${source}: branch name must not contain whitespace.`);
59
- }
60
- // git ref-name rules (subset): reject characters git itself forbids in refs.
61
- if (/[~^:?*[\\]/.test(trimmed)) {
62
- throw new InvalidBranchError(`${source}: branch name contains characters not allowed in a git ref (~ ^ : ? * [ \\).`);
63
- }
64
- if (trimmed.startsWith('-')) {
65
- throw new InvalidBranchError(`${source}: branch name must not start with "-".`);
66
- }
67
- // Force-refspec prefix (`git fetch origin +main` / `+refs/heads/main:…`).
68
- // Rejected here so neither the CLI nor HTTP can pass a force-update refspec
69
- // through as a "branch" (#3199 review, defense in depth).
70
- if (trimmed.startsWith('+')) {
71
- throw new InvalidBranchError(`${source}: branch name must not start with "+".`);
72
- }
73
- // The symbolic ref HEAD (case-sensitive). A repo can have a branch named
74
- // `head`; git itself treats only `HEAD` as the current-commit alias.
75
- if (trimmed === 'HEAD') {
76
- throw new InvalidBranchError(`${source}: branch name must not be "HEAD".`);
77
- }
78
- if (trimmed.includes('..')) {
79
- throw new InvalidBranchError(`${source}: branch name must not contain "..".`);
80
- }
81
- // The remaining `git check-ref-format` rules. Without these the validator
82
- // accepted refs git itself refuses (`feature.lock`, `/feature`, `feature/`,
83
- // `feature//next`, `@`, `.hidden`), so the failure surfaced later from the
84
- // git subprocess instead of here. No real branch can violate them — git
85
- // could not have created one — so nothing that works today starts failing.
86
- if (trimmed.endsWith('.lock') || trimmed.split('/').some((part) => part.endsWith('.lock'))) {
87
- throw new InvalidBranchError(`${source}: branch name must not end with ".lock".`);
88
- }
89
- if (trimmed.startsWith('/') || trimmed.endsWith('/')) {
90
- throw new InvalidBranchError(`${source}: branch name must not start or end with "/".`);
91
- }
92
- if (trimmed.includes('//')) {
93
- throw new InvalidBranchError(`${source}: branch name must not contain consecutive slashes.`);
94
- }
95
- if (trimmed === '@') {
96
- throw new InvalidBranchError(`${source}: branch name must not be the single character "@".`);
97
- }
98
- if (trimmed.includes('@{')) {
99
- throw new InvalidBranchError(`${source}: branch name must not contain "@{".`);
100
- }
101
- if (trimmed.endsWith('.') || trimmed.split('/').some((part) => part.startsWith('.'))) {
102
- throw new InvalidBranchError(`${source}: branch name must not end with "." or have a path component starting with ".".`);
103
- }
104
- // Git permits a backtick in a ref, but the branch is embedded inside a
105
- // Markdown inline-code span in the generated AGENTS.md/CLAUDE.md regression
106
- // example, where a backtick would close the span early and let the rest of
107
- // the template render as instruction text. Reject it at this single
108
- // chokepoint so all three tiers (CLI flag, .gitnexusrc, auto-detect via
109
- // sanitizeDetectedBranch) are covered (#1996 tri-review P1).
110
- if (trimmed.includes('`')) {
111
- throw new InvalidBranchError(`${source}: branch name must not contain a backtick (it would break the generated Markdown).`);
112
- }
113
- return trimmed;
114
- }