gitnexus 1.6.12-rc.2 → 1.6.12-rc.21
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/_shared/graph/types.d.ts +1 -1
- package/dist/_shared/graph/types.d.ts.map +1 -1
- package/dist/_shared/language-detection.d.ts.map +1 -1
- package/dist/_shared/language-detection.js +2 -0
- package/dist/_shared/language-detection.js.map +1 -1
- package/dist/_shared/languages.d.ts +1 -0
- package/dist/_shared/languages.d.ts.map +1 -1
- package/dist/_shared/languages.js +1 -0
- package/dist/_shared/languages.js.map +1 -1
- package/dist/_shared/lbug/schema-constants.d.ts +1 -1
- package/dist/_shared/lbug/schema-constants.d.ts.map +1 -1
- package/dist/_shared/lbug/schema-constants.js +2 -0
- package/dist/_shared/lbug/schema-constants.js.map +1 -1
- package/dist/_shared/scope-resolution/language-classification.d.ts +2 -1
- package/dist/_shared/scope-resolution/language-classification.d.ts.map +1 -1
- package/dist/_shared/scope-resolution/language-classification.js +3 -1
- package/dist/_shared/scope-resolution/language-classification.js.map +1 -1
- package/dist/_shared/scope-resolution/registries/class-registry.d.ts +3 -3
- package/dist/_shared/scope-resolution/registries/class-registry.js +3 -3
- package/dist/_shared/scope-resolution/registries/context.d.ts.map +1 -1
- package/dist/_shared/scope-resolution/registries/context.js +2 -0
- package/dist/_shared/scope-resolution/registries/context.js.map +1 -1
- package/dist/_shared/scope-resolution/resolve-type-ref.d.ts.map +1 -1
- package/dist/_shared/scope-resolution/resolve-type-ref.js +2 -0
- package/dist/_shared/scope-resolution/resolve-type-ref.js.map +1 -1
- package/dist/cli/analyze-config.d.ts +2 -2
- package/dist/cli/analyze-config.js +10 -33
- package/dist/cli/doctor.js +6 -4
- package/dist/cli/eval-server.js +13 -1
- package/dist/cli/i18n/en.d.ts +4 -2
- package/dist/cli/i18n/en.js +4 -2
- package/dist/cli/i18n/resources.d.ts +6 -2
- package/dist/cli/i18n/zh-CN.d.ts +2 -0
- package/dist/cli/i18n/zh-CN.js +4 -2
- package/dist/core/analysis-feature-registry.d.ts +1 -1
- package/dist/core/analysis-feature-registry.js +2 -0
- package/dist/core/auto-sync/starter.js +2 -2
- package/dist/core/embeddings/ast-utils.js +8 -3
- package/dist/core/embeddings/chunker.js +67 -10
- package/dist/core/embeddings/embedding-pipeline.d.ts +1 -1
- package/dist/core/embeddings/embedding-pipeline.js +1 -1
- package/dist/core/embeddings/structural-extractor.js +2 -2
- package/dist/core/embeddings/types.d.ts +4 -2
- package/dist/core/embeddings/types.js +18 -0
- package/dist/core/git-ref.d.ts +22 -0
- package/dist/core/git-ref.js +114 -0
- package/dist/core/git-staleness.js +14 -0
- package/dist/core/group/extractors/graphql-extractor.d.ts +1 -1
- package/dist/core/group/extractors/graphql-extractor.js +149 -13
- package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
- package/dist/core/group/extractors/manifest-extractor.js +2 -2
- package/dist/core/ingestion/call-extractors/zig-static-gating.d.ts +5 -8
- package/dist/core/ingestion/content-language-classification.d.ts +10 -0
- package/dist/core/ingestion/content-language-classification.js +30 -0
- package/dist/core/ingestion/filesystem-walker.d.ts +1 -0
- package/dist/core/ingestion/filesystem-walker.js +1 -1
- package/dist/core/ingestion/import-resolvers/node-workspace-packages.js +3 -2
- package/dist/core/ingestion/language-config.d.ts +82 -13
- package/dist/core/ingestion/language-config.js +217 -13
- package/dist/core/ingestion/language-provider.d.ts +88 -1
- package/dist/core/ingestion/languages/index.d.ts +7 -0
- package/dist/core/ingestion/languages/index.js +30 -1
- package/dist/core/ingestion/languages/objective-c/analysis-features.d.ts +10 -0
- package/dist/core/ingestion/languages/objective-c/analysis-features.js +19 -0
- package/dist/core/ingestion/languages/objective-c/compilation-unit-siblings.d.ts +13 -0
- package/dist/core/ingestion/languages/objective-c/compilation-unit-siblings.js +124 -0
- package/dist/core/ingestion/languages/objective-c/facts.d.ts +148 -0
- package/dist/core/ingestion/languages/objective-c/facts.js +1150 -0
- package/dist/core/ingestion/languages/objective-c/import-target.d.ts +11 -0
- package/dist/core/ingestion/languages/objective-c/import-target.js +122 -0
- package/dist/core/ingestion/languages/objective-c/macro-marker-preprocess.d.ts +24 -0
- package/dist/core/ingestion/languages/objective-c/macro-marker-preprocess.js +261 -0
- package/dist/core/ingestion/languages/objective-c/resolution-config.d.ts +41 -0
- package/dist/core/ingestion/languages/objective-c/resolution-config.js +282 -0
- package/dist/core/ingestion/languages/objective-c/scope-resolver.d.ts +2 -0
- package/dist/core/ingestion/languages/objective-c/scope-resolver.js +503 -0
- package/dist/core/ingestion/languages/objective-c.d.ts +2 -0
- package/dist/core/ingestion/languages/objective-c.js +300 -0
- package/dist/core/ingestion/languages/typescript/query.d.ts +31 -0
- package/dist/core/ingestion/languages/typescript/query.js +7 -1
- package/dist/core/ingestion/languages/typescript/tsconfig.js +3 -2
- package/dist/core/ingestion/languages/typescript.js +1 -1
- package/dist/core/ingestion/languages/zig/query.d.ts +26 -0
- package/dist/core/ingestion/languages/zig/query.js +61 -1
- package/dist/core/ingestion/languages/zig/range-binding.d.ts +8 -0
- package/dist/core/ingestion/languages/zig/range-binding.js +24 -0
- package/dist/core/ingestion/languages/zig/scope-resolver.d.ts +3 -2
- package/dist/core/ingestion/languages/zig/scope-resolver.js +18 -5
- package/dist/core/ingestion/languages/zig/this-alias-bindings.d.ts +58 -0
- package/dist/core/ingestion/languages/zig/this-alias-bindings.js +176 -0
- package/dist/core/ingestion/languages/zig/workspace-static-gating.d.ts +8 -0
- package/dist/core/ingestion/languages/zig/workspace-static-gating.js +82 -0
- package/dist/core/ingestion/model/registration-table.d.ts +3 -3
- package/dist/core/ingestion/model/registration-table.js +9 -5
- package/dist/core/ingestion/model/symbol-table.d.ts +1 -1
- package/dist/core/ingestion/model/symbol-table.js +2 -0
- package/dist/core/ingestion/model/type-registry.d.ts +1 -1
- package/dist/core/ingestion/parsing-processor.d.ts +22 -6
- package/dist/core/ingestion/parsing-processor.js +39 -20
- package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +9 -1
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +307 -96
- package/dist/core/ingestion/pipeline-phases/parse-round-budget.d.ts +42 -0
- package/dist/core/ingestion/pipeline-phases/parse-round-budget.js +50 -0
- package/dist/core/ingestion/pipeline-phases/parse.d.ts +7 -1
- package/dist/core/ingestion/scope-extractor.js +4 -0
- package/dist/core/ingestion/scope-resolution/contract/scope-resolver.d.ts +13 -0
- package/dist/core/ingestion/scope-resolution/graph-bridge/ids.js +2 -0
- package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.js +2 -0
- package/dist/core/ingestion/scope-resolution/passes/property-dispatch.d.ts +78 -5
- package/dist/core/ingestion/scope-resolution/passes/property-dispatch.js +262 -7
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +5 -2
- package/dist/core/ingestion/scope-resolution/pipeline/registry.js +2 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +10 -1
- package/dist/core/ingestion/scope-resolution/scope/walkers.d.ts +39 -3
- package/dist/core/ingestion/scope-resolution/scope/walkers.js +82 -3
- package/dist/core/ingestion/scope-resolution/value-ref-edges.d.ts +14 -0
- package/dist/core/ingestion/scope-resolution/value-ref-edges.js +14 -0
- package/dist/core/ingestion/tree-sitter-queries.js +2 -0
- package/dist/core/ingestion/utils/symbol-labels.js +2 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +1 -1
- package/dist/core/ingestion/workers/parse-worker.js +63 -3
- package/dist/core/ingestion/workers/worker-pool.d.ts +56 -2
- package/dist/core/ingestion/workers/worker-pool.js +117 -26
- package/dist/core/lbug/csv-generator.js +2 -0
- package/dist/core/lbug/schema.d.ts +3 -1
- package/dist/core/lbug/schema.js +27 -0
- package/dist/core/search/fts-indexes.js +13 -4
- package/dist/core/search/fts-schema.js +2 -0
- package/dist/core/tree-sitter/parser-loader.js +10 -0
- package/dist/core/tree-sitter/vendored-grammars.d.ts +1 -1
- package/dist/core/tree-sitter/vendored-grammars.js +2 -1
- package/dist/mcp/local/local-backend.d.ts +33 -0
- package/dist/mcp/local/local-backend.js +166 -5
- package/dist/mcp/tools.js +6 -4
- 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 +68 -21
- package/dist/server/git-clone.d.ts +38 -5
- package/dist/server/git-clone.js +216 -38
- package/dist/server/repo-projection.d.ts +88 -0
- package/dist/server/repo-projection.js +53 -0
- package/dist/storage/file-lock.js +5 -3
- package/dist/storage/parse-cache.js +32 -1
- package/dist/storage/parsedfile-store.js +53 -1
- package/dist/storage/v8-sidecar.d.ts +11 -0
- package/dist/storage/v8-sidecar.js +15 -7
- package/dist/utils/process-identity.d.ts +13 -0
- package/dist/utils/process-identity.js +18 -0
- package/package.json +1 -1
- package/scripts/build-tree-sitter-grammars.cjs +4 -3
- package/vendor/tree-sitter-objc/LICENSE +21 -0
- package/vendor/tree-sitter-objc/README.md +18 -0
- package/vendor/tree-sitter-objc/binding.gyp +35 -0
- package/vendor/tree-sitter-objc/bindings/node/binding.cc +19 -0
- package/vendor/tree-sitter-objc/bindings/node/binding_test.js +9 -0
- package/vendor/tree-sitter-objc/bindings/node/index.d.ts +27 -0
- package/vendor/tree-sitter-objc/bindings/node/index.js +11 -0
- package/vendor/tree-sitter-objc/grammar.js +1286 -0
- package/vendor/tree-sitter-objc/package.json +56 -0
- package/vendor/tree-sitter-objc/prebuilds/SHA256SUMS +6 -0
- package/vendor/tree-sitter-objc/prebuilds/darwin-arm64/tree-sitter-objc.node +0 -0
- package/vendor/tree-sitter-objc/prebuilds/darwin-x64/tree-sitter-objc.node +0 -0
- package/vendor/tree-sitter-objc/prebuilds/linux-arm64/tree-sitter-objc.node +0 -0
- package/vendor/tree-sitter-objc/prebuilds/linux-x64/tree-sitter-objc.node +0 -0
- package/vendor/tree-sitter-objc/prebuilds/win32-arm64/tree-sitter-objc.node +0 -0
- package/vendor/tree-sitter-objc/prebuilds/win32-x64/tree-sitter-objc.node +0 -0
- package/vendor/tree-sitter-objc/queries/folds.scm +20 -0
- package/vendor/tree-sitter-objc/queries/highlights.scm +216 -0
- package/vendor/tree-sitter-objc/queries/indents.scm +1 -0
- package/vendor/tree-sitter-objc/queries/injections.scm +10 -0
- package/vendor/tree-sitter-objc/queries/locals.scm +1 -0
- package/vendor/tree-sitter-objc/src/grammar.json +16292 -0
- package/vendor/tree-sitter-objc/src/node-types.json +7524 -0
- package/vendor/tree-sitter-objc/src/parser.c +684023 -0
- package/vendor/tree-sitter-objc/src/tree_sitter/alloc.h +54 -0
- package/vendor/tree-sitter-objc/src/tree_sitter/array.h +291 -0
- package/vendor/tree-sitter-objc/src/tree_sitter/parser.h +266 -0
- package/vendor/tree-sitter-objc/tree-sitter-objc.wasm +0 -0
- package/vendor/tree-sitter-objc/tree-sitter.json +38 -0
- package/web/assets/{agent-BMRqXorA.js → agent-C2n33ANd.js} +1 -1
- package/web/assets/{index-Dj3vQOHK.js → index-CHJoW_P1.js} +18 -18
- package/web/assets/src-jc8Ffy3T.js +1 -0
- package/web/index.html +2 -2
- package/web/assets/src-Df5C1Nz4.js +0 -1
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 {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response projections for the `serve` repo routes.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the route bodies so the field list is assertable. Inline, the
|
|
5
|
+
* projections were only reachable by booting a server and indexing a real
|
|
6
|
+
* repository, which is how `branch` came to sit on `RegistryEntry` unexposed
|
|
7
|
+
* over HTTP while `gitnexus list` printed it, with no test to notice (#3226).
|
|
8
|
+
*
|
|
9
|
+
* These are pure: the caller resolves the registry entry, the on-disk metadata
|
|
10
|
+
* and the staleness check, and passes the results in.
|
|
11
|
+
*/
|
|
12
|
+
import type { StalenessInfo } from '../core/git-staleness.js';
|
|
13
|
+
import type { RegistryEntry } from '../storage/repo-manager.js';
|
|
14
|
+
import type { RepoMeta } from '../storage/repo-meta.js';
|
|
15
|
+
/**
|
|
16
|
+
* Staleness in the shape MCP `list_repos` already returns
|
|
17
|
+
* (`mcp/local/local-backend.ts`): the key is present only when the index is
|
|
18
|
+
* actually behind, so "fresh" stays the absence of a field rather than a second
|
|
19
|
+
* thing for a client to interpret. Deliberately identical across the two
|
|
20
|
+
* surfaces — the same fact should not have two shapes.
|
|
21
|
+
*
|
|
22
|
+
* `checkStalenessAsync` self-catches and reports 0 commits behind when the
|
|
23
|
+
* commit cannot be resolved, so an unanswerable check degrades to "not stale"
|
|
24
|
+
* rather than failing the request that carries it.
|
|
25
|
+
*
|
|
26
|
+
* That makes the field meaningful mainly for `path`-registered repositories,
|
|
27
|
+
* where an operator commits into the working tree the index was built from.
|
|
28
|
+
* A `url` repository is cloned `--depth 1` and is re-analyzed by the same run
|
|
29
|
+
* that pulls it, so its recorded commit is HEAD; and were the two ever to
|
|
30
|
+
* diverge, `git rev-list <old>..HEAD` cannot walk a shallow history and fails
|
|
31
|
+
* closed to "not stale". Reporting fresh there is not a claim that the remote
|
|
32
|
+
* has not moved — this measures the index against the local working tree, the
|
|
33
|
+
* same thing `gitnexus status` and MCP `list_repos` measure.
|
|
34
|
+
*/
|
|
35
|
+
export declare const stalenessField: (info: StalenessInfo) => {
|
|
36
|
+
staleness?: {
|
|
37
|
+
commitsBehind: number;
|
|
38
|
+
hint?: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/** One entry of `GET /api/repos`. */
|
|
42
|
+
export declare const projectRepoListEntry: (entry: RegistryEntry, staleness: StalenessInfo) => {
|
|
43
|
+
staleness?: {
|
|
44
|
+
commitsBehind: number;
|
|
45
|
+
hint?: string;
|
|
46
|
+
};
|
|
47
|
+
name: string;
|
|
48
|
+
path: string;
|
|
49
|
+
repoPath: string;
|
|
50
|
+
indexedAt: string;
|
|
51
|
+
lastCommit: string;
|
|
52
|
+
stats: {
|
|
53
|
+
files?: number;
|
|
54
|
+
nodes?: number;
|
|
55
|
+
edges?: number;
|
|
56
|
+
communities?: number;
|
|
57
|
+
processes?: number;
|
|
58
|
+
embeddings?: number;
|
|
59
|
+
};
|
|
60
|
+
branch: string;
|
|
61
|
+
branches: import("../storage/branch-index.js").BranchSummary[];
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* `GET /api/repo`. `meta ?? entry` throughout, for the reason `indexedAt`
|
|
65
|
+
* already did it: the on-disk metadata is the fresher record when the two
|
|
66
|
+
* disagree, and the entry is the fallback for a repo whose meta cannot be read.
|
|
67
|
+
*/
|
|
68
|
+
export declare const projectRepoDetail: (entry: RegistryEntry, meta: RepoMeta | null | undefined, staleness: StalenessInfo) => {
|
|
69
|
+
staleness?: {
|
|
70
|
+
commitsBehind: number;
|
|
71
|
+
hint?: string;
|
|
72
|
+
};
|
|
73
|
+
name: string;
|
|
74
|
+
repoPath: string;
|
|
75
|
+
indexedAt: string;
|
|
76
|
+
stats: {
|
|
77
|
+
files?: number;
|
|
78
|
+
nodes?: number;
|
|
79
|
+
edges?: number;
|
|
80
|
+
communities?: number;
|
|
81
|
+
processes?: number;
|
|
82
|
+
embeddings?: number;
|
|
83
|
+
};
|
|
84
|
+
lastCommit: string;
|
|
85
|
+
branch: string;
|
|
86
|
+
};
|
|
87
|
+
/** The commit a `/api/repo` staleness check should be measured against. */
|
|
88
|
+
export declare const resolveLastCommit: (entry: RegistryEntry, meta: RepoMeta | null | undefined) => string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness in the shape MCP `list_repos` already returns
|
|
3
|
+
* (`mcp/local/local-backend.ts`): the key is present only when the index is
|
|
4
|
+
* actually behind, so "fresh" stays the absence of a field rather than a second
|
|
5
|
+
* thing for a client to interpret. Deliberately identical across the two
|
|
6
|
+
* surfaces — the same fact should not have two shapes.
|
|
7
|
+
*
|
|
8
|
+
* `checkStalenessAsync` self-catches and reports 0 commits behind when the
|
|
9
|
+
* commit cannot be resolved, so an unanswerable check degrades to "not stale"
|
|
10
|
+
* rather than failing the request that carries it.
|
|
11
|
+
*
|
|
12
|
+
* That makes the field meaningful mainly for `path`-registered repositories,
|
|
13
|
+
* where an operator commits into the working tree the index was built from.
|
|
14
|
+
* A `url` repository is cloned `--depth 1` and is re-analyzed by the same run
|
|
15
|
+
* that pulls it, so its recorded commit is HEAD; and were the two ever to
|
|
16
|
+
* diverge, `git rev-list <old>..HEAD` cannot walk a shallow history and fails
|
|
17
|
+
* closed to "not stale". Reporting fresh there is not a claim that the remote
|
|
18
|
+
* has not moved — this measures the index against the local working tree, the
|
|
19
|
+
* same thing `gitnexus status` and MCP `list_repos` measure.
|
|
20
|
+
*/
|
|
21
|
+
export const stalenessField = (info) => info.isStale ? { staleness: { commitsBehind: info.commitsBehind, hint: info.hint } } : {};
|
|
22
|
+
/** One entry of `GET /api/repos`. */
|
|
23
|
+
export const projectRepoListEntry = (entry, staleness) => ({
|
|
24
|
+
name: entry.name,
|
|
25
|
+
path: entry.path,
|
|
26
|
+
repoPath: entry.path,
|
|
27
|
+
indexedAt: entry.indexedAt,
|
|
28
|
+
lastCommit: entry.lastCommit,
|
|
29
|
+
stats: entry.stats,
|
|
30
|
+
// The registry has carried these since #2106; #3199 made them load-bearing
|
|
31
|
+
// over HTTP, because a branch-pinned analyze now gets its own entry and the
|
|
32
|
+
// only other way to tell two entries apart is to parse the clone-directory
|
|
33
|
+
// slug — a layout detail, not an API contract.
|
|
34
|
+
branch: entry.branch,
|
|
35
|
+
branches: entry.branches,
|
|
36
|
+
...stalenessField(staleness),
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* `GET /api/repo`. `meta ?? entry` throughout, for the reason `indexedAt`
|
|
40
|
+
* already did it: the on-disk metadata is the fresher record when the two
|
|
41
|
+
* disagree, and the entry is the fallback for a repo whose meta cannot be read.
|
|
42
|
+
*/
|
|
43
|
+
export const projectRepoDetail = (entry, meta, staleness) => ({
|
|
44
|
+
name: entry.name,
|
|
45
|
+
repoPath: entry.path,
|
|
46
|
+
indexedAt: meta?.indexedAt ?? entry.indexedAt,
|
|
47
|
+
stats: meta?.stats ?? entry.stats ?? {},
|
|
48
|
+
lastCommit: meta?.lastCommit ?? entry.lastCommit,
|
|
49
|
+
branch: meta?.branch ?? entry.branch,
|
|
50
|
+
...stalenessField(staleness),
|
|
51
|
+
});
|
|
52
|
+
/** The commit a `/api/repo` staleness check should be measured against. */
|
|
53
|
+
export const resolveLastCommit = (entry, meta) => meta?.lastCommit ?? entry.lastCommit;
|
|
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
-
import { isProcessAlive,
|
|
6
|
+
import { isProcessAlive, readProcessStartTimeCached } from '../utils/process-identity.js';
|
|
7
7
|
const HOSTNAME = os.hostname();
|
|
8
8
|
export class FileLockBusyError extends Error {
|
|
9
9
|
lockPath;
|
|
@@ -22,7 +22,9 @@ export async function acquireFileLock(lockPath, options = {}) {
|
|
|
22
22
|
const owner = {
|
|
23
23
|
pid,
|
|
24
24
|
ownerId: crypto.randomUUID(),
|
|
25
|
-
processStartTime: options.processStartTime ??
|
|
25
|
+
processStartTime: options.processStartTime ??
|
|
26
|
+
(options.readProcessStartTime ?? readProcessStartTimeCached)(pid) ??
|
|
27
|
+
'',
|
|
26
28
|
hostname: options.hostname ?? HOSTNAME,
|
|
27
29
|
};
|
|
28
30
|
if (!owner.processStartTime) {
|
|
@@ -40,7 +42,7 @@ export async function acquireFileLock(lockPath, options = {}) {
|
|
|
40
42
|
catch (error) {
|
|
41
43
|
if (!(await isLockConflict(error, resolvedPath)))
|
|
42
44
|
throw error;
|
|
43
|
-
if (await reclaimStaleLock(resolvedPath, owner, options.isProcessAlive ?? isProcessAlive, options.readProcessStartTime ??
|
|
45
|
+
if (await reclaimStaleLock(resolvedPath, owner, options.isProcessAlive ?? isProcessAlive, options.readProcessStartTime ?? readProcessStartTimeCached)) {
|
|
44
46
|
continue;
|
|
45
47
|
}
|
|
46
48
|
if (attempt >= retries)
|
|
@@ -730,7 +730,38 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|
|
730
730
|
// v93: Zig call captures inside a comptime-false branch carry
|
|
731
731
|
// `@reference.static-gated` (feat/zig-static-gated-edges); the site gains
|
|
732
732
|
// `staticGated` and the CALLS edge a BOOLEAN column.
|
|
733
|
-
|
|
733
|
+
// v94: Objective-C now elides bare, file-scope macro markers before parsing.
|
|
734
|
+
// A warm v93 cache can retain error-recovered trees and provider facts that
|
|
735
|
+
// omit Objective-C declarations following markers such as RCT_EXTERN_C_END.
|
|
736
|
+
// v95: Objective-C header classification no longer treats framework `#import`
|
|
737
|
+
// alone as Objective-C syntax. A warm v94 cache can replay Objective-C worker
|
|
738
|
+
// output for a C++ header during `--force`, even though the current classifier
|
|
739
|
+
// routes that same header through the C++ provider.
|
|
740
|
+
// v96: Objective-C macro-marker preprocessing recognizes all C preprocessing
|
|
741
|
+
// whitespace before a directive or bare marker. A warm v95 cache can retain
|
|
742
|
+
// error-recovered facts for sources that begin those lines with form feed or
|
|
743
|
+
// vertical tab.
|
|
744
|
+
// v97: Objective-C macro-marker preprocessing recognizes comment-prefixed
|
|
745
|
+
// directives and rejects invalid numeric marker prefixes. A warm v96 cache can
|
|
746
|
+
// replay error-recovered facts from the previous normalization behavior.
|
|
747
|
+
// v98 (#3219): `ZIG_SCOPE_QUERY` gained three `@reference.value-ref` rules —
|
|
748
|
+
// bare call argument, qualified call argument (with `@reference.receiver`), and
|
|
749
|
+
// const-binding initialiser — so a Zig callable named in VALUE position now
|
|
750
|
+
// produces a `value-ref` entry in `ParsedFile.referenceSites` where it produced
|
|
751
|
+
// none before. These captures are PARSE-TIME facts, so a warm pre-v98 cache
|
|
752
|
+
// replays unchanged `.zig` files with zero value-ref sites, `--force` included
|
|
753
|
+
// (shards are content-addressed): `emitPropertyDispatchCalls` then emits no
|
|
754
|
+
// USES edge, `callableValueReferenceBoundaries` measures a real zero, and
|
|
755
|
+
// `impact` on a registered accessor republishes `epistemic: "exact"` — the exact
|
|
756
|
+
// #3399 defect this change exists to close, silently un-fixed.
|
|
757
|
+
//
|
|
758
|
+
// 94-97 went to #3179 (Objective-C), which has since merged; 98 is the next
|
|
759
|
+
// value above it. Re-checked against origin/main and every open PR touching
|
|
760
|
+
// this file at merge time, which is the rule the paragraphs above were written
|
|
761
|
+
// by PRs that each checked only once: main is at 97, and the remaining open
|
|
762
|
+
// claims (#3190 at 94, #2840 at 71, #1616 at 2) all sit below it and must
|
|
763
|
+
// re-bump themselves.
|
|
764
|
+
const SCHEMA_BUMP = 98;
|
|
734
765
|
const GITNEXUS_PKG_VERSION = (() => {
|
|
735
766
|
try {
|
|
736
767
|
// package.json sits at gitnexus/package.json — two levels up from
|
|
@@ -149,6 +149,7 @@ export const getParsedFileStoreDir = (storagePath) => path.join(storagePath, STO
|
|
|
149
149
|
/** Remove any prior run's shards so a fresh parse starts clean. Idempotent. */
|
|
150
150
|
export const clearParsedFileStore = async (storagePath) => {
|
|
151
151
|
await fs.rm(getParsedFileStoreDir(storagePath), { recursive: true, force: true });
|
|
152
|
+
forgetShardListings();
|
|
152
153
|
};
|
|
153
154
|
const isV8ShardName = (name) => name.endsWith('.v8') && !name.includes('.v8.');
|
|
154
155
|
const shardPath = (storagePath, shardId) => path.join(getParsedFileStoreDir(storagePath), `${shardId}.v8`);
|
|
@@ -200,11 +201,46 @@ const listV8Shards = async (dir) => {
|
|
|
200
201
|
return [];
|
|
201
202
|
}
|
|
202
203
|
};
|
|
204
|
+
/**
|
|
205
|
+
* Per-run memo of each shard's authenticated path listing, so the SECOND and
|
|
206
|
+
* later passes over the store can decide "this shard holds nothing I want"
|
|
207
|
+
* without reopening it.
|
|
208
|
+
*
|
|
209
|
+
* Scope resolution calls `loadParsedFilesForPaths` once per language, and each
|
|
210
|
+
* call walks every shard in the store. The skip decision needs the envelope's
|
|
211
|
+
* path listing, and that listing is only trustworthy once the payload digest
|
|
212
|
+
* has been checked — so today a pass that wants 50 Python files still reads and
|
|
213
|
+
* SHA-256s all ~300MB of a TypeScript-dominated store to prove it can skip it.
|
|
214
|
+
* Measured on a 2234-file repo: a pass wanting a single file costs 335ms, and
|
|
215
|
+
* the three language passes together spend 4426ms in here. The cost scales with
|
|
216
|
+
* LANGUAGE COUNT, so a polyglot repo pays it worst.
|
|
217
|
+
*
|
|
218
|
+
* Keyed by size+mtime as well as name. Shard names are content-addressed
|
|
219
|
+
* (parse-chunk hash + worker id), so a name collision across different content
|
|
220
|
+
* should be impossible — but that invariant lives in the parse-cache keying,
|
|
221
|
+
* not here, and one `stat` per shard is a few ms against the hundreds this
|
|
222
|
+
* saves. The memo holds one store directory at a time: a different `dir` (a new
|
|
223
|
+
* repo in a long-lived MCP process, or a wiped store) drops the previous set
|
|
224
|
+
* rather than accumulating.
|
|
225
|
+
*/
|
|
226
|
+
let shardListingMemo;
|
|
227
|
+
const shardIdentity = (file, size, mtimeMs) => `${file}\0${size}\0${mtimeMs}`;
|
|
228
|
+
const memoFor = (dir) => {
|
|
229
|
+
if (shardListingMemo?.dir !== dir)
|
|
230
|
+
shardListingMemo = { dir, byIdentity: new Map() };
|
|
231
|
+
return shardListingMemo.byIdentity;
|
|
232
|
+
};
|
|
233
|
+
/** Drop the memo when the store it describes is removed. */
|
|
234
|
+
const forgetShardListings = () => {
|
|
235
|
+
shardListingMemo = undefined;
|
|
236
|
+
};
|
|
203
237
|
export const loadParsedFilesForPaths = async (storagePath, wantPaths) => {
|
|
204
238
|
const out = new Map();
|
|
205
239
|
if (wantPaths.size === 0)
|
|
206
240
|
return out;
|
|
207
|
-
const
|
|
241
|
+
const storeDir = getParsedFileStoreDir(storagePath);
|
|
242
|
+
const shardPaths = await listV8Shards(storeDir);
|
|
243
|
+
const listings = memoFor(storeDir);
|
|
208
244
|
const pool = new Map();
|
|
209
245
|
let droppedSites = 0;
|
|
210
246
|
let filesWithDroppedSites = 0;
|
|
@@ -227,11 +263,27 @@ export const loadParsedFilesForPaths = async (storagePath, wantPaths) => {
|
|
|
227
263
|
}
|
|
228
264
|
};
|
|
229
265
|
for (const shardFull of shardPaths) {
|
|
266
|
+
// Re-stat rather than trusting the name alone, then reuse this run's
|
|
267
|
+
// authenticated listing to skip without opening the file. A shard with no
|
|
268
|
+
// memoized listing — first pass, or an envelope whose listing did not
|
|
269
|
+
// validate — falls through to the full read, so this only ever removes
|
|
270
|
+
// work that a later pass had already proved unnecessary.
|
|
271
|
+
const st = await fs.stat(shardFull).catch(() => undefined);
|
|
272
|
+
const identity = st ? shardIdentity(shardFull, st.size, st.mtimeMs) : undefined;
|
|
273
|
+
const memoized = identity === undefined ? undefined : listings.get(identity);
|
|
274
|
+
if (memoized !== undefined && !memoized.some((p) => wantPaths.has(p))) {
|
|
275
|
+
bytesSinceGc += st?.size ?? 0;
|
|
276
|
+
await maybeYieldAndGc(bytesSinceGc >= parsedFileLoadGc.byteBudget);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
230
279
|
const loaded = await tryLoadV8Cache(shardFull, pool, wantPaths);
|
|
231
280
|
if (loaded === undefined) {
|
|
232
281
|
await maybeYieldAndGc(false);
|
|
233
282
|
continue;
|
|
234
283
|
}
|
|
284
|
+
if (identity !== undefined && loaded.paths !== undefined) {
|
|
285
|
+
listings.set(identity, loaded.paths);
|
|
286
|
+
}
|
|
235
287
|
if (loaded.kind === 'skip') {
|
|
236
288
|
bytesSinceGc += loaded.bytes;
|
|
237
289
|
await maybeYieldAndGc(bytesSinceGc >= parsedFileLoadGc.byteBudget);
|
|
@@ -16,14 +16,25 @@ export declare const encodeCachePathListing: (paths: readonly string[]) => Buffe
|
|
|
16
16
|
* that does not match the remaining lines.
|
|
17
17
|
*/
|
|
18
18
|
export declare const parseCachePathListing: (raw: Buffer) => string[] | null;
|
|
19
|
+
/**
|
|
20
|
+
* `paths` is the envelope's digest-authenticated path listing, present only
|
|
21
|
+
* when it parsed and its entry count matched the recorded `pathCount` — i.e.
|
|
22
|
+
* exactly when the skip decision below is willing to trust it. A caller that
|
|
23
|
+
* loads the same immutable shard more than once per run can memoize it and
|
|
24
|
+
* make its own skip decision without reopening the file (see
|
|
25
|
+
* `parsedfile-store.ts`). Absent means "this envelope has no listing worth
|
|
26
|
+
* trusting", which is fail-closed: load, never skip.
|
|
27
|
+
*/
|
|
19
28
|
export type V8CacheHit = {
|
|
20
29
|
kind: 'hit';
|
|
21
30
|
value: unknown;
|
|
22
31
|
bytes: number;
|
|
32
|
+
paths?: readonly string[];
|
|
23
33
|
};
|
|
24
34
|
export type V8CacheSkip = {
|
|
25
35
|
kind: 'skip';
|
|
26
36
|
bytes: number;
|
|
37
|
+
paths: readonly string[];
|
|
27
38
|
};
|
|
28
39
|
export type V8CacheLoad = V8CacheHit | V8CacheSkip;
|
|
29
40
|
export type V8CacheInspection = {
|