gitnexus 1.6.12-rc.11 → 1.6.12-rc.13

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}. Conservative but accepts the
45
- * shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
44
+ * trimmed name or throws {@link GitNexusRcError}. Rules live in
45
+ * `core/git-ref.ts`; this wrapper keeps the CLI / `.gitnexusrc` error type.
46
46
  */
47
47
  export declare function validateBranchName(value: string, source: string): string;
48
48
  /**
@@ -30,11 +30,10 @@
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';
33
34
  export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
34
35
  /** Final fallback when no branch is configured or detectable. */
35
36
  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;
38
37
  /**
39
38
  * Thrown for any `.gitnexusrc` problem (missing-file is NOT an error — it
40
39
  * returns `undefined`). The message is user-facing and names the file so the
@@ -126,41 +125,19 @@ const assertNoHiddenChars = (value, source) => {
126
125
  };
127
126
  /**
128
127
  * Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
129
- * trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
130
- * shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
128
+ * trimmed name or throws {@link GitNexusRcError}. Rules live in
129
+ * `core/git-ref.ts`; this wrapper keeps the CLI / `.gitnexusrc` error type.
131
130
  */
132
131
  export function validateBranchName(value, source) {
133
- const trimmed = value.trim();
134
- if (!trimmed) {
135
- throw new GitNexusRcError(`${source}: branch name must not be empty.`);
136
- }
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 "..".`);
132
+ try {
133
+ return validateBranchNameCore(value, source);
153
134
  }
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).`);
135
+ catch (err) {
136
+ if (err instanceof InvalidBranchError) {
137
+ throw new GitNexusRcError(err.message);
138
+ }
139
+ throw err;
162
140
  }
163
- return trimmed;
164
141
  }
165
142
  /**
166
143
  * Best-effort validation for an auto-detected branch (from git). Never throws —
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js';
6
6
  import { getGlobalDir } from '../../storage/repo-manager.js';
7
- import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.js';
7
+ import { isProcessAlive, readProcessStartTimeCached } from '../../utils/process-identity.js';
8
8
  import { loadAutoSyncConfig } from './config.js';
9
9
  import { runAutoSyncOnce } from './runner.js';
10
10
  import { getAutoSyncMutexPath, getAutoSyncWatchDir } from './state.js';
@@ -488,7 +488,7 @@ function resolveWatchDeps(deps = {}) {
488
488
  return undefined;
489
489
  }
490
490
  }),
491
- readProcessStartTime: deps.readProcessStartTime ?? readProcessStartTime,
491
+ readProcessStartTime: deps.readProcessStartTime ?? readProcessStartTimeCached,
492
492
  sleep: deps.sleep ??
493
493
  ((ms) => new Promise((resolve) => {
494
494
  setTimeout(resolve, ms);
@@ -0,0 +1,22 @@
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;
@@ -0,0 +1,114 @@
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
+ }
@@ -53,6 +53,13 @@ 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;
56
63
  progress: AnalyzeJobProgress;
57
64
  error?: string;
58
65
  /** Set only when a terminal `failed` job still persisted usable work. */
@@ -70,10 +77,21 @@ export declare class JobManager {
70
77
  private emitter;
71
78
  private cleanupTimer;
72
79
  constructor();
73
- /** Create a new job, or return existing active job for the same repo. */
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
+ */
74
91
  createJob(params: {
75
92
  repoUrl?: string;
76
93
  repoPath?: string;
94
+ branch?: string;
77
95
  }): AnalyzeJob;
78
96
  getJob(id: string): AnalyzeJob | undefined;
79
97
  /** Return a snapshot of all tracked jobs for inspection. */
@@ -34,14 +34,24 @@ export class JobManager {
34
34
  constructor() {
35
35
  this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
36
36
  }
37
- /** Create a new job, or return existing active job for the same repo. */
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
+ */
38
48
  createJob(params) {
39
- // Dedup: return existing active job for the same repo (by URL or path)
49
+ // Dedup: return existing active job for the same repo (by URL or path) and branch
40
50
  for (const job of this.jobs.values()) {
41
51
  if (!this.isTerminal(job.status)) {
42
52
  const isSameRepo = (params.repoUrl && job.repoUrl === params.repoUrl) ||
43
53
  (params.repoPath && job.repoPath === params.repoPath);
44
- if (isSameRepo) {
54
+ if (isSameRepo && job.branch === params.branch) {
45
55
  return job;
46
56
  }
47
57
  }
@@ -57,6 +67,7 @@ export class JobManager {
57
67
  status: 'queued',
58
68
  repoUrl: params.repoUrl,
59
69
  repoPath: params.repoPath,
70
+ branch: params.branch,
60
71
  progress: { phase: 'queued', percent: 0, message: 'Waiting to start...' },
61
72
  startedAt: Date.now(),
62
73
  retryCount: 0,
@@ -31,6 +31,20 @@ 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;
34
48
  }
35
49
  export declare function createLaunchAnalysisWorker(deps: LaunchDeps): (job: {
36
50
  id: string;
@@ -15,6 +15,7 @@ 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';
18
19
  import { logger } from '../core/logger.js';
19
20
  import { autoHeapCapMb } from '../core/ingestion/utils/effective-ram.js';
20
21
  import { isTerminalJobStatus } from './analyze-job.js';
@@ -32,17 +33,6 @@ const MAX_WORKER_RETRIES = 2;
32
33
  */
33
34
  const FINALIZE_SETTLE_TIMEOUT_MS = 60_000;
34
35
  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
- */
46
36
  /**
47
37
  * Look up the analyzed repo's registered storage path. The request's
48
38
  * user-provided path is used only as a comparison key; the filesystem probes
@@ -56,7 +46,36 @@ const registeredStoragePath = async (targetPath) => {
56
46
  const entry = entries.find((e) => registryPathEquals(canonicalizePath(e.path), target));
57
47
  return entry?.storagePath ?? null;
58
48
  };
59
- const waitForSettledIndex = async (targetPath, jobStartMs) => {
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) => {
60
79
  const settled = (storagePath) => {
61
80
  try {
62
81
  const lbugStat = statSync(path.join(storagePath, 'lbug'));
@@ -74,7 +93,7 @@ const waitForSettledIndex = async (targetPath, jobStartMs) => {
74
93
  // Re-resolved each round: the worker registers the repo as part of the
75
94
  // finalization this gate is waiting out.
76
95
  const storagePath = await registeredStoragePath(targetPath);
77
- if (storagePath && settled(storagePath))
96
+ if (storagePath && settled(settleDirFor(storagePath, branch, isPrimaryBranch)))
78
97
  return;
79
98
  if (Date.now() > deadline) {
80
99
  logger.warn({ targetPath }, 'analyze finalization not visible after timeout; completing job anyway');
@@ -121,6 +140,13 @@ export function createLaunchAnalysisWorker(deps) {
121
140
  });
122
141
  // Capture stderr for crash diagnostics
123
142
  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;
124
150
  child.stderr?.on('data', (chunk) => {
125
151
  stderrChunks += chunk.toString();
126
152
  if (stderrChunks.length > 4096)
@@ -134,6 +160,8 @@ export function createLaunchAnalysisWorker(deps) {
134
160
  const current = jobManager.getJob(job.id);
135
161
  if (!current || isTerminalJobStatus(current.status))
136
162
  return;
163
+ if (msg.type === 'complete' || msg.type === 'error')
164
+ terminalIpcSeen = true;
137
165
  if (msg.type === 'progress') {
138
166
  jobManager.updateJob(job.id, {
139
167
  status: 'analyzing',
@@ -151,7 +179,16 @@ export function createLaunchAnalysisWorker(deps) {
151
179
  // below true in practice: the repo is actually queryable when the
152
180
  // client receives the SSE complete event, and an index this run knows
153
181
  // to be incomplete is never published at all.
154
- waitForSettledIndex(targetPath, jobStartMs)
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
155
192
  .then(() => closeDbHandle())
156
193
  .catch(() => { }) // best-effort: eviction failure must not fail the job
157
194
  .then(() => {
@@ -243,6 +280,13 @@ export function createLaunchAnalysisWorker(deps) {
243
280
  const j = jobManager.getJob(job.id);
244
281
  if (!j || isTerminalJobStatus(j.status))
245
282
  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;
246
290
  // Worker crashed — attempt retry if under the limit
247
291
  if (j.retryCount < MAX_WORKER_RETRIES) {
248
292
  j.retryCount++;
@@ -283,6 +327,7 @@ export function createLaunchAnalysisWorker(deps) {
283
327
  ...(opts.springActuatorPath ? { springActuatorPath: opts.springActuatorPath } : {}),
284
328
  ...(opts.asyncApiSpecPath ? { asyncApiSpecPath: opts.asyncApiSpecPath } : {}),
285
329
  ...(opts.registryName ? { registryName: opts.registryName } : {}),
330
+ ...(opts.branch ? { branch: opts.branch } : {}),
286
331
  },
287
332
  });
288
333
  };
@@ -43,12 +43,13 @@ 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 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.
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.
50
51
  */
51
- export type AnalyzeResultIpc = Pick<AnalyzeResult, 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' | 'graphWriteCollapsed'>;
52
+ export type AnalyzeResultIpc = Pick<AnalyzeResult, 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' | 'graphWriteCollapsed' | 'isPrimaryBranch'>;
52
53
  /**
53
54
  * Project an `AnalyzeResult` down to the JSON-safe fields the parent consumes,
54
55
  * dropping `pipelineResult` (the live `KnowledgeGraph`) and any other field not
@@ -16,5 +16,9 @@ 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,
19
23
  };
20
24
  }
@@ -29,8 +29,12 @@ 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 { extractWebRepoName, getCloneDir, cloneOrPull, warnIfInsecureAzureConfig, GITHUB_TOKEN_HOSTS, } from './git-clone.js';
32
+ import { analyzeCloneOptions, 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';
34
38
  import { assertServeAuthForPublicOrigin, createPublicOriginMatcher, createWriteOriginGuard, logOriginPolicy, PUBLIC_ORIGIN_ENV, resolveTrustProxy, TRUST_PROXY_ENV, warnIfRateLimitKeysCollapse, } from './middleware.js';
35
39
  import { createLaunchAnalysisWorker } from './analyze-launch.js';
36
40
  import { UPLOAD_ROOT } from './upload-paths.js';
@@ -1264,7 +1268,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
1264
1268
  // POST /api/analyze — start a new analysis job
1265
1269
  app.post('/api/analyze', createRouteLimiter({ limit: 10 }), requireTrustedOrigin, async (req, res) => {
1266
1270
  try {
1267
- const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings, springActuatorPath, asyncApiSpecPath, token: repoToken, } = req.body;
1271
+ const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings, springActuatorPath, asyncApiSpecPath, token: repoToken, branch: repoBranch, } = req.body;
1268
1272
  // Input type validation
1269
1273
  if (repoUrl !== undefined && typeof repoUrl !== 'string') {
1270
1274
  res.status(400).json({ error: '"url" must be a string' });
@@ -1288,6 +1292,27 @@ export const createServer = async (port, host = '127.0.0.1') => {
1288
1292
  res.status(400).json({ error: 'Provide "url" (git URL) or "path" (local path)' });
1289
1293
  return;
1290
1294
  }
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
+ }
1291
1316
  // Token: optional, restricted charset to prevent header smuggling
1292
1317
  // (CRLF), bound length, and bound to github.com (see validateAnalyzeToken).
1293
1318
  const tokenError = validateAnalyzeToken(repoToken, repoUrl);
@@ -1311,7 +1336,11 @@ export const createServer = async (port, host = '127.0.0.1') => {
1311
1336
  res.status(400).json({ error: '"path" must be an absolute path' });
1312
1337
  return;
1313
1338
  }
1314
- const job = jobManager.createJob({ repoUrl, repoPath: repoLocalPath });
1339
+ const job = jobManager.createJob({
1340
+ repoUrl,
1341
+ repoPath: repoLocalPath,
1342
+ branch: analyzeBranch,
1343
+ });
1315
1344
  // If job was already running (dedup), just return its id. The token is
1316
1345
  // not part of the dedup identity and is never stored on the job, so a
1317
1346
  // token on THIS request had no effect — the existing job already
@@ -1337,17 +1366,21 @@ export const createServer = async (port, host = '127.0.0.1') => {
1337
1366
  // Clone if URL provided
1338
1367
  if (repoUrl && !repoLocalPath) {
1339
1368
  const repoName = extractWebRepoName(repoUrl);
1340
- targetPath = getCloneDir(repoName);
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);
1341
1372
  jobManager.updateJob(job.id, {
1342
1373
  status: 'cloning',
1343
- repoName,
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,
1344
1377
  progress: { phase: 'cloning', percent: 0, message: `Cloning ${repoUrl}...` },
1345
1378
  });
1346
1379
  await cloneOrPull(repoUrl, targetPath, (progress) => {
1347
1380
  jobManager.updateJob(job.id, {
1348
1381
  progress: { phase: progress.phase, percent: 5, message: progress.message },
1349
1382
  });
1350
- }, repoToken ? { token: repoToken } : undefined);
1383
+ }, analyzeCloneOptions(repoToken, analyzeBranch));
1351
1384
  }
1352
1385
  if (!targetPath) {
1353
1386
  throw new Error('No target path resolved');
@@ -1358,6 +1391,20 @@ export const createServer = async (port, host = '127.0.0.1') => {
1358
1391
  dropEmbeddings,
1359
1392
  springActuatorPath,
1360
1393
  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
+ : {}),
1361
1408
  });
1362
1409
  }
1363
1410
  catch (err) {
@@ -25,12 +25,25 @@ 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. */
29
- export declare function getCloneDir(repoName: 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;
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;
34
47
  export interface CloneOrPullOptions {
35
48
  token?: string;
36
49
  allowedCloneRoot?: string;
@@ -119,9 +132,29 @@ export declare function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Pro
119
132
  export declare function assertRemoteMatchesRequestedUrl(targetDir: string, requestedUrl: string, timeoutMs?: number): Promise<void>;
120
133
  /**
121
134
  * Clone or pull a git repository.
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).
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.
125
158
  *
126
159
  * Security:
127
160
  * - targetDir must resolve inside CLONE_ROOT (~/.gitnexus/repos/). The
@@ -10,6 +10,7 @@ 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';
13
14
  import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js';
14
15
  import { validateGitUrl } from '../core/net/url-guard.js';
15
16
  import { assertDirectoryOwnerAndPermissions, quarantineAutoSyncPartial, } from '../core/auto-sync/path-security.js';
@@ -74,14 +75,83 @@ export function extractWebRepoName(url) {
74
75
  }
75
76
  return safeName;
76
77
  }
77
- /** Get the clone target directory for a repo name. */
78
- export function getCloneDir(repoName) {
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) {
79
111
  // Re-validate at the boundary even though extractRepoName already checked —
80
112
  // callers may pass a repoName from another source (test fixtures, scripts).
81
113
  if (!repoName || repoName === '.' || repoName === '..' || !REPO_NAME_PATTERN.test(repoName)) {
82
114
  throw new Error('Invalid repository name');
83
115
  }
84
- return path.join(CLONE_ROOT, repoName);
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
+ };
85
155
  }
86
156
  /**
87
157
  * Build the `git clone` argument list for a given URL and target directory.
@@ -248,11 +318,110 @@ export async function assertRemoteMatchesRequestedUrl(targetDir, requestedUrl, t
248
318
  `not the requested URL ${stripUrlCredentials(requestedUrl)}`);
249
319
  }
250
320
  }
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
+ }
251
400
  /**
252
401
  * Clone or pull a git repository.
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).
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.
256
425
  *
257
426
  * Security:
258
427
  * - targetDir must resolve inside CLONE_ROOT (~/.gitnexus/repos/). The
@@ -321,39 +490,47 @@ export async function cloneOrPull(url, targetDir, onProgress, options) {
321
490
  await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs);
322
491
  onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
323
492
  const runGitImpl = options?.runGitForTest ?? runGit;
324
- if (options?.branch) {
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) {
325
520
  if (!options.overwriteLocalChanges) {
326
- const status = await runGitImpl(['status', '--porcelain'], safeTarget, {
327
- token: options?.token,
328
- url,
329
- timeoutMs: options?.timeoutMs,
330
- });
521
+ const status = await runGitImpl(['status', '--porcelain'], safeTarget, gitOpts);
331
522
  if (status.trim()) {
332
523
  throw new Error(`Refusing to update ${safeTarget}: local changes detected. Set overwrite_local_changes: true to overwrite them.`);
333
524
  }
334
525
  }
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
- });
526
+ await runGitImpl(['fetch', '--depth', '1', 'origin', branchFetchRefspec(options.branch)], safeTarget, gitOpts);
346
527
  await runGitImpl([
347
528
  'checkout',
348
529
  ...(options.overwriteLocalChanges ? ['--force'] : []),
349
530
  '-B',
350
531
  options.branch,
351
532
  `origin/${options.branch}`,
352
- ], safeTarget, {
353
- token: options?.token,
354
- url,
355
- timeoutMs: options?.timeoutMs,
356
- });
533
+ ], safeTarget, gitOpts);
357
534
  if (options.overwriteLocalChanges) {
358
535
  // `checkout --force` rewrites tracked files only, so untracked sources
359
536
  // left by an operator or an earlier branch survive and then get indexed
@@ -361,19 +538,20 @@ export async function cloneOrPull(url, targetDir, onProgress, options) {
361
538
  // ignored paths must survive, and `-e /.gitnexus` is belt-and-braces
362
539
  // because `.git/info/exclude` is skipped on a read-only storage mount
363
540
  // and a freshly cloned repo may not have been analyzed yet at all.
364
- await runGitImpl(['clean', '--force', '-d', '-e', '/.gitnexus'], safeTarget, {
365
- token: options?.token,
366
- url,
367
- timeoutMs: options?.timeoutMs,
368
- });
541
+ await runGitImpl(['clean', '--force', '-d', '-e', '/.gitnexus'], safeTarget, gitOpts);
369
542
  }
370
543
  }
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
+ }
371
553
  else {
372
- await runGitImpl(['pull', '--ff-only'], safeTarget, {
373
- token: options?.token,
374
- url,
375
- timeoutMs: options?.timeoutMs,
376
- });
554
+ await runGitImpl(['pull', '--ff-only'], safeTarget, gitOpts);
377
555
  }
378
556
  }
379
557
  else {
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { setTimeout as sleep } from 'node:timers/promises';
6
- import { isProcessAlive, readProcessStartTime } from '../utils/process-identity.js';
6
+ import { isProcessAlive, readProcessStartTimeCached } from '../utils/process-identity.js';
7
7
  const HOSTNAME = os.hostname();
8
8
  export class FileLockBusyError extends Error {
9
9
  lockPath;
@@ -22,7 +22,9 @@ export async function acquireFileLock(lockPath, options = {}) {
22
22
  const owner = {
23
23
  pid,
24
24
  ownerId: crypto.randomUUID(),
25
- processStartTime: options.processStartTime ?? (options.readProcessStartTime ?? readProcessStartTime)(pid) ?? '',
25
+ processStartTime: options.processStartTime ??
26
+ (options.readProcessStartTime ?? readProcessStartTimeCached)(pid) ??
27
+ '',
26
28
  hostname: options.hostname ?? HOSTNAME,
27
29
  };
28
30
  if (!owner.processStartTime) {
@@ -40,7 +42,7 @@ export async function acquireFileLock(lockPath, options = {}) {
40
42
  catch (error) {
41
43
  if (!(await isLockConflict(error, resolvedPath)))
42
44
  throw error;
43
- if (await reclaimStaleLock(resolvedPath, owner, options.isProcessAlive ?? isProcessAlive, options.readProcessStartTime ?? readProcessStartTime)) {
45
+ if (await reclaimStaleLock(resolvedPath, owner, options.isProcessAlive ?? isProcessAlive, options.readProcessStartTime ?? readProcessStartTimeCached)) {
44
46
  continue;
45
47
  }
46
48
  if (attempt >= retries)
@@ -1,2 +1,15 @@
1
1
  export declare function isProcessAlive(pid: number): boolean;
2
2
  export declare function readProcessStartTime(pid: number): string | undefined;
3
+ /**
4
+ * `readProcessStartTime`, except this process's own start time is probed once.
5
+ * It cannot change while we are running, and every `acquireFileLock` — plus
6
+ * each retry attempt and each stale-lock reclaim guard — stamps the owner file
7
+ * with it. On Windows that probe is a `powershell.exe` spawn and a WMI query,
8
+ * so a process taking several locks pays it several times for one constant.
9
+ *
10
+ * A foreign pid is never cached: that process can exit and its pid can be
11
+ * reused, which is the very thing the stamp exists to detect. A failed probe
12
+ * is not cached either — one transient failure would otherwise leave the
13
+ * process unable to take a lock for its whole lifetime.
14
+ */
15
+ export declare function readProcessStartTimeCached(pid: number): string | undefined;
@@ -33,3 +33,21 @@ export function readProcessStartTime(pid) {
33
33
  return undefined;
34
34
  }
35
35
  }
36
+ let ownStartTime;
37
+ /**
38
+ * `readProcessStartTime`, except this process's own start time is probed once.
39
+ * It cannot change while we are running, and every `acquireFileLock` — plus
40
+ * each retry attempt and each stale-lock reclaim guard — stamps the owner file
41
+ * with it. On Windows that probe is a `powershell.exe` spawn and a WMI query,
42
+ * so a process taking several locks pays it several times for one constant.
43
+ *
44
+ * A foreign pid is never cached: that process can exit and its pid can be
45
+ * reused, which is the very thing the stamp exists to detect. A failed probe
46
+ * is not cached either — one transient failure would otherwise leave the
47
+ * process unable to take a lock for its whole lifetime.
48
+ */
49
+ export function readProcessStartTimeCached(pid) {
50
+ if (pid !== process.pid)
51
+ return readProcessStartTime(pid);
52
+ return (ownStartTime ??= readProcessStartTime(pid));
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.11",
3
+ "version": "1.6.12-rc.13",
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",