mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge remote-tracking branch 'upstream/main' into codex/xaml-support-3202
This commit is contained in:
commit
e528c14e09
15 changed files with 795 additions and 45 deletions
|
|
@ -508,6 +508,10 @@ analyze_timeout: 5m
|
|||
projects:
|
||||
- local_path: /absolute/path/to/clones
|
||||
branches: [main, master]
|
||||
# pdg: omit = preserve live index mode; true = keep PDG current;
|
||||
# false = init default (warns, then strips PDG on the next successful rebuild).
|
||||
# Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.
|
||||
pdg: false
|
||||
overwrite_local_changes: false
|
||||
remote_urls:
|
||||
- git@github.com:owner/repo.git
|
||||
|
|
@ -516,7 +520,8 @@ projects:
|
|||
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
|
||||
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
|
||||
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
|
||||
- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
|
||||
- Set per-project `pdg: true` to keep the full control-flow, control/data-dependence, and taint layers current. Untouched configs that omit `pdg` preserve an existing index's mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG; an explicit `false` opt-out logs a warning before removing existing PDG data. Auto-sync requests atomic incremental publication where supported, so readers keep using the previous graph until a successful update is ready and a failed staged analysis leaves it intact; unsupported paths retain the analyzer's existing in-place behavior.
|
||||
- Analysis runs in an isolated worker; `analyze_timeout` defaults to half of `sync_interval_minutes`, but may be longer (for example, a `30m` analysis timeout with `5` minute polling) up to Node's timer limit. If a polling tick arrives while analysis is active, it is coalesced into one immediate follow-up run using the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
|
||||
- Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days.
|
||||
|
||||
See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files.
|
||||
|
|
|
|||
|
|
@ -325,6 +325,10 @@ analyze_failure_threshold: 3
|
|||
projects:
|
||||
- local_path: /abs/path/to/repos
|
||||
branches: [master, main]
|
||||
# pdg: omit = preserve live index mode; true = keep PDG current;
|
||||
# false = init default (warns, then strips PDG on the next successful rebuild).
|
||||
# Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.
|
||||
pdg: false
|
||||
overwrite_local_changes: false
|
||||
remote_urls:
|
||||
- git@github.com:owner/repo.git
|
||||
|
|
@ -332,7 +336,9 @@ projects:
|
|||
- git@gitee.com:owner/repo.git
|
||||
```
|
||||
|
||||
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
|
||||
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker and defaults to half of `sync_interval_minutes`, but it is independent of polling and may be longer, up to Node's timer limit (`2147483647ms`). A `5` minute poll with `analyze_timeout: 30m` is valid. A tick that arrives while the previous loop is active never overlaps it: ticks coalesce into one immediate follow-up run, which pulls and analyzes the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows.
|
||||
|
||||
`pdg` is configured per project. `pdg: true` builds and maintains the full CFG, control-dependence, reaching-definition, and taint layers on both initial and incremental analyses. Auto-sync requests staged atomic incremental publication where the analyzer supports it: the old graph remains available to readers until the replacement succeeds, and analysis errors are recorded while the old graph remains intact. Unsupported paths retain the analyzer's existing in-place behavior. Untouched configs that omit `pdg` preserve the existing index mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG. An explicit `pdg: false` disables PDG and emits a warning before a successful rebuild removes those layers. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch, commit, and requested PDG mode; a new commit, a PDG mode change, or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
|
||||
|
||||
GraphQL contract matching is opt-in in the group's `group.yaml`:
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,10 @@ function defaultSyncConfig(localPath: string): string {
|
|||
'projects:',
|
||||
` - local_path: ${localPath}`,
|
||||
' branches: [master, main]',
|
||||
' # pdg: omit = preserve live index mode; true = keep PDG current;',
|
||||
' # false = init default (warns, then strips PDG on the next successful rebuild).',
|
||||
' # Do not paste pdg: false onto an existing watch file unless you intend to drop PDG.',
|
||||
' pdg: false',
|
||||
' overwrite_local_changes: false',
|
||||
' remote_urls:',
|
||||
' - git@github.com:owner/repo.git',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,27 @@ interface AnalysisWorker extends Pick<ChildProcess, 'send' | 'on'> {
|
|||
*/
|
||||
const AUTO_SYNC_CANCEL_GRACE_MS = 5_000;
|
||||
|
||||
export class AutoSyncAnalysisError extends Error {
|
||||
readonly code?: 'index-lock-timeout';
|
||||
readonly retryable?: boolean;
|
||||
readonly abandonedWorker: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: {
|
||||
code?: 'index-lock-timeout';
|
||||
retryable?: boolean;
|
||||
abandonedWorker?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AutoSyncAnalysisError';
|
||||
this.code = options.code;
|
||||
this.retryable = options.retryable;
|
||||
this.abandonedWorker = options.abandonedWorker === true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AutoSyncAnalysisLaunchDeps {
|
||||
forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker;
|
||||
setTimeoutFn: typeof setTimeout;
|
||||
|
|
@ -144,9 +165,10 @@ export function createAutoSyncAnalysisRunner(
|
|||
if (settled) return;
|
||||
releaseChild();
|
||||
settle(
|
||||
new Error(
|
||||
new AutoSyncAnalysisError(
|
||||
`${error.message} The analyze worker did not exit within ${deps.cancelGraceMs}ms; ` +
|
||||
'it was left running so its native work is not interrupted.',
|
||||
{ abandonedWorker: true, retryable: true },
|
||||
),
|
||||
);
|
||||
}, deps.cancelGraceMs);
|
||||
|
|
@ -186,7 +208,12 @@ export function createAutoSyncAnalysisRunner(
|
|||
return;
|
||||
}
|
||||
if (terminalOutcome?.type === 'error') {
|
||||
settle(new Error(terminalOutcome.message));
|
||||
settle(
|
||||
new AutoSyncAnalysisError(terminalOutcome.message, {
|
||||
code: terminalOutcome.code,
|
||||
retryable: terminalOutcome.retryable,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
settle(
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ const REMOTE_PATH_SEGMENT_PATTERN = REMOTE_REPO_NAME_PATTERN;
|
|||
export interface AutoSyncProjectConfig {
|
||||
localPath: string;
|
||||
groupName?: string;
|
||||
/** Explicit opt-in/out. Omitted legacy configs preserve the index's current mode. */
|
||||
pdg?: boolean;
|
||||
overwriteLocalChanges: boolean;
|
||||
branches: string[];
|
||||
remoteUrls: string[];
|
||||
|
|
@ -159,7 +161,7 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy
|
|||
);
|
||||
}
|
||||
|
||||
const maxAnalyzeTimeoutMs =
|
||||
const defaultAnalyzeTimeoutMs =
|
||||
Number.isInteger(interval) &&
|
||||
interval >= MIN_SYNC_INTERVAL_MINUTES &&
|
||||
interval <= MAX_SYNC_INTERVAL_MINUTES
|
||||
|
|
@ -167,14 +169,12 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy
|
|||
: undefined;
|
||||
const analyzeTimeoutMs =
|
||||
raw.analyze_timeout === undefined
|
||||
? (maxAnalyzeTimeoutMs ?? 0)
|
||||
? (defaultAnalyzeTimeoutMs ?? 0)
|
||||
: parseDurationMs(raw.analyze_timeout);
|
||||
if (!Number.isInteger(analyzeTimeoutMs) || analyzeTimeoutMs <= 0) {
|
||||
errors.push('analyze_timeout must be a positive duration such as 30m');
|
||||
} else if (maxAnalyzeTimeoutMs !== undefined && analyzeTimeoutMs > maxAnalyzeTimeoutMs) {
|
||||
errors.push(
|
||||
`analyze_timeout must not exceed half of sync_interval_minutes (${maxAnalyzeTimeoutMs / 60_000}m)`,
|
||||
);
|
||||
} else if (analyzeTimeoutMs > MAX_TIMER_DELAY_MS) {
|
||||
errors.push(`analyze_timeout must not exceed ${MAX_TIMER_DELAY_MS}ms`);
|
||||
}
|
||||
|
||||
const analyzeFailureThreshold =
|
||||
|
|
@ -256,10 +256,15 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy
|
|||
errors.push(`projects[${index}].overwrite_local_changes must be a boolean`);
|
||||
}
|
||||
|
||||
if (project.pdg !== undefined && typeof project.pdg !== 'boolean') {
|
||||
errors.push(`projects[${index}].pdg must be a boolean`);
|
||||
}
|
||||
|
||||
if (localPath && remoteUrls.length > 0 && branches.length > 0) {
|
||||
projects.push({
|
||||
localPath,
|
||||
groupName,
|
||||
...(typeof project.pdg === 'boolean' ? { pdg: project.pdg } : {}),
|
||||
overwriteLocalChanges: overwriteLocalChanges === true,
|
||||
branches,
|
||||
remoteUrls,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import { createRequire } from 'node:module';
|
|||
import { loadGroupConfig } from '../group/config-parser.js';
|
||||
import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js';
|
||||
import { syncGroup } from '../group/sync.js';
|
||||
import { registerRepo, resolveBranchPlacement, type RepoMeta } from '../../storage/repo-manager.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
loadMeta,
|
||||
registerRepo,
|
||||
resolveBranchPlacement,
|
||||
type RepoMeta,
|
||||
} from '../../storage/repo-manager.js';
|
||||
import { extractRepoNameFromRemoteUrl } from './repo.js';
|
||||
import { cloneOrPull, runGit } from '../../server/git-clone.js';
|
||||
import { resolveConfiguredCloneRoot } from './path-security.js';
|
||||
|
|
@ -20,7 +26,11 @@ import {
|
|||
} from './state.js';
|
||||
import type { AutoSyncConfig, AutoSyncProjectConfig } from './config.js';
|
||||
import { validateAutoSyncRemoteUrl } from './config.js';
|
||||
import { runAutoSyncAnalysis, type AutoSyncAnalysisRunner } from './analysis-worker-launch.js';
|
||||
import {
|
||||
AutoSyncAnalysisError,
|
||||
runAutoSyncAnalysis,
|
||||
type AutoSyncAnalysisRunner,
|
||||
} from './analysis-worker-launch.js';
|
||||
|
||||
export interface AutoSyncLogger {
|
||||
info(message: string): void;
|
||||
|
|
@ -32,6 +42,7 @@ export interface AutoSyncRunDeps {
|
|||
cloneOrPull: typeof cloneOrPull;
|
||||
getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise<string | undefined>;
|
||||
getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise<string>;
|
||||
getExistingPdgMode: (repoPath: string, branch: string) => Promise<boolean>;
|
||||
runAnalysis: AutoSyncAnalysisRunner;
|
||||
registerRepo: typeof registerRepo;
|
||||
resolveBranchPlacement: typeof resolveBranchPlacement;
|
||||
|
|
@ -49,6 +60,8 @@ export interface AutoSyncRunResult {
|
|||
analyzed: number;
|
||||
skippedAnalysis: number;
|
||||
failed: number;
|
||||
/** True when a timed-out analyze worker was left running and may still hold the index lock. */
|
||||
abandonedAnalysisWorker?: boolean;
|
||||
}
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
|
@ -68,6 +81,23 @@ const DEFAULT_DEPS: AutoSyncRunDeps = {
|
|||
},
|
||||
getCurrentCommit: async (repoPath, timeoutMs) =>
|
||||
(await runGit(['rev-parse', 'HEAD'], repoPath, { timeoutMs })).trim(),
|
||||
getExistingPdgMode: async (repoPath, branch) => {
|
||||
const placement = await resolveBranchPlacement(repoPath, branch);
|
||||
const paths = getStoragePaths(repoPath, placement.branch);
|
||||
const meta = await loadMeta(path.dirname(paths.metaPath));
|
||||
if (meta) return meta.pdg !== undefined;
|
||||
|
||||
try {
|
||||
await fs.stat(paths.lbugPath);
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') return false;
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`Cannot determine whether the existing index at ${paths.lbugPath} contains PDG data because its metadata is missing or unreadable.`,
|
||||
);
|
||||
},
|
||||
runAnalysis: runAutoSyncAnalysis,
|
||||
registerRepo,
|
||||
resolveBranchPlacement,
|
||||
|
|
@ -99,6 +129,7 @@ export async function runAutoSyncOnce(
|
|||
const groupsToSync = new Set<string>();
|
||||
const groupStateKeys = new Map<string, string[]>();
|
||||
const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 };
|
||||
let abandonedAnalysisWorker = false;
|
||||
const commitInfoEntries: ProjectCommitInfoEntry[] = [];
|
||||
const actualConcurrency = resolveActualConcurrency(
|
||||
config.maxConcurrency,
|
||||
|
|
@ -155,6 +186,23 @@ export async function runAutoSyncOnce(
|
|||
const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs);
|
||||
const stateKey = buildStateKey(targetDir, currentBranch);
|
||||
const previous = state[stateKey];
|
||||
let existingPdg: boolean | undefined;
|
||||
try {
|
||||
existingPdg = await deps.getExistingPdgMode(targetDir, currentBranch);
|
||||
} catch (error: unknown) {
|
||||
if (item.project.pdg === undefined) {
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)} ` +
|
||||
'Refusing to analyze so the live graph is preserved.',
|
||||
);
|
||||
}
|
||||
logger.warn(
|
||||
`[auto-sync] Existing PDG mode could not be determined for ${targetDir}; ` +
|
||||
`applying explicit pdg=${item.project.pdg}.`,
|
||||
);
|
||||
}
|
||||
const requestedPdg = item.project.pdg ?? existingPdg!;
|
||||
const pdgModeChanged = existingPdg === undefined || requestedPdg !== existingPdg;
|
||||
let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped';
|
||||
let analyzedCommitId = previous?.analyzedCommitId;
|
||||
let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0;
|
||||
|
|
@ -162,17 +210,32 @@ export async function runAutoSyncOnce(
|
|||
const groupSyncPending = previous?.groupSyncPending === true;
|
||||
let stats: RepoMeta['stats'] | undefined;
|
||||
|
||||
if (previous && previous.codeCommitId !== currentCommit) {
|
||||
const previousRequestedPdg = previous?.requestedPdg ?? existingPdg;
|
||||
if (
|
||||
previous &&
|
||||
(previous.codeCommitId !== currentCommit || previousRequestedPdg !== requestedPdg)
|
||||
) {
|
||||
analyzeConsecutiveFailures = 0;
|
||||
lastAnalyzeError = undefined;
|
||||
}
|
||||
|
||||
if (item.project.pdg === undefined && existingPdg) {
|
||||
logger.info(
|
||||
`[auto-sync] Preserving existing PDG mode for ${targetDir}; project configuration does not set pdg.`,
|
||||
);
|
||||
} else if (item.project.pdg === false && existingPdg) {
|
||||
logger.warn(
|
||||
`[auto-sync] PDG is explicitly disabled for ${targetDir}; the next successful rebuild will remove existing PDG data.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (analyzeConsecutiveFailures >= config.analyzeFailureThreshold) {
|
||||
analyzeStatus = 'threshold_skipped';
|
||||
logger.error(
|
||||
`[auto-sync] Skip analysis for ${targetDir}; analyze consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold} reached threshold. Fix the repository or clear auto-sync state before retrying.`,
|
||||
);
|
||||
} else if (
|
||||
pdgModeChanged ||
|
||||
shouldAnalyzeCommit({
|
||||
currentCommit,
|
||||
previousAnalyzedCommit: previous?.analyzedCommitId,
|
||||
|
|
@ -182,7 +245,15 @@ export async function runAutoSyncOnce(
|
|||
try {
|
||||
const analysis = await deps.runAnalysis(
|
||||
targetDir,
|
||||
{ branch: currentBranch, skipAgentsMd: true, skipSkills: true },
|
||||
{
|
||||
branch: currentBranch,
|
||||
skipAgentsMd: true,
|
||||
skipSkills: true,
|
||||
...(item.project.pdg === undefined
|
||||
? { preserveExistingPdg: true }
|
||||
: { pdg: requestedPdg }),
|
||||
atomicIncremental: true,
|
||||
},
|
||||
config.analyzeTimeoutMs,
|
||||
options.signal,
|
||||
options.onAnalysisCancellationRequested,
|
||||
|
|
@ -197,14 +268,21 @@ export async function runAutoSyncOnce(
|
|||
} catch (err: unknown) {
|
||||
if (options.signal?.aborted) throw err;
|
||||
analyzeStatus = 'failed';
|
||||
analyzeConsecutiveFailures += 1;
|
||||
lastAnalyzeError = shortErrorMessage(err);
|
||||
logger.error(
|
||||
`[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`,
|
||||
);
|
||||
if (isAbandonedAnalyzeWorkerError(err)) abandonedAnalysisWorker = true;
|
||||
if (isRetryableAnalyzeContention(err)) {
|
||||
logger.error(
|
||||
`[auto-sync] Analysis failed for ${targetDir}; retryable leftover-worker or index-lock wait (not counted toward threshold): ${lastAnalyzeError}`,
|
||||
);
|
||||
} else {
|
||||
analyzeConsecutiveFailures += 1;
|
||||
logger.error(
|
||||
`[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`);
|
||||
logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit and PDG mode unchanged.`);
|
||||
}
|
||||
throwIfAborted(options.signal);
|
||||
|
||||
|
|
@ -220,6 +298,7 @@ export async function runAutoSyncOnce(
|
|||
analyzeStatus,
|
||||
analyzeConsecutiveFailures,
|
||||
lastAnalyzeError,
|
||||
requestedPdg,
|
||||
groupSyncPending,
|
||||
stats,
|
||||
stateKey,
|
||||
|
|
@ -303,6 +382,7 @@ export async function runAutoSyncOnce(
|
|||
codeCommitId: repoResult.currentCommit,
|
||||
analyzedCommitId,
|
||||
lastAnalyzeStatus: analyzeStatus,
|
||||
requestedPdg: repoResult.requestedPdg,
|
||||
analyzeConsecutiveFailures,
|
||||
lastAnalyzeError,
|
||||
groupSyncPending: repoResult.groupSyncPending,
|
||||
|
|
@ -378,6 +458,7 @@ export async function runAutoSyncOnce(
|
|||
}
|
||||
}
|
||||
if (groupStateChanged) await deps.saveState(state);
|
||||
if (abandonedAnalysisWorker) result.abandonedAnalysisWorker = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -386,6 +467,22 @@ function shortErrorMessage(err: unknown): string {
|
|||
return message.replace(/\s+/g, ' ').slice(0, 240);
|
||||
}
|
||||
|
||||
const ABANDONED_WORKER_MARKER = 'it was left running so its native work is not interrupted';
|
||||
|
||||
function isAbandonedAnalyzeWorkerError(err: unknown): boolean {
|
||||
if (err instanceof AutoSyncAnalysisError && err.abandonedWorker) return true;
|
||||
return err instanceof Error && err.message.includes(ABANDONED_WORKER_MARKER);
|
||||
}
|
||||
|
||||
function isRetryableAnalyzeContention(err: unknown): boolean {
|
||||
if (isAbandonedAnalyzeWorkerError(err)) return true;
|
||||
return (
|
||||
err instanceof AutoSyncAnalysisError &&
|
||||
err.code === 'index-lock-timeout' &&
|
||||
err.retryable === true
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredRepoPath(
|
||||
project: Pick<AutoSyncProjectConfig, 'localPath'>,
|
||||
repoName: string,
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ export async function startAutoSyncWatch(
|
|||
let activeRun: Promise<void> | undefined;
|
||||
let activeAbortController: AbortController | undefined;
|
||||
let stopping = false;
|
||||
let rerunRequested = false;
|
||||
let statusWrite = Promise.resolve();
|
||||
const updateStatus = (state: WatchStatusState, message?: string) => {
|
||||
const write = statusWrite.then(() =>
|
||||
|
|
@ -152,10 +153,12 @@ export async function startAutoSyncWatch(
|
|||
const runSafely = () => {
|
||||
if (stopping) return;
|
||||
if (activeRun) {
|
||||
stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n');
|
||||
rerunRequested = true;
|
||||
stderr.write('[auto-sync] Previous run is still active; queued one immediate follow-up.\n');
|
||||
return;
|
||||
}
|
||||
const startedAt = new Date();
|
||||
let deferFollowUp = false;
|
||||
stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`);
|
||||
const abortController = new AbortController();
|
||||
const run = runOnce(loaded.config, {
|
||||
|
|
@ -170,6 +173,7 @@ export async function startAutoSyncWatch(
|
|||
},
|
||||
})
|
||||
.then((result) => {
|
||||
deferFollowUp = result.abandonedAnalysisWorker === true;
|
||||
stderr.write(
|
||||
`[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`,
|
||||
);
|
||||
|
|
@ -179,6 +183,8 @@ export async function startAutoSyncWatch(
|
|||
stderr.write('[auto-sync] Watch loop finished: failed.\n');
|
||||
})
|
||||
.finally(async () => {
|
||||
const runAgain = rerunRequested;
|
||||
rerunRequested = false;
|
||||
if (activeRun === run) {
|
||||
activeRun = undefined;
|
||||
activeAbortController = undefined;
|
||||
|
|
@ -186,6 +192,15 @@ export async function startAutoSyncWatch(
|
|||
if (!stopping) {
|
||||
await updateStatus('running').catch(reportStatusWriteFailure);
|
||||
}
|
||||
if (runAgain && !stopping && !activeRun) {
|
||||
if (deferFollowUp) {
|
||||
stderr.write(
|
||||
'[auto-sync] Previous run left an analyze worker running; deferring the coalesced follow-up to the next interval.\n',
|
||||
);
|
||||
} else {
|
||||
runSafely();
|
||||
}
|
||||
}
|
||||
});
|
||||
activeRun = run;
|
||||
activeAbortController = abortController;
|
||||
|
|
@ -195,6 +210,7 @@ export async function startAutoSyncWatch(
|
|||
const stop = () =>
|
||||
(stopPromise ??= (async () => {
|
||||
stopping = true;
|
||||
rerunRequested = false;
|
||||
clearIntervalFn(timer);
|
||||
clearIntervalFn(controlTimer);
|
||||
activeAbortController?.abort();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ export interface AutoSyncCommitStateEntry {
|
|||
codeCommitId: string;
|
||||
analyzedCommitId?: string;
|
||||
lastAnalyzeStatus?: AutoSyncAnalyzeStatus;
|
||||
/**
|
||||
* PDG mode from the most recent successful sync result. Written even when
|
||||
* analysis was threshold-skipped and `runAnalysis` did not run.
|
||||
*/
|
||||
requestedPdg?: boolean;
|
||||
analyzeConsecutiveFailures?: number;
|
||||
lastAnalyzeError?: string;
|
||||
groupSyncPending?: boolean;
|
||||
|
|
@ -105,6 +110,7 @@ function isAutoSyncCommitStateEntry(value: unknown): value is AutoSyncCommitStat
|
|||
entry.lastAnalyzeStatus === 'failed' ||
|
||||
entry.lastAnalyzeStatus === 'skipped' ||
|
||||
entry.lastAnalyzeStatus === 'threshold_skipped') &&
|
||||
(entry.requestedPdg === undefined || typeof entry.requestedPdg === 'boolean') &&
|
||||
(entry.analyzeConsecutiveFailures === undefined ||
|
||||
(typeof entry.analyzeConsecutiveFailures === 'number' &&
|
||||
Number.isInteger(entry.analyzeConsecutiveFailures) &&
|
||||
|
|
|
|||
|
|
@ -427,6 +427,11 @@ export interface AnalyzeOptions {
|
|||
* scope-resolution (BasicBlock/CFG emit gate). Off by default.
|
||||
*/
|
||||
pdg?: boolean;
|
||||
/**
|
||||
* Internal auto-sync mode: resolve `pdg` from the live index metadata only
|
||||
* after acquiring its writer lock. An explicit `pdg` value always wins.
|
||||
*/
|
||||
preserveExistingPdg?: boolean;
|
||||
/** Per-function source-line cap for worker-side CFG construction (#2081 M1).
|
||||
* Forwarded to `PipelineOptions.pdgMaxFunctionLines`. No CLI flag in M1 —
|
||||
* programmatic / server analyze-worker path only; the worker applies
|
||||
|
|
@ -1275,6 +1280,28 @@ async function runFullAnalysisInner(
|
|||
}
|
||||
|
||||
const loadedMeta = await loadMeta(metaDir);
|
||||
if (options.preserveExistingPdg && options.pdg === undefined) {
|
||||
if (loadedMeta) {
|
||||
options = { ...options, pdg: loadedMeta.pdg !== undefined };
|
||||
} else {
|
||||
try {
|
||||
await fs.stat(lbugPath);
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
options = { ...options, pdg: false };
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (options.pdg === undefined) {
|
||||
throw new Error(
|
||||
`Cannot determine whether the existing index at ${lbugPath} contains PDG data; ` +
|
||||
'refusing to analyze so the live graph is preserved.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const previousFtsDisabledReason = getFtsDisabledReason(loadedMeta?.capabilities?.fts);
|
||||
// Flag and env are equivalent disablements. Only a true enable↔disable flip
|
||||
// needs a write plan; a discriminator-only change restamps on the
|
||||
|
|
|
|||
|
|
@ -108,15 +108,17 @@ async function writeWithRetry(
|
|||
}
|
||||
|
||||
// Bounded timeout so a genuine deadlock fails the test instead of hanging CI
|
||||
// (mirrors the convention in parse-impl-large-fixture.test.ts). 60s is far
|
||||
// above the ~2.5s this run takes locally on Linux — deliberately generous
|
||||
// margin since native LadybugDB operations are slower on Windows CI and this
|
||||
// test is registered into the Windows-inclusive LBUG_NATIVE group. A timeout
|
||||
// here is a genuine deadlock regression signal, not routine flake — if
|
||||
// Windows CI shows this margin is too tight (or too loose to catch a real
|
||||
// regression promptly), tighten/loosen this constant based on observed
|
||||
// LBUG_NATIVE run times rather than guessing again.
|
||||
const DEADLOCK_TIMEOUT_MS = 60_000;
|
||||
// (mirrors the convention in parse-impl-large-fixture.test.ts). Linux local
|
||||
// and ubuntu/macOS CI finish in ~3–8s, so 60s stays a hang detector there.
|
||||
// Windows CI typical is ~14–16s, but the native checkpoint-vs-reader stress
|
||||
// has a long tail: same-day windows-latest 3/3 jobs completed in 13803ms /
|
||||
// 15488ms / 15658ms typical and 56518ms on the slow tail — 4s under the
|
||||
// previous 60s budget. That budget therefore false-positives under Windows
|
||||
// runner load (PR #3290 job 104765024772: both vitest retries timed out at
|
||||
// 60s while Ubuntu/macOS and this PR's prior Windows 3/3 at 15.7s passed).
|
||||
// win32 uses 180s (~3× the observed tail) and still fails a hang that never
|
||||
// completes. Do not skip Windows; this test is in LBUG_NATIVE for a reason.
|
||||
const DEADLOCK_TIMEOUT_MS = process.platform === 'win32' ? 180_000 : 60_000;
|
||||
|
||||
// Unlike lbug-core-adapter.test.ts / lbug-close-handle-release.test.ts /
|
||||
// lbug-orphan-sidecar-recovery.test.ts, this test never closes and reopens
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ vi.mock('../../src/core/ingestion/utils/effective-ram.js', () => ({
|
|||
autoHeapCapMb: autoHeapCapMbMock,
|
||||
}));
|
||||
|
||||
import { createAutoSyncAnalysisRunner } from '../../src/core/auto-sync/analysis-worker-launch.js';
|
||||
import {
|
||||
AutoSyncAnalysisError,
|
||||
createAutoSyncAnalysisRunner,
|
||||
} from '../../src/core/auto-sync/analysis-worker-launch.js';
|
||||
|
||||
function createChild() {
|
||||
return Object.assign(new EventEmitter(), {
|
||||
|
|
@ -74,6 +77,29 @@ describe('auto-sync analysis worker', () => {
|
|||
await expect(result).rejects.toThrow('parser crashed');
|
||||
});
|
||||
|
||||
it('preserves worker index-lock-timeout code and retryable on the parent error', async () => {
|
||||
const child = createChild();
|
||||
const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) });
|
||||
|
||||
const result = run('/tmp/repo', { branch: 'main' }, 50);
|
||||
child.emit('message', {
|
||||
type: 'error',
|
||||
message: 'waited for the index lock',
|
||||
code: 'index-lock-timeout',
|
||||
retryable: true,
|
||||
});
|
||||
child.emit('exit', 1, null);
|
||||
|
||||
await expect(result).rejects.toMatchObject({
|
||||
name: 'AutoSyncAnalysisError',
|
||||
message: 'waited for the index lock',
|
||||
code: 'index-lock-timeout',
|
||||
retryable: true,
|
||||
abandonedWorker: false,
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(AutoSyncAnalysisError);
|
||||
});
|
||||
|
||||
it('requests cancellation after timeout, reports it, and waits for exit', async () => {
|
||||
const child = createChild();
|
||||
const timers: Array<() => void> = [];
|
||||
|
|
@ -211,6 +237,11 @@ describe('auto-sync analysis worker', () => {
|
|||
timers[1]!();
|
||||
|
||||
await expect(result).rejects.toThrow('did not exit within');
|
||||
await expect(result).rejects.toMatchObject({
|
||||
name: 'AutoSyncAnalysisError',
|
||||
abandonedWorker: true,
|
||||
retryable: true,
|
||||
});
|
||||
// The parent stops waiting; the child is released, never killed.
|
||||
expect(child.channel.unref).toHaveBeenCalled();
|
||||
expect(child.unref).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
AutoSyncRunDeps,
|
||||
AutoSyncWatchPaths,
|
||||
} from '../../src/core/auto-sync/index.js';
|
||||
import { AutoSyncAnalysisError } from '../../src/core/auto-sync/analysis-worker-launch.js';
|
||||
|
||||
const config: AutoSyncConfig = {
|
||||
configPath: '/tmp/.gitnexus/watch_config.yml',
|
||||
|
|
@ -31,6 +32,7 @@ const config: AutoSyncConfig = {
|
|||
{
|
||||
localPath: '/tmp/repos',
|
||||
groupName: 'back_end',
|
||||
pdg: false,
|
||||
overwriteLocalChanges: false,
|
||||
branches: ['master'],
|
||||
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
|
||||
|
|
@ -49,10 +51,69 @@ const verifiedProcessStartTime = 'Tue Aug 4 12:00:00 2026';
|
|||
function withCloneRoot(deps: Partial<AutoSyncRunDeps>): Partial<AutoSyncRunDeps> {
|
||||
return {
|
||||
resolveCloneRoot: vi.fn(async () => cloneRoot),
|
||||
getExistingPdgMode: vi.fn(async () => false),
|
||||
...deps,
|
||||
};
|
||||
}
|
||||
|
||||
async function runWithOnDiskPdgState(options: {
|
||||
metadata?: Record<string, unknown>;
|
||||
createLbug?: boolean;
|
||||
pdg?: boolean;
|
||||
}) {
|
||||
const base = path.join(process.cwd(), '.tmp-test');
|
||||
await fs.mkdir(base, { recursive: true });
|
||||
const localPath = await fs.realpath(await fs.mkdtemp(path.join(base, 'auto-sync-pdg-mode-')));
|
||||
const targetDir = path.join(localPath, 'github.com', 'team', 'repo');
|
||||
const indexDir = path.join(targetDir, '.gitnexus');
|
||||
await fs.mkdir(indexDir, { recursive: true });
|
||||
if (options.metadata) {
|
||||
await fs.writeFile(path.join(indexDir, 'gitnexus.json'), JSON.stringify(options.metadata));
|
||||
}
|
||||
if (options.createLbug) await fs.mkdir(path.join(indexDir, 'lbug'));
|
||||
|
||||
const runAnalysis = vi.fn(async () => ({ stats: { files: 1 } }) as any);
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const localConfig: AutoSyncConfig = {
|
||||
...config,
|
||||
projects: [
|
||||
{
|
||||
...config.projects[0],
|
||||
localPath,
|
||||
groupName: undefined,
|
||||
pdg: options.pdg,
|
||||
branches: ['main'],
|
||||
remoteUrls: ['git@github.com:team/repo.git'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const deps: Partial<AutoSyncRunDeps> = {
|
||||
resolveCloneRoot: vi.fn(async () => ({
|
||||
root: localPath,
|
||||
quarantineRoot: path.join(localPath, 'quarantine'),
|
||||
quarantineRetentionDays: 14,
|
||||
})),
|
||||
cloneOrPull: vi.fn(async () => targetDir),
|
||||
getCurrentBranch: vi.fn(async () => 'main'),
|
||||
getCurrentCommit: vi.fn(async () => 'commit-1'),
|
||||
runAnalysis,
|
||||
registerRepo: vi.fn(async () => 'repo'),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
writeCommitInfo: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
getAvailableMemoryGB: vi.fn(() => 8),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runAutoSyncOnce(localConfig, { deps, logger });
|
||||
return { result, runAnalysis, logger, targetDir };
|
||||
} finally {
|
||||
await fs.rm(localPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function writeWatchOwner(
|
||||
paths: AutoSyncWatchPaths,
|
||||
pid: number,
|
||||
|
|
@ -130,7 +191,13 @@ describe('auto-sync runner', () => {
|
|||
);
|
||||
expect(deps.runAnalysis).toHaveBeenCalledWith(
|
||||
'/tmp/repos/gitee.com/qts_server/qts_account',
|
||||
{ branch: 'master', skipAgentsMd: true, skipSkills: true },
|
||||
{
|
||||
branch: 'master',
|
||||
skipAgentsMd: true,
|
||||
skipSkills: true,
|
||||
pdg: false,
|
||||
atomicIncremental: true,
|
||||
},
|
||||
1_800_000,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -156,6 +223,188 @@ describe('auto-sync runner', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('enables PDG atomically at an unchanged commit when project configuration opts in', async () => {
|
||||
const pdgConfig: AutoSyncConfig = {
|
||||
...config,
|
||||
projects: [{ ...config.projects[0], pdg: true }],
|
||||
};
|
||||
const stateKey = '/tmp/repos/gitee.com/qts_server/qts_account|master';
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-1'),
|
||||
getExistingPdgMode: vi.fn(async () => false),
|
||||
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
||||
registerRepo: vi.fn(async () => 'qts_account'),
|
||||
loadState: vi.fn(async () => ({
|
||||
[stateKey]: {
|
||||
codeCommitId: 'commit-1',
|
||||
analyzedCommitId: 'commit-1',
|
||||
lastAnalyzeStatus: 'failed',
|
||||
analyzeConsecutiveFailures: 3,
|
||||
lastAnalyzeError: 'old non-PDG failure',
|
||||
lastSyncTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
writeCommitInfo: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
getAvailableMemoryGB: vi.fn(() => 8),
|
||||
});
|
||||
|
||||
const result = await runAutoSyncOnce(pdgConfig, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
|
||||
expect(deps.runAnalysis).toHaveBeenCalledWith(
|
||||
'/tmp/repos/gitee.com/qts_server/qts_account',
|
||||
expect.objectContaining({ pdg: true, atomicIncremental: true }),
|
||||
1_800_000,
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
expect(deps.saveState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
[stateKey]: expect.objectContaining({ requestedPdg: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an existing PDG index when legacy project configuration omits pdg', async () => {
|
||||
const legacyConfig: AutoSyncConfig = {
|
||||
...config,
|
||||
projects: [{ ...config.projects[0], pdg: undefined }],
|
||||
};
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-2'),
|
||||
getExistingPdgMode: vi.fn(async () => true),
|
||||
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
||||
registerRepo: vi.fn(async () => 'qts_account'),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
writeCommitInfo: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
getAvailableMemoryGB: vi.fn(() => 8),
|
||||
});
|
||||
|
||||
await runAutoSyncOnce(legacyConfig, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
expect(deps.runAnalysis).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ preserveExistingPdg: true, atomicIncremental: true }),
|
||||
expect.any(Number),
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['PDG metadata', { branch: 'main', pdg: { version: 5 } }, true],
|
||||
['non-PDG metadata', { branch: 'main' }, false],
|
||||
['no prior index', undefined, false],
|
||||
] as const)(
|
||||
'derives the omitted PDG setting from %s on disk',
|
||||
async (_label, metadata, expectedPdg) => {
|
||||
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({ metadata });
|
||||
|
||||
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
|
||||
expect(runAnalysis).toHaveBeenCalledWith(
|
||||
targetDir,
|
||||
expect.objectContaining({ preserveExistingPdg: true, atomicIncremental: true }),
|
||||
expect.any(Number),
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
if (expectedPdg) {
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
`[auto-sync] Preserving existing PDG mode for ${targetDir}; project configuration does not set pdg.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses to overwrite an index when its on-disk PDG mode is ambiguous', async () => {
|
||||
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({
|
||||
createLbug: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 });
|
||||
expect(runAnalysis).not.toHaveBeenCalled();
|
||||
const error = String(logger.error.mock.calls[0]?.[0]);
|
||||
expect(error).toContain(
|
||||
`Cannot determine whether the existing index at ${path.join(targetDir, '.gitnexus', 'lbug')} contains PDG data`,
|
||||
);
|
||||
expect(error).toContain('Refusing to analyze so the live graph is preserved.');
|
||||
});
|
||||
|
||||
it('uses an explicit PDG mode to recover an index whose metadata is ambiguous', async () => {
|
||||
const { result, runAnalysis, logger, targetDir } = await runWithOnDiskPdgState({
|
||||
createLbug: true,
|
||||
pdg: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
|
||||
expect(runAnalysis).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ pdg: true, atomicIncremental: true }),
|
||||
expect.any(Number),
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
const warning = String(logger.warn.mock.calls[0]?.[0]);
|
||||
expect(warning).toBe(
|
||||
`[auto-sync] Existing PDG mode could not be determined for ${targetDir}; applying explicit pdg=true.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('warns before an explicit PDG opt-out removes existing PDG data', async () => {
|
||||
const warn = vi.fn();
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-1'),
|
||||
getExistingPdgMode: vi.fn(async () => true),
|
||||
runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
||||
registerRepo: vi.fn(async () => 'qts_account'),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
writeCommitInfo: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
getAvailableMemoryGB: vi.fn(() => 8),
|
||||
});
|
||||
|
||||
await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn, error: vi.fn() },
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[auto-sync] PDG is explicitly disabled for /tmp/repos/gitee.com/qts_server/qts_account; the next successful rebuild will remove existing PDG data.',
|
||||
);
|
||||
expect(deps.runAnalysis).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ pdg: false, atomicIncremental: true }),
|
||||
expect.any(Number),
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers into the branch slot the analyze worker placed the index in', async () => {
|
||||
// Without this the parent always takes the primary/flat arm and relabels a
|
||||
// pinned branch entry with whatever this tick happened to sync.
|
||||
|
|
@ -476,7 +725,13 @@ describe('auto-sync runner', () => {
|
|||
|
||||
expect(runAnalysis).toHaveBeenCalledWith(
|
||||
'/tmp/repos/gitee.com/qts_server/qts_account',
|
||||
{ branch: 'master', skipAgentsMd: true, skipSkills: true },
|
||||
{
|
||||
branch: 'master',
|
||||
skipAgentsMd: true,
|
||||
skipSkills: true,
|
||||
pdg: false,
|
||||
atomicIncremental: true,
|
||||
},
|
||||
1_800_000,
|
||||
controller.signal,
|
||||
onAnalysisCancellationRequested,
|
||||
|
|
@ -530,7 +785,13 @@ describe('auto-sync runner', () => {
|
|||
);
|
||||
expect(deps.runAnalysis).toHaveBeenCalledWith(
|
||||
'/tmp/repos/gitee.com/qts_server/qts_account',
|
||||
{ branch: 'develop', skipAgentsMd: true, skipSkills: true },
|
||||
{
|
||||
branch: 'develop',
|
||||
skipAgentsMd: true,
|
||||
skipSkills: true,
|
||||
pdg: false,
|
||||
atomicIncremental: true,
|
||||
},
|
||||
1_800_000,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -1058,6 +1319,137 @@ describe('auto-sync runner', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not count leftover-worker timeout toward the analyze failure threshold', async () => {
|
||||
const errorLogger = vi.fn();
|
||||
const leftover = new AutoSyncAnalysisError(
|
||||
'Analysis timed out after 50ms. The analyze worker did not exit within 5000ms; it was left running so its native work is not interrupted.',
|
||||
{ abandonedWorker: true, retryable: true },
|
||||
);
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-1'),
|
||||
runAnalysis: vi.fn(async () => {
|
||||
throw leftover;
|
||||
}),
|
||||
registerRepo: vi.fn(),
|
||||
loadState: vi.fn(async () => ({
|
||||
'/tmp/repos/gitee.com/qts_server/qts_account|master': {
|
||||
codeCommitId: 'commit-1',
|
||||
analyzedCommitId: 'commit-1',
|
||||
lastAnalyzeStatus: 'failed',
|
||||
analyzeConsecutiveFailures: 1,
|
||||
lastSyncTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
writeCommitInfo: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
getAvailableMemoryGB: vi.fn(() => 8),
|
||||
});
|
||||
|
||||
const result = await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
|
||||
now: () => new Date('2026-06-30T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
synced: 1,
|
||||
analyzed: 0,
|
||||
skippedAnalysis: 0,
|
||||
failed: 1,
|
||||
abandonedAnalysisWorker: true,
|
||||
});
|
||||
expect(deps.saveState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
|
||||
analyzeConsecutiveFailures: 1,
|
||||
lastAnalyzeStatus: 'failed',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(errorLogger).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'retryable leftover-worker or index-lock wait (not counted toward threshold)',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not count a retryable live-holder index-lock timeout toward the analyze failure threshold', async () => {
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-1'),
|
||||
runAnalysis: vi.fn(async () => {
|
||||
throw new AutoSyncAnalysisError('waited for the index lock', {
|
||||
code: 'index-lock-timeout',
|
||||
retryable: true,
|
||||
});
|
||||
}),
|
||||
registerRepo: vi.fn(),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
writeCommitInfo: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
getAvailableMemoryGB: vi.fn(() => 8),
|
||||
});
|
||||
|
||||
const result = await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
now: () => new Date('2026-06-30T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 });
|
||||
expect(deps.saveState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
|
||||
analyzeConsecutiveFailures: 0,
|
||||
lastAnalyzeStatus: 'failed',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('still counts a non-retryable index-lock guard timeout toward the analyze failure threshold', async () => {
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-1'),
|
||||
runAnalysis: vi.fn(async () => {
|
||||
throw new AutoSyncAnalysisError('index lock guard timeout', {
|
||||
code: 'index-lock-timeout',
|
||||
retryable: false,
|
||||
});
|
||||
}),
|
||||
registerRepo: vi.fn(),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
writeCommitInfo: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
getAvailableMemoryGB: vi.fn(() => 8),
|
||||
});
|
||||
|
||||
await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
now: () => new Date('2026-06-30T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(deps.saveState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({
|
||||
analyzeConsecutiveFailures: 1,
|
||||
lastAnalyzeStatus: 'failed',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a null analysis failure without masking it with a TypeError', async () => {
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'),
|
||||
|
|
@ -1277,7 +1669,7 @@ describe('auto-sync starter', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('skips overlapping scheduled runs while a previous run is active', async () => {
|
||||
it('coalesces overlapping scheduled ticks into one immediate follow-up run', async () => {
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
|
||||
const timer = { unref: vi.fn() };
|
||||
|
|
@ -1314,18 +1706,80 @@ describe('auto-sync starter', () => {
|
|||
|
||||
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
|
||||
scheduled?.();
|
||||
scheduled?.();
|
||||
|
||||
expect(runOnce).toHaveBeenCalledTimes(1);
|
||||
expect(stderr.write).toHaveBeenCalledWith(
|
||||
'[auto-sync] Previous run is still active; skipping overlapping run.\n',
|
||||
'[auto-sync] Previous run is still active; queued one immediate follow-up.\n',
|
||||
);
|
||||
|
||||
releaseRuns.shift()?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await vi.waitFor(() => expect(runOnce).toHaveBeenCalledTimes(2));
|
||||
releaseRuns.shift()?.();
|
||||
await handle?.stop();
|
||||
handle = undefined;
|
||||
} finally {
|
||||
releaseRuns.splice(0).forEach((release) => release());
|
||||
await handle?.stop();
|
||||
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousHome;
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('defers a coalesced follow-up when the finished run left an analyze worker running', async () => {
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
|
||||
const timer = { unref: vi.fn() };
|
||||
let scheduled: (() => void) | undefined;
|
||||
const setIntervalFn = vi.fn((fn: () => void) => {
|
||||
scheduled = fn;
|
||||
return timer;
|
||||
}) as unknown as typeof setInterval;
|
||||
const stderr = { write: vi.fn() };
|
||||
const releaseRuns: Array<() => void> = [];
|
||||
const runOnce = vi.fn(
|
||||
() =>
|
||||
new Promise<any>((resolve) => {
|
||||
releaseRuns.push(() =>
|
||||
resolve({
|
||||
synced: 1,
|
||||
analyzed: 0,
|
||||
skippedAnalysis: 0,
|
||||
failed: 1,
|
||||
abandonedAnalysisWorker: true,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
let handle: Awaited<ReturnType<typeof startAutoSyncWatch>> | undefined;
|
||||
|
||||
try {
|
||||
process.env.GITNEXUS_HOME = tempDir;
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'watch_config.yml'),
|
||||
[
|
||||
'sync_interval_minutes: 5',
|
||||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' branch: master',
|
||||
' remote_urls:',
|
||||
' - git@github.com:team/repo.git',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
|
||||
scheduled?.();
|
||||
scheduled?.();
|
||||
|
||||
expect(runOnce).toHaveBeenCalledTimes(2);
|
||||
expect(runOnce).toHaveBeenCalledTimes(1);
|
||||
releaseRuns.shift()?.();
|
||||
await vi.waitFor(() => {
|
||||
expect(stderr.write).toHaveBeenCalledWith(
|
||||
'[auto-sync] Previous run left an analyze worker running; deferring the coalesced follow-up to the next interval.\n',
|
||||
);
|
||||
});
|
||||
expect(runOnce).toHaveBeenCalledTimes(1);
|
||||
await handle?.stop();
|
||||
handle = undefined;
|
||||
} finally {
|
||||
|
|
@ -1372,6 +1826,7 @@ describe('auto-sync starter', () => {
|
|||
|
||||
handle = await startAutoSyncWatch({ setIntervalFn, runOnce, stderr });
|
||||
expect(runOnce).toHaveBeenCalledTimes(1);
|
||||
scheduled?.();
|
||||
|
||||
const stopping = handle!.stop();
|
||||
releaseRun?.();
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ describe('auto-sync', () => {
|
|||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' group_name: back_end',
|
||||
' pdg: true',
|
||||
' overwrite_local_changes: true',
|
||||
' branches: [test, master, test]',
|
||||
' remote_urls:',
|
||||
|
|
@ -124,6 +125,7 @@ describe('auto-sync', () => {
|
|||
expect(loaded.config.projects[0]).toMatchObject({
|
||||
localPath: '/tmp/repos',
|
||||
groupName: 'back_end',
|
||||
pdg: true,
|
||||
overwriteLocalChanges: true,
|
||||
branches: ['test', 'master'],
|
||||
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
|
||||
|
|
@ -153,6 +155,7 @@ describe('auto-sync', () => {
|
|||
expect(loaded.config.maxConcurrency).toBe(1);
|
||||
expect(loaded.config.analyzeFailureThreshold).toBe(3);
|
||||
expect(loaded.config.projects[0].groupName).toBeUndefined();
|
||||
expect(loaded.config.projects[0].pdg).toBeUndefined();
|
||||
expect(loaded.config.projects[0].overwriteLocalChanges).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -209,12 +212,12 @@ describe('auto-sync', () => {
|
|||
expect(() => parseAutoSyncConfig(config('600000ms'), '/tmp/watch_config.yml')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects analyze_timeout values above half the sync interval', async () => {
|
||||
it('allows a 30 minute analysis timeout with 5 minute polling', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(gitnexusHome, 'watch_config.yml'),
|
||||
[
|
||||
'sync_interval_minutes: 10',
|
||||
'analyze_timeout: 6m',
|
||||
'sync_interval_minutes: 5',
|
||||
'analyze_timeout: 30m',
|
||||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' branch: master',
|
||||
|
|
@ -225,11 +228,43 @@ describe('auto-sync', () => {
|
|||
|
||||
const loaded = await loadAutoSyncConfig();
|
||||
|
||||
expect(loaded.ok).toBe(false);
|
||||
if (loaded.ok) throw new Error('expected invalid config');
|
||||
expect(loaded.message).toContain(
|
||||
'analyze_timeout must not exceed half of sync_interval_minutes (5m)',
|
||||
);
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error('expected valid config');
|
||||
expect(loaded.config.analyzeTimeoutMs).toBe(1_800_000);
|
||||
});
|
||||
|
||||
it('rejects analyze_timeout values above the Node timer limit', () => {
|
||||
expect(() =>
|
||||
parseAutoSyncConfig(
|
||||
[
|
||||
'sync_interval_minutes: 5',
|
||||
'analyze_timeout: 2147483648ms',
|
||||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' branch: master',
|
||||
' remote_urls:',
|
||||
' - git@github.com:owner/repo.git',
|
||||
].join('\n'),
|
||||
'/tmp/watch_config.yml',
|
||||
),
|
||||
).toThrow('analyze_timeout must not exceed 2147483647ms');
|
||||
});
|
||||
|
||||
it('rejects non-boolean per-project pdg configuration', () => {
|
||||
expect(() =>
|
||||
parseAutoSyncConfig(
|
||||
[
|
||||
'sync_interval_minutes: 5',
|
||||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' branch: master',
|
||||
' pdg: yes',
|
||||
' remote_urls:',
|
||||
' - git@github.com:owner/repo.git',
|
||||
].join('\n'),
|
||||
'/tmp/watch_config.yml',
|
||||
),
|
||||
).toThrow('projects[0].pdg must be a boolean');
|
||||
});
|
||||
|
||||
it('rejects invalid analyze_failure_threshold values', async () => {
|
||||
|
|
@ -608,6 +643,7 @@ describe('auto-sync', () => {
|
|||
codeCommitId: 'abc',
|
||||
analyzedCommitId: 'abc',
|
||||
lastAnalyzeStatus: 'success',
|
||||
requestedPdg: true,
|
||||
analyzeConsecutiveFailures: 2,
|
||||
lastAnalyzeError: 'old error',
|
||||
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
||||
|
|
@ -624,6 +660,7 @@ describe('auto-sync', () => {
|
|||
codeCommitId: 'abc',
|
||||
analyzedCommitId: 'abc',
|
||||
lastAnalyzeStatus: 'success',
|
||||
requestedPdg: true,
|
||||
analyzeConsecutiveFailures: 2,
|
||||
lastAnalyzeError: 'old error',
|
||||
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
||||
|
|
@ -670,6 +707,11 @@ describe('auto-sync', () => {
|
|||
analyzeConsecutiveFailures: -1,
|
||||
lastSyncTime: null,
|
||||
},
|
||||
'/tmp/repos/invalid-pdg|main': {
|
||||
codeCommitId: 'abc',
|
||||
requestedPdg: 'true',
|
||||
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -310,6 +310,8 @@ describe('CLI help surface', () => {
|
|||
expect(config).toContain('sync_interval_minutes: 10');
|
||||
expect(config).toContain('analyze_failure_threshold: 3');
|
||||
expect(config).toContain('analyze_timeout: 5m');
|
||||
expect(config).toContain('pdg: false');
|
||||
expect(config).toContain('omit = preserve live index mode');
|
||||
expect(config).toContain('overwrite_local_changes: false');
|
||||
expect(config).toContain(`local_path: ${path.join(home, 'repos')}`);
|
||||
expect(config).not.toContain('/abs/path/to/repos');
|
||||
|
|
|
|||
|
|
@ -261,6 +261,31 @@ describe('detect_changes BasicBlock exclusion (#2082 U7)', () => {
|
|||
});
|
||||
|
||||
describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => {
|
||||
it('resolves preserveExistingPdg from metadata after entering the locked analysis path', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
try {
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const { storagePath } = getStoragePaths(repo.dbPath);
|
||||
const cb = { onProgress: () => {}, onLog: () => {} };
|
||||
|
||||
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb);
|
||||
const blocks = await countBasicBlocks(repo.dbPath);
|
||||
expect(blocks).toBeGreaterThan(0);
|
||||
|
||||
const preserved = await runFullAnalysis(
|
||||
repo.dbPath,
|
||||
{ skipAgentsMd: true, preserveExistingPdg: true },
|
||||
cb,
|
||||
);
|
||||
|
||||
expect(preserved.alreadyUpToDate).toBe(true);
|
||||
expect((await loadMeta(storagePath))!.pdg).toBeDefined();
|
||||
expect(await countBasicBlocks(repo.dbPath)).toBe(blocks);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
}, 600_000);
|
||||
|
||||
it('off→on flip forces a full writeback that persists the CFG layer; on→off removes it', async () => {
|
||||
const repo = await setupMiniRepo();
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue