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