From ac2acab2d06669b0238f4cf058ff25279e7a3155 Mon Sep 17 00:00:00 2001 From: weiyf Date: Mon, 20 Jul 2026 13:17:22 +0800 Subject: [PATCH] fix(watch): protect local repos and cancel active analysis --- README.md | 3 + gitnexus/README.md | 24 +- gitnexus/src/cli/watch.ts | 2 + .../core/auto-sync/analysis-worker-launch.ts | 120 ++++++++ gitnexus/src/core/auto-sync/config.ts | 39 ++- gitnexus/src/core/auto-sync/index.ts | 1 + gitnexus/src/core/auto-sync/runner.ts | 265 ++++++++++------- gitnexus/src/core/auto-sync/starter.ts | 81 +++++- gitnexus/src/core/auto-sync/state.ts | 29 +- gitnexus/src/server/git-clone.ts | 64 ++++- .../unit/auto-sync-analysis-worker.test.ts | 54 ++++ gitnexus/test/unit/auto-sync-runner.test.ts | 270 ++++++++++++++++-- gitnexus/test/unit/auto-sync.test.ts | 64 ++++- gitnexus/test/unit/cli-index-help.test.ts | 2 + gitnexus/test/unit/git-clone.test.ts | 94 ++++++ 15 files changed, 940 insertions(+), 172 deletions(-) create mode 100644 gitnexus/src/core/auto-sync/analysis-worker-launch.ts create mode 100644 gitnexus/test/unit/auto-sync-analysis-worker.test.ts diff --git a/README.md b/README.md index 54d8e1ff1..4a1ecd3de 100644 --- a/README.md +++ b/README.md @@ -440,9 +440,11 @@ gitnexus watch stop ```yaml sync_interval_minutes: 10 +analyze_timeout: 5m projects: - local_path: /absolute/path/to/clones branches: [main, master] + overwrite_local_changes: false remote_urls: - git@github.com:owner/repo.git ``` @@ -450,6 +452,7 @@ projects: - `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. - 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`. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten. Stopping watch cancels an active analysis immediately. - Add `group_name` only after creating that group with `gitnexus group create `. See the [full watch configuration and runtime reference](gitnexus/README.md#gitnexus-watch) for concurrency, timeouts, failure thresholds, and runtime files. diff --git a/gitnexus/README.md b/gitnexus/README.md index 275e0bc41..5c3f15cf4 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -283,18 +283,20 @@ gitnexus group impact --target --repo # Cross-repo sync_interval_minutes: 10 max_concurrency: 1 repo_git_timeout: 10s +analyze_timeout: 5m analyze_failure_threshold: 3 projects: - local_path: /abs/path/to/repos branches: [master, main] group_name: back_end + overwrite_local_changes: false remote_urls: - git@github.com:owner/repo.git - git@gitlab.com:group/repo.git - 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. `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. `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. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and skips repeated failing analyze runs for the same repo branch until the auto-sync state is cleared. 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 `. `$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. `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. `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. On timeout watch terminates that worker, records the failed attempt, and resumes scheduling only after the worker exits. `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. Stopping watch cancels an active analysis worker immediately. `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. `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 resets the failure count and is analyzed 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 `. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`. > **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove. @@ -488,16 +490,16 @@ GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnex Configure the behavior with these environment variables: -| Variable | Values | Default | Effect | -| -------------------------------------------- | ------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. | -| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. | -| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. | -| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. | -| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. | -| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | -| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. | -| `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. | +| Variable | Values | Default | Effect | +| -------------------------------------------- | ------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. | +| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. | +| `GITNEXUS_FTS_STEMMER` | supported LadybugDB stemmer | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. | +| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. | +| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. | +| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | +| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. | +| `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. | ```bash # Offline/airgapped: never reach the network for extensions diff --git a/gitnexus/src/cli/watch.ts b/gitnexus/src/cli/watch.ts index c995ccf6b..41dc2ba59 100644 --- a/gitnexus/src/cli/watch.ts +++ b/gitnexus/src/cli/watch.ts @@ -91,11 +91,13 @@ function defaultSyncConfig(localPath: string): string { 'sync_interval_minutes: 10', 'max_concurrency: 1', 'repo_git_timeout: 10s', + 'analyze_timeout: 5m', 'analyze_failure_threshold: 3', 'projects:', ` - local_path: ${localPath}`, ' branches: [master, main]', ' group_name: back_end', + ' overwrite_local_changes: false', ' remote_urls:', ' - git@github.com:owner/repo.git', '', diff --git a/gitnexus/src/core/auto-sync/analysis-worker-launch.ts b/gitnexus/src/core/auto-sync/analysis-worker-launch.ts new file mode 100644 index 000000000..8b8d492b2 --- /dev/null +++ b/gitnexus/src/core/auto-sync/analysis-worker-launch.ts @@ -0,0 +1,120 @@ +import { fork, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { AnalyzeOptions, AnalyzeResult } from '../run-analyze.js'; +import type { WorkerMessage } from '../../server/analyze-worker.js'; + +const _require = createRequire(import.meta.url); +const TERMINATION_GRACE_MS = 10_000; + +export type AutoSyncAnalysisRunner = ( + repoPath: string, + options: AnalyzeOptions, + timeoutMs: number, + signal?: AbortSignal, +) => Promise>; + +interface AnalysisWorker extends Pick {} + +export interface AutoSyncAnalysisLaunchDeps { + forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker; + setTimeoutFn: typeof setTimeout; + clearTimeoutFn: typeof clearTimeout; +} + +const DEFAULT_DEPS: AutoSyncAnalysisLaunchDeps = { + forkWorker: (workerPath, execArgv) => + fork(workerPath, [], { + execArgv, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }), + setTimeoutFn: setTimeout, + clearTimeoutFn: clearTimeout, +}; + +export function createAutoSyncAnalysisRunner( + overrides: Partial = {}, +): AutoSyncAnalysisRunner { + const deps = { ...DEFAULT_DEPS, ...overrides }; + return (repoPath, options, timeoutMs, signal) => + new Promise>((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Analysis cancelled.')); + return; + } + const callerPath = fileURLToPath(import.meta.url); + const isDev = callerPath.endsWith('.ts'); + const workerPath = path.join( + path.dirname(callerPath), + '../../server', + isDev ? 'analyze-worker.ts' : 'analyze-worker.js', + ); + if (!existsSync(workerPath)) { + reject(new Error(`Auto-sync analyze worker is missing: ${workerPath}`)); + return; + } + const execArgv = isDev + ? ['--import', pathToFileURL(_require.resolve('tsx/esm')).href, '--max-old-space-size=8192'] + : ['--max-old-space-size=8192']; + const child = deps.forkWorker(workerPath, execArgv); + let outcome: WorkerMessage | undefined; + let timedOut = false; + let cancelled = false; + let terminationGrace: ReturnType | undefined; + const timeout = deps.setTimeoutFn(() => { + timedOut = true; + child.kill('SIGTERM'); + terminationGrace = deps.setTimeoutFn(() => child.kill('SIGKILL'), TERMINATION_GRACE_MS); + }, timeoutMs); + const onAbort = () => { + cancelled = true; + deps.clearTimeoutFn(timeout); + if (terminationGrace) deps.clearTimeoutFn(terminationGrace); + child.kill('SIGKILL'); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + + child.on('message', (message: WorkerMessage) => { + if (message.type !== 'progress') outcome ??= message; + else outcome = message; + }); + child.on('error', (error) => { + outcome = { type: 'error', message: `Auto-sync analyze worker error: ${error.message}` }; + }); + child.on('exit', (code, childSignal) => { + deps.clearTimeoutFn(timeout); + if (terminationGrace) deps.clearTimeoutFn(terminationGrace); + signal?.removeEventListener('abort', onAbort); + if (cancelled) { + reject(new Error('Analysis cancelled.')); + return; + } + if (timedOut) { + reject( + new Error( + `Analysis timed out after ${timeoutMs}ms and worker exited (${childSignal ?? code ?? 'unknown'}).`, + ), + ); + return; + } + if (outcome?.type === 'complete') { + resolve({ stats: outcome.result.stats }); + return; + } + if (outcome?.type === 'error') { + reject(new Error(outcome.message)); + return; + } + reject( + new Error( + `Auto-sync analyze worker exited before completion (${signal ?? code ?? 'unknown'}).`, + ), + ); + }); + child.send({ type: 'start', repoPath, options }); + }); +} + +export const runAutoSyncAnalysis = createAutoSyncAnalysisRunner(); diff --git a/gitnexus/src/core/auto-sync/config.ts b/gitnexus/src/core/auto-sync/config.ts index 95579b91a..7096b1cbc 100644 --- a/gitnexus/src/core/auto-sync/config.ts +++ b/gitnexus/src/core/auto-sync/config.ts @@ -10,6 +10,8 @@ const yaml = _require('js-yaml') as typeof import('js-yaml'); export const AUTO_SYNC_CONFIG_FILE = 'watch_config.yml'; const GROUP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; const MIN_SYNC_INTERVAL_MINUTES = 5; +const MAX_TIMER_DELAY_MS = 2_147_483_647; +const MAX_SYNC_INTERVAL_MINUTES = Math.floor(MAX_TIMER_DELAY_MS / 60_000); const DEFAULT_REPO_GIT_TIMEOUT_MS = 10_000; const DEFAULT_MAX_CONCURRENCY = 1; export const DEFAULT_ANALYZE_FAILURE_THRESHOLD = 3; @@ -19,6 +21,7 @@ const ALLOWED_REMOTE_HOSTS = new Set(['github.com', 'gitlab.com', 'gitee.com']); export interface AutoSyncProjectConfig { localPath: string; groupName?: string; + overwriteLocalChanges: boolean; branches: string[]; remoteUrls: string[]; } @@ -27,6 +30,7 @@ export interface AutoSyncConfig { configPath: string; syncIntervalMinutes: number; repoGitTimeoutMs: number; + analyzeTimeoutMs: number; maxConcurrency: number; analyzeFailureThreshold: number; projects: AutoSyncProjectConfig[]; @@ -100,6 +104,8 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy errors.push('sync_interval_minutes must be a positive integer'); } else if (interval < MIN_SYNC_INTERVAL_MINUTES) { errors.push(`sync_interval_minutes must be at least ${MIN_SYNC_INTERVAL_MINUTES}`); + } else if (interval > MAX_SYNC_INTERVAL_MINUTES) { + errors.push(`sync_interval_minutes must not exceed ${MAX_SYNC_INTERVAL_MINUTES}`); } const maxConcurrency = @@ -116,6 +122,24 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy errors.push('repo_git_timeout must be a positive duration such as 10s'); } + const maxAnalyzeTimeoutMs = + Number.isInteger(interval) && + interval >= MIN_SYNC_INTERVAL_MINUTES && + interval <= MAX_SYNC_INTERVAL_MINUTES + ? interval * 30_000 + : undefined; + const analyzeTimeoutMs = + raw.analyze_timeout === undefined + ? (maxAnalyzeTimeoutMs ?? 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)`, + ); + } + const analyzeFailureThreshold = raw.analyze_failure_threshold === undefined ? DEFAULT_ANALYZE_FAILURE_THRESHOLD @@ -189,8 +213,20 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy errors.push(`projects[${index}].group_name is invalid`); } + const overwriteLocalChanges = + project.overwrite_local_changes === undefined ? false : project.overwrite_local_changes; + if (typeof overwriteLocalChanges !== 'boolean') { + errors.push(`projects[${index}].overwrite_local_changes must be a boolean`); + } + if (localPath && remoteUrls.length > 0 && branches.length > 0) { - projects.push({ localPath, groupName, branches, remoteUrls }); + projects.push({ + localPath, + groupName, + overwriteLocalChanges: overwriteLocalChanges === true, + branches, + remoteUrls, + }); } }); } @@ -200,6 +236,7 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy configPath, syncIntervalMinutes: interval, repoGitTimeoutMs, + analyzeTimeoutMs, maxConcurrency, analyzeFailureThreshold, projects, diff --git a/gitnexus/src/core/auto-sync/index.ts b/gitnexus/src/core/auto-sync/index.ts index 6dc23a177..4182843bf 100644 --- a/gitnexus/src/core/auto-sync/index.ts +++ b/gitnexus/src/core/auto-sync/index.ts @@ -34,6 +34,7 @@ export { } from './path-security.js'; export { addRepoToGroup, + getAutoSyncRepoIdentity, getConfiguredRepoPath, resolveActualConcurrency, runAutoSyncOnce, diff --git a/gitnexus/src/core/auto-sync/runner.ts b/gitnexus/src/core/auto-sync/runner.ts index 558896290..9f18d39a3 100644 --- a/gitnexus/src/core/auto-sync/runner.ts +++ b/gitnexus/src/core/auto-sync/runner.ts @@ -22,6 +22,7 @@ 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'; export interface AutoSyncLogger { info(message: string): void; @@ -33,7 +34,8 @@ export interface AutoSyncRunDeps { cloneOrPull: typeof cloneOrPull; getCurrentBranch: typeof getCurrentBranch; getCurrentCommit: typeof getCurrentCommit; - runFullAnalysis: typeof runFullAnalysis; + runFullAnalysis?: typeof runFullAnalysis; + runAnalysis: AutoSyncAnalysisRunner; registerRepo: typeof registerRepo; loadState: typeof loadAutoSyncState; saveState: typeof saveAutoSyncState; @@ -61,7 +63,7 @@ const DEFAULT_DEPS: AutoSyncRunDeps = { cloneOrPull, getCurrentBranch, getCurrentCommit, - runFullAnalysis, + runAnalysis: runAutoSyncAnalysis, registerRepo, loadState: loadAutoSyncState, saveState: saveAutoSyncState, @@ -74,12 +76,19 @@ const DEFAULT_DEPS: AutoSyncRunDeps = { export async function runAutoSyncOnce( config: AutoSyncConfig, - options: { deps?: Partial; logger?: AutoSyncLogger; now?: () => Date } = {}, + options: { + deps?: Partial; + logger?: AutoSyncLogger; + now?: () => Date; + signal?: AbortSignal; + } = {}, ): Promise { const deps = { ...DEFAULT_DEPS, ...options.deps }; const logger = options.logger ?? DEFAULT_LOGGER; const now = options.now ?? (() => new Date()); + throwIfAborted(options.signal); const state = await deps.loadState(); + throwIfAborted(options.signal); const groupsToSync = new Set(); const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }; const commitInfoEntries: ProjectCommitInfoEntry[] = []; @@ -92,112 +101,135 @@ export async function runAutoSyncOnce( ); const workItems = await buildWorkItems(config, deps); - const repoResults = await mapWithConcurrency(workItems, actualConcurrency, async (item) => { - const lastSyncTime = now().toISOString(); - try { - validateAutoSyncRemoteUrl(item.remoteUrl); - const repoName = extractRepoNameFromRemoteUrl(item.remoteUrl); - const targetDir = getConfiguredRepoPath({ localPath: item.cloneRoot.root }, repoName); - const syncResult = await syncFirstAvailableBranch({ - item, - repoName, - targetDir, - timeoutMs: config.repoGitTimeoutMs, - deps, - logger, - }); - if (syncResult.ok === false) { + const repoResults = await mapWithConcurrency( + workItems, + actualConcurrency, + options.signal, + async (item) => { + const lastSyncTime = now().toISOString(); + try { + throwIfAborted(options.signal); + validateAutoSyncRemoteUrl(item.remoteUrl); + const repoName = extractRepoNameFromRemoteUrl(item.remoteUrl); + const targetDir = getConfiguredRepoPath({ localPath: item.cloneRoot.root }, repoName); + const syncResult = await syncFirstAvailableBranch({ + item, + repoName, + targetDir, + timeoutMs: config.repoGitTimeoutMs, + deps, + logger, + }); + throwIfAborted(options.signal); + if (syncResult.ok === false) { + logger.error( + `[auto-sync] Repository sync failed for ${item.remoteUrl}; no configured branch could be pulled: ${syncResult.message}`, + ); + return { + kind: 'failed' as const, + project: item.project, + remoteUrl: item.remoteUrl, + targetDir, + branch: item.project.branches[0], + status: syncResult.status, + analyzeConsecutiveFailures: 0, + lastSyncTime, + }; + } + + const currentBranch = syncResult.branch; + + const currentCommit = deps.getCurrentCommit(targetDir); + const stateKey = buildStateKey(targetDir, currentBranch); + const previous = state[stateKey]; + let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped'; + let analyzedCommitId = previous?.analyzedCommitId; + let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0; + let lastAnalyzeError = previous?.lastAnalyzeError; + let stats: RepoMeta['stats'] | undefined; + + if (previous && previous.codeCommitId !== currentCommit) { + analyzeConsecutiveFailures = 0; + lastAnalyzeError = undefined; + } + + 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 ( + shouldAnalyzeCommit({ + currentCommit, + previousAnalyzedCommit: previous?.analyzedCommitId, + previousStatus: previous?.lastAnalyzeStatus, + }) + ) { + try { + const analysis = deps.runFullAnalysis + ? await deps.runFullAnalysis( + targetDir, + { branch: currentBranch, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ) + : await deps.runAnalysis( + targetDir, + { branch: currentBranch, skipAgentsMd: true, skipSkills: true }, + config.analyzeTimeoutMs, + options.signal, + ); + throwIfAborted(options.signal); + stats = analysis.stats; + analyzeStatus = 'success'; + analyzedCommitId = currentCommit; + analyzeConsecutiveFailures = 0; + lastAnalyzeError = undefined; + } 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}`, + ); + } + } else { + logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`); + } + throwIfAborted(options.signal); + + return { + kind: 'synced' as const, + project: item.project, + repoName, + remoteUrl: item.remoteUrl, + targetDir, + branch: currentBranch, + currentCommit, + analyzedCommitId, + analyzeStatus, + analyzeConsecutiveFailures, + lastAnalyzeError, + stats, + stateKey, + lastSyncTime, + }; + } catch (err: unknown) { + if (options.signal?.aborted) throw err; logger.error( - `[auto-sync] Repository sync failed for ${item.remoteUrl}; no configured branch could be pulled: ${syncResult.message}`, + `[auto-sync] Repository sync failed for ${item.remoteUrl}: ${(err as Error).message}`, ); return { kind: 'failed' as const, project: item.project, remoteUrl: item.remoteUrl, - targetDir, - branch: item.project.branches[0], - status: syncResult.status, - analyzeConsecutiveFailures: 0, + targetDir: '', + status: 'sync_failed' as const, lastSyncTime, }; } - - const currentBranch = syncResult.branch; - - const currentCommit = deps.getCurrentCommit(targetDir); - const stateKey = buildStateKey(targetDir, currentBranch); - const previous = state[stateKey]; - let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped'; - let analyzedCommitId = previous?.analyzedCommitId; - let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0; - let lastAnalyzeError = previous?.lastAnalyzeError; - let stats: RepoMeta['stats'] | undefined; - - 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 ( - shouldAnalyzeCommit({ - currentCommit, - previousAnalyzedCommit: previous?.analyzedCommitId, - previousStatus: previous?.lastAnalyzeStatus, - }) - ) { - try { - const analysis = await deps.runFullAnalysis( - targetDir, - { branch: currentBranch, skipAgentsMd: true, skipSkills: true }, - { onProgress: () => {} }, - ); - stats = analysis.stats; - analyzeStatus = 'success'; - analyzedCommitId = currentCommit; - analyzeConsecutiveFailures = 0; - lastAnalyzeError = undefined; - } catch (err: unknown) { - analyzeStatus = 'failed'; - analyzeConsecutiveFailures += 1; - lastAnalyzeError = shortErrorMessage(err); - 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.`); - } - - return { - kind: 'synced' as const, - project: item.project, - repoName, - remoteUrl: item.remoteUrl, - targetDir, - branch: currentBranch, - currentCommit, - analyzedCommitId, - analyzeStatus, - analyzeConsecutiveFailures, - lastAnalyzeError, - stats, - stateKey, - lastSyncTime, - }; - } catch (err: unknown) { - logger.error( - `[auto-sync] Repository sync failed for ${item.remoteUrl}: ${(err as Error).message}`, - ); - return { - kind: 'failed' as const, - project: item.project, - remoteUrl: item.remoteUrl, - targetDir: '', - status: 'sync_failed' as const, - lastSyncTime, - }; - } - }); + }, + ); for (const repoResult of repoResults) { if (repoResult.kind === 'failed') { @@ -231,8 +263,7 @@ export async function runAutoSyncOnce( branch: repoResult.branch, }; await deps.registerRepo(repoResult.targetDir, meta, { - name: repoResult.repoName, - allowDuplicateName: true, + name: getAutoSyncRepoIdentity(repoResult.remoteUrl), }); result.analyzed += 1; } else if (repoResult.analyzeStatus === 'failed') { @@ -259,7 +290,11 @@ export async function runAutoSyncOnce( if (repoResult.project.groupName) { let groupMembershipOk = false; try { - await deps.addRepoToGroup(repoResult.project, repoResult.repoName); + await deps.addRepoToGroup( + repoResult.project, + getAutoSyncRepoIdentity(repoResult.remoteUrl), + getAutoSyncRepoIdentity(repoResult.remoteUrl), + ); groupMembershipOk = true; } catch (err: unknown) { result.failed += 1; @@ -300,17 +335,27 @@ export function getConfiguredRepoPath( export async function addRepoToGroup( project: Pick, - repoName: string, + groupPath: string, + registryName = groupPath, ): Promise { if (!project.groupName) return false; const groupDir = getGroupDir(getDefaultGitnexusDir(), project.groupName); const config = await loadGroupConfig(groupDir); - if (Object.values(config.repos).includes(repoName)) return false; - config.repos[repoName] = repoName; + if (config.repos[groupPath] === registryName) return false; + if (config.repos[groupPath] !== undefined) { + throw new Error(`group path ${groupPath} is already mapped to ${config.repos[groupPath]}`); + } + config.repos[groupPath] = registryName; await writeGroupConfigAtomic(path.join(groupDir, 'group.yaml'), config); return true; } +export function getAutoSyncRepoIdentity(remoteUrl: string): string { + validateAutoSyncRemoteUrl(remoteUrl); + const [, host, remotePath] = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim())!; + return `${host.toLowerCase()}/${remotePath.replace(/\.git$/, '')}`; +} + export async function syncGroupByName(groupName: string): Promise { const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName); const config = await loadGroupConfig(groupDir); @@ -359,21 +404,28 @@ async function buildWorkItems( async function mapWithConcurrency( items: T[], concurrency: number, + signal: AbortSignal | undefined, worker: (item: T) => Promise, ): Promise { const results: R[] = new Array(items.length); let nextIndex = 0; const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (nextIndex < items.length) { + throwIfAborted(signal); const currentIndex = nextIndex; nextIndex += 1; results[currentIndex] = await worker(items[currentIndex]); + throwIfAborted(signal); } }); await Promise.all(runners); return results; } +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new Error('Auto-sync run cancelled.'); +} + interface AutoSyncWorkItem { project: AutoSyncProjectConfig; remoteUrl: string; @@ -402,6 +454,7 @@ async function syncFirstAvailableBranch(input: { allowAutoSyncSsh: true, timeoutMs: input.timeoutMs, branch, + overwriteLocalChanges: input.item.project.overwriteLocalChanges, }); const currentBranch = input.deps.getCurrentBranch(input.targetDir); if (currentBranch === branch) return { ok: true, branch }; diff --git a/gitnexus/src/core/auto-sync/starter.ts b/gitnexus/src/core/auto-sync/starter.ts index 15bb06295..bad0c901b 100644 --- a/gitnexus/src/core/auto-sync/starter.ts +++ b/gitnexus/src/core/auto-sync/starter.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import crypto from 'node:crypto'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { getGlobalDir } from '../../storage/repo-manager.js'; import { loadAutoSyncConfig } from './config.js'; import { runAutoSyncOnce } from './runner.js'; @@ -35,6 +36,7 @@ export interface AutoSyncWatchPaths { export interface AutoSyncWatchControlDeps { isProcessAlive(pid: number): boolean; + readProcessCommand(pid: number): string | undefined; killProcess(pid: number, signal?: NodeJS.Signals): void; sleep(ms: number): Promise; } @@ -94,16 +96,17 @@ export async function startAutoSyncWatch( }); const runOnce = options.runOnce ?? runAutoSyncOnce; - let running = false; + let activeRun: Promise | undefined; + let activeAbortController: AbortController | undefined; const runSafely = () => { - if (running) { + if (activeRun) { stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n'); return; } - running = true; const startedAt = new Date(); stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`); - void runOnce(loaded.config) + const abortController = new AbortController(); + const run = runOnce(loaded.config, { signal: abortController.signal }) .then((result) => { stderr.write( `[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`, @@ -112,10 +115,15 @@ export async function startAutoSyncWatch( .catch((err: unknown) => { stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`); stderr.write('[auto-sync] Watch loop finished: failed.\n'); - }) - .finally(() => { - running = false; }); + activeRun = run; + activeAbortController = abortController; + void run.finally(() => { + if (activeRun === run) { + activeRun = undefined; + activeAbortController = undefined; + } + }); }; runSafely(); @@ -127,6 +135,15 @@ export async function startAutoSyncWatch( return { stop: async () => { clearIntervalFn(timer); + activeAbortController?.abort(); + await writeWatchStatus(paths, { + state: 'stopping', + pid: process.pid, + ownerId, + configPath: loaded.config.configPath, + updatedAt: new Date().toISOString(), + }); + await activeRun?.catch(() => {}); await writeWatchStatus(paths, { state: 'stopped', pid: process.pid, @@ -162,6 +179,18 @@ async function acquireWatchLock( } if (deps.isProcessAlive(lock.pid)) { + const reason = getWatchProcessIdentityError(lock.pid, deps); + if (reason) { + stderr.write(`[auto-sync] Refusing to trust existing watch pid ${lock.pid}; ${reason}.\n`); + await writeWatchStatus(paths, { + state: 'error', + pid: lock.pid, + ownerId: lock.ownerId, + message: reason, + updatedAt: new Date().toISOString(), + }); + return null; + } stderr.write(`[auto-sync] Watch is already running with pid ${lock.pid}.\n`); await writeWatchStatus(paths, { state: 'running', @@ -272,7 +301,7 @@ export async function stopAutoSyncWatch( }); return false; } - const owner = await readVerifiedWatchOwner(paths, pid); + const owner = await readVerifiedWatchOwner(paths, pid, deps); if (owner.ok === false) { const message = `refusing to stop pid ${pid}; ${owner.reason}`; stderr.write(`[auto-sync] ${message}.\n`); @@ -334,7 +363,10 @@ export async function readAutoSyncWatchStatus( } if (pid) { const stored = await readStatusFile(paths.statusPath); - const owner = await readVerifiedWatchOwner(paths, pid); + if (stored?.state === 'error') { + return { ...stored, pid, updatedAt: new Date().toISOString() }; + } + const owner = await readVerifiedWatchOwner(paths, pid, resolvedDeps); if (owner.ok === false) { return { ...stored, @@ -380,6 +412,7 @@ async function readLockFile(lockPath: string): Promise { const [status, lock] = await Promise.all([ readStatusFile(paths.statusPath), @@ -392,9 +425,26 @@ async function readVerifiedWatchOwner( if (!status.ownerId || status.ownerId !== lock.ownerId) { return { ok: false, reason: 'watch status owner does not match lock owner' }; } + const identityError = getWatchProcessIdentityError(pid, deps); + if (identityError) return { ok: false, reason: identityError }; return { ok: true, owner: lock }; } +function getWatchProcessIdentityError( + pid: number, + deps: AutoSyncWatchControlDeps, +): string | undefined { + const command = deps.readProcessCommand(pid); + if (!command) return 'unable to verify process command'; + if ( + !/(?:^|\s)watch(?:\s|$)/.test(command) || + !/(?:gitnexus|[\\/]cli[\\/]index\.(?:ts|[cm]?js))/.test(command) + ) { + return 'pid command is not a GitNexus watch process'; + } + return undefined; +} + async function waitForProcessExit( pid: number, options: { deps: AutoSyncWatchControlDeps; timeoutMs: number; pollMs: number }, @@ -474,6 +524,19 @@ function resolveWatchDeps(deps: Partial = {}): AutoSyn return false; } }), + readProcessCommand: + deps.readProcessCommand ?? + ((pid) => { + try { + const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return command || undefined; + } catch { + return undefined; + } + }), killProcess: deps.killProcess ?? ((pid, signal = 'SIGTERM') => { diff --git a/gitnexus/src/core/auto-sync/state.ts b/gitnexus/src/core/auto-sync/state.ts index 5c4d1e39c..f38fce3f9 100644 --- a/gitnexus/src/core/auto-sync/state.ts +++ b/gitnexus/src/core/auto-sync/state.ts @@ -47,9 +47,12 @@ export async function loadAutoSyncState( try { const raw = await fs.readFile(statePath, 'utf-8'); const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as AutoSyncCommitState) - : {}; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, AutoSyncCommitStateEntry] => + isAutoSyncCommitStateEntry(entry[1]), + ), + ); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { process.stderr.write( @@ -60,6 +63,26 @@ export async function loadAutoSyncState( } } +function isAutoSyncCommitStateEntry(value: unknown): value is AutoSyncCommitStateEntry { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const entry = value as Record; + return ( + typeof entry.codeCommitId === 'string' && + typeof entry.lastSyncTime === 'string' && + (entry.analyzedCommitId === undefined || typeof entry.analyzedCommitId === 'string') && + (entry.lastAnalyzeStatus === undefined || + entry.lastAnalyzeStatus === 'success' || + entry.lastAnalyzeStatus === 'failed' || + entry.lastAnalyzeStatus === 'skipped' || + entry.lastAnalyzeStatus === 'threshold_skipped') && + (entry.analyzeConsecutiveFailures === undefined || + (typeof entry.analyzeConsecutiveFailures === 'number' && + Number.isInteger(entry.analyzeConsecutiveFailures) && + entry.analyzeConsecutiveFailures >= 0)) && + (entry.lastAnalyzeError === undefined || typeof entry.lastAnalyzeError === 'string') + ); +} + export async function saveAutoSyncState( state: AutoSyncCommitState, statePath = getAutoSyncStatePath(), diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 5553d115b..91df78f3c 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -272,6 +272,7 @@ export interface CloneOrPullOptions { allowAutoSyncSsh?: boolean; timeoutMs?: number; branch?: string; + overwriteLocalChanges?: boolean; runGitForTest?: typeof runGit; } @@ -551,22 +552,55 @@ export async function cloneOrPull( onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' }); const runGitImpl = options?.runGitForTest ?? runGit; if (options?.branch) { - await runGitImpl(['fetch', '--depth', '1', 'origin', options.branch], safeTarget, { - token: options?.token, - url, - timeoutMs: options?.timeoutMs, - }); - await runGitImpl(['checkout', options.branch], safeTarget, { + if (!options.overwriteLocalChanges) { + const status = await runGitImpl(['status', '--porcelain'], safeTarget, { + token: options?.token, + url, + timeoutMs: options?.timeoutMs, + }); + if (status.trim()) { + throw new Error( + `Refusing to update ${safeTarget}: local changes detected. Set overwrite_local_changes: true to overwrite them.`, + ); + } + } + await runGitImpl( + [ + 'fetch', + '--depth', + '1', + 'origin', + `refs/heads/${options.branch}:refs/remotes/origin/${options.branch}`, + ], + safeTarget, + { + token: options?.token, + url, + timeoutMs: options?.timeoutMs, + }, + ); + await runGitImpl( + [ + 'checkout', + ...(options.overwriteLocalChanges ? ['--force'] : []), + '-B', + options.branch, + `origin/${options.branch}`, + ], + safeTarget, + { + token: options?.token, + url, + timeoutMs: options?.timeoutMs, + }, + ); + } else { + await runGitImpl(['pull', '--ff-only'], safeTarget, { token: options?.token, url, timeoutMs: options?.timeoutMs, }); } - await runGitImpl(['pull', '--ff-only'], safeTarget, { - token: options?.token, - url, - timeoutMs: options?.timeoutMs, - }); } else { if (targetExists) { throw new Error(`Clone target already exists but is not a git repository: ${safeTarget}`); @@ -790,7 +824,7 @@ export function buildGitEnv( // host-scoped Authorization header (GitHub PAT for github.com, else the // server's AZURE_DEVOPS_PAT for Azure hosts) via the GIT_CONFIG_* protocol — // never in argv. See resolveGitCredential / buildExtraHeaderKey. -function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise { +function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise { return new Promise((resolve, reject) => { const spawnGit = options?.spawnForTest ?? spawn; const proc = spawnGit('git', args, { @@ -800,6 +834,7 @@ function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise< env: buildGitEnv(process.env, options), }); + let stdout = ''; let stderr = ''; let settled = false; let timedOut = false; @@ -821,6 +856,9 @@ function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise< }, options.timeoutKillGraceMs ?? 1_000); }, options.timeoutMs) : undefined; + proc.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk; + }); proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk; }); @@ -830,7 +868,7 @@ function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise< finish(() => reject(new Error(`git ${args[0]} timed out after ${options?.timeoutMs}ms`))); return; } - if (code === 0) finish(resolve); + if (code === 0) finish(() => resolve(stdout)); else { // Log full stderr internally but don't expose it to API callers (SSRF mitigation) if (stderr.trim()) logger.error(`git ${args[0]} stderr: ${stderr.trim()}`); diff --git a/gitnexus/test/unit/auto-sync-analysis-worker.test.ts b/gitnexus/test/unit/auto-sync-analysis-worker.test.ts new file mode 100644 index 000000000..f6e06f869 --- /dev/null +++ b/gitnexus/test/unit/auto-sync-analysis-worker.test.ts @@ -0,0 +1,54 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { createAutoSyncAnalysisRunner } from '../../src/core/auto-sync/analysis-worker-launch.js'; + +describe('auto-sync analysis worker', () => { + it('waits for timed-out worker exit before releasing the scheduled run', async () => { + const child = Object.assign(new EventEmitter(), { + send: vi.fn(), + kill: vi.fn(), + }); + const timers: Array<() => void> = []; + const run = createAutoSyncAnalysisRunner({ + forkWorker: vi.fn(() => child as any), + setTimeoutFn: vi.fn((callback: () => void) => { + timers.push(callback); + return timers.length as any; + }) as any, + clearTimeoutFn: vi.fn() as any, + }); + + const result = run('/tmp/repo', { branch: 'main' }, 50); + expect(child.send).toHaveBeenCalledWith({ + type: 'start', + repoPath: '/tmp/repo', + options: { branch: 'main' }, + }); + + timers[0](); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + timers[1](); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + + child.emit('exit', null, 'SIGKILL'); + await expect(result).rejects.toThrow('Analysis timed out after 50ms'); + }); + + it('kills an active worker immediately when watch is stopped', async () => { + const child = Object.assign(new EventEmitter(), { + send: vi.fn(), + kill: vi.fn(), + }); + const run = createAutoSyncAnalysisRunner({ + forkWorker: vi.fn(() => child as any), + }); + const controller = new AbortController(); + + const result = run('/tmp/repo', { branch: 'main' }, 50, controller.signal); + controller.abort(); + + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + child.emit('exit', null, 'SIGKILL'); + await expect(result).rejects.toThrow('Analysis cancelled'); + }); +}); diff --git a/gitnexus/test/unit/auto-sync-runner.test.ts b/gitnexus/test/unit/auto-sync-runner.test.ts index ada907d7c..79501c3ed 100644 --- a/gitnexus/test/unit/auto-sync-runner.test.ts +++ b/gitnexus/test/unit/auto-sync-runner.test.ts @@ -23,12 +23,14 @@ const config: AutoSyncConfig = { configPath: '/tmp/.gitnexus/watch_config.yml', syncIntervalMinutes: 10, repoGitTimeoutMs: 10_000, + analyzeTimeoutMs: 1_800_000, maxConcurrency: 1, analyzeFailureThreshold: 3, projects: [ { localPath: '/tmp/repos', groupName: 'back_end', + overwriteLocalChanges: false, branches: ['master'], remoteUrls: ['git@gitee.com:qts_server/qts_account.git'], }, @@ -40,6 +42,7 @@ const cloneRoot = { quarantineRoot: '/tmp/.gitnexus/watch/quarantine', quarantineRetentionDays: 14, }; +const verifiedWatchCommand = 'node /gitnexus/dist/cli/index.js watch'; function withCloneRoot(deps: Partial): Partial { return { @@ -112,6 +115,7 @@ describe('auto-sync runner', () => { allowAutoSyncSsh: true, timeoutMs: 10_000, branch: 'master', + overwriteLocalChanges: false, }, ); expect(deps.getCurrentBranch).toHaveBeenCalledWith('/tmp/repos/qts_account'); @@ -123,7 +127,7 @@ describe('auto-sync runner', () => { expect(deps.registerRepo).toHaveBeenCalledWith( '/tmp/repos/qts_account', expect.objectContaining({ lastCommit: 'commit-2', branch: 'master' }), - { name: 'qts_account', allowDuplicateName: true }, + { name: 'gitee.com/qts_server/qts_account' }, ); expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); expect(deps.writeCommitInfo).toHaveBeenCalledWith([ @@ -156,7 +160,11 @@ describe('auto-sync runner', () => { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, }); - expect(deps.addRepoToGroup).toHaveBeenCalledWith(config.projects[0], 'qts_account'); + expect(deps.addRepoToGroup).toHaveBeenCalledWith( + config.projects[0], + 'gitee.com/qts_server/qts_account', + 'gitee.com/qts_server/qts_account', + ); expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); }); @@ -188,10 +196,79 @@ describe('auto-sync runner', () => { }); expect(result.analyzed).toBe(1); - expect(deps.addRepoToGroup).toHaveBeenCalledWith(config.projects[0], 'qts_account'); + expect(deps.addRepoToGroup).toHaveBeenCalledWith( + config.projects[0], + 'gitee.com/qts_server/qts_account', + 'gitee.com/qts_server/qts_account', + ); expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); }); + it('uses distinct registry and group identities for repositories with the same basename', async () => { + const duplicateConfig: AutoSyncConfig = { + ...config, + projects: [ + { + localPath: '/tmp/repos-a', + groupName: 'back_end', + branches: ['main'], + remoteUrls: ['git@github.com:team-a/service.git'], + }, + { + localPath: '/tmp/repos-b', + groupName: 'back_end', + branches: ['main'], + remoteUrls: ['git@gitlab.com:team-b/service.git'], + }, + ], + }; + const deps: Partial = withCloneRoot({ + resolveCloneRoot: vi.fn(async (localPath: string) => ({ + ...cloneRoot, + root: localPath, + })), + cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), + getCurrentBranch: vi.fn(() => 'main'), + getCurrentCommit: vi.fn(() => 'commit-2'), + runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + registerRepo: vi.fn(async () => 'service'), + loadState: vi.fn(async () => ({})), + saveState: vi.fn(async () => {}), + writeCommitInfo: vi.fn(async () => {}), + addRepoToGroup: vi.fn(async () => true), + syncGroupByName: vi.fn(async () => {}), + getAvailableMemoryGB: vi.fn(() => 8), + }); + + await runAutoSyncOnce(duplicateConfig, { + deps, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + expect(deps.registerRepo).toHaveBeenNthCalledWith( + 1, + '/tmp/repos-a/service', + expect.anything(), + { name: 'github.com/team-a/service' }, + ); + expect(deps.registerRepo).toHaveBeenNthCalledWith( + 2, + '/tmp/repos-b/service', + expect.anything(), + { name: 'gitlab.com/team-b/service' }, + ); + expect(deps.addRepoToGroup).toHaveBeenCalledWith( + duplicateConfig.projects[0], + 'github.com/team-a/service', + 'github.com/team-a/service', + ); + expect(deps.addRepoToGroup).toHaveBeenCalledWith( + duplicateConfig.projects[1], + 'gitlab.com/team-b/service', + 'gitlab.com/team-b/service', + ); + }); + it('skips analysis when commit id has not changed', async () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), @@ -258,10 +335,42 @@ describe('auto-sync runner', () => { allowAutoSyncSsh: true, timeoutMs: 10_000, branch: 'master', + overwriteLocalChanges: false, }, ); }); + it('passes the watch stop signal to the isolated analysis runner', async () => { + const controller = new AbortController(); + const runAnalysis = vi.fn(async () => ({ stats: { files: 1 } }) as any); + const deps: Partial = withCloneRoot({ + cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + getCurrentBranch: vi.fn(() => 'master'), + getCurrentCommit: vi.fn(() => 'commit-2'), + runAnalysis, + 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: vi.fn(), error: vi.fn() }, + signal: controller.signal, + }); + + expect(runAnalysis).toHaveBeenCalledWith( + '/tmp/repos/qts_account', + { branch: 'master', skipAgentsMd: true, skipSkills: true }, + 1_800_000, + controller.signal, + ); + }); + it('falls back through configured branches and analyzes the first pullable branch', async () => { const warnLogger = vi.fn(); const errorLogger = vi.fn(); @@ -470,7 +579,11 @@ describe('auto-sync runner', () => { expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 2 }); expect(deps.cloneOrPull).toHaveBeenCalledTimes(2); expect(deps.registerRepo).not.toHaveBeenCalled(); - expect(deps.addRepoToGroup).toHaveBeenCalledWith(failingConfig.projects[0], 'qts_account'); + expect(deps.addRepoToGroup).toHaveBeenCalledWith( + failingConfig.projects[0], + 'gitee.com/qts_server/qts_account', + 'gitee.com/qts_server/qts_account', + ); expect(deps.syncGroupByName).not.toHaveBeenCalled(); expect(deps.saveState).toHaveBeenCalledWith( expect.objectContaining({ @@ -514,7 +627,11 @@ describe('auto-sync runner', () => { }); expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 1 }); - expect(deps.addRepoToGroup).toHaveBeenCalledWith(config.projects[0], 'qts_account'); + expect(deps.addRepoToGroup).toHaveBeenCalledWith( + config.projects[0], + 'gitee.com/qts_server/qts_account', + 'gitee.com/qts_server/qts_account', + ); expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end'); expect(errorLogger).toHaveBeenCalledWith( expect.stringContaining('Group sync failed for back_end'), @@ -673,7 +790,7 @@ describe('auto-sync runner', () => { expect(deps.saveState).toHaveBeenCalledWith( expect.objectContaining({ '/tmp/repos/qts_account|master': expect.objectContaining({ - analyzeConsecutiveFailures: 2, + analyzeConsecutiveFailures: 1, lastAnalyzeError: 'parser crashed with stack', lastAnalyzeStatus: 'failed', }), @@ -682,24 +799,24 @@ describe('auto-sync runner', () => { expect(deps.writeCommitInfo).toHaveBeenCalledWith([ expect.objectContaining({ status: 'failed', - analyzeConsecutiveFailures: 2, + analyzeConsecutiveFailures: 1, analyzeFailureThreshold: 3, lastAnalyzeError: 'parser crashed with stack', }), ]); expect(errorLogger).toHaveBeenCalledWith( - '[auto-sync] Analysis failed for /tmp/repos/qts_account; consecutive failures 2/3: parser crashed with stack', + '[auto-sync] Analysis failed for /tmp/repos/qts_account; consecutive failures 1/3: parser crashed with stack', ); }); - it('skips analyze when consecutive failures have reached the threshold', async () => { + it('retries analysis on a new commit after consecutive failures reached the threshold', async () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(), - registerRepo: vi.fn(), + runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({ '/tmp/repos/qts_account|master': { codeCommitId: 'commit-1', @@ -723,28 +840,26 @@ describe('auto-sync runner', () => { now: () => new Date('2026-06-30T00:00:00.000Z'), }); - expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 1, failed: 0 }); - expect(deps.runFullAnalysis).not.toHaveBeenCalled(); + expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 }); + expect(deps.runFullAnalysis).toHaveBeenCalledTimes(1); expect(deps.saveState).toHaveBeenCalledWith( expect.objectContaining({ '/tmp/repos/qts_account|master': expect.objectContaining({ - analyzeConsecutiveFailures: 3, - lastAnalyzeError: 'parser crashed', - lastAnalyzeStatus: 'threshold_skipped', + analyzeConsecutiveFailures: 0, + lastAnalyzeError: undefined, + lastAnalyzeStatus: 'success', }), }), ); expect(deps.writeCommitInfo).toHaveBeenCalledWith([ expect.objectContaining({ - status: 'threshold_skipped', - analyzeConsecutiveFailures: 3, + status: 'success', + analyzeConsecutiveFailures: 0, analyzeFailureThreshold: 3, - lastAnalyzeError: 'parser crashed', + lastAnalyzeError: undefined, }), ]); - expect(errorLogger).toHaveBeenCalledWith( - '[auto-sync] Skip analysis for /tmp/repos/qts_account; analyze consecutive failures 3/3 reached threshold. Fix the repository or clear auto-sync state before retrying.', - ); + expect(errorLogger).not.toHaveBeenCalled(); }); it('clears prior analyze failure count after a successful analyze', async () => { @@ -807,7 +922,9 @@ describe('auto-sync runner', () => { ['version: 1', 'name: back_end', 'repos:', ' hr/hiring/backend: qts_account'].join('\n'), ); - await expect(addRepoToGroup({ groupName: 'back_end' }, 'qts_account')).resolves.toBe(false); + await expect( + addRepoToGroup({ groupName: 'back_end' }, 'hr/hiring/backend', 'qts_account'), + ).resolves.toBe(false); await expect(fs.readFile(path.join(groupDir, 'group.yaml'), 'utf-8')).resolves.toContain( 'hr/hiring/backend: qts_account', @@ -929,6 +1046,54 @@ describe('auto-sync starter', () => { } }); + it('cancels the active run before removing watch ownership files', async () => { + const previousHome = process.env.GITNEXUS_HOME; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-')); + const cancelled = vi.fn(); + const runOnce = vi.fn( + (_config, options) => + new Promise((resolve) => { + options?.signal?.addEventListener( + 'abort', + () => { + cancelled(); + resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }); + }, + { once: true }, + ); + }), + ); + 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'), + ); + const handle = await startAutoSyncWatch({ + runOnce, + keepAlive: false, + deps: { isProcessAlive: vi.fn(() => false) }, + }); + const paths = getAutoSyncWatchPaths(tempDir); + await handle!.stop(); + + expect(cancelled).toHaveBeenCalledTimes(1); + await expect(fs.access(paths.pidPath)).rejects.toThrow(); + await expect(fs.access(paths.lockPath)).rejects.toThrow(); + } finally { + if (previousHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = previousHome; + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('refuses a second running watch for the same GITNEXUS_HOME', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); const paths = getAutoSyncWatchPaths(tempDir); @@ -938,7 +1103,10 @@ describe('auto-sync starter', () => { const handle = await startAutoSyncWatch({ paths, stderr, - deps: { isProcessAlive: vi.fn(() => true) }, + deps: { + isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => verifiedWatchCommand), + }, }); expect(handle).toBeNull(); @@ -1019,7 +1187,10 @@ describe('auto-sync starter', () => { paths, stderr, runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })), - deps: { isProcessAlive: vi.fn(() => true) }, + deps: { + isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => verifiedWatchCommand), + }, }); expect(handle).toBeNull(); @@ -1114,7 +1285,10 @@ describe('auto-sync starter', () => { await writeWatchOwner(paths, 12345); await expect( - readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true) }), + readAutoSyncWatchStatus(paths, { + isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => verifiedWatchCommand), + }), ).resolves.toMatchObject({ state: 'running', pid: 12345 }); await expect( stopAutoSyncWatch({ @@ -1123,6 +1297,7 @@ describe('auto-sync starter', () => { pollMs: 1, deps: { isProcessAlive: vi.fn(() => alive), + readProcessCommand: vi.fn(() => verifiedWatchCommand), killProcess: vi.fn((pid, signal) => { killProcess(pid, signal); alive = false; @@ -1155,6 +1330,7 @@ describe('auto-sync starter', () => { pollMs: 1, deps: { isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => verifiedWatchCommand), killProcess: vi.fn(), sleep: vi.fn(async () => {}), }, @@ -1162,7 +1338,10 @@ describe('auto-sync starter', () => { ).resolves.toBe(false); await expect( - readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true) }), + readAutoSyncWatchStatus(paths, { + isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => verifiedWatchCommand), + }), ).resolves.toMatchObject({ state: 'stopping', pid: 12345, @@ -1211,6 +1390,42 @@ describe('auto-sync starter', () => { } }); + it('refuses to signal a reused pid whose command is not GitNexus watch', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); + const paths = getAutoSyncWatchPaths(tempDir); + const killProcess = vi.fn(); + try { + await writeWatchOwner(paths, 12345); + + await expect( + stopAutoSyncWatch({ + paths, + stderr: { write: vi.fn() }, + deps: { + isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => 'node unrelated-service.js'), + killProcess, + sleep: vi.fn(async () => {}), + }, + }), + ).resolves.toBe(false); + + expect(killProcess).not.toHaveBeenCalled(); + await expect( + readAutoSyncWatchStatus(paths, { + isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => 'node unrelated-service.js'), + }), + ).resolves.toMatchObject({ + state: 'error', + pid: 12345, + message: expect.stringContaining('not a GitNexus watch process'), + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('restart can start only after stop confirms pid and lock cleanup', async () => { const previousHome = process.env.GITNEXUS_HOME; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); @@ -1242,6 +1457,7 @@ describe('auto-sync starter', () => { stderr: { write: vi.fn() }, deps: { isProcessAlive: vi.fn(() => alive), + readProcessCommand: vi.fn(() => verifiedWatchCommand), killProcess: vi.fn(() => { alive = false; }), diff --git a/gitnexus/test/unit/auto-sync.test.ts b/gitnexus/test/unit/auto-sync.test.ts index 896613a90..cca2365e7 100644 --- a/gitnexus/test/unit/auto-sync.test.ts +++ b/gitnexus/test/unit/auto-sync.test.ts @@ -56,13 +56,15 @@ describe('auto-sync', () => { await fs.writeFile( path.join(gitnexusHome, 'watch_config.yml'), [ - 'sync_interval_minutes: 10', + 'sync_interval_minutes: 120', 'max_concurrency: 3', 'repo_git_timeout: 12s', + 'analyze_timeout: 45m', 'analyze_failure_threshold: 2', 'projects:', ' - local_path: /tmp/repos', ' group_name: back_end', + ' overwrite_local_changes: true', ' branches: [test, master, test]', ' remote_urls:', ' - git@gitee.com:qts_server/qts_account.git', @@ -74,13 +76,15 @@ describe('auto-sync', () => { expect(loaded.ok).toBe(true); if (!loaded.ok) throw new Error('expected config to load'); expect(loaded.config.configPath).toBe(path.join(gitnexusHome, 'watch_config.yml')); - expect(loaded.config.syncIntervalMinutes).toBe(10); + expect(loaded.config.syncIntervalMinutes).toBe(120); expect(loaded.config.maxConcurrency).toBe(3); expect(loaded.config.repoGitTimeoutMs).toBe(12_000); + expect(loaded.config.analyzeTimeoutMs).toBe(2_700_000); expect(loaded.config.analyzeFailureThreshold).toBe(2); expect(loaded.config.projects[0]).toMatchObject({ localPath: '/tmp/repos', groupName: 'back_end', + overwriteLocalChanges: true, branches: ['test', 'master'], remoteUrls: ['git@gitee.com:qts_server/qts_account.git'], }); @@ -105,9 +109,34 @@ describe('auto-sync', () => { expect(loaded.ok).toBe(true); if (!loaded.ok) throw new Error('expected config'); expect(loaded.config.repoGitTimeoutMs).toBe(10_000); + expect(loaded.config.analyzeTimeoutMs).toBe(300_000); expect(loaded.config.maxConcurrency).toBe(1); expect(loaded.config.analyzeFailureThreshold).toBe(3); expect(loaded.config.projects[0].groupName).toBeUndefined(); + expect(loaded.config.projects[0].overwriteLocalChanges).toBe(false); + }); + + it('rejects analyze_timeout values above half the sync interval', async () => { + await fs.writeFile( + path.join(gitnexusHome, 'watch_config.yml'), + [ + 'sync_interval_minutes: 10', + 'analyze_timeout: 6m', + 'projects:', + ' - local_path: /tmp/repos', + ' branch: master', + ' remote_urls:', + ' - git@github.com:owner/repo.git', + ].join('\n'), + ); + + 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)', + ); }); it('rejects invalid analyze_failure_threshold values', async () => { @@ -375,6 +404,37 @@ describe('auto-sync', () => { ); }); + it('drops malformed state entries while preserving valid entries', async () => { + const statePath = path.join(tempDir, 'auto-sync-state.json'); + await fs.writeFile( + statePath, + JSON.stringify({ + '/tmp/repos/valid|main': { + codeCommitId: 'abc', + analyzedCommitId: 'abc', + lastAnalyzeStatus: 'success', + analyzeConsecutiveFailures: 0, + lastSyncTime: '2026-06-30T00:00:00.000Z', + }, + '/tmp/repos/invalid|main': { + codeCommitId: 123, + analyzeConsecutiveFailures: -1, + lastSyncTime: null, + }, + }), + ); + + await expect(loadAutoSyncState(statePath)).resolves.toEqual({ + '/tmp/repos/valid|main': { + codeCommitId: 'abc', + analyzedCommitId: 'abc', + lastAnalyzeStatus: 'success', + analyzeConsecutiveFailures: 0, + lastSyncTime: '2026-06-30T00:00:00.000Z', + }, + }); + }); + it('writes project_commit_info.txt atomically', async () => { const infoPath = path.join(tempDir, 'project_commit_info.txt'); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index d6737950f..97ef63567 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -268,6 +268,8 @@ describe('CLI help surface', () => { const config = fs.readFileSync(configPath, 'utf8'); expect(config).toContain('sync_interval_minutes: 10'); expect(config).toContain('analyze_failure_threshold: 3'); + expect(config).toContain('analyze_timeout: 5m'); + expect(config).toContain('overwrite_local_changes: false'); expect(config).toContain(`local_path: ${path.join(home, 'repo')}`); expect(config).not.toContain('/abs/path/to/repos'); expect(config).toContain('git@github.com:owner/repo.git'); diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 11ec037e6..1b57b8347 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -52,6 +52,25 @@ async function mkControlledRoot(prefix: string): Promise { return fs.realpath(await fs.mkdtemp(path.join(base, prefix))); } +function runGit(args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (chunk: Buffer) => { + stdout += chunk; + }); + proc.stderr.on('data', (chunk: Buffer) => { + stderr += chunk; + }); + proc.on('close', (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`git ${args.join(' ')} failed (${code}): ${stderr}`)); + }); + proc.on('error', reject); + }); +} + describe('git-clone', () => { describe('extractRepoName', () => { it('extracts name from HTTPS URL', () => { @@ -588,6 +607,7 @@ describe('git-clone', () => { const target = path.join(root, 'repo'); const runGitForTest = vi.fn(async () => { await fs.mkdir(target); + return ''; }); try { await expect( @@ -702,6 +722,80 @@ describe('git-clone', () => { } }); + it('switches a shallow single-branch clone to a fallback branch', async () => { + const root = await mkControlledRoot('gitnexus-shallow-fallback-'); + const source = path.join(root, 'source'); + const remote = path.join(root, 'remote.git'); + const target = path.join(root, 'repo'); + const remoteUrl = 'git@github.com:team/repo.git'; + const gitConfig = path.join(root, 'gitconfig'); + const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL; + const previousNoSystemConfig = process.env.GIT_CONFIG_NOSYSTEM; + + try { + await runGit(['init', '--bare', remote], root); + await runGit(['init', '--initial-branch=master', source], root); + await runGit(['config', 'user.email', 'test@example.com'], source); + await runGit(['config', 'user.name', 'GitNexus Test'], source); + await fs.writeFile(path.join(source, 'branch.txt'), 'master\n'); + await runGit(['add', 'branch.txt'], source); + await runGit(['commit', '-m', 'master'], source); + await runGit(['checkout', '-b', 'main'], source); + await fs.writeFile(path.join(source, 'branch.txt'), 'main\n'); + await runGit(['commit', '-am', 'main'], source); + await runGit(['remote', 'add', 'origin', `file://${remote}`], source); + await runGit(['push', 'origin', 'master', 'main'], source); + + await fs.writeFile( + gitConfig, + `[protocol "file"]\n\tallow = always\n[url "file://${remote}"]\n\tinsteadOf = ${remoteUrl}\n`, + ); + process.env.GIT_CONFIG_GLOBAL = gitConfig; + process.env.GIT_CONFIG_NOSYSTEM = '1'; + + await runGit(['clone', '--depth', '1', '--branch', 'master', remoteUrl, target], root); + await expect( + runGit(['show-ref', '--verify', '--quiet', 'refs/remotes/origin/main'], target), + ).rejects.toThrow(); + await expect(runGit(['rev-parse', '--is-shallow-repository'], target)).resolves.toBe( + 'true\n', + ); + + await fs.writeFile(path.join(target, 'branch.txt'), 'local changes\n'); + await expect( + cloneOrPull(remoteUrl, target, undefined, { + allowedCloneRoot: root, + expectedRepoName: 'repo', + allowAutoSyncSsh: true, + branch: 'main', + }), + ).rejects.toThrow(); + await expect(fs.readFile(path.join(target, 'branch.txt'), 'utf8')).resolves.toBe( + 'local changes\n', + ); + + await cloneOrPull(remoteUrl, target, undefined, { + allowedCloneRoot: root, + expectedRepoName: 'repo', + allowAutoSyncSsh: true, + branch: 'main', + overwriteLocalChanges: true, + }); + + await expect(runGit(['branch', '--show-current'], target)).resolves.toBe('main\n'); + await expect(fs.readFile(path.join(target, 'branch.txt'), 'utf8')).resolves.toBe('main\n'); + await expect(runGit(['rev-parse', 'main'], target)).resolves.toBe( + await runGit(['rev-parse', 'origin/main'], target), + ); + } finally { + if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL; + else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig; + if (previousNoSystemConfig === undefined) delete process.env.GIT_CONFIG_NOSYSTEM; + else process.env.GIT_CONFIG_NOSYSTEM = previousNoSystemConfig; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it('quarantines partial auto-sync clone output on clone failure', async () => { const root = await mkControlledRoot('gitnexus-controlled-root-'); const quarantineRoot = path.join(root, 'quarantine');