gitnexus 1.6.12-rc.11 → 1.6.12-rc.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/analyze-config.d.ts +2 -2
- package/dist/cli/analyze-config.js +10 -33
- package/dist/core/git-ref.d.ts +22 -0
- package/dist/core/git-ref.js +114 -0
- package/dist/server/analyze-job.d.ts +19 -1
- package/dist/server/analyze-job.js +14 -3
- package/dist/server/analyze-launch.d.ts +14 -0
- package/dist/server/analyze-launch.js +59 -14
- package/dist/server/analyze-worker-ipc.d.ts +6 -5
- package/dist/server/analyze-worker-ipc.js +4 -0
- package/dist/server/api.js +53 -6
- package/dist/server/git-clone.d.ts +38 -5
- package/dist/server/git-clone.js +216 -38
- package/package.json +1 -1
|
@@ -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}.
|
|
45
|
-
*
|
|
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}.
|
|
130
|
-
*
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 —
|
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
}
|
package/dist/server/api.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
123
|
-
* If targetDir
|
|
124
|
-
*
|
|
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
|
package/dist/server/git-clone.js
CHANGED
|
@@ -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
|
-
/**
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
254
|
-
* If targetDir
|
|
255
|
-
*
|
|
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
|
-
|
|
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 {
|
package/package.json
CHANGED