gitnexus 1.6.12-rc.27 → 1.6.12-rc.28
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/embeddings-sync.d.ts +2 -0
- package/dist/cli/embeddings-sync.js +127 -0
- package/dist/cli/i18n/en.d.ts +2 -1
- package/dist/cli/i18n/en.js +2 -1
- package/dist/cli/i18n/resources.d.ts +3 -1
- package/dist/cli/i18n/zh-CN.d.ts +1 -0
- package/dist/cli/i18n/zh-CN.js +2 -1
- package/dist/cli/index.js +9 -5
- package/dist/core/embeddings/http-client.js +45 -13
- package/dist/mcp/local/local-backend.js +13 -5
- package/dist/storage/git.d.ts +17 -0
- package/dist/storage/git.js +221 -4
- package/package.json +1 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { cliInfo } from './cli-message.js';
|
|
4
|
+
import { getGitRoot } from '../storage/git.js';
|
|
5
|
+
import { acquireIndexLock } from '../storage/index-lock.js';
|
|
6
|
+
import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js';
|
|
7
|
+
import { closeLbug, executeQuery, executeWithReusedStatement, fetchExistingEmbeddingHashes, initLbug, } from '../core/lbug/lbug-adapter.js';
|
|
8
|
+
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
|
|
9
|
+
import { resolveEmbeddingIdentity } from '../core/embeddings/embedding-identity.js';
|
|
10
|
+
import { checkpointKind, decideEmbeddingResume, mintInterruptedCheckpoint, mintPartialCheckpoint, mintUnverifiedCountCheckpoint, } from '../core/embedding-checkpoint.js';
|
|
11
|
+
import { measurePersistedEmbeddingCount, persistedEmbeddingCountOrUndefined, } from '../core/embedding-count.js';
|
|
12
|
+
/** Add missing embeddings directly to a healthy index, checkpointing periodically. */
|
|
13
|
+
export const embeddingsSyncCommand = async (inputPath) => {
|
|
14
|
+
const repoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
|
|
15
|
+
if (!repoPath)
|
|
16
|
+
throw new Error('Not inside a git repository. Pass a repository path.');
|
|
17
|
+
const { lbugPath, metaPath } = getStoragePaths(repoPath);
|
|
18
|
+
const metaDir = path.dirname(metaPath);
|
|
19
|
+
const lock = await acquireIndexLock(metaDir);
|
|
20
|
+
try {
|
|
21
|
+
const meta = await loadMeta(metaDir);
|
|
22
|
+
if (!meta)
|
|
23
|
+
throw new Error(`No GitNexus index found for ${repoPath}. Run gitnexus analyze first.`);
|
|
24
|
+
if (meta.incrementalInProgress) {
|
|
25
|
+
throw new Error('The structural index is incomplete. Run gitnexus analyze --force first.');
|
|
26
|
+
}
|
|
27
|
+
let lbugStat;
|
|
28
|
+
try {
|
|
29
|
+
lbugStat = await lstat(lbugPath);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error(`The LadybugDB graph store at ${lbugPath} is missing. Run gitnexus analyze first.`);
|
|
33
|
+
}
|
|
34
|
+
if (!lbugStat.isFile()) {
|
|
35
|
+
throw new Error(`The LadybugDB graph store at ${lbugPath} is not a usable database file. Run gitnexus analyze first.`);
|
|
36
|
+
}
|
|
37
|
+
const identity = resolveEmbeddingIdentity();
|
|
38
|
+
let forceReembedNodeIds;
|
|
39
|
+
let resumedFrom;
|
|
40
|
+
if (meta.embeddingCheckpoint) {
|
|
41
|
+
const checkpoint = meta.embeddingCheckpoint;
|
|
42
|
+
const decision = decideEmbeddingResume(checkpoint, identity);
|
|
43
|
+
if (decision.action === 'abort')
|
|
44
|
+
throw new Error(decision.error);
|
|
45
|
+
const identityDiffers = checkpoint.provider !== identity.provider ||
|
|
46
|
+
checkpoint.model !== identity.model ||
|
|
47
|
+
checkpoint.dimensions !== identity.dimensions;
|
|
48
|
+
// `abandon` on a non-interrupted foreign identity drops the pending set
|
|
49
|
+
// only. Existing rows stay; sync would then embed the holes under the new
|
|
50
|
+
// identity and mix vector spaces. Fail closed — rebuild via analyze.
|
|
51
|
+
if (identityDiffers && checkpointKind(checkpoint) !== 'unverified-count') {
|
|
52
|
+
throw new Error(`Cannot sync embeddings: the index checkpoint was written by ${checkpoint.model} ` +
|
|
53
|
+
`(${checkpoint.provider}) at ${checkpoint.dimensions} dimensions, but this run ` +
|
|
54
|
+
`resolves ${identity.model} (${identity.provider}) at ${identity.dimensions}. ` +
|
|
55
|
+
'Run `gitnexus analyze --embeddings --force` to rebuild under the new identity.');
|
|
56
|
+
}
|
|
57
|
+
cliInfo(decision.log);
|
|
58
|
+
if (decision.action === 'resume') {
|
|
59
|
+
forceReembedNodeIds = decision.pendingNodeIds;
|
|
60
|
+
resumedFrom = decision.resumedFrom;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await initLbug(lbugPath);
|
|
64
|
+
try {
|
|
65
|
+
const existing = await fetchExistingEmbeddingHashes(executeQuery);
|
|
66
|
+
let lastPercent = -1;
|
|
67
|
+
const countEmbeddings = async () => persistedEmbeddingCountOrUndefined(await measurePersistedEmbeddingCount(executeQuery));
|
|
68
|
+
const saveCheckpoint = async (checkpoint, pendingNodeIds, embeddings) => {
|
|
69
|
+
const latest = (await loadMeta(metaDir)) ?? meta;
|
|
70
|
+
await saveMeta(metaDir, {
|
|
71
|
+
...latest,
|
|
72
|
+
...(embeddings === undefined ? {} : { stats: { ...latest.stats, embeddings } }),
|
|
73
|
+
embeddingCheckpoint: mintInterruptedCheckpoint(identity, checkpoint, pendingNodeIds),
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
cliInfo(`Embedding ${repoPath}`);
|
|
77
|
+
cliInfo(`Checkpointed nodes already present: ${existing?.size ?? 0}`);
|
|
78
|
+
const result = await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (progress) => {
|
|
79
|
+
const percent = Math.floor(progress.percent);
|
|
80
|
+
if (percent !== lastPercent && (percent % 5 === 0 || percent === 100)) {
|
|
81
|
+
lastPercent = percent;
|
|
82
|
+
cliInfo(` ${percent}% — ${progress.nodesProcessed ?? 0}/${progress.totalNodes ?? '?'} nodes`);
|
|
83
|
+
}
|
|
84
|
+
}, {}, undefined, existing && existing.size ? existing : undefined, {
|
|
85
|
+
forceReembedNodeIds,
|
|
86
|
+
onCheckpointWindowStart: async ({ nodeIds, ...checkpoint }) => {
|
|
87
|
+
await saveCheckpoint(checkpoint, nodeIds);
|
|
88
|
+
},
|
|
89
|
+
onCheckpoint: async (checkpoint) => {
|
|
90
|
+
await saveCheckpoint(checkpoint, [], await countEmbeddings());
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const embeddings = await countEmbeddings();
|
|
94
|
+
const latest = (await loadMeta(metaDir)) ?? meta;
|
|
95
|
+
if (embeddings === undefined) {
|
|
96
|
+
// Keep last-known stats.embeddings. An interrupted window marker would
|
|
97
|
+
// fail the identity gate on the next run even though this run finished;
|
|
98
|
+
// unverified-count is the recovery kind that forces a recount (#2790).
|
|
99
|
+
await saveMeta(metaDir, {
|
|
100
|
+
...latest,
|
|
101
|
+
embeddingCheckpoint: result.failedNodeIds.length
|
|
102
|
+
? mintPartialCheckpoint(identity, result, resumedFrom)
|
|
103
|
+
: mintUnverifiedCountCheckpoint(identity, {
|
|
104
|
+
nodesProcessed: result.nodesProcessed,
|
|
105
|
+
totalNodes: result.nodesProcessed,
|
|
106
|
+
chunksProcessed: result.chunksProcessed,
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
throw new Error('Could not verify persisted embedding count.');
|
|
110
|
+
}
|
|
111
|
+
await saveMeta(metaDir, {
|
|
112
|
+
...latest,
|
|
113
|
+
stats: { ...latest.stats, embeddings },
|
|
114
|
+
embeddingCheckpoint: result.failedNodeIds.length
|
|
115
|
+
? mintPartialCheckpoint(identity, result, resumedFrom)
|
|
116
|
+
: undefined,
|
|
117
|
+
});
|
|
118
|
+
cliInfo(`Embeddings ready: ${embeddings}`);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await closeLbug().catch(() => { });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
lock.release();
|
|
126
|
+
}
|
|
127
|
+
};
|
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -140,6 +140,7 @@ export declare const en: {
|
|
|
140
140
|
readonly 'help.watch.details': "\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n";
|
|
141
141
|
readonly 'error.watch.ambiguous': "`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n";
|
|
142
142
|
readonly 'help.command.analyze.description': "Index a repository (full analysis)";
|
|
143
|
+
readonly 'help.command.embeddings.sync.description': "Add missing embeddings to an existing index, checkpointing periodically for safe resume";
|
|
143
144
|
readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
|
|
144
145
|
readonly 'help.command.serve.description': "Start local HTTP server for web UI connection";
|
|
145
146
|
readonly 'help.command.mcp.description': "Start MCP server. Default: stdio. Use --http for a remote HTTP server (Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).";
|
|
@@ -277,5 +278,5 @@ export declare const en: {
|
|
|
277
278
|
readonly 'help.option.group.contracts.repo': "Filter by repo";
|
|
278
279
|
readonly 'help.option.group.contracts.unmatched': "Show only unmatched contracts";
|
|
279
280
|
readonly 'help.identityCache.environment': "\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.";
|
|
280
|
-
readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
|
|
281
|
+
readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
|
|
281
282
|
};
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -142,6 +142,7 @@ export const en = {
|
|
|
142
142
|
'help.watch.details': '\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
|
|
143
143
|
'error.watch.ambiguous': '`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
|
|
144
144
|
'help.command.analyze.description': 'Index a repository (full analysis)',
|
|
145
|
+
'help.command.embeddings.sync.description': 'Add missing embeddings to an existing index, checkpointing periodically for safe resume',
|
|
145
146
|
'help.command.index.description': 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
|
|
146
147
|
'help.command.serve.description': 'Start local HTTP server for web UI connection',
|
|
147
148
|
'help.command.mcp.description': 'Start MCP server. Default: stdio. Use --http for a remote HTTP server (Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).',
|
|
@@ -279,5 +280,5 @@ export const en = {
|
|
|
279
280
|
'help.option.group.contracts.repo': 'Filter by repo',
|
|
280
281
|
'help.option.group.contracts.unmatched': 'Show only unmatched contracts',
|
|
281
282
|
'help.identityCache.environment': '\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.',
|
|
282
|
-
'help.analyze.environment': '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
|
283
|
+
'help.analyze.environment': '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
|
283
284
|
};
|
|
@@ -141,6 +141,7 @@ export declare const cliResources: {
|
|
|
141
141
|
readonly 'help.watch.details': "\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n";
|
|
142
142
|
readonly 'error.watch.ambiguous': "`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n";
|
|
143
143
|
readonly 'help.command.analyze.description': "Index a repository (full analysis)";
|
|
144
|
+
readonly 'help.command.embeddings.sync.description': "Add missing embeddings to an existing index, checkpointing periodically for safe resume";
|
|
144
145
|
readonly 'help.command.index.description': "Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)";
|
|
145
146
|
readonly 'help.command.serve.description': "Start local HTTP server for web UI connection";
|
|
146
147
|
readonly 'help.command.mcp.description': "Start MCP server. Default: stdio. Use --http for a remote HTTP server (Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).";
|
|
@@ -278,7 +279,7 @@ export declare const cliResources: {
|
|
|
278
279
|
readonly 'help.option.group.contracts.repo': "Filter by repo";
|
|
279
280
|
readonly 'help.option.group.contracts.unmatched': "Show only unmatched contracts";
|
|
280
281
|
readonly 'help.identityCache.environment': "\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.";
|
|
281
|
-
readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
|
|
282
|
+
readonly 'help.analyze.environment': "\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).";
|
|
282
283
|
};
|
|
283
284
|
readonly 'zh-CN': {
|
|
284
285
|
'common.notIndexed': string;
|
|
@@ -422,6 +423,7 @@ export declare const cliResources: {
|
|
|
422
423
|
'help.watch.details': string;
|
|
423
424
|
'error.watch.ambiguous': string;
|
|
424
425
|
'help.command.analyze.description': string;
|
|
426
|
+
'help.command.embeddings.sync.description': string;
|
|
425
427
|
'help.command.index.description': string;
|
|
426
428
|
'help.command.serve.description': string;
|
|
427
429
|
'help.command.mcp.description': string;
|
package/dist/cli/i18n/zh-CN.d.ts
CHANGED
|
@@ -140,6 +140,7 @@ export declare const zhCN: {
|
|
|
140
140
|
'help.watch.details': string;
|
|
141
141
|
'error.watch.ambiguous': string;
|
|
142
142
|
'help.command.analyze.description': string;
|
|
143
|
+
'help.command.embeddings.sync.description': string;
|
|
143
144
|
'help.command.index.description': string;
|
|
144
145
|
'help.command.serve.description': string;
|
|
145
146
|
'help.command.mcp.description': string;
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -140,6 +140,7 @@ export const zhCN = {
|
|
|
140
140
|
'help.watch.details': '\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
|
|
141
141
|
'error.watch.ambiguous': '`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
|
|
142
142
|
'help.command.analyze.description': '索引仓库(完整分析)',
|
|
143
|
+
'help.command.embeddings.sync.description': '向现有索引添加缺失的嵌入,并定期保存检查点以安全续跑',
|
|
143
144
|
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
|
|
144
145
|
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
|
|
145
146
|
'help.command.mcp.description': '启动 MCP 服务器。默认为 stdio。使用 --http 启动远程 HTTP 服务器(Streamable HTTP: POST /mcp + 遗留 SSE: GET /sse, POST /messages)。',
|
|
@@ -277,5 +278,5 @@ export const zhCN = {
|
|
|
277
278
|
'help.option.group.contracts.repo': '按仓库过滤',
|
|
278
279
|
'help.option.group.contracts.unmatched': '仅显示未匹配契约',
|
|
279
280
|
'help.identityCache.environment': '\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。',
|
|
280
|
-
'help.analyze.environment': '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
|
281
|
+
'help.analyze.environment': '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
|
281
282
|
};
|
package/dist/cli/index.js
CHANGED
|
@@ -184,17 +184,21 @@ program
|
|
|
184
184
|
.command('update')
|
|
185
185
|
.description('Install the latest published GitNexus globally (`npm i -g gitnexus@<x.y.z>`).')
|
|
186
186
|
.action(createLazyAction(() => import('./update.js'), 'updateCommand'));
|
|
187
|
-
program
|
|
187
|
+
const embeddings = program
|
|
188
188
|
.command('embeddings')
|
|
189
|
-
.description('
|
|
189
|
+
.description(t('help.command.embeddings.description'));
|
|
190
|
+
embeddings
|
|
190
191
|
.command('install')
|
|
191
|
-
.description('
|
|
192
|
-
'Heals installs where npm skipped the optional packages (e.g. behind an HTTP proxy, #2370). ' +
|
|
193
|
-
'Downloads only from your configured npm registry — mirrors and proxies apply.')
|
|
192
|
+
.description(t('help.command.embeddings.install.description'))
|
|
194
193
|
.option('--cuda', "Also download the CUDA GPU binaries (runs onnxruntime-node's NuGet postinstall; " +
|
|
195
194
|
'set GLOBAL_AGENT_HTTPS_PROXY behind a proxy)')
|
|
196
195
|
.option('--force', 'Install into the runtime prefix even when the stack already resolves')
|
|
197
196
|
.action(createLazyAction(() => import('./embeddings.js'), 'embeddingsInstallCommand'));
|
|
197
|
+
embeddings
|
|
198
|
+
.command('sync [path]')
|
|
199
|
+
.description(t('help.command.embeddings.sync.description'))
|
|
200
|
+
.addHelpText('after', () => t('help.analyze.environment'))
|
|
201
|
+
.action(createLbugLazyAction(() => import('./embeddings-sync.js'), 'embeddingsSyncCommand'));
|
|
198
202
|
program
|
|
199
203
|
.command('clean')
|
|
200
204
|
.description('Delete GitNexus index for current repo')
|
|
@@ -157,6 +157,7 @@ const readConfig = () => {
|
|
|
157
157
|
retryCapMs: parsePositiveIntegerEnv('GITNEXUS_EMBEDDING_RETRY_CAP_MS', HTTP_RETRY_CAP_MS, 300_000),
|
|
158
158
|
minIntervalMs: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 0, 300_000),
|
|
159
159
|
timeoutMs: parsePositiveIntegerEnv(HTTP_TIMEOUT_ENV, DEFAULT_HTTP_TIMEOUT_MS, MAX_HTTP_TIMEOUT_MS),
|
|
160
|
+
retryTimeouts: parseNonNegativeIntegerEnv('GITNEXUS_EMBEDDING_RETRY_TIMEOUTS', 0, 1) === 1,
|
|
160
161
|
requestDimensions,
|
|
161
162
|
};
|
|
162
163
|
};
|
|
@@ -277,6 +278,23 @@ class RetryableEmbeddingBodyError extends Error {
|
|
|
277
278
|
this.name = 'RetryableEmbeddingBodyError';
|
|
278
279
|
}
|
|
279
280
|
}
|
|
281
|
+
class RetryableEmbeddingTimeoutError extends Error {
|
|
282
|
+
timeoutMs;
|
|
283
|
+
constructor(timeoutMs, options) {
|
|
284
|
+
super(`Embedding request timed out after ${timeoutMs}ms`, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
|
285
|
+
this.timeoutMs = timeoutMs;
|
|
286
|
+
this.name = 'RetryableEmbeddingTimeoutError';
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** Re-wrap an opt-in TimeoutError so `resilientFetch` retries it. Abort stays terminal. */
|
|
290
|
+
const throwIfRetryableTimeout = (err, retryTimeouts, callerAborted, timeoutMs) => {
|
|
291
|
+
if (retryTimeouts &&
|
|
292
|
+
!callerAborted &&
|
|
293
|
+
isTerminalNetworkError(err) &&
|
|
294
|
+
err.name === 'TimeoutError') {
|
|
295
|
+
throw new RetryableEmbeddingTimeoutError(timeoutMs, { cause: err });
|
|
296
|
+
}
|
|
297
|
+
};
|
|
280
298
|
/**
|
|
281
299
|
* Build the message for a 2xx body carrying the wrong number of vectors.
|
|
282
300
|
*
|
|
@@ -304,7 +322,7 @@ const countMismatchMessage = (received, expected, safeEndpoint, batchIndex) => `
|
|
|
304
322
|
* `GITNEXUS_EMBEDDING_REQUEST_DIMS=omit` for strict backends while keeping
|
|
305
323
|
* `GITNEXUS_EMBEDDING_DIMS` set to the returned vector size.
|
|
306
324
|
*/
|
|
307
|
-
const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensions, requestOptions = {}, maxAttempts = HTTP_MAX_RETRIES + 1, retryCapMs = HTTP_RETRY_CAP_MS, minIntervalMs = 0, timeoutMs = DEFAULT_HTTP_TIMEOUT_MS) => {
|
|
325
|
+
const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensions, requestOptions = {}, maxAttempts = HTTP_MAX_RETRIES + 1, retryCapMs = HTTP_RETRY_CAP_MS, minIntervalMs = 0, timeoutMs = DEFAULT_HTTP_TIMEOUT_MS, retryTimeouts = false) => {
|
|
308
326
|
const requestBody = {
|
|
309
327
|
input: batch,
|
|
310
328
|
model,
|
|
@@ -341,7 +359,14 @@ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensi
|
|
|
341
359
|
const signal = requestOptions.signal
|
|
342
360
|
? AbortSignal.any([requestOptions.signal, timeoutSignal])
|
|
343
361
|
: timeoutSignal;
|
|
344
|
-
|
|
362
|
+
let attemptResp;
|
|
363
|
+
try {
|
|
364
|
+
attemptResp = await globalThis.fetch(input, { ...init, signal });
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
throwIfRetryableTimeout(err, retryTimeouts, requestOptions.signal?.aborted, timeoutMs);
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
345
370
|
// Non-OK bodies are none of our business: hand the response straight
|
|
346
371
|
// back so `resilientFetch` keeps classifying 4xx/5xx/429 unchanged.
|
|
347
372
|
if (!attemptResp.ok)
|
|
@@ -361,15 +386,19 @@ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensi
|
|
|
361
386
|
// Not every `.json()` rejection is a parse error: the per-attempt
|
|
362
387
|
// signal (`AbortSignal.any([caller, AbortSignal.timeout(...)])`) is
|
|
363
388
|
// wired to the body stream, so a stalled body rejects with the abort
|
|
364
|
-
// reason. Re-raise
|
|
365
|
-
// `resilientFetch`'s own
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
389
|
+
// reason. Re-raise AbortError (and TimeoutError when retry is off)
|
|
390
|
+
// untouched — `isTerminalNetworkError` is `resilientFetch`'s own
|
|
391
|
+
// predicate, so this test agrees with `classifyOutcome` by
|
|
392
|
+
// construction. Wrapping one would flip its verdict from
|
|
393
|
+
// `terminal-network` (returned without retry AND without touching
|
|
394
|
+
// the breaker, via `recordNeutral()`) to `retryable-network`
|
|
395
|
+
// (retried, then `breaker.recordFailure()`): the same timeout would
|
|
396
|
+
// take 3 attempts instead of 1, count toward the process-global
|
|
397
|
+
// `embeddings-http` breaker, and reach the operator as "unparseable
|
|
398
|
+
// response" so they never reach for the timeout knob.
|
|
399
|
+
// Opt-in `GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1` is the exception:
|
|
400
|
+
// TimeoutError is re-wrapped so the existing retry loop can retry it.
|
|
401
|
+
throwIfRetryableTimeout(err, retryTimeouts, requestOptions.signal?.aborted, timeoutMs);
|
|
373
402
|
if (isTerminalNetworkError(err))
|
|
374
403
|
throw err;
|
|
375
404
|
throw new RetryableEmbeddingBodyError(unparseableMessage(), { cause: err });
|
|
@@ -411,6 +440,9 @@ const httpEmbedBatch = async (url, batch, model, apiKey, batchIndex = 0, dimensi
|
|
|
411
440
|
if (err instanceof RetryableEmbeddingBodyError) {
|
|
412
441
|
throw new HttpEmbeddingError(err.terminalMessage, { cause: err.cause });
|
|
413
442
|
}
|
|
443
|
+
if (err instanceof RetryableEmbeddingTimeoutError) {
|
|
444
|
+
throw new HttpEmbeddingError(`${err.message} after ${maxAttempts} attempt(s) (${safeUrl(url)}, batch ${batchIndex})`, { cause: err.cause });
|
|
445
|
+
}
|
|
414
446
|
if (err instanceof CircuitOpenError) {
|
|
415
447
|
throw new HttpEmbeddingError(`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`, { cause: err });
|
|
416
448
|
}
|
|
@@ -455,7 +487,7 @@ export const httpEmbed = async (texts, requestOptions = {}) => {
|
|
|
455
487
|
const url = `${config.baseUrl}/embeddings`;
|
|
456
488
|
const allVectors = [];
|
|
457
489
|
for (const [batchIndex, batch] of chunk(texts, HTTP_BATCH_SIZE).entries()) {
|
|
458
|
-
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex, config.requestDimensions, requestOptions, config.maxAttempts, config.retryCapMs, config.minIntervalMs, config.timeoutMs);
|
|
490
|
+
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex, config.requestDimensions, requestOptions, config.maxAttempts, config.retryCapMs, config.minIntervalMs, config.timeoutMs, config.retryTimeouts);
|
|
459
491
|
// Defensive backstop, deliberately kept: `httpEmbedBatch` now rejects a
|
|
460
492
|
// short body from *inside* the retry loop (through the same
|
|
461
493
|
// `countMismatchMessage`), so in practice this branch is unreachable. It
|
|
@@ -506,7 +538,7 @@ export const httpEmbedQuery = async (text, requestOptions = {}) => {
|
|
|
506
538
|
if (!config)
|
|
507
539
|
throw new Error('HTTP embedding not configured');
|
|
508
540
|
const url = `${config.baseUrl}/embeddings`;
|
|
509
|
-
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey, 0, config.requestDimensions, requestOptions, config.maxAttempts, config.retryCapMs, config.minIntervalMs, config.timeoutMs);
|
|
541
|
+
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey, 0, config.requestDimensions, requestOptions, config.maxAttempts, config.retryCapMs, config.minIntervalMs, config.timeoutMs, config.retryTimeouts);
|
|
510
542
|
// Defensive backstop like the `httpEmbed` one above: an empty `data` array is
|
|
511
543
|
// now a cardinality mismatch (0 vectors for 1 text) rejected and retried
|
|
512
544
|
// inside `httpEmbedBatch`, so this branch is unreachable in practice.
|
|
@@ -25,7 +25,7 @@ import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/l
|
|
|
25
25
|
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
|
26
26
|
// git utilities available if needed
|
|
27
27
|
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
|
|
28
|
-
import {
|
|
28
|
+
import { parseDiffHunksResult, coalesceHunksByPath, hunksOverlapRange, findGitRootByDotGit, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
|
|
29
29
|
import { realpathSync } from 'fs';
|
|
30
30
|
import { listRegisteredRepos, cleanupOldKuzuFiles, canonicalizePath, getStoragePaths, loadMeta, RegistryAmbiguousTargetError, } from '../../storage/repo-manager.js';
|
|
31
31
|
import { GroupService, } from '../../core/group/service.js';
|
|
@@ -795,6 +795,12 @@ export function buildDetectChangesDiffArgs(scope, baseRef) {
|
|
|
795
795
|
// not `--default-prefix`, which needs git >= 2.42. `--no-ext-diff` stops a
|
|
796
796
|
// configured external diff driver from replacing the unified output we parse.
|
|
797
797
|
const args = [
|
|
798
|
+
// Before the subcommand: default core.quotePath C-quotes non-ASCII so
|
|
799
|
+
// `diff --git` / `+++` tokens no longer match the unquoted `a/` `b/` forms
|
|
800
|
+
// the parser also accepts. The parser still decodes quoted tokens; this
|
|
801
|
+
// pin keeps production git from emitting them.
|
|
802
|
+
'-c',
|
|
803
|
+
'core.quotePath=false',
|
|
798
804
|
'diff',
|
|
799
805
|
'--ignore-cr-at-eol',
|
|
800
806
|
'--no-ext-diff',
|
|
@@ -4464,10 +4470,10 @@ export class LocalBackend {
|
|
|
4464
4470
|
catch (err) {
|
|
4465
4471
|
return { error: `Git diff failed: ${err.message}` };
|
|
4466
4472
|
}
|
|
4467
|
-
const fileDiffs =
|
|
4473
|
+
const { files: fileDiffs, unparsedGitHeaders } = parseDiffHunksResult(diffOutput);
|
|
4468
4474
|
if (fileDiffs.length === 0) {
|
|
4469
|
-
// Git printed a diff but none of it parsed:
|
|
4470
|
-
//
|
|
4475
|
+
// Git printed a diff but none of it parsed: no `diff --git` / `+++`
|
|
4476
|
+
// file header was recognised. That is a PARSE failure, not a clean tree,
|
|
4471
4477
|
// and the clean branch below would report it to the pre-commit gate as
|
|
4472
4478
|
// `risk_level:'none'`, no `partial`, exit 0 — a false all-clear (#2915).
|
|
4473
4479
|
const parseFailed = diffOutput.trim().length > 0;
|
|
@@ -4499,7 +4505,9 @@ export class LocalBackend {
|
|
|
4499
4505
|
const changedSymbols = new Map();
|
|
4500
4506
|
// Set if a swallowed graph query fails below — surfaces `partial:true` so a
|
|
4501
4507
|
// degraded run cannot report a false-clean `risk_level:'low'` (#2283).
|
|
4502
|
-
|
|
4508
|
+
// An unparsed `diff --git` is the same class: later hunks must not make
|
|
4509
|
+
// the gate look complete.
|
|
4510
|
+
let queryDegraded = unparsedGitHeaders > 0;
|
|
4503
4511
|
// Hunks arrive grouped per path and already in the graph's 0-based line
|
|
4504
4512
|
// space, so every comparison below is base-neutral (#2377).
|
|
4505
4513
|
const hunksByPath = coalesceHunksByPath(fileDiffs);
|
package/dist/storage/git.d.ts
CHANGED
|
@@ -268,10 +268,20 @@ export interface FileDiff {
|
|
|
268
268
|
filePath: string;
|
|
269
269
|
hunks: DiffHunk[];
|
|
270
270
|
}
|
|
271
|
+
/** `parseDiffHunks` plus how many `diff --git` headers could not be decoded. */
|
|
272
|
+
export interface DiffHunkParseResult {
|
|
273
|
+
files: FileDiff[];
|
|
274
|
+
unparsedGitHeaders: number;
|
|
275
|
+
}
|
|
271
276
|
/**
|
|
272
277
|
* Parse unified diff output (with -U0) into per-file hunk ranges.
|
|
273
278
|
* Extracts the new-file line ranges from @@ hunk headers.
|
|
274
279
|
*
|
|
280
|
+
* The `diff --git` header is also retained as a file entry. This matters for
|
|
281
|
+
* binary, rename-only, and mode-only changes, which have no `+++ b/` header.
|
|
282
|
+
* Such entries intentionally have no hunks: callers can count the changed
|
|
283
|
+
* path without pretending that a symbol line range was touched.
|
|
284
|
+
*
|
|
275
285
|
* A pure deletion adds no new lines, and unified diff spells that empty range
|
|
276
286
|
* as the line BEFORE it: `@@ -4,2 +3,0 @@` removed old lines 4–5 from between
|
|
277
287
|
* new lines 3 and 4 (git emits `+0,0` when the deletion is at the head of the
|
|
@@ -287,6 +297,13 @@ export interface FileDiff {
|
|
|
287
297
|
* gap — the widening {@link coalesceHunks} is careful never to do.
|
|
288
298
|
*/
|
|
289
299
|
export declare function parseDiffHunks(diffOutput: string): FileDiff[];
|
|
300
|
+
/**
|
|
301
|
+
* Same as {@link parseDiffHunks}, plus a count of `diff --git` lines that
|
|
302
|
+
* could not be decoded. `detect_changes` uses the count to fail closed
|
|
303
|
+
* (`partial` + `risk_level:'unknown'`) instead of attaching later hunks to a
|
|
304
|
+
* previous file.
|
|
305
|
+
*/
|
|
306
|
+
export declare function parseDiffHunksResult(diffOutput: string): DiffHunkParseResult;
|
|
290
307
|
/**
|
|
291
308
|
* Merge a file's hunks into sorted, non-touching ranges.
|
|
292
309
|
*
|
package/dist/storage/git.js
CHANGED
|
@@ -696,10 +696,183 @@ export const parseRepoNameFromUrl = (url) => {
|
|
|
696
696
|
export const getInferredRepoName = (repoPath) => {
|
|
697
697
|
return parseRepoNameFromUrl(getRemoteOriginUrl(repoPath));
|
|
698
698
|
};
|
|
699
|
+
const DIFF_GIT_PREFIX = 'diff --git ';
|
|
700
|
+
/**
|
|
701
|
+
* Decode one Git C-quoted token (`"a/\\344\\270\\255.png"`). Returns
|
|
702
|
+
* `undefined` when the quotes are unbalanced or a trailing escape is bare.
|
|
703
|
+
*/
|
|
704
|
+
function unquoteCStyleGitToken(quoted) {
|
|
705
|
+
if (quoted.length < 2 || quoted[0] !== '"' || quoted[quoted.length - 1] !== '"') {
|
|
706
|
+
return undefined;
|
|
707
|
+
}
|
|
708
|
+
// Git C-quotes are byte-oriented: non-ASCII is `\nnn` octal of the UTF-8
|
|
709
|
+
// code units, not JS UTF-16 characters.
|
|
710
|
+
const bytes = [];
|
|
711
|
+
const pushChar = (ch) => {
|
|
712
|
+
const code = ch.charCodeAt(0);
|
|
713
|
+
if (code < 0x80)
|
|
714
|
+
bytes.push(code);
|
|
715
|
+
else
|
|
716
|
+
bytes.push(...new TextEncoder().encode(ch));
|
|
717
|
+
};
|
|
718
|
+
for (let i = 1; i < quoted.length - 1; i++) {
|
|
719
|
+
const ch = quoted[i];
|
|
720
|
+
if (ch !== '\\') {
|
|
721
|
+
pushChar(ch);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const next = quoted[++i];
|
|
725
|
+
if (next === undefined)
|
|
726
|
+
return undefined;
|
|
727
|
+
switch (next) {
|
|
728
|
+
case '\\':
|
|
729
|
+
case '"':
|
|
730
|
+
pushChar(next);
|
|
731
|
+
break;
|
|
732
|
+
case 'n':
|
|
733
|
+
bytes.push(0x0a);
|
|
734
|
+
break;
|
|
735
|
+
case 't':
|
|
736
|
+
bytes.push(0x09);
|
|
737
|
+
break;
|
|
738
|
+
case 'r':
|
|
739
|
+
bytes.push(0x0d);
|
|
740
|
+
break;
|
|
741
|
+
case 'a':
|
|
742
|
+
bytes.push(0x07);
|
|
743
|
+
break;
|
|
744
|
+
case 'b':
|
|
745
|
+
bytes.push(0x08);
|
|
746
|
+
break;
|
|
747
|
+
case 'v':
|
|
748
|
+
bytes.push(0x0b);
|
|
749
|
+
break;
|
|
750
|
+
case 'f':
|
|
751
|
+
bytes.push(0x0c);
|
|
752
|
+
break;
|
|
753
|
+
default: {
|
|
754
|
+
if (next < '0' || next > '7') {
|
|
755
|
+
pushChar(next);
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
let oct = next;
|
|
759
|
+
while (oct.length < 3 && i + 1 < quoted.length - 1) {
|
|
760
|
+
const digit = quoted[i + 1];
|
|
761
|
+
if (digit < '0' || digit > '7')
|
|
762
|
+
break;
|
|
763
|
+
oct += digit;
|
|
764
|
+
i++;
|
|
765
|
+
}
|
|
766
|
+
bytes.push(parseInt(oct, 8));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes));
|
|
771
|
+
}
|
|
772
|
+
function takeCQuotedToken(source, start) {
|
|
773
|
+
if (source[start] !== '"')
|
|
774
|
+
return undefined;
|
|
775
|
+
for (let i = start + 1; i < source.length; i++) {
|
|
776
|
+
if (source[i] === '\\') {
|
|
777
|
+
i++;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (source[i] === '"')
|
|
781
|
+
return { token: source.slice(start, i + 1), end: i + 1 };
|
|
782
|
+
}
|
|
783
|
+
return undefined;
|
|
784
|
+
}
|
|
785
|
+
function stripGitDstPrefix(raw) {
|
|
786
|
+
return raw.startsWith('b/') ? raw.slice(2) : undefined;
|
|
787
|
+
}
|
|
788
|
+
/** Unified-diff paths end at the first TAB (timestamp / empty terminator). */
|
|
789
|
+
function stripUnifiedDiffTab(pathWithOptionalTab) {
|
|
790
|
+
const tab = pathWithOptionalTab.indexOf('\t');
|
|
791
|
+
return tab === -1 ? pathWithOptionalTab : pathWithOptionalTab.slice(0, tab);
|
|
792
|
+
}
|
|
793
|
+
function decodeGitPathToken(raw) {
|
|
794
|
+
const trimmed = stripUnifiedDiffTab(raw);
|
|
795
|
+
if (trimmed.startsWith('"'))
|
|
796
|
+
return unquoteCStyleGitToken(trimmed);
|
|
797
|
+
return trimmed;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Destination path from `diff --git a/<src> b/<dst>`.
|
|
801
|
+
*
|
|
802
|
+
* Same-path headers recover `name` from `a/${name} b/${name}` so a dest that
|
|
803
|
+
* itself contains ` b/` is not split at the last occurrence. C-quoted tokens
|
|
804
|
+
* (default `core.quotePath`) are decoded. Renames that the greedy split would
|
|
805
|
+
* mis-parse stay a best-effort dest; `rename to` / `+++` correct them.
|
|
806
|
+
*/
|
|
807
|
+
function takeGitHeaderPathToken(source, start) {
|
|
808
|
+
if (start >= source.length)
|
|
809
|
+
return undefined;
|
|
810
|
+
if (source[start] === '"') {
|
|
811
|
+
const tok = takeCQuotedToken(source, start);
|
|
812
|
+
if (!tok)
|
|
813
|
+
return undefined;
|
|
814
|
+
const path = unquoteCStyleGitToken(tok.token);
|
|
815
|
+
if (path === undefined)
|
|
816
|
+
return undefined;
|
|
817
|
+
return { path, end: tok.end };
|
|
818
|
+
}
|
|
819
|
+
if (source.startsWith('b/', start)) {
|
|
820
|
+
return { path: stripUnifiedDiffTab(source.slice(start)), end: source.length };
|
|
821
|
+
}
|
|
822
|
+
if (source.startsWith('a/', start)) {
|
|
823
|
+
for (let i = start + 2; i < source.length; i++) {
|
|
824
|
+
if (source.startsWith(' b/', i) || source.startsWith(' "', i)) {
|
|
825
|
+
return { path: source.slice(start, i), end: i };
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return undefined;
|
|
830
|
+
}
|
|
831
|
+
function filePathFromGitHeader(line) {
|
|
832
|
+
if (!line.startsWith(DIFF_GIT_PREFIX))
|
|
833
|
+
return undefined;
|
|
834
|
+
const rest = line.slice(DIFF_GIT_PREFIX.length);
|
|
835
|
+
if (rest.startsWith('a/')) {
|
|
836
|
+
for (let i = 2; i < rest.length; i++) {
|
|
837
|
+
if (!rest.startsWith(' b/', i))
|
|
838
|
+
continue;
|
|
839
|
+
const nameA = rest.slice(2, i);
|
|
840
|
+
const nameB = rest.slice(i + 3);
|
|
841
|
+
if (nameA.length > 0 && nameA === nameB)
|
|
842
|
+
return nameA;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const src = takeGitHeaderPathToken(rest, 0);
|
|
846
|
+
if (!src)
|
|
847
|
+
return undefined;
|
|
848
|
+
let i = src.end;
|
|
849
|
+
while (rest[i] === ' ')
|
|
850
|
+
i++;
|
|
851
|
+
const dest = takeGitHeaderPathToken(rest, i);
|
|
852
|
+
return dest ? stripGitDstPrefix(dest.path) : undefined;
|
|
853
|
+
}
|
|
854
|
+
function pathFromPlusPlusPlus(line) {
|
|
855
|
+
if (!line.startsWith('+++ '))
|
|
856
|
+
return undefined;
|
|
857
|
+
const raw = decodeGitPathToken(line.slice(4));
|
|
858
|
+
if (!raw || raw === '/dev/null')
|
|
859
|
+
return undefined;
|
|
860
|
+
return stripGitDstPrefix(raw);
|
|
861
|
+
}
|
|
862
|
+
function pathFromRenameTo(line) {
|
|
863
|
+
if (!line.startsWith('rename to '))
|
|
864
|
+
return undefined;
|
|
865
|
+
return decodeGitPathToken(line.slice('rename to '.length));
|
|
866
|
+
}
|
|
699
867
|
/**
|
|
700
868
|
* Parse unified diff output (with -U0) into per-file hunk ranges.
|
|
701
869
|
* Extracts the new-file line ranges from @@ hunk headers.
|
|
702
870
|
*
|
|
871
|
+
* The `diff --git` header is also retained as a file entry. This matters for
|
|
872
|
+
* binary, rename-only, and mode-only changes, which have no `+++ b/` header.
|
|
873
|
+
* Such entries intentionally have no hunks: callers can count the changed
|
|
874
|
+
* path without pretending that a symbol line range was touched.
|
|
875
|
+
*
|
|
703
876
|
* A pure deletion adds no new lines, and unified diff spells that empty range
|
|
704
877
|
* as the line BEFORE it: `@@ -4,2 +3,0 @@` removed old lines 4–5 from between
|
|
705
878
|
* new lines 3 and 4 (git emits `+0,0` when the deletion is at the head of the
|
|
@@ -715,14 +888,58 @@ export const getInferredRepoName = (repoPath) => {
|
|
|
715
888
|
* gap — the widening {@link coalesceHunks} is careful never to do.
|
|
716
889
|
*/
|
|
717
890
|
export function parseDiffHunks(diffOutput) {
|
|
891
|
+
return parseDiffHunksResult(diffOutput).files;
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Same as {@link parseDiffHunks}, plus a count of `diff --git` lines that
|
|
895
|
+
* could not be decoded. `detect_changes` uses the count to fail closed
|
|
896
|
+
* (`partial` + `risk_level:'unknown'`) instead of attaching later hunks to a
|
|
897
|
+
* previous file.
|
|
898
|
+
*/
|
|
899
|
+
export function parseDiffHunksResult(diffOutput) {
|
|
718
900
|
const files = [];
|
|
719
901
|
let current = null;
|
|
902
|
+
let unparsedGitHeaders = 0;
|
|
903
|
+
// `+++` after the first `@@` of a file is hunk body (`+` plus source text
|
|
904
|
+
// that itself starts `++ …`), not another file header.
|
|
905
|
+
let inHunk = false;
|
|
720
906
|
for (const line of diffOutput.split('\n')) {
|
|
721
|
-
if (line.startsWith(
|
|
722
|
-
|
|
723
|
-
|
|
907
|
+
if (line.startsWith(DIFF_GIT_PREFIX)) {
|
|
908
|
+
// Drop the previous file first: an unparsed header must not leave
|
|
909
|
+
// `current` live for a later `@@` / quoted `+++` to steal.
|
|
910
|
+
current = null;
|
|
911
|
+
inHunk = false;
|
|
912
|
+
const filePath = filePathFromGitHeader(line);
|
|
913
|
+
if (filePath) {
|
|
914
|
+
current = { filePath, hunks: [] };
|
|
915
|
+
files.push(current);
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
unparsedGitHeaders++;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
else if (line.startsWith('rename to ')) {
|
|
922
|
+
const filePath = pathFromRenameTo(line);
|
|
923
|
+
if (!filePath)
|
|
924
|
+
continue;
|
|
925
|
+
if (current)
|
|
926
|
+
current.filePath = filePath;
|
|
927
|
+
else {
|
|
928
|
+
current = { filePath, hunks: [] };
|
|
929
|
+
files.push(current);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
else if (!inHunk && line.startsWith('+++ ')) {
|
|
933
|
+
const filePath = pathFromPlusPlusPlus(line);
|
|
934
|
+
if (!filePath)
|
|
935
|
+
continue;
|
|
936
|
+
if (!current || current.filePath !== filePath) {
|
|
937
|
+
current = { filePath, hunks: [] };
|
|
938
|
+
files.push(current);
|
|
939
|
+
}
|
|
724
940
|
}
|
|
725
941
|
else if (line.startsWith('@@') && current) {
|
|
942
|
+
inHunk = true;
|
|
726
943
|
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
727
944
|
if (match) {
|
|
728
945
|
const start = parseInt(match[1], 10);
|
|
@@ -739,7 +956,7 @@ export function parseDiffHunks(diffOutput) {
|
|
|
739
956
|
}
|
|
740
957
|
}
|
|
741
958
|
}
|
|
742
|
-
return files;
|
|
959
|
+
return { files, unparsedGitHeaders };
|
|
743
960
|
}
|
|
744
961
|
/**
|
|
745
962
|
* Merge a file's hunks into sorted, non-touching ranges.
|
package/package.json
CHANGED