diff --git a/.gitignore b/.gitignore index 794ed8145..ffde3bb06 100644 --- a/.gitignore +++ b/.gitignore @@ -128,7 +128,6 @@ local_docs/ !.agents/plugins/marketplace.json .context/ gitnexus/web/ -/log/ # Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py) eval/workflow_bench/learnings.jsonl diff --git a/Dockerfile.cli b/Dockerfile.cli index 633d23f5d..365d0e539 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -51,8 +51,9 @@ RUN npm run postinstall --prefix gitnexus # node:22-bookworm-slim FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime -# curl for the healthcheck; git for cloning; ca-certificates for TLS verification. -RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \ +# curl for the healthcheck; git for cloning; procps for watch process identity; +# ca-certificates for TLS verification. +RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \ && rm -rf /usr/local/lib/node_modules/npm \ && rm -rf /usr/local/lib/node_modules/corepack \ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack diff --git a/README.md b/README.md index 4a1ecd3de..a89c4d74b 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,7 @@ gitnexus watch start # `gitnexus watch` is equivalent gitnexus watch status gitnexus watch restart # Required after config changes gitnexus watch stop +gitnexus watch reset # Clear failure state; leaves clones and indexes intact ``` `GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration: @@ -449,11 +450,11 @@ projects: - git@github.com:owner/repo.git ``` -- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. +- `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`. `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 `. +- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timed-out workers are terminated before scheduling resumes. `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 `. Partial clone output is isolated and removed after 14 days. 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 a9bf37417..15153cf1f 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -240,7 +240,7 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768) gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB) -gitnexus watch [init|start|restart|stop|status] # Control auto-sync from GITNEXUS_HOME/watch_config.yml +gitnexus watch [init|start|restart|stop|status|reset] # Control auto-sync from GITNEXUS_HOME/watch_config.yml gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI gitnexus index # Register an existing .gitnexus/ folder into the global registry @@ -277,7 +277,7 @@ gitnexus group impact --target --repo # Cross-repo ### `gitnexus watch` -`gitnexus watch` is the explicit long-running auto-sync entrypoint. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus watch init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus watch` is the same as `gitnexus watch start`; `restart`, `stop`, and `status` manage the same `GITNEXUS_HOME` instance. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.lock` prevents multiple watch processes for one home, `watch.pid` and `watch.status.json` expose process state, and `quarantine/` stores partial clone output. +`gitnexus watch` is the explicit long-running auto-sync entrypoint. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus watch init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus watch` is the same as `gitnexus watch start`; `restart`, `stop`, `status`, and `reset` manage the same `GITNEXUS_HOME` instance. `reset` removes only the derived analysis state and commit snapshot; clones, indexes, and registry entries are untouched. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.mutex` prevents multiple watch processes for one home, `watch.owner.json` records ownership metadata, `watch.pid` plus `watch.status.json` expose process state, and `quarantine/` stores partial clone output before entries are removed after 14 days. Mutexes with verified dead owners are reclaimed automatically after an abnormal exit. Invalid or legacy mutexes fail closed; confirm no watch process is running before manually removing `watch.mutex` and stale `watch.pid` / `watch.owner.json`. ```yaml sync_interval_minutes: 10 @@ -288,7 +288,6 @@ 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 @@ -296,7 +295,7 @@ 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. `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`. +`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. `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 or `gitnexus watch 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 `. `$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. diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 94fa3c8be..8d8a05097 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -126,6 +126,8 @@ export const en = { 'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors', 'help.command.watch.description': 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml', + 'help.watch.details': + '\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only git@github.com:owner/repo.git, git@gitlab.com:group/repo.git, and git@gitee.com:owner/repo.git are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.', 'help.command.analyze.description': 'Index a repository (full analysis)', 'help.command.index.description': 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 04b319d25..7ecb4dc72 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -129,6 +129,8 @@ export const zhCN = { '撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子', 'help.command.watch.description': '控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析', + 'help.watch.details': + '\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:仅允许 git@github.com:owner/repo.git、git@gitlab.com:group/repo.git 和 git@gitee.com:owner/repo.git。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。', 'help.command.analyze.description': '索引仓库(完整分析)', 'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)', 'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 4dc6decf6..182b39c7b 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -50,18 +50,7 @@ program .description( 'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml', ) - .addHelpText( - 'after', - [ - '', - 'Actions: init, start (default), restart, stop, status', - 'Configuration: GITNEXUS_HOME/watch_config.yml', - 'Runtime files: GITNEXUS_HOME/watch/watch.pid, watch.lock, watch.status.json, auto-sync-state.json', - 'Writes: GITNEXUS_HOME/watch/project_commit_info.txt', - 'Remote URLs: only git@github.com:owner/repo.git, git@gitlab.com:group/repo.git, and git@gitee.com:owner/repo.git are allowed.', - 'Runs once immediately, then repeats on sync_interval_minutes.', - ].join('\n'), - ) + .addHelpText('after', () => t('help.watch.details')) .action(createLazyAction(() => import('./watch.js'), 'watchCommand')); // Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook diff --git a/gitnexus/src/cli/watch.ts b/gitnexus/src/cli/watch.ts index 41dc2ba59..ed7d4e0a9 100644 --- a/gitnexus/src/cli/watch.ts +++ b/gitnexus/src/cli/watch.ts @@ -2,7 +2,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import { getAutoSyncConfigPath, + getAutoSyncMutexPath, readAutoSyncWatchStatus, + resetAutoSyncState, startAutoSyncWatch, stopAutoSyncWatch, type WatchStatusRecord, @@ -13,17 +15,28 @@ export async function watchCommand(action = 'start'): Promise { await initWatchConfig(); return; } + if (action === 'reset') { + if (!(await resetAutoSyncState())) { + process.stderr.write( + `[auto-sync] Cannot reset analysis state while the watch mutex is held. Confirm no watch process is running, then remove ${getAutoSyncMutexPath()}.\n`, + ); + process.exitCode = 1; + return; + } + process.stdout.write('[auto-sync] Reset analysis state.\n'); + return; + } if (action === 'status') { printStatus(await readAutoSyncWatchStatus()); return; } if (action === 'stop') { - await stopAutoSyncWatch(); + if ((await stopAutoSyncWatch()) !== 'stopped') process.exitCode = 1; return; } if (action === 'restart') { - const stopped = await stopAutoSyncWatch(); - if (!stopped) { + const result = await stopAutoSyncWatch(); + if (result === 'refused' || result === 'timeout') { process.exitCode = 1; return; } @@ -46,10 +59,17 @@ async function startWatchProcess(): Promise { } const stop = () => { - void handle.stop().finally(() => { - process.stderr.write('[auto-sync] Watch stopped.\n'); - process.exit(0); - }); + void handle.stop().then( + () => { + process.stderr.write('[auto-sync] Watch stopped.\n'); + process.exit(0); + }, + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`[auto-sync] Failed to stop watch: ${message}\n`); + process.exit(1); + }, + ); }; process.once('SIGINT', stop); process.once('SIGTERM', stop); @@ -70,7 +90,7 @@ async function initWatchConfig(): Promise { await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile( configPath, - defaultSyncConfig(path.resolve(path.dirname(configPath), 'repo')), + defaultSyncConfig(path.resolve(path.dirname(configPath), 'repos')), { flag: 'wx', }, @@ -96,7 +116,6 @@ function defaultSyncConfig(localPath: string): string { '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 index 8b8d492b2..cda91902a 100644 --- a/gitnexus/src/core/auto-sync/analysis-worker-launch.ts +++ b/gitnexus/src/core/auto-sync/analysis-worker-launch.ts @@ -16,7 +16,10 @@ export type AutoSyncAnalysisRunner = ( signal?: AbortSignal, ) => Promise>; -interface AnalysisWorker extends Pick {} +interface AnalysisWorker extends Pick { + stdout?: Pick | null; + stderr?: Pick | null; +} export interface AutoSyncAnalysisLaunchDeps { forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker; @@ -59,61 +62,73 @@ export function createAutoSyncAnalysisRunner( ? ['--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; + child.stdout?.resume(); + child.stderr?.resume(); + + let terminalOutcome: WorkerMessage | undefined; 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; + let settled = false; + const cleanup = () => { deps.clearTimeoutFn(timeout); if (terminationGrace) deps.clearTimeoutFn(terminationGrace); + signal?.removeEventListener('abort', onAbort); + }; + const settle = (error?: Error, result?: Pick) => { + if (settled) return; + settled = true; + cleanup(); + if (error) reject(error); + else resolve(result!); + }; + const timeout = deps.setTimeoutFn(() => { + child.kill('SIGTERM'); + terminationGrace = deps.setTimeoutFn(() => { + child.kill('SIGKILL'); + settle(new Error(`Analysis timed out after ${timeoutMs}ms.`)); + }, TERMINATION_GRACE_MS); + }, timeoutMs); + const onAbort = () => { child.kill('SIGKILL'); + settle(new Error('Analysis cancelled.')); }; signal?.addEventListener('abort', onAbort, { once: true }); child.on('message', (message: WorkerMessage) => { - if (message.type !== 'progress') outcome ??= message; - else outcome = message; + if (message.type !== 'progress') terminalOutcome ??= message; }); child.on('error', (error) => { - outcome = { type: 'error', message: `Auto-sync analyze worker error: ${error.message}` }; + settle(new Error(`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( + if (settled) return; + if (terminationGrace) { + settle( new Error( `Analysis timed out after ${timeoutMs}ms and worker exited (${childSignal ?? code ?? 'unknown'}).`, ), ); return; } - if (outcome?.type === 'complete') { - resolve({ stats: outcome.result.stats }); + if (terminalOutcome?.type === 'complete') { + settle(undefined, { stats: terminalOutcome.result.stats }); return; } - if (outcome?.type === 'error') { - reject(new Error(outcome.message)); + if (terminalOutcome?.type === 'error') { + settle(new Error(terminalOutcome.message)); return; } - reject( + settle( new Error( - `Auto-sync analyze worker exited before completion (${signal ?? code ?? 'unknown'}).`, + `Auto-sync analyze worker exited before completion (${childSignal ?? code ?? 'unknown'}).`, ), ); }); - child.send({ type: 'start', repoPath, options }); + try { + child.send({ type: 'start', repoPath, options }); + } catch (error) { + child.kill('SIGKILL'); + settle(new Error(`Failed to start auto-sync analyze worker: ${(error as Error).message}`)); + } }); } diff --git a/gitnexus/src/core/auto-sync/config.ts b/gitnexus/src/core/auto-sync/config.ts index 7096b1cbc..f85c0b812 100644 --- a/gitnexus/src/core/auto-sync/config.ts +++ b/gitnexus/src/core/auto-sync/config.ts @@ -120,6 +120,8 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy : parseDurationMs(raw.repo_git_timeout); if (!Number.isInteger(repoGitTimeoutMs) || repoGitTimeoutMs <= 0) { errors.push('repo_git_timeout must be a positive duration such as 10s'); + } else if (repoGitTimeoutMs > MAX_TIMER_DELAY_MS) { + errors.push(`repo_git_timeout must not exceed ${MAX_TIMER_DELAY_MS}ms`); } const maxAnalyzeTimeoutMs = diff --git a/gitnexus/src/core/auto-sync/index.ts b/gitnexus/src/core/auto-sync/index.ts index 4182843bf..b02948779 100644 --- a/gitnexus/src/core/auto-sync/index.ts +++ b/gitnexus/src/core/auto-sync/index.ts @@ -13,10 +13,12 @@ export { } from './config.js'; export { buildStateKey, + getAutoSyncMutexPath, getAutoSyncWatchDir, getAutoSyncStatePath, getProjectCommitInfoPath, loadAutoSyncState, + resetAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, writeProjectCommitInfo, @@ -49,6 +51,7 @@ export { startAutoSyncWatch, stopAutoSyncWatch, type AutoSyncStartHandle, + type AutoSyncWatchStopResult, type AutoSyncWatchPaths, type WatchStatusRecord, } from './starter.js'; diff --git a/gitnexus/src/core/auto-sync/path-security.ts b/gitnexus/src/core/auto-sync/path-security.ts index 7359c4679..2b1d3e77b 100644 --- a/gitnexus/src/core/auto-sync/path-security.ts +++ b/gitnexus/src/core/auto-sync/path-security.ts @@ -4,6 +4,16 @@ import path from 'node:path'; import { getGlobalDir } from '../../storage/repo-manager.js'; import { getAutoSyncWatchDir } from './state.js'; +const WINDOWS_DANGEROUS_ROOTS = + process.platform === 'win32' + ? [ + process.env.SystemRoot, + process.env.ProgramData, + process.env.ProgramFiles, + process.env['ProgramFiles(x86)'], + ].filter((entry): entry is string => Boolean(entry)) + : []; + const DANGEROUS_ROOTS = new Set( [ '/', @@ -25,6 +35,7 @@ const DANGEROUS_ROOTS = new Set( '/tmp', '/usr', '/var', + ...WINDOWS_DANGEROUS_ROOTS, ].map((entry) => path.resolve(entry)), ); @@ -47,6 +58,7 @@ const DANGEROUS_PARENT_ROOTS = new Set( '/tmp', '/usr', '/var', + ...WINDOWS_DANGEROUS_ROOTS, ].map((entry) => path.resolve(entry)), ); @@ -72,10 +84,12 @@ export async function resolveConfiguredCloneRoot(localPath: string): Promise { + const cutoff = Date.now() - QUARANTINE_RETENTION_DAYS * 24 * 60 * 60 * 1_000; + let entries; + try { + entries = await fs.readdir(quarantineRoot); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return; + throw err; + } + await Promise.all( + entries + .filter((entry) => entry.startsWith('auto-sync-')) + .map(async (entry) => { + const entryPath = path.join(quarantineRoot, entry); + const stat = await fs.stat(entryPath).catch(() => undefined); + if (stat && stat.mtimeMs < cutoff) { + await fs.rm(entryPath, { recursive: true, force: true }); + } + }), + ); +} + function assertNotDangerousRoot(root: string): void { + if (root === path.resolve(getGlobalDir(), 'repos')) return; if (DANGEROUS_ROOTS.has(root)) throw new Error(`Refusing unsafe auto-sync clone root: ${root}`); for (const dangerousRoot of DANGEROUS_PARENT_ROOTS) { const rel = path.relative(dangerousRoot, root); @@ -167,14 +210,20 @@ async function assertNoSymlinkPath(root: string): Promise { export async function assertDirectoryOwnerAndPermissions(root: string): Promise { const stat = await fs.stat(root); if (!stat.isDirectory()) throw new Error(`auto-sync clone root is not a directory: ${root}`); + if (process.platform === 'win32') { + throw new Error('auto-sync clone root ownership/ACL verification is not supported on Windows'); + } if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { throw new Error(`auto-sync clone root is owned by uid ${stat.uid}, not current process uid`); } const mode = stat.mode & 0o777; + const groupWritable = (mode & 0o020) !== 0; const worldWritable = (mode & 0o002) !== 0; - const sticky = (stat.mode & 0o1000) !== 0; - if (worldWritable && !sticky) { - throw new Error(`Refusing world-writable auto-sync clone root without sticky bit: ${root}`); + if (worldWritable) { + throw new Error(`Refusing world-writable auto-sync clone root: ${root}`); + } + if (groupWritable) { + throw new Error(`Refusing group-writable auto-sync clone root: ${root}`); } } diff --git a/gitnexus/src/core/auto-sync/runner.ts b/gitnexus/src/core/auto-sync/runner.ts index 9f18d39a3..70d159eba 100644 --- a/gitnexus/src/core/auto-sync/runner.ts +++ b/gitnexus/src/core/auto-sync/runner.ts @@ -1,14 +1,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import yaml from 'js-yaml'; +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 { runFullAnalysis } from '../run-analyze.js'; -import { getCurrentBranch, getCurrentCommit } from '../../storage/git.js'; import { registerRepo, type RepoMeta } from '../../storage/repo-manager.js'; import { extractRepoNameFromRemoteUrl } from './repo.js'; -import { cloneOrPull } from '../../server/git-clone.js'; +import { cloneOrPull, runGit } from '../../server/git-clone.js'; import { resolveConfiguredCloneRoot } from './path-security.js'; import { buildStateKey, @@ -32,9 +30,8 @@ export interface AutoSyncLogger { export interface AutoSyncRunDeps { cloneOrPull: typeof cloneOrPull; - getCurrentBranch: typeof getCurrentBranch; - getCurrentCommit: typeof getCurrentCommit; - runFullAnalysis?: typeof runFullAnalysis; + getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise; + getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise; runAnalysis: AutoSyncAnalysisRunner; registerRepo: typeof registerRepo; loadState: typeof loadAutoSyncState; @@ -53,6 +50,9 @@ export interface AutoSyncRunResult { failed: number; } +const _require = createRequire(import.meta.url); +const yaml = _require('js-yaml') as typeof import('js-yaml'); + const DEFAULT_LOGGER: AutoSyncLogger = { info: (message) => process.stderr.write(`${message}\n`), warn: (message) => process.stderr.write(`${message}\n`), @@ -61,8 +61,12 @@ const DEFAULT_LOGGER: AutoSyncLogger = { const DEFAULT_DEPS: AutoSyncRunDeps = { cloneOrPull, - getCurrentBranch, - getCurrentCommit, + getCurrentBranch: async (repoPath, timeoutMs) => { + const branch = (await runGit(['branch', '--show-current'], repoPath, { timeoutMs })).trim(); + return branch || undefined; + }, + getCurrentCommit: async (repoPath, timeoutMs) => + (await runGit(['rev-parse', 'HEAD'], repoPath, { timeoutMs })).trim(), runAnalysis: runAutoSyncAnalysis, registerRepo, loadState: loadAutoSyncState, @@ -109,9 +113,11 @@ export async function runAutoSyncOnce( const lastSyncTime = now().toISOString(); try { throwIfAborted(options.signal); - validateAutoSyncRemoteUrl(item.remoteUrl); - const repoName = extractRepoNameFromRemoteUrl(item.remoteUrl); - const targetDir = getConfiguredRepoPath({ localPath: item.cloneRoot.root }, repoName); + if (!item.cloneRoot || !item.repoName || !item.targetDir) { + throw new Error(item.error ?? 'Invalid auto-sync work item'); + } + const repoName = item.repoName; + const targetDir = item.targetDir; const syncResult = await syncFirstAvailableBranch({ item, repoName, @@ -139,7 +145,7 @@ export async function runAutoSyncOnce( const currentBranch = syncResult.branch; - const currentCommit = deps.getCurrentCommit(targetDir); + const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs); const stateKey = buildStateKey(targetDir, currentBranch); const previous = state[stateKey]; let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped'; @@ -166,18 +172,12 @@ export async function runAutoSyncOnce( }) ) { 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, - ); + const analysis = await deps.runAnalysis( + targetDir, + { branch: currentBranch, skipAgentsMd: true, skipSkills: true }, + config.analyzeTimeoutMs, + options.signal, + ); throwIfAborted(options.signal); stats = analysis.stats; analyzeStatus = 'success'; @@ -245,16 +245,11 @@ export async function runAutoSyncOnce( } result.synced += 1; - const stateEntry: AutoSyncCommitStateEntry = { - codeCommitId: repoResult.currentCommit, - analyzedCommitId: repoResult.analyzedCommitId, - lastAnalyzeStatus: repoResult.analyzeStatus, - analyzeConsecutiveFailures: repoResult.analyzeConsecutiveFailures, - lastAnalyzeError: repoResult.lastAnalyzeError, - lastSyncTime: repoResult.lastSyncTime, - }; - state[repoResult.stateKey] = stateEntry; - if (repoResult.analyzeStatus === 'success') { + let analyzeStatus = repoResult.analyzeStatus; + let analyzeConsecutiveFailures = repoResult.analyzeConsecutiveFailures; + let lastAnalyzeError = repoResult.lastAnalyzeError; + let analyzedCommitId = repoResult.analyzedCommitId; + if (analyzeStatus === 'success') { const meta: RepoMeta = { repoPath: repoResult.targetDir, lastCommit: repoResult.currentCommit, @@ -262,28 +257,45 @@ export async function runAutoSyncOnce( stats: repoResult.stats!, branch: repoResult.branch, }; - await deps.registerRepo(repoResult.targetDir, meta, { - name: getAutoSyncRepoIdentity(repoResult.remoteUrl), - }); - result.analyzed += 1; - } else if (repoResult.analyzeStatus === 'failed') { + try { + await deps.registerRepo(repoResult.targetDir, meta, { + name: getAutoSyncRepoIdentity(repoResult.remoteUrl), + }); + result.analyzed += 1; + } catch (err: unknown) { + analyzeStatus = 'failed'; + analyzedCommitId = undefined; + analyzeConsecutiveFailures += 1; + lastAnalyzeError = `Repository registration failed: ${shortErrorMessage(err)}`; + result.failed += 1; + logger.error(`[auto-sync] ${lastAnalyzeError}`); + } + } else if (analyzeStatus === 'failed') { result.failed += 1; - } else if (repoResult.analyzeStatus === 'threshold_skipped') { - result.skippedAnalysis += 1; } else { result.skippedAnalysis += 1; } + const stateEntry: AutoSyncCommitStateEntry = { + codeCommitId: repoResult.currentCommit, + analyzedCommitId, + lastAnalyzeStatus: analyzeStatus, + analyzeConsecutiveFailures, + lastAnalyzeError, + lastSyncTime: repoResult.lastSyncTime, + }; + state[repoResult.stateKey] = stateEntry; + commitInfoEntries.push({ remoteUrl: repoResult.remoteUrl, localPath: repoResult.targetDir, branch: repoResult.branch, codeCommitId: repoResult.currentCommit, - analyzedCommitId: repoResult.analyzedCommitId, - status: repoResult.analyzeStatus, - analyzeConsecutiveFailures: repoResult.analyzeConsecutiveFailures, + analyzedCommitId, + status: analyzeStatus, + analyzeConsecutiveFailures, analyzeFailureThreshold: config.analyzeFailureThreshold, - lastAnalyzeError: repoResult.lastAnalyzeError, + lastAnalyzeError, lastSyncTime: repoResult.lastSyncTime, }); @@ -302,7 +314,7 @@ export async function runAutoSyncOnce( `[auto-sync] Group update failed for ${repoResult.project.groupName}: ${(err as Error).message}`, ); } - if (groupMembershipOk && repoResult.analyzeStatus === 'success') { + if (groupMembershipOk && analyzeStatus === 'success') { groupsToSync.add(repoResult.project.groupName); } } @@ -329,8 +341,11 @@ function shortErrorMessage(err: unknown): string { export function getConfiguredRepoPath( project: Pick, repoName: string, + remoteUrl?: string, ): string { - return path.resolve(project.localPath, repoName); + if (!remoteUrl) return path.resolve(project.localPath, repoName); + const identity = getAutoSyncRepoIdentity(remoteUrl); + return path.resolve(project.localPath, ...identity.split('/').slice(0, -1), repoName); } export async function addRepoToGroup( @@ -380,11 +395,19 @@ async function buildWorkItems( const items: AutoSyncWorkItem[] = []; const targetOwners = new Map(); for (const project of config.projects) { - const cloneRoot = await deps.resolveCloneRoot(project.localPath); + let cloneRoot: AutoSyncWorkItem['cloneRoot']; + try { + cloneRoot = await deps.resolveCloneRoot(project.localPath); + } catch (err: unknown) { + for (const remoteUrl of project.remoteUrls) { + items.push({ project, remoteUrl, error: shortErrorMessage(err) }); + } + continue; + } for (const remoteUrl of project.remoteUrls) { try { const repoName = extractRepoNameFromRemoteUrl(remoteUrl); - const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName); + const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName, remoteUrl); const previous = targetOwners.get(targetDir); if (previous !== undefined) { throw new Error( @@ -392,10 +415,10 @@ async function buildWorkItems( ); } targetOwners.set(targetDir, remoteUrl); + items.push({ project, remoteUrl, cloneRoot, repoName, targetDir }); } catch (err: unknown) { - if ((err as Error).message.startsWith('Duplicate auto-sync targetDir')) throw err; + items.push({ project, remoteUrl, error: shortErrorMessage(err) }); } - items.push({ project, remoteUrl, cloneRoot }); } } return items; @@ -429,7 +452,10 @@ function throwIfAborted(signal: AbortSignal | undefined): void { interface AutoSyncWorkItem { project: AutoSyncProjectConfig; remoteUrl: string; - cloneRoot: Awaited>; + cloneRoot?: Awaited>; + repoName?: string; + targetDir?: string; + error?: string; } async function syncFirstAvailableBranch(input: { @@ -448,15 +474,15 @@ async function syncFirstAvailableBranch(input: { for (const branch of input.item.project.branches) { try { await input.deps.cloneOrPull(input.item.remoteUrl, input.targetDir, undefined, { - allowedCloneRoot: input.item.cloneRoot.root, + allowedCloneRoot: input.item.cloneRoot!.root, expectedRepoName: input.repoName, - quarantineRoot: input.item.cloneRoot.quarantineRoot, + quarantineRoot: input.item.cloneRoot!.quarantineRoot, allowAutoSyncSsh: true, timeoutMs: input.timeoutMs, branch, overwriteLocalChanges: input.item.project.overwriteLocalChanges, }); - const currentBranch = input.deps.getCurrentBranch(input.targetDir); + const currentBranch = await input.deps.getCurrentBranch(input.targetDir, input.timeoutMs); if (currentBranch === branch) return { ok: true, branch }; failures.push(`${branch}: checked out ${currentBranch ?? ''}`); input.logger.warn( diff --git a/gitnexus/src/core/auto-sync/starter.ts b/gitnexus/src/core/auto-sync/starter.ts index bad0c901b..026ea512b 100644 --- a/gitnexus/src/core/auto-sync/starter.ts +++ b/gitnexus/src/core/auto-sync/starter.ts @@ -2,16 +2,19 @@ import fs from 'node:fs/promises'; import crypto from 'node:crypto'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js'; import { getGlobalDir } from '../../storage/repo-manager.js'; +import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.js'; import { loadAutoSyncConfig } from './config.js'; import { runAutoSyncOnce } from './runner.js'; -import { getAutoSyncWatchDir } from './state.js'; +import { getAutoSyncMutexPath, getAutoSyncWatchDir } from './state.js'; export interface AutoSyncStartHandle { stop(): Promise; } export type WatchStatusState = 'running' | 'stopping' | 'stopped' | 'stale' | 'error'; +export type AutoSyncWatchStopResult = 'stopped' | 'not_running' | 'refused' | 'timeout'; export interface WatchStatusRecord { state: WatchStatusState; @@ -22,21 +25,24 @@ export interface WatchStatusRecord { updatedAt: string; } -export interface WatchLockRecord { +export interface WatchOwnerRecord { pid: number; ownerId: string; + processStartTime: string; createdAt: string; } export interface AutoSyncWatchPaths { pidPath: string; - lockPath: string; + mutexPath: string; + ownerPath: string; statusPath: string; } export interface AutoSyncWatchControlDeps { isProcessAlive(pid: number): boolean; readProcessCommand(pid: number): string | undefined; + readProcessStartTime(pid: number): string | undefined; killProcess(pid: number, signal?: NodeJS.Signals): void; sleep(ms: number): Promise; } @@ -45,7 +51,8 @@ export function getAutoSyncWatchPaths(gitnexusDir = getGlobalDir()): AutoSyncWat const watchDir = getAutoSyncWatchDir(gitnexusDir); return { pidPath: path.join(watchDir, 'watch.pid'), - lockPath: path.join(watchDir, 'watch.lock'), + mutexPath: getAutoSyncMutexPath(gitnexusDir), + ownerPath: path.join(watchDir, 'watch.owner.json'), statusPath: path.join(watchDir, 'watch.status.json'), }; } @@ -65,169 +72,152 @@ export async function startAutoSyncWatch( const paths = options.paths ?? getAutoSyncWatchPaths(); const deps = resolveWatchDeps(options.deps); const ownerId = crypto.randomUUID(); - await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); - const lockHandle = await acquireWatchLock(paths, deps, stderr); - if (!lockHandle) return null; - await lockHandle.writeFile( - `${JSON.stringify({ pid: process.pid, ownerId, createdAt: new Date().toISOString() })}\n`, - 'utf-8', - ); - await fs.writeFile(paths.pidPath, `${process.pid}\n`, 'utf-8'); - - const loaded = await loadAutoSyncConfig(); - if (loaded.ok === false) { - stderr.write(`${loaded.message}\n`); - await writeWatchStatus(paths, { - state: 'error', - pid: process.pid, - ownerId, - message: loaded.message, - updatedAt: new Date().toISOString(), - }); - await cleanupWatchFiles(paths, lockHandle); + const processStartTime = deps.readProcessStartTime(process.pid); + if (!processStartTime) { + stderr.write('[auto-sync] Unable to verify the watch process start time.\n'); return null; } - await writeWatchStatus(paths, { - state: 'running', - pid: process.pid, - ownerId, - configPath: loaded.config.configPath, - updatedAt: new Date().toISOString(), - }); + await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); + const releaseLock = await acquireWatchLock(paths, deps, stderr, processStartTime); + if (!releaseLock) return null; - const runOnce = options.runOnce ?? runAutoSyncOnce; - let activeRun: Promise | undefined; - let activeAbortController: AbortController | undefined; - const runSafely = () => { - if (activeRun) { - stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n'); - return; - } - const startedAt = new Date(); - stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`); - 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`, - ); - }) - .catch((err: unknown) => { - stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`); - stderr.write('[auto-sync] Watch loop finished: failed.\n'); - }); - activeRun = run; - activeAbortController = abortController; - void run.finally(() => { - if (activeRun === run) { - activeRun = undefined; - activeAbortController = undefined; - } + try { + await writeWatchOwner(paths, { + pid: process.pid, + ownerId, + processStartTime, + createdAt: new Date().toISOString(), }); - }; + await writeAtomicText(paths.pidPath, `${process.pid}\n`); - runSafely(); - const intervalMs = loaded.config.syncIntervalMinutes * 60_000; - const setIntervalFn = options.setIntervalFn ?? setInterval; - const clearIntervalFn = options.clearIntervalFn ?? clearInterval; - const timer = setIntervalFn(runSafely, intervalMs); - if (options.keepAlive === false) timer.unref?.(); - return { - stop: async () => { - clearIntervalFn(timer); - activeAbortController?.abort(); + const loaded = await loadAutoSyncConfig(); + if (loaded.ok === false) { + stderr.write(`${loaded.message}\n`); await writeWatchStatus(paths, { - state: 'stopping', + state: 'error', pid: process.pid, ownerId, - configPath: loaded.config.configPath, + message: loaded.message, updatedAt: new Date().toISOString(), }); - await activeRun?.catch(() => {}); - await writeWatchStatus(paths, { - state: 'stopped', - pid: process.pid, - ownerId, - configPath: loaded.config.configPath, - updatedAt: new Date().toISOString(), - }).finally(() => cleanupWatchFiles(paths, lockHandle)); - }, - }; + await cleanupWatchFiles(paths, ownerId, releaseLock); + return null; + } + await writeWatchStatus(paths, { + state: 'running', + pid: process.pid, + ownerId, + configPath: loaded.config.configPath, + updatedAt: new Date().toISOString(), + }); + + const runOnce = options.runOnce ?? runAutoSyncOnce; + let activeRun: Promise | undefined; + let activeAbortController: AbortController | undefined; + const runSafely = () => { + if (activeRun) { + stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n'); + return; + } + const startedAt = new Date(); + stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`); + 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`, + ); + }) + .catch((err: unknown) => { + stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`); + stderr.write('[auto-sync] Watch loop finished: failed.\n'); + }); + activeRun = run; + activeAbortController = abortController; + void run.finally(() => { + if (activeRun === run) { + activeRun = undefined; + activeAbortController = undefined; + } + }); + }; + + runSafely(); + const intervalMs = loaded.config.syncIntervalMinutes * 60_000; + const setIntervalFn = options.setIntervalFn ?? setInterval; + const clearIntervalFn = options.clearIntervalFn ?? clearInterval; + const timer = setIntervalFn(runSafely, intervalMs); + if (options.keepAlive === false) timer.unref?.(); + let stopPromise: Promise | undefined; + return { + stop: () => + (stopPromise ??= (async () => { + clearIntervalFn(timer); + activeAbortController?.abort(); + try { + 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, + ownerId, + configPath: loaded.config.configPath, + updatedAt: new Date().toISOString(), + }); + } finally { + await cleanupWatchFiles(paths, ownerId, releaseLock); + } + })()), + }; + } catch (error) { + await cleanupWatchFiles(paths, ownerId, releaseLock).catch(() => {}); + throw error; + } } async function acquireWatchLock( paths: AutoSyncWatchPaths, deps: AutoSyncWatchControlDeps, stderr: Pick, -): Promise { + processStartTime: string, +): Promise<(() => Promise) | null> { try { - return await fs.open(paths.lockPath, 'wx'); + return await acquireFileLock(paths.mutexPath, { + pid: process.pid, + processStartTime, + isProcessAlive: deps.isProcessAlive, + readProcessStartTime: deps.readProcessStartTime, + }); } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + if (!(err instanceof FileLockBusyError)) throw err; } - const lock = await readLockFile(paths.lockPath); - if (!lock) { - const message = 'watch lock already exists but has no readable owner; refusing to start'; - stderr.write(`[auto-sync] ${message}.\n`); - await writeWatchStatus(paths, { - state: 'error', - message, - updatedAt: new Date().toISOString(), - }); + const owner = await readOwnerFile(paths.ownerPath); + if (!owner) { + stderr.write( + `[auto-sync] Watch mutex is held but owner metadata is not ready or invalid. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, + ); return null; } - - 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', - pid: lock.pid, - ownerId: lock.ownerId, - message: 'watch already running', - updatedAt: new Date().toISOString(), - }); + if (!deps.isProcessAlive(owner.pid)) { + stderr.write( + `[auto-sync] Watch mutex remains after owner pid ${owner.pid} exited. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, + ); return null; } - - stderr.write(`[auto-sync] Removing stale watch lock for pid ${lock.pid}.\n`); - await removeIfExists(paths.pidPath); - await removeIfExists(paths.lockPath); - await writeWatchStatus(paths, { - state: 'stale', - pid: lock.pid, - ownerId: lock.ownerId, - message: 'removed stale lock and pid', - updatedAt: new Date().toISOString(), - }); - - try { - return await fs.open(paths.lockPath, 'wx'); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code === 'EEXIST') { - const message = 'watch lock was reacquired by another process; refusing to start'; - stderr.write(`[auto-sync] ${message}.\n`); - await writeWatchStatus(paths, { - state: 'error', - message, - updatedAt: new Date().toISOString(), - }); - return null; - } - throw err; + const reason = getWatchProcessIdentityError(owner, deps); + if (reason) { + stderr.write(`[auto-sync] Refusing to trust existing watch pid ${owner.pid}; ${reason}.\n`); + return null; } + stderr.write(`[auto-sync] Watch is already running with pid ${owner.pid}.\n`); + return null; } export async function stopAutoSyncWatch( @@ -238,7 +228,7 @@ export async function stopAutoSyncWatch( timeoutMs?: number; pollMs?: number; } = {}, -): Promise { +): Promise { const stderr = options.stderr ?? process.stderr; const paths = options.paths ?? getAutoSyncWatchPaths(); const deps = resolveWatchDeps(options.deps); @@ -246,105 +236,54 @@ export async function stopAutoSyncWatch( const pollMs = options.pollMs ?? 100; const pid = await readPid(paths.pidPath); if (!pid) { - const lock = await readLockFile(paths.lockPath); - if (lock && deps.isProcessAlive(lock.pid)) { - const message = `watch appears to be starting with pid ${lock.pid}; pid file is not ready`; - stderr.write(`[auto-sync] ${message}.\n`); - await writeWatchStatus(paths, { - state: 'error', - pid: lock.pid, - ownerId: lock.ownerId, - message, - updatedAt: new Date().toISOString(), - }); - return false; + const owner = await readOwnerFile(paths.ownerPath); + if (owner && deps.isProcessAlive(owner.pid)) { + stderr.write( + `[auto-sync] Watch appears to be starting with pid ${owner.pid}; pid file is not ready.\n`, + ); + return 'refused'; } - if (lock) { - stderr.write(`[auto-sync] Removing stale watch lock for pid ${lock.pid}.\n`); - await removeIfExists(paths.lockPath); - await writeWatchStatus(paths, { - state: 'stale', - pid: lock.pid, - ownerId: lock.ownerId, - message: 'removed stale lock without pid file', - updatedAt: new Date().toISOString(), - }); - return false; - } - if (await fileExists(paths.lockPath)) { - const message = 'watch lock exists but has no readable owner; refusing to stop'; - stderr.write(`[auto-sync] ${message}.\n`); - await writeWatchStatus(paths, { - state: 'error', - message, - updatedAt: new Date().toISOString(), - }); - return false; + if (owner || (await fileExists(paths.mutexPath))) { + stderr.write( + `[auto-sync] Watch ownership is stale or incomplete. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, + ); + return 'refused'; } stderr.write('[auto-sync] Watch is not running.\n'); - await writeWatchStatus(paths, { - state: 'stopped', - message: 'no pid file', - updatedAt: new Date().toISOString(), - }); - return false; + return 'not_running'; } if (!deps.isProcessAlive(pid)) { - stderr.write(`[auto-sync] Removing stale watch pid ${pid}.\n`); - await removeIfExists(paths.pidPath); - await removeIfExists(paths.lockPath); - await writeWatchStatus(paths, { - state: 'stale', - pid, - message: 'removed stale pid and lock', - updatedAt: new Date().toISOString(), - }); - return false; + stderr.write( + `[auto-sync] Watch pid ${pid} is stale. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`, + ); + return 'refused'; } + 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`); - await writeWatchStatus(paths, { - state: 'error', - pid, - message, - updatedAt: new Date().toISOString(), - }); - return false; + stderr.write(`[auto-sync] Refusing to stop pid ${pid}; ${owner.reason}.\n`); + return 'refused'; } - await writeWatchStatus(paths, { - state: 'stopping', - pid, - ownerId: owner.owner.ownerId, - message: 'stop signal sent; waiting for watch process to exit', - updatedAt: new Date().toISOString(), - }); + + const currentPid = await readPid(paths.pidPath); + const currentOwner = await readVerifiedWatchOwner(paths, pid, deps); + if ( + currentPid !== pid || + currentOwner.ok === false || + currentOwner.owner.ownerId !== owner.owner.ownerId + ) { + stderr.write(`[auto-sync] Refusing to stop pid ${pid}; watch ownership changed.\n`); + return 'refused'; + } + deps.killProcess(pid, 'SIGTERM'); stderr.write(`[auto-sync] Stop signal sent to watch pid ${pid}.\n`); const stopped = await waitForProcessExit(pid, { deps, timeoutMs, pollMs }); if (!stopped) { - const message = `watch pid ${pid} did not exit within ${timeoutMs}ms`; - stderr.write(`[auto-sync] ${message}.\n`); - await writeWatchStatus(paths, { - state: 'stopping', - pid, - ownerId: owner.owner.ownerId, - message, - updatedAt: new Date().toISOString(), - }); - return false; + stderr.write(`[auto-sync] Watch pid ${pid} did not exit within ${timeoutMs}ms.\n`); + return 'timeout'; } - await removeIfExists(paths.pidPath); - await removeIfExists(paths.lockPath); - await writeWatchStatus(paths, { - state: 'stopped', - pid, - ownerId: owner.owner.ownerId, - message: 'watch stopped', - updatedAt: new Date().toISOString(), - }); - return true; + return 'stopped'; } export async function readAutoSyncWatchStatus( @@ -388,17 +327,19 @@ export async function readAutoSyncWatchStatus( return stored ?? { state: 'stopped', updatedAt: new Date().toISOString() }; } -async function readLockFile(lockPath: string): Promise { +async function readOwnerFile(ownerPath: string): Promise { try { - const raw = await fs.readFile(lockPath, 'utf-8'); - const parsed = JSON.parse(raw) as WatchLockRecord; + const raw = await fs.readFile(ownerPath, 'utf-8'); + const parsed = JSON.parse(raw) as WatchOwnerRecord; if ( parsed && typeof parsed === 'object' && Number.isInteger(parsed.pid) && parsed.pid > 0 && typeof parsed.ownerId === 'string' && - parsed.ownerId + parsed.ownerId && + typeof parsed.processStartTime === 'string' && + parsed.processStartTime ) { return parsed; } @@ -413,28 +354,31 @@ async function readVerifiedWatchOwner( paths: AutoSyncWatchPaths, pid: number, deps: AutoSyncWatchControlDeps, -): Promise<{ ok: true; owner: WatchLockRecord } | { ok: false; reason: string }> { - const [status, lock] = await Promise.all([ +): Promise<{ ok: true; owner: WatchOwnerRecord } | { ok: false; reason: string }> { + const [status, owner] = await Promise.all([ readStatusFile(paths.statusPath), - readLockFile(paths.lockPath), + readOwnerFile(paths.ownerPath), ]); - if (!lock) return { ok: false, reason: 'watch lock is missing or invalid' }; + if (!owner) return { ok: false, reason: 'watch owner is missing or invalid' }; if (!status) return { ok: false, reason: 'watch status is missing or invalid' }; - if (lock.pid !== pid) return { ok: false, reason: 'watch lock pid does not match pid file' }; + if (owner.pid !== pid) return { ok: false, reason: 'watch owner pid does not match pid file' }; if (status.pid !== pid) return { ok: false, reason: 'watch status pid does not match pid file' }; - if (!status.ownerId || status.ownerId !== lock.ownerId) { - return { ok: false, reason: 'watch status owner does not match lock owner' }; + if (!status.ownerId || status.ownerId !== owner.ownerId) { + return { ok: false, reason: 'watch status owner does not match watch owner' }; } - const identityError = getWatchProcessIdentityError(pid, deps); + const identityError = getWatchProcessIdentityError(owner, deps); if (identityError) return { ok: false, reason: identityError }; - return { ok: true, owner: lock }; + return { ok: true, owner }; } function getWatchProcessIdentityError( - pid: number, + owner: WatchOwnerRecord, deps: AutoSyncWatchControlDeps, ): string | undefined { - const command = deps.readProcessCommand(pid); + const processStartTime = deps.readProcessStartTime(owner.pid); + if (!processStartTime) return 'unable to verify process start time'; + if (processStartTime !== owner.processStartTime) return 'pid belongs to a different process'; + const command = deps.readProcessCommand(owner.pid); if (!command) return 'unable to verify process command'; if ( !/(?:^|\s)watch(?:\s|$)/.test(command) || @@ -492,13 +436,33 @@ async function writeWatchStatus( await fs.rename(tmpPath, paths.statusPath); } +async function writeWatchOwner(paths: AutoSyncWatchPaths, record: WatchOwnerRecord): Promise { + await writeAtomicText(paths.ownerPath, `${JSON.stringify(record, null, 2)}\n`); +} + async function cleanupWatchFiles( paths: AutoSyncWatchPaths, - lockHandle?: fs.FileHandle, + ownerId: string, + releaseLock: () => Promise, ): Promise { - await lockHandle?.close().catch(() => {}); - await removeIfExists(paths.pidPath); - await removeIfExists(paths.lockPath); + try { + const owner = await readOwnerFile(paths.ownerPath); + if (owner?.ownerId === ownerId) { + if ((await readPid(paths.pidPath)) === owner.pid) await removeIfExists(paths.pidPath); + if ((await readOwnerFile(paths.ownerPath))?.ownerId === ownerId) { + await removeIfExists(paths.ownerPath); + } + } + } finally { + await releaseLock(); + } +} + +async function writeAtomicText(filePath: string, content: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; + await fs.writeFile(tmpPath, content, 'utf-8'); + await fs.rename(tmpPath, filePath); } async function removeIfExists(filePath: string): Promise { @@ -514,29 +478,33 @@ async function fileExists(filePath: string): Promise { function resolveWatchDeps(deps: Partial = {}): AutoSyncWatchControlDeps { return { - isProcessAlive: - deps.isProcessAlive ?? - ((pid) => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } - }), + isProcessAlive: deps.isProcessAlive ?? isProcessAlive, readProcessCommand: deps.readProcessCommand ?? ((pid) => { try { - const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); + const command = + process.platform === 'win32' + ? execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `(Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\").CommandLine`, + ], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim() + : execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); return command || undefined; } catch { return undefined; } }), + readProcessStartTime: deps.readProcessStartTime ?? readProcessStartTime, 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 f38fce3f9..6fa1892b4 100644 --- a/gitnexus/src/core/auto-sync/state.ts +++ b/gitnexus/src/core/auto-sync/state.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js'; import { getGlobalDir } from '../../storage/repo-manager.js'; export type AutoSyncAnalyzeStatus = 'success' | 'failed' | 'skipped' | 'threshold_skipped'; @@ -19,6 +20,10 @@ export function getAutoSyncWatchDir(gitnexusDir = getGlobalDir()): string { return path.join(gitnexusDir, 'watch'); } +export function getAutoSyncMutexPath(gitnexusDir = getGlobalDir()): string { + return path.join(getAutoSyncWatchDir(gitnexusDir), 'watch.mutex'); +} + export function getAutoSyncStatePath(gitnexusDir = getGlobalDir()): string { return path.join(getAutoSyncWatchDir(gitnexusDir), 'auto-sync-state.json'); } @@ -27,6 +32,26 @@ export function getProjectCommitInfoPath(gitnexusDir = getGlobalDir()): string { return path.join(getAutoSyncWatchDir(gitnexusDir), 'project_commit_info.txt'); } +export async function resetAutoSyncState(gitnexusDir = getGlobalDir()): Promise { + let releaseLock: () => Promise; + try { + releaseLock = await acquireFileLock(getAutoSyncMutexPath(gitnexusDir)); + } catch (error) { + if (error instanceof FileLockBusyError) return false; + throw error; + } + + try { + await Promise.all([ + fs.rm(getAutoSyncStatePath(gitnexusDir), { force: true }), + fs.rm(getProjectCommitInfoPath(gitnexusDir), { force: true }), + ]); + return true; + } finally { + await releaseLock(); + } +} + export function buildStateKey(repoPath: string, branch: string): string { return `${path.resolve(repoPath)}|${branch}`; } diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 51af63b03..0fc2ac01b 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -52,6 +52,7 @@ import { shadowSidecarRecoveryMessage, } from './sidecar-recovery.js'; import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js'; +import { isProcessAlive } from '../../utils/process-identity.js'; import { logger } from '../logger.js'; // --------------------------------------------------------------------------- @@ -305,20 +306,6 @@ const INIT_LOCK_RETRY_DELAY_MS = 500; const initLockPath = (dbPath: string): string => `${dbPath}.init.lock`; -/** - * Returns true when the process identified by `pid` is still running. - * Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) — - * it throws ESRCH when the process does not exist. - */ -const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; - /** * Try to break a stale lock whose owning process has exited. * Returns `true` if the stale lock was removed (caller should retry acquire). diff --git a/gitnexus/src/server/analyze-worker-core.ts b/gitnexus/src/server/analyze-worker-core.ts index 5c35d69a3..4f3924057 100644 --- a/gitnexus/src/server/analyze-worker-core.ts +++ b/gitnexus/src/server/analyze-worker-core.ts @@ -20,6 +20,7 @@ import { projectAnalyzeResultForIpc } from './analyze-worker-ipc.js'; export interface WorkerAnalysisDeps { runFullAnalysis: typeof import('../core/run-analyze.js').runFullAnalysis; assertAnalysisFinalized: typeof import('../storage/repo-manager.js').assertAnalysisFinalized; + acquireAnalysisLock: (repoPath: string) => Promise<() => Promise>; send: (msg: WorkerMessage) => void; /** * Claim the single terminal-outcome slot. Returns `true` for the first caller @@ -44,33 +45,38 @@ export async function runWorkerAnalysis( ): Promise { let terminal: WorkerMessage; try { - const bootstrapArgs: [] | [AnalyzerRunnerIdentity] = runnerIdentityAtBootstrap - ? [runnerIdentityAtBootstrap] - : []; - const result = await deps.runFullAnalysis( - repoPath, - // This worker force-exits right after reporting, so skip the native close - // (it can double-free in LadybugDB's ClientContext destructor after --pdg - // writes); flushWAL still persists the index, process.exit reclaims handles. - { ...options, skipNativeCloseOnExit: true }, - { - onProgress: (phase, percent, message) => - deps.send({ type: 'progress', phase, percent, message }), - onLog: (message) => deps.send({ type: 'progress', phase: 'log', percent: -1, message }), - }, - ...bootstrapArgs, - ); - // P2 (#2264): a half-finalized repo — meta.json written but the global - // registry entry missing (e.g. a prior collision-aborted run, or a wiped - // registry) — must NOT be reported as a successful analysis. Mirror the CLI's - // assertAnalysisFinalized guard so the worker surfaces it as an error instead - // of a false `complete` that leaves the repo invisible to list_repos. - await deps.assertAnalysisFinalized(repoPath); + const releaseAnalysisLock = await deps.acquireAnalysisLock(repoPath); + try { + const bootstrapArgs: [] | [AnalyzerRunnerIdentity] = runnerIdentityAtBootstrap + ? [runnerIdentityAtBootstrap] + : []; + const result = await deps.runFullAnalysis( + repoPath, + // This worker force-exits right after reporting, so skip the native close + // (it can double-free in LadybugDB's ClientContext destructor after --pdg + // writes); flushWAL still persists the index, process.exit reclaims handles. + { ...options, skipNativeCloseOnExit: true }, + { + onProgress: (phase, percent, message) => + deps.send({ type: 'progress', phase, percent, message }), + onLog: (message) => deps.send({ type: 'progress', phase: 'log', percent: -1, message }), + }, + ...bootstrapArgs, + ); + // P2 (#2264): a half-finalized repo — meta.json written but the global + // registry entry missing (e.g. a prior collision-aborted run, or a wiped + // registry) — must NOT be reported as a successful analysis. Mirror the CLI's + // assertAnalysisFinalized guard so the worker surfaces it as an error instead + // of a false `complete` that leaves the repo invisible to list_repos. + await deps.assertAnalysisFinalized(repoPath); - // Send a JSON-safe projection, NOT the raw result: the IPC channel is - // default-JSON serialization and `result.pipelineResult` carries the live - // KnowledgeGraph. See analyze-worker-ipc.ts. - terminal = { type: 'complete', result: projectAnalyzeResultForIpc(result) }; + // Send a JSON-safe projection, NOT the raw result: the IPC channel is + // default-JSON serialization and `result.pipelineResult` carries the live + // KnowledgeGraph. See analyze-worker-ipc.ts. + terminal = { type: 'complete', result: projectAnalyzeResultForIpc(result) }; + } finally { + await releaseAnalysisLock(); + } } catch (err: unknown) { // Report the failure to the parent over IPC (the parent surfaces the message). const message = err instanceof Error ? err.message : 'Analysis failed'; diff --git a/gitnexus/src/server/analyze-worker.ts b/gitnexus/src/server/analyze-worker.ts index 37ae1713b..1e0c89aae 100644 --- a/gitnexus/src/server/analyze-worker.ts +++ b/gitnexus/src/server/analyze-worker.ts @@ -11,6 +11,8 @@ * Child -> Parent: { type: 'error', message: string } */ +import path from 'path'; +import { createHash } from 'crypto'; import type { AnalyzeOptions } from '../core/run-analyze.js'; import { type AnalyzeResultIpc } from './analyze-worker-ipc.js'; import { runWorkerAnalysis, createTerminalClaim } from './analyze-worker-core.js'; @@ -123,12 +125,13 @@ process.on('message', async (msg: StartMessage) => { const prepared = await identityModule.captureAnalyzerIdentityBeforeLoad( import.meta.url, async () => { - const [analysisModule, repoManager, shutdownHelpers] = await Promise.all([ + const [analysisModule, repoManager, shutdownHelpers, fileLock] = await Promise.all([ import('../core/run-analyze.js'), import('../storage/repo-manager.js'), import('../core/lbug/shutdown-helpers.js'), + import('../storage/file-lock.js'), ]); - return { analysisModule, repoManager, shutdownHelpers }; + return { analysisModule, repoManager, shutdownHelpers, fileLock }; }, ); boundedCheckpointBeforeExit = prepared.loaded.shutdownHelpers.boundedCheckpointBeforeExit; @@ -142,6 +145,18 @@ process.on('message', async (msg: StartMessage) => { { runFullAnalysis: prepared.loaded.analysisModule.runFullAnalysis, assertAnalysisFinalized: prepared.loaded.repoManager.assertAnalysisFinalized, + acquireAnalysisLock: (repoPath) => { + const repoKey = createHash('sha256') + .update(prepared.loaded.repoManager.canonicalizePath(repoPath)) + .digest('hex'); + return prepared.loaded.fileLock.acquireFileLock( + path.join( + prepared.loaded.repoManager.getGlobalDir(), + 'locks', + `analyze-${repoKey}.lock`, + ), + ); + }, send, claimTerminal, }, diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 91df78f3c..c74b2d53f 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -9,6 +9,7 @@ import { spawn } from 'child_process'; import path from 'path'; import fs from 'fs/promises'; import { isIP } from 'net'; +import os from 'node:os'; import { logger } from '../core/logger.js'; import { getGlobalDir } from '../storage/repo-manager.js'; import { sanitizeRepoName } from '../storage/git.js'; @@ -416,27 +417,14 @@ export function normalizeGitUrlForCompare(url: string): string { * remote means for its threat model — for cloneOrPull, a missing remote * on an existing clone is treated as a refuse-to-pull condition. */ -export function getRemoteOriginUrl(cwd: string): Promise { - return new Promise((resolve) => { - const proc = spawn('git', ['config', '--get', 'remote.origin.url'], { - cwd, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, - }); - let stdout = ''; - proc.stdout.on('data', (chunk: Buffer) => { - stdout += chunk; - }); - proc.on('close', (code) => { - if (code === 0 && stdout.trim()) { - resolve(stdout.trim()); - } else { - resolve(null); - } - }); - proc.on('error', () => resolve(null)); - }); +export async function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Promise { + try { + const stdout = await runGit(['config', '--get', 'remote.origin.url'], cwd, { timeoutMs }); + return stdout.trim() || null; + } catch (error) { + if ((error as Error).message.includes('timed out')) throw error; + return null; + } } /** @@ -456,8 +444,9 @@ export function getRemoteOriginUrl(cwd: string): Promise { export async function assertRemoteMatchesRequestedUrl( targetDir: string, requestedUrl: string, + timeoutMs?: number, ): Promise { - const remoteUrl = await getRemoteOriginUrl(targetDir); + const remoteUrl = await getRemoteOriginUrl(targetDir, timeoutMs); if (remoteUrl === null) { throw new Error(`Existing clone at ${targetDir} has no remote.origin — refusing to pull`); } @@ -530,7 +519,7 @@ export async function cloneOrPull( if (options?.allowedCloneRoot) { await assertDirectoryOwnerAndPermissions(cloneRoot); } - await assertNoSymlinkPath(cloneRoot, safeTarget); + await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot)); await assertPreRealpathContainment(cloneRoot, safeTarget); const exists = await fs.access(path.join(safeTarget, '.git')).then( @@ -544,11 +533,14 @@ export async function cloneOrPull( ); if (exists) { + if (options?.allowedCloneRoot) { + await assertNoSymlinkPath(cloneRoot, path.join(safeTarget, '.git'), true); + } await assertPostRealpathContainment(cloneRoot, safeTarget); // Confirm the existing clone is actually the same repository the caller // requested. Without this check, a pull would silently succeed against // whatever remote the dir was originally cloned from. - await assertRemoteMatchesRequestedUrl(safeTarget, url); + await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs); onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' }); const runGitImpl = options?.runGitForTest ?? runGit; if (options?.branch) { @@ -602,11 +594,11 @@ export async function cloneOrPull( }); } } else { - if (targetExists) { + if (targetExists && (await fs.readdir(safeTarget)).length > 0) { throw new Error(`Clone target already exists but is not a git repository: ${safeTarget}`); } await fs.mkdir(path.dirname(safeTarget), { recursive: true }); - await assertNoSymlinkPath(cloneRoot, safeTarget); + await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot)); await assertPreRealpathContainment(cloneRoot, safeTarget); onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` }); try { @@ -622,12 +614,20 @@ export async function cloneOrPull( await assertPostRealpathContainment(cloneRoot, safeTarget); } catch (err: unknown) { if (options?.quarantineRoot) { - await fs - .access(safeTarget) - .then(async () => { - await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot!); - }) - .catch(() => {}); + const partialExists = await fs.access(safeTarget).then( + () => true, + () => false, + ); + if (partialExists) { + try { + await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot); + } catch (quarantineError) { + throw new AggregateError( + [err, quarantineError], + `Clone failed and partial checkout could not be quarantined: ${safeTarget}`, + ); + } + } } throw err; } @@ -654,7 +654,11 @@ async function assertPostRealpathContainment(root: string, target: string): Prom } } -async function assertNoSymlinkPath(root: string, target: string): Promise { +async function assertNoSymlinkPath( + root: string, + target: string, + verifyOwnership = false, +): Promise { const resolvedRoot = path.resolve(root); const resolvedTarget = path.resolve(target); const relativeTarget = path.relative(resolvedRoot, resolvedTarget); @@ -672,6 +676,7 @@ async function assertNoSymlinkPath(root: string, target: string): Promise if (stat.isSymbolicLink()) { throw new Error(`Refusing symlink in clone target path: ${current}`); } + if (verifyOwnership) await assertDirectoryOwnerAndPermissions(current); } } @@ -776,11 +781,10 @@ function warnIfCleartextCredential(url?: string): void { } /** - * Build the spawn env for `git`. Suppresses credential prompts and, when a - * credential resolves (see resolveGitCredential), injects a single - * host-scoped Authorization header via the `GIT_CONFIG_*` env protocol - * (git ≥2.31) so credentials never appear in argv or the URL. Appends after - * any existing `GIT_CONFIG_COUNT` rather than overwriting it. Exported for + * Build the spawn env for managed `git` commands. Suppresses credential + * prompts, disables repository hooks, and injects at most one host-scoped + * Authorization header via the `GIT_CONFIG_*` env protocol (git ≥2.31). + * Managed settings append after any existing GIT_CONFIG_COUNT. Exported for * unit tests. */ export function buildGitEnv( @@ -803,18 +807,21 @@ export function buildGitEnv( GIT_CURL_VERBOSE: undefined, }; + const existing = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10); + let next = Number.isInteger(existing) && existing > 0 ? existing : 0; + env[`GIT_CONFIG_KEY_${next}`] = 'core.hooksPath'; + env[`GIT_CONFIG_VALUE_${next}`] = os.devNull; + next += 1; + const credential = resolveGitCredential(options); const key = options?.url ? buildExtraHeaderKey(options.url) : undefined; if (credential && key) { - // Append after any GIT_CONFIG_* the operator already set, so we never - // clobber their git config (e.g. an enforced http.sslVerify). - const existing = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10); - const base = Number.isInteger(existing) && existing > 0 ? existing : 0; - env.GIT_CONFIG_COUNT = String(base + 1); - env[`GIT_CONFIG_KEY_${base}`] = key; - env[`GIT_CONFIG_VALUE_${base}`] = `Authorization: Basic ${credential}`; + env[`GIT_CONFIG_KEY_${next}`] = key; + env[`GIT_CONFIG_VALUE_${next}`] = `Authorization: Basic ${credential}`; + next += 1; warnIfCleartextCredential(options?.url); } + env.GIT_CONFIG_COUNT = String(next); return env; } @@ -824,7 +831,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 { +export function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise { return new Promise((resolve, reject) => { const spawnGit = options?.spawnForTest ?? spawn; const proc = spawnGit('git', args, { @@ -853,6 +860,9 @@ function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise< proc.kill('SIGTERM'); killTimer = setTimeout(() => { proc.kill('SIGKILL'); + finish(() => + reject(new Error(`git ${args[0]} timed out after ${options.timeoutMs}ms`)), + ); }, options.timeoutKillGraceMs ?? 1_000); }, options.timeoutMs) : undefined; diff --git a/gitnexus/src/storage/file-lock.ts b/gitnexus/src/storage/file-lock.ts new file mode 100644 index 000000000..2ed6a66cc --- /dev/null +++ b/gitnexus/src/storage/file-lock.ts @@ -0,0 +1,156 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { isProcessAlive, readProcessStartTime } from '../utils/process-identity.js'; + +export interface FileLockOptions { + retries?: number; + retryDelayMs?: number; + pid?: number; + processStartTime?: string; + isProcessAlive?: (pid: number) => boolean; + readProcessStartTime?: (pid: number) => string | undefined; +} + +interface FileLockOwner { + pid: number; + ownerId: string; + processStartTime: string; +} + +export class FileLockBusyError extends Error { + constructor(public readonly lockPath: string) { + super( + `Lock is already held: ${lockPath}. Confirm no owner process is active, then remove it manually.`, + ); + this.name = 'FileLockBusyError'; + } +} + +/** Acquire a recoverable cross-process mutex using an atomically published owner file. */ +export async function acquireFileLock( + lockPath: string, + options: FileLockOptions = {}, +): Promise<() => Promise> { + const resolvedPath = path.resolve(lockPath); + const retries = options.retries ?? 0; + const retryDelayMs = options.retryDelayMs ?? 50; + const pid = options.pid ?? process.pid; + const owner: FileLockOwner = { + pid, + ownerId: crypto.randomUUID(), + processStartTime: + options.processStartTime ?? (options.readProcessStartTime ?? readProcessStartTime)(pid) ?? '', + }; + if (!owner.processStartTime) { + throw new Error(`Unable to determine process start time for file lock owner pid ${owner.pid}.`); + } + + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + const pendingPath = `${resolvedPath}.pending-${owner.ownerId}`; + await fs.writeFile(pendingPath, `${JSON.stringify(owner)}\n`, { encoding: 'utf-8', flag: 'wx' }); + + try { + for (let attempt = 0; ; attempt += 1) { + try { + await fs.link(pendingPath, resolvedPath); + break; + } catch (error) { + if (!isLockConflict(error)) throw error; + if ( + await reclaimStaleLock( + resolvedPath, + options.isProcessAlive ?? isProcessAlive, + options.readProcessStartTime ?? readProcessStartTime, + ) + ) { + continue; + } + if (attempt >= retries) throw new FileLockBusyError(lockPath); + await sleep(retryDelayMs); + } + } + } finally { + await fs.rm(pendingPath, { force: true }); + } + + let releasePromise: Promise | undefined; + return () => (releasePromise ??= releaseOwnedLock(resolvedPath, owner.ownerId)); +} + +async function reclaimStaleLock( + lockPath: string, + ownerIsAlive: (pid: number) => boolean, + getProcessStartTime: (pid: number) => string | undefined, +): Promise { + const reclaimGuardPath = `${lockPath}.reclaim`; + try { + await fs.mkdir(reclaimGuardPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw error; + } + + try { + const owner = await readOwner(lockPath); + if (!owner) return false; + if (ownerIsAlive(owner.pid)) { + const currentStartTime = getProcessStartTime(owner.pid); + if (!currentStartTime || currentStartTime === owner.processStartTime) return false; + } + + await fs.rm(lockPath, { force: true }); + return true; + } finally { + await fs.rmdir(reclaimGuardPath); + } +} + +async function releaseOwnedLock(lockPath: string, ownerId: string): Promise { + const releasePath = `${lockPath}.release-${ownerId}-${crypto.randomUUID()}`; + if (!(await moveOwnedLock(lockPath, releasePath, ownerId))) return; + await fs.rm(releasePath, { force: true }); +} + +async function moveOwnedLock( + lockPath: string, + destinationPath: string, + ownerId: string, +): Promise { + if ((await readOwner(lockPath))?.ownerId !== ownerId) return false; + try { + await fs.rename(lockPath, destinationPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + + if ((await readOwner(destinationPath))?.ownerId === ownerId) return true; + await fs.rename(destinationPath, lockPath).catch(() => {}); + return false; +} + +async function readOwner(lockPath: string): Promise { + try { + const parsed = JSON.parse(await fs.readFile(lockPath, 'utf-8')) as Partial; + if ( + Number.isInteger(parsed.pid) && + Number(parsed.pid) > 0 && + typeof parsed.ownerId === 'string' && + parsed.ownerId && + typeof parsed.processStartTime === 'string' && + parsed.processStartTime + ) { + return parsed as FileLockOwner; + } + } catch { + // Invalid or legacy locks fail closed; only verified dead owners are reclaimed. + } + return undefined; +} + +function isLockConflict(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === 'EEXIST' || code === 'EPERM'; +} diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 568d288bd..987f8e46c 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -21,6 +21,7 @@ import os from 'os'; import { randomBytes } from 'crypto'; import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js'; import { retryRename } from './fs-atomic.js'; +import { acquireFileLock } from './file-lock.js'; import { logger } from '../core/logger.js'; import { branchSlug, @@ -947,9 +948,25 @@ const writeRegistry = async (entries: RegistryEntry[]): Promise => { // truncated/half-written registry.json that the next load would treat as // empty and silently drop every registered repo (#2106 R9). const target = getGlobalRegistryPath(); - const tmp = `${target}.tmp`; - await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf-8'); - await fs.rename(tmp, target); + const tmp = `${target}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`; + try { + await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf-8'); + await fs.rename(tmp, target); + } finally { + await fs.unlink(tmp).catch(() => {}); + } +}; + +const withRegistryLock = async (operation: () => Promise): Promise => { + const release = await acquireFileLock(`${getGlobalRegistryPath()}.lock`, { + retries: 400, + retryDelayMs: 25, + }); + try { + return await operation(); + } finally { + await release(); + } }; /** @@ -1070,7 +1087,7 @@ const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): bool * caller can re-use it to keep AGENTS.md / skill files aligned with the * MCP-visible repo name (#979). */ -export const registerRepo = async ( +const registerRepoUnlocked = async ( repoPath: string, meta: RepoMeta, opts?: RegisterRepoOptions, @@ -1237,21 +1254,28 @@ export const registerRepo = async ( return name; }; +export const registerRepo = async ( + repoPath: string, + meta: RepoMeta, + opts?: RegisterRepoOptions, +): Promise => withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts)); + /** * Remove a repo from the global registry. * Called after `gitnexus clean`. */ -export const unregisterRepo = async (repoPath: string): Promise => { - // Canonicalise BOTH sides so an unregister call issued with the - // symlink form (`/var/folders/.../repo`) still matches an entry - // written with the realpath form (`/private/var/folders/.../repo`), - // and vice versa. Matches the semantics of `registerRepo` and - // `resolveRegistryEntry` post-#1003 review. - const resolved = canonicalizePath(repoPath); - const entries = await readRegistry(); - const filtered = entries.filter((e) => !registryPathEquals(canonicalizePath(e.path), resolved)); - await writeRegistry(filtered); -}; +export const unregisterRepo = async (repoPath: string): Promise => + withRegistryLock(async () => { + // Canonicalise BOTH sides so an unregister call issued with the + // symlink form (`/var/folders/.../repo`) still matches an entry + // written with the realpath form (`/private/var/folders/.../repo`), + // and vice versa. Matches the semantics of `registerRepo` and + // `resolveRegistryEntry` post-#1003 review. + const resolved = canonicalizePath(repoPath); + const entries = await readRegistry(); + const filtered = entries.filter((e) => !registryPathEquals(canonicalizePath(e.path), resolved)); + await writeRegistry(filtered); + }); /** * Remove a single non-primary branch's summary from a repo's registry entry @@ -1261,22 +1285,23 @@ export const unregisterRepo = async (repoPath: string): Promise => { * primary entry is left intact; an empty `branches[]` is dropped to keep the * registry shape legacy-clean. */ -export const removeBranchIndex = async (repoPath: string, branch: string): Promise => { - const resolved = canonicalizePath(repoPath); - const entries = await readRegistry(); - const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved)); - if (idx < 0) return false; - const entry = entries[idx]; - const before = entry.branches?.length ?? 0; - if (!entry.branches || before === 0) return false; - const remaining = entry.branches.filter((b) => b.branch !== branch); - if (remaining.length === before) return false; // branch not recorded - if (remaining.length > 0) entry.branches = remaining; - else delete entry.branches; - entries[idx] = entry; - await writeRegistry(entries); - return true; -}; +export const removeBranchIndex = async (repoPath: string, branch: string): Promise => + withRegistryLock(async () => { + const resolved = canonicalizePath(repoPath); + const entries = await readRegistry(); + const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved)); + if (idx < 0) return false; + const entry = entries[idx]; + const before = entry.branches?.length ?? 0; + if (!entry.branches || before === 0) return false; + const remaining = entry.branches.filter((b) => b.branch !== branch); + if (remaining.length === before) return false; // branch not recorded + if (remaining.length > 0) entry.branches = remaining; + else delete entry.branches; + entries[idx] = entry; + await writeRegistry(entries); + return true; + }); /** * Record that the flat workspace slot now serves `branch` (#2354). @@ -1348,18 +1373,20 @@ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Pr // multi-writer whole-file overwrite, and writing a pre-rm snapshot would // silently clobber concurrent registerRepo/removeBranchIndex writers — // the #2106 R9 re-read-before-write discipline registerRepo follows. - const entries = await readRegistry(); - const idx = isRegistered(entries); - if (idx < 0) return; // unregistered concurrently → still a no-op - const entry = entries[idx]; - const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches; - const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0); - if (entry.branch === branch && !droppedSummary) return; // already coherent - entry.branch = branch; - if (remaining && remaining.length > 0) entry.branches = remaining; - else delete entry.branches; - entries[idx] = entry; - await writeRegistry(entries); + await withRegistryLock(async () => { + const entries = await readRegistry(); + const idx = isRegistered(entries); + if (idx < 0) return; // unregistered concurrently → still a no-op + const entry = entries[idx]; + const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches; + const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0); + if (entry.branch === branch && !droppedSummary) return; // already coherent + entry.branch = branch; + if (remaining && remaining.length > 0) entry.branches = remaining; + else delete entry.branches; + entries[idx] = entry; + await writeRegistry(entries); + }); }; /** @@ -1651,6 +1678,7 @@ export const listRegisteredRepos = async (opts?: { // Validate each entry still has a .gitnexus/ directory with metadata const valid: RegistryEntry[] = []; + const prunedPaths: string[] = []; for (const entry of entries) { // Named to avoid shadowing the exported `hasIndex` function above. let indexFound = false; @@ -1681,6 +1709,7 @@ export const listRegisteredRepos = async (opts?: { valid.push(entry); } else if (!firstNonMissingError && lastMissingError) { // Index genuinely removed — safe to prune + prunedPaths.push(canonicalizePath(entry.path)); } else { // Not provably absent — keep entry to prevent mass registry wipe. // Warn so an I/O storm becomes observable instead of silently @@ -1693,9 +1722,17 @@ export const listRegisteredRepos = async (opts?: { } } - // If we pruned any entries, save the cleaned registry - if (valid.length !== entries.length) { - await writeRegistry(valid); + // Re-apply only the confirmed removals to a fresh snapshot while holding + // the registry lock; concurrent registrations must survive validation cleanup. + if (prunedPaths.length > 0) { + await withRegistryLock(async () => { + const fresh = await readRegistry(); + const cleaned = fresh.filter( + (entry) => + !prunedPaths.some((pruned) => registryPathEquals(canonicalizePath(entry.path), pruned)), + ); + if (cleaned.length !== fresh.length) await writeRegistry(cleaned); + }); } return valid; diff --git a/gitnexus/src/utils/process-identity.ts b/gitnexus/src/utils/process-identity.ts new file mode 100644 index 000000000..90972f0d4 --- /dev/null +++ b/gitnexus/src/utils/process-identity.ts @@ -0,0 +1,34 @@ +import { execFileSync } from 'node:child_process'; + +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } +} + +export function readProcessStartTime(pid: number): string | undefined { + try { + const startedAt = + process.platform === 'win32' + ? execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("O") }`, + ], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim() + : execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return startedAt || undefined; + } catch { + return undefined; + } +} diff --git a/gitnexus/test/unit/analyze-worker-core.test.ts b/gitnexus/test/unit/analyze-worker-core.test.ts index f63ad08da..165ba3999 100644 --- a/gitnexus/test/unit/analyze-worker-core.test.ts +++ b/gitnexus/test/unit/analyze-worker-core.test.ts @@ -31,6 +31,7 @@ const baseResult: AnalyzeResult = { const okRun: WorkerAnalysisDeps['runFullAnalysis'] = vi.fn(async () => baseResult); const okFinalize: WorkerAnalysisDeps['assertAnalysisFinalized'] = vi.fn(async () => undefined); +const okLock: WorkerAnalysisDeps['acquireAnalysisLock'] = vi.fn(async () => async () => undefined); const alwaysClaim: WorkerAnalysisDeps['claimTerminal'] = () => true; describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => { @@ -48,6 +49,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => { { runFullAnalysis: okRun, assertAnalysisFinalized, + acquireAnalysisLock: okLock, send, claimTerminal: alwaysClaim, }, @@ -69,6 +71,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => { { runFullAnalysis: okRun, assertAnalysisFinalized: okFinalize, + acquireAnalysisLock: okLock, send, claimTerminal: alwaysClaim, }, @@ -89,6 +92,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => { { runFullAnalysis: run, assertAnalysisFinalized: okFinalize, + acquireAnalysisLock: okLock, send, claimTerminal: alwaysClaim, }, @@ -98,6 +102,31 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => { expect(run.mock.calls[0]?.[3]).toBe(receipt); }); + it('does not enter analysis when another worker holds the repo lock', async () => { + const send = vi.fn<(msg: WorkerMessage) => void>(); + const run = vi.fn(async () => baseResult); + + await runWorkerAnalysis( + '/repo', + {}, + { + runFullAnalysis: run, + assertAnalysisFinalized: okFinalize, + acquireAnalysisLock: vi.fn(async () => { + throw new Error('Lock is already held for /repo'); + }), + send, + claimTerminal: alwaysClaim, + }, + ); + + expect(run).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledWith({ + type: 'error', + message: 'Lock is already held for /repo', + }); + }); + it('reports error when finalization passes but the analysis itself throws', async () => { const send = vi.fn<(msg: WorkerMessage) => void>(); const failingRun: WorkerAnalysisDeps['runFullAnalysis'] = vi.fn(async () => { @@ -113,6 +142,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => { { runFullAnalysis: failingRun, assertAnalysisFinalized: finalize, + acquireAnalysisLock: okLock, send, claimTerminal: alwaysClaim, }, @@ -134,6 +164,7 @@ describe('runWorkerAnalysis — terminal-claim coordination (#2264 P3)', () => { { runFullAnalysis: okRun, assertAnalysisFinalized: okFinalize, + acquireAnalysisLock: okLock, send, claimTerminal: alreadyClaimed, }, diff --git a/gitnexus/test/unit/auto-sync-analysis-worker.test.ts b/gitnexus/test/unit/auto-sync-analysis-worker.test.ts index f6e06f869..2908bfc32 100644 --- a/gitnexus/test/unit/auto-sync-analysis-worker.test.ts +++ b/gitnexus/test/unit/auto-sync-analysis-worker.test.ts @@ -3,6 +3,52 @@ import { describe, expect, it, vi } from 'vitest'; import { createAutoSyncAnalysisRunner } from '../../src/core/auto-sync/analysis-worker-launch.js'; describe('auto-sync analysis worker', () => { + it('ignores progress and resolves from the terminal complete message', async () => { + const child = Object.assign(new EventEmitter(), { + send: vi.fn(), + kill: vi.fn(), + stdout: { resume: vi.fn() }, + stderr: { resume: vi.fn() }, + }); + const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) }); + + const result = run('/tmp/repo', { branch: 'main' }, 50); + child.emit('message', { type: 'progress', phase: 'parsing', progress: 20 }); + child.emit('message', { type: 'complete', result: { stats: { files: 3 } } }); + child.emit('exit', 0, null); + + await expect(result).resolves.toEqual({ stats: { files: 3 } }); + expect(child.stdout.resume).toHaveBeenCalled(); + expect(child.stderr.resume).toHaveBeenCalled(); + }); + + it('rejects immediately when the worker emits an error without exiting', async () => { + const child = Object.assign(new EventEmitter(), { + send: vi.fn(), + kill: vi.fn(), + }); + const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) }); + + const result = run('/tmp/repo', { branch: 'main' }, 50); + child.emit('error', new Error('spawn failed')); + + await expect(result).rejects.toThrow('Auto-sync analyze worker error: spawn failed'); + }); + + it('preserves a worker error after progress messages', async () => { + const child = Object.assign(new EventEmitter(), { + send: vi.fn(), + kill: vi.fn(), + }); + const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) }); + + const result = run('/tmp/repo', { branch: 'main' }, 50); + child.emit('message', { type: 'progress', phase: 'parsing', progress: 20 }); + child.emit('message', { type: 'error', message: 'parser crashed' }); + child.emit('exit', 1, null); + + await expect(result).rejects.toThrow('parser crashed'); + }); it('waits for timed-out worker exit before releasing the scheduled run', async () => { const child = Object.assign(new EventEmitter(), { send: vi.fn(), diff --git a/gitnexus/test/unit/auto-sync-runner.test.ts b/gitnexus/test/unit/auto-sync-runner.test.ts index 79501c3ed..8a924c877 100644 --- a/gitnexus/test/unit/auto-sync-runner.test.ts +++ b/gitnexus/test/unit/auto-sync-runner.test.ts @@ -43,6 +43,7 @@ const cloneRoot = { quarantineRetentionDays: 14, }; const verifiedWatchCommand = 'node /gitnexus/dist/cli/index.js watch'; +const verifiedProcessStartTime = 'Tue Aug 4 12:00:00 2026'; function withCloneRoot(deps: Partial): Partial { return { @@ -59,8 +60,12 @@ async function writeWatchOwner( await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); await fs.writeFile(paths.pidPath, `${pid}\n`); await fs.writeFile( - paths.lockPath, - `${JSON.stringify({ pid, ownerId, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, + paths.mutexPath, + `${JSON.stringify({ pid, ownerId: `mutex-${ownerId}`, processStartTime: verifiedProcessStartTime })}\n`, + ); + await fs.writeFile( + paths.ownerPath, + `${JSON.stringify({ pid, ownerId, processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, ); await fs.writeFile( paths.statusPath, @@ -77,13 +82,13 @@ async function writeWatchOwner( describe('auto-sync runner', () => { it('runs clone, analyzes changed commits, registers the repo, and syncs changed groups', async () => { const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({ - '/tmp/repos/qts_account|master': { + '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', lastAnalyzeStatus: 'success', @@ -106,7 +111,7 @@ describe('auto-sync runner', () => { expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 }); expect(deps.cloneOrPull).toHaveBeenCalledWith( 'git@gitee.com:qts_server/qts_account.git', - '/tmp/repos/qts_account', + '/tmp/repos/gitee.com/qts_server/qts_account', undefined, { allowedCloneRoot: '/tmp/repos', @@ -118,14 +123,18 @@ describe('auto-sync runner', () => { overwriteLocalChanges: false, }, ); - expect(deps.getCurrentBranch).toHaveBeenCalledWith('/tmp/repos/qts_account'); - expect(deps.runFullAnalysis).toHaveBeenCalledWith( - '/tmp/repos/qts_account', + expect(deps.getCurrentBranch).toHaveBeenCalledWith( + '/tmp/repos/gitee.com/qts_server/qts_account', + 10_000, + ); + expect(deps.runAnalysis).toHaveBeenCalledWith( + '/tmp/repos/gitee.com/qts_server/qts_account', { branch: 'master', skipAgentsMd: true, skipSkills: true }, - { onProgress: expect.any(Function) }, + 1_800_000, + undefined, ); expect(deps.registerRepo).toHaveBeenCalledWith( - '/tmp/repos/qts_account', + '/tmp/repos/gitee.com/qts_server/qts_account', expect.objectContaining({ lastCommit: 'commit-2', branch: 'master' }), { name: 'gitee.com/qts_server/qts_account' }, ); @@ -142,10 +151,10 @@ describe('auto-sync runner', () => { it('syncs a group when a repo is newly added to the group', async () => { const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -170,13 +179,13 @@ describe('auto-sync runner', () => { it('syncs a group after successful re-analysis even when membership already exists', async () => { const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-3'), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 2 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 2 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({ - '/tmp/repos/qts_account|master': { + '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-2', analyzedCommitId: 'commit-2', lastAnalyzeStatus: 'success', @@ -230,7 +239,7 @@ describe('auto-sync runner', () => { 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), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'service'), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -247,13 +256,13 @@ describe('auto-sync runner', () => { expect(deps.registerRepo).toHaveBeenNthCalledWith( 1, - '/tmp/repos-a/service', + '/tmp/repos-a/github.com/team-a/service', expect.anything(), { name: 'github.com/team-a/service' }, ); expect(deps.registerRepo).toHaveBeenNthCalledWith( 2, - '/tmp/repos-b/service', + '/tmp/repos-b/gitlab.com/team-b/service', expect.anything(), { name: 'gitlab.com/team-b/service' }, ); @@ -271,13 +280,13 @@ describe('auto-sync runner', () => { it('skips analysis when commit id has not changed', async () => { const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-1'), - runFullAnalysis: vi.fn(), + runAnalysis: vi.fn(), registerRepo: vi.fn(), loadState: vi.fn(async () => ({ - '/tmp/repos/qts_account|master': { + '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', lastAnalyzeStatus: 'success', @@ -298,18 +307,24 @@ describe('auto-sync runner', () => { expect(result.analyzed).toBe(0); expect(result.skippedAnalysis).toBe(1); - expect(deps.runFullAnalysis).not.toHaveBeenCalled(); + expect(deps.runAnalysis).not.toHaveBeenCalled(); expect(deps.syncGroupByName).not.toHaveBeenCalled(); }); - it('uses local_path plus repo name as the clone target', async () => { - expect(getConfiguredRepoPath(config.projects[0], 'qts_account')).toBe('/tmp/repos/qts_account'); + it('uses remote identity under local_path as the clone target', async () => { + expect( + getConfiguredRepoPath( + config.projects[0], + 'qts_account', + 'git@gitee.com:qts_server/qts_account.git', + ), + ).toBe('/tmp/repos/gitee.com/qts_server/qts_account'); const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -326,7 +341,7 @@ describe('auto-sync runner', () => { expect(deps.cloneOrPull).toHaveBeenCalledWith( 'git@gitee.com:qts_server/qts_account.git', - '/tmp/repos/qts_account', + '/tmp/repos/gitee.com/qts_server/qts_account', undefined, { allowedCloneRoot: '/tmp/repos', @@ -344,7 +359,7 @@ describe('auto-sync runner', () => { 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'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), runAnalysis, @@ -364,7 +379,7 @@ describe('auto-sync runner', () => { }); expect(runAnalysis).toHaveBeenCalledWith( - '/tmp/repos/qts_account', + '/tmp/repos/gitee.com/qts_server/qts_account', { branch: 'master', skipAgentsMd: true, skipSkills: true }, 1_800_000, controller.signal, @@ -381,11 +396,11 @@ describe('auto-sync runner', () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async (_remoteUrl, _targetDir, _progress, options) => { if (options?.branch === 'missing') throw new Error('remote branch not found'); - return '/tmp/repos/qts_account'; + return '/tmp/repos/gitee.com/qts_server/qts_account'; }), getCurrentBranch: vi.fn(() => 'develop'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -404,21 +419,22 @@ describe('auto-sync runner', () => { expect(deps.cloneOrPull).toHaveBeenNthCalledWith( 1, 'git@gitee.com:qts_server/qts_account.git', - '/tmp/repos/qts_account', + '/tmp/repos/gitee.com/qts_server/qts_account', undefined, expect.objectContaining({ branch: 'missing' }), ); expect(deps.cloneOrPull).toHaveBeenNthCalledWith( 2, 'git@gitee.com:qts_server/qts_account.git', - '/tmp/repos/qts_account', + '/tmp/repos/gitee.com/qts_server/qts_account', undefined, expect.objectContaining({ branch: 'develop' }), ); - expect(deps.runFullAnalysis).toHaveBeenCalledWith( - '/tmp/repos/qts_account', + expect(deps.runAnalysis).toHaveBeenCalledWith( + '/tmp/repos/gitee.com/qts_server/qts_account', { branch: 'develop', skipAgentsMd: true, skipSkills: true }, - { onProgress: expect.any(Function) }, + 1_800_000, + undefined, ); expect(warnLogger).toHaveBeenCalledWith( '[auto-sync] Branch missing unavailable for git@gitee.com:qts_server/qts_account.git: remote branch not found', @@ -439,7 +455,7 @@ describe('auto-sync runner', () => { }), getCurrentBranch: vi.fn(), getCurrentCommit: vi.fn(), - runFullAnalysis: vi.fn(), + runAnalysis: vi.fn(), registerRepo: vi.fn(), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -480,10 +496,10 @@ describe('auto-sync runner', () => { it('records branch_unavailable when checkout ends on an unexpected branch', async () => { const warnLogger = vi.fn(); const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'develop'), getCurrentCommit: vi.fn(), - runFullAnalysis: vi.fn(), + runAnalysis: vi.fn(), registerRepo: vi.fn(), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -500,7 +516,7 @@ describe('auto-sync runner', () => { expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 }); expect(deps.getCurrentCommit).not.toHaveBeenCalled(); - expect(deps.runFullAnalysis).not.toHaveBeenCalled(); + expect(deps.runAnalysis).not.toHaveBeenCalled(); expect(deps.addRepoToGroup).not.toHaveBeenCalled(); expect(warnLogger).toHaveBeenCalledWith( '[auto-sync] Branch master for git@gitee.com:qts_server/qts_account.git synced but current branch is develop; trying next branch.', @@ -510,10 +526,10 @@ describe('auto-sync runner', () => { it('records branch_unavailable when the checked out repository is detached', async () => { const warnLogger = vi.fn(); const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => undefined), getCurrentCommit: vi.fn(), - runFullAnalysis: vi.fn(), + runAnalysis: vi.fn(), registerRepo: vi.fn(), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -530,7 +546,7 @@ describe('auto-sync runner', () => { expect(result).toEqual({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 1 }); expect(deps.getCurrentCommit).not.toHaveBeenCalled(); - expect(deps.runFullAnalysis).not.toHaveBeenCalled(); + expect(deps.runAnalysis).not.toHaveBeenCalled(); expect(deps.addRepoToGroup).not.toHaveBeenCalled(); expect(warnLogger).toHaveBeenCalledWith( '[auto-sync] Branch master for git@gitee.com:qts_server/qts_account.git synced but current branch is ; trying next branch.', @@ -554,11 +570,11 @@ describe('auto-sync runner', () => { const deps: Partial = withCloneRoot({ cloneOrPull: vi.fn(async (remoteUrl) => { if (remoteUrl.includes('failing_sync')) throw new Error('sync failed'); - return '/tmp/repos/qts_account'; + return '/tmp/repos/gitee.com/qts_server/qts_account'; }), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => { + runAnalysis: vi.fn(async () => { throw new Error('analysis failed'); }), registerRepo: vi.fn(), @@ -587,7 +603,7 @@ describe('auto-sync runner', () => { expect(deps.syncGroupByName).not.toHaveBeenCalled(); expect(deps.saveState).toHaveBeenCalledWith( expect.objectContaining({ - '/tmp/repos/qts_account|master': expect.objectContaining({ + '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ codeCommitId: 'commit-2', lastAnalyzeStatus: 'failed', }), @@ -599,17 +615,94 @@ describe('auto-sync runner', () => { ), ); expect(errorLogger).toHaveBeenCalledWith( - expect.stringContaining('Analysis failed for /tmp/repos/qts_account'), + expect.stringContaining('Analysis failed for /tmp/repos/gitee.com/qts_server/qts_account'), + ); + }); + + it('isolates clone-root resolution failures to the affected project', async () => { + const isolatedConfig: AutoSyncConfig = { + ...config, + projects: [ + { ...config.projects[0], localPath: '/bad/repos' }, + { ...config.projects[0], localPath: '/tmp/repos' }, + ], + }; + const deps: Partial = withCloneRoot({ + resolveCloneRoot: vi.fn(async (localPath: string) => { + if (localPath === '/bad/repos') throw new Error('unsafe clone root'); + return cloneRoot; + }), + cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), + getCurrentBranch: vi.fn(() => 'master'), + getCurrentCommit: vi.fn(() => 'commit-2'), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + 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), + }); + + await expect( + runAutoSyncOnce(isolatedConfig, { + deps, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }), + ).resolves.toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 1 }); + expect(deps.cloneOrPull).toHaveBeenCalledTimes(1); + expect(deps.saveState).toHaveBeenCalledTimes(1); + expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1); + }); + + it('persists state and commit info when repository registration fails', async () => { + const errorLogger = vi.fn(); + const deps: Partial = withCloneRoot({ + cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), + getCurrentBranch: vi.fn(() => 'master'), + getCurrentCommit: vi.fn(() => 'commit-2'), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + registerRepo: vi.fn(async () => { + throw new Error('registry busy'); + }), + 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: errorLogger }, + 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({ + lastAnalyzeStatus: 'failed', + analyzedCommitId: undefined, + lastAnalyzeError: 'Repository registration failed: registry busy', + }), + }), + ); + expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1); + expect(errorLogger).toHaveBeenCalledWith( + '[auto-sync] Repository registration failed: registry busy', ); }); it('reports group sync failures after successful analysis', async () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -670,7 +763,7 @@ describe('auto-sync runner', () => { getCurrentCommit: vi.fn((repoPath) => repoPath.endsWith('/one') ? 'one-commit' : 'two-commit', ), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'repo'), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), @@ -696,19 +789,24 @@ describe('auto-sync runner', () => { expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1); }); - it('rejects duplicate resolved targetDir before clone work starts', async () => { + it('keeps same-basename remotes in distinct clone directories', async () => { const duplicateConfig: AutoSyncConfig = { ...config, maxConcurrency: 2, projects: [ { ...config.projects[0], + branches: ['main'], remoteUrls: ['git@github.com:owner/repo.git', 'git@gitlab.com:group/repo.git'], }, ], }; const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(), + cloneOrPull: vi.fn(async (_url, targetDir) => targetDir), + getCurrentBranch: vi.fn(() => 'main'), + getCurrentCommit: vi.fn(() => 'commit-2'), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + registerRepo: vi.fn(async (_path, _meta, options) => options?.name ?? 'repo'), loadState: vi.fn(async () => ({})), saveState: vi.fn(async () => {}), writeCommitInfo: vi.fn(async () => {}), @@ -722,11 +820,24 @@ describe('auto-sync runner', () => { deps, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, }), - ).rejects.toThrow('Duplicate auto-sync targetDir'); + ).resolves.toEqual({ synced: 2, analyzed: 2, skippedAnalysis: 0, failed: 0 }); - expect(deps.cloneOrPull).not.toHaveBeenCalled(); - expect(deps.saveState).not.toHaveBeenCalled(); - expect(deps.writeCommitInfo).not.toHaveBeenCalled(); + expect(deps.cloneOrPull).toHaveBeenNthCalledWith( + 1, + 'git@github.com:owner/repo.git', + '/tmp/repos/github.com/owner/repo', + undefined, + expect.any(Object), + ); + expect(deps.cloneOrPull).toHaveBeenNthCalledWith( + 2, + 'git@gitlab.com:group/repo.git', + '/tmp/repos/gitlab.com/group/repo', + undefined, + expect.any(Object), + ); + expect(deps.saveState).toHaveBeenCalledTimes(1); + expect(deps.writeCommitInfo).toHaveBeenCalledTimes(1); }); it('rejects non auto-sync SSH URLs at runner boundary', async () => { @@ -756,15 +867,15 @@ describe('auto-sync runner', () => { it('increments analyze failure count and writes threshold details', async () => { const errorLogger = vi.fn(); const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => { + runAnalysis: vi.fn(async () => { throw new Error('parser crashed\nwith stack'); }), registerRepo: vi.fn(), loadState: vi.fn(async () => ({ - '/tmp/repos/qts_account|master': { + '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', lastAnalyzeStatus: 'failed', @@ -789,7 +900,7 @@ describe('auto-sync runner', () => { expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 1 }); expect(deps.saveState).toHaveBeenCalledWith( expect.objectContaining({ - '/tmp/repos/qts_account|master': expect.objectContaining({ + '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ analyzeConsecutiveFailures: 1, lastAnalyzeError: 'parser crashed with stack', lastAnalyzeStatus: 'failed', @@ -805,20 +916,20 @@ describe('auto-sync runner', () => { }), ]); expect(errorLogger).toHaveBeenCalledWith( - '[auto-sync] Analysis failed for /tmp/repos/qts_account; consecutive failures 1/3: parser crashed with stack', + '[auto-sync] Analysis failed for /tmp/repos/gitee.com/qts_server/qts_account; consecutive failures 1/3: parser crashed with stack', ); }); 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'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({ - '/tmp/repos/qts_account|master': { + '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', lastAnalyzeStatus: 'failed', @@ -841,10 +952,10 @@ describe('auto-sync runner', () => { }); expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 }); - expect(deps.runFullAnalysis).toHaveBeenCalledTimes(1); + expect(deps.runAnalysis).toHaveBeenCalledTimes(1); expect(deps.saveState).toHaveBeenCalledWith( expect.objectContaining({ - '/tmp/repos/qts_account|master': expect.objectContaining({ + '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ analyzeConsecutiveFailures: 0, lastAnalyzeError: undefined, lastAnalyzeStatus: 'success', @@ -864,13 +975,13 @@ describe('auto-sync runner', () => { it('clears prior analyze failure count after a successful analyze', async () => { const deps: Partial = withCloneRoot({ - cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'), + cloneOrPull: vi.fn(async () => '/tmp/repos/gitee.com/qts_server/qts_account'), getCurrentBranch: vi.fn(() => 'master'), getCurrentCommit: vi.fn(() => 'commit-2'), - runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), + runAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any), registerRepo: vi.fn(async () => 'qts_account'), loadState: vi.fn(async () => ({ - '/tmp/repos/qts_account|master': { + '/tmp/repos/gitee.com/qts_server/qts_account|master': { codeCommitId: 'commit-1', analyzedCommitId: 'commit-1', lastAnalyzeStatus: 'failed', @@ -895,7 +1006,7 @@ describe('auto-sync runner', () => { expect(result.analyzed).toBe(1); expect(deps.saveState).toHaveBeenCalledWith( expect.objectContaining({ - '/tmp/repos/qts_account|master': expect.objectContaining({ + '/tmp/repos/gitee.com/qts_server/qts_account|master': expect.objectContaining({ analyzeConsecutiveFailures: 0, lastAnalyzeError: undefined, lastAnalyzeStatus: 'success', @@ -1086,7 +1197,7 @@ describe('auto-sync starter', () => { expect(cancelled).toHaveBeenCalledTimes(1); await expect(fs.access(paths.pidPath)).rejects.toThrow(); - await expect(fs.access(paths.lockPath)).rejects.toThrow(); + await expect(fs.access(paths.ownerPath)).rejects.toThrow(); } finally { if (previousHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = previousHome; @@ -1106,6 +1217,7 @@ describe('auto-sync starter', () => { deps: { isProcessAlive: vi.fn(() => true), readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), }, }); @@ -1118,16 +1230,14 @@ describe('auto-sync starter', () => { } }); - it('removes stale pid and lock before starting watch', async () => { + it('recovers an abandoned watch mutex after the owner exits', async () => { const previousHome = process.env.GITNEXUS_HOME; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); const paths = getAutoSyncWatchPaths(tempDir); - const timer = { unref: vi.fn() }; - const setIntervalFn = vi.fn(() => timer) as unknown as typeof setInterval; - const clearIntervalFn = vi.fn() as unknown as typeof clearInterval; + const stderr = { write: vi.fn() }; try { process.env.GITNEXUS_HOME = tempDir; - await writeWatchOwner(paths, 12345); + await writeWatchOwner(paths, 12345, 'abandoned-owner'); await fs.writeFile( path.join(tempDir, 'watch_config.yml'), [ @@ -1142,8 +1252,7 @@ describe('auto-sync starter', () => { const handle = await startAutoSyncWatch({ paths, - setIntervalFn, - clearIntervalFn, + stderr, runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })), keepAlive: false, deps: { isProcessAlive: vi.fn(() => false) }, @@ -1151,7 +1260,9 @@ describe('auto-sync starter', () => { expect(handle).not.toBeNull(); expect(await fs.readFile(paths.pidPath, 'utf-8')).toBe(`${process.pid}\n`); + expect(await fs.readFile(paths.ownerPath, 'utf-8')).not.toContain('abandoned-owner'); await handle?.stop(); + await expect(fs.access(paths.mutexPath)).rejects.toThrow(); } finally { if (previousHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = previousHome; @@ -1159,7 +1270,7 @@ describe('auto-sync starter', () => { } }); - it('does not delete a half-initialized lock when pid has not been written yet', async () => { + it('does not delete a half-initialized lease when pid has not been written yet', async () => { const previousHome = process.env.GITNEXUS_HOME; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); const paths = getAutoSyncWatchPaths(tempDir); @@ -1167,9 +1278,10 @@ describe('auto-sync starter', () => { try { process.env.GITNEXUS_HOME = tempDir; await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); + await fs.mkdir(paths.mutexPath); await fs.writeFile( - paths.lockPath, - `${JSON.stringify({ pid: 12345, ownerId: 'starting-owner', createdAt: '2026-06-30T00:00:00.000Z' })}\n`, + paths.ownerPath, + `${JSON.stringify({ pid: 12345, ownerId: 'starting-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, ); await fs.writeFile( path.join(tempDir, 'watch_config.yml'), @@ -1190,6 +1302,7 @@ describe('auto-sync starter', () => { deps: { isProcessAlive: vi.fn(() => true), readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), }, }); @@ -1197,7 +1310,8 @@ describe('auto-sync starter', () => { expect(stderr.write).toHaveBeenCalledWith( '[auto-sync] Watch is already running with pid 12345.\n', ); - expect(await fs.readFile(paths.lockPath, 'utf-8')).toContain('starting-owner'); + expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('starting-owner'); + await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); await expect(fs.access(paths.pidPath)).rejects.toThrow(); } finally { if (previousHome === undefined) delete process.env.GITNEXUS_HOME; @@ -1206,16 +1320,17 @@ describe('auto-sync starter', () => { } }); - it('does not delete a live half-initialized lock when stop runs before pid is written', async () => { + it('does not delete a live half-initialized lease when stop runs before pid is written', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); const paths = getAutoSyncWatchPaths(tempDir); const stderr = { write: vi.fn() }; const killProcess = vi.fn(); try { await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); + await fs.mkdir(paths.mutexPath); await fs.writeFile( - paths.lockPath, - `${JSON.stringify({ pid: 12345, ownerId: 'starting-owner', createdAt: '2026-06-30T00:00:00.000Z' })}\n`, + paths.ownerPath, + `${JSON.stringify({ pid: 12345, ownerId: 'starting-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, ); await expect( @@ -1224,31 +1339,29 @@ describe('auto-sync starter', () => { stderr, deps: { isProcessAlive: vi.fn(() => true), killProcess, sleep: vi.fn(async () => {}) }, }), - ).resolves.toBe(false); + ).resolves.toBe('refused'); expect(killProcess).not.toHaveBeenCalled(); - expect(await fs.readFile(paths.lockPath, 'utf-8')).toContain('starting-owner'); - await expect(fs.access(paths.pidPath)).rejects.toThrow(); - const status = JSON.parse(await fs.readFile(paths.statusPath, 'utf-8')); - expect(status).toMatchObject({ - state: 'error', - pid: 12345, - ownerId: 'starting-owner', - message: expect.stringContaining('appears to be starting'), - }); + expect(stderr.write).toHaveBeenCalledWith( + '[auto-sync] Watch appears to be starting with pid 12345; pid file is not ready.\n', + ); + expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('starting-owner'); + await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); + await expect(fs.access(paths.statusPath)).rejects.toThrow(); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); - it('removes a stale half-initialized lock when stop runs before pid is written', async () => { + it('does not delete a stale half-initialized lease from the stopper', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); const paths = getAutoSyncWatchPaths(tempDir); try { await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); + await fs.mkdir(paths.mutexPath); await fs.writeFile( - paths.lockPath, - `${JSON.stringify({ pid: 12345, ownerId: 'stale-owner', createdAt: '2026-06-30T00:00:00.000Z' })}\n`, + paths.ownerPath, + `${JSON.stringify({ pid: 12345, ownerId: 'stale-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, ); await expect( @@ -1261,22 +1374,31 @@ describe('auto-sync starter', () => { sleep: vi.fn(async () => {}), }, }), - ).resolves.toBe(false); + ).resolves.toBe('refused'); - await expect(fs.access(paths.lockPath)).rejects.toThrow(); - const status = JSON.parse(await fs.readFile(paths.statusPath, 'utf-8')); - expect(status).toMatchObject({ - state: 'stale', - pid: 12345, - ownerId: 'stale-owner', - message: 'removed stale lock without pid file', - }); + expect(await fs.readFile(paths.ownerPath, 'utf-8')).toContain('stale-owner'); + await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); + await expect(fs.access(paths.statusPath)).rejects.toThrow(); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); - it('reports status and sends stop signals from pid files', async () => { + it('reports not_running when no watch lease exists', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); + try { + await expect( + stopAutoSyncWatch({ + paths: getAutoSyncWatchPaths(tempDir), + stderr: { write: vi.fn() }, + }), + ).resolves.toBe('not_running'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('reports status and signals the verified owner without deleting its files', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); const paths = getAutoSyncWatchPaths(tempDir); const killProcess = vi.fn(); @@ -1288,6 +1410,7 @@ describe('auto-sync starter', () => { readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true), readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), }), ).resolves.toMatchObject({ state: 'running', pid: 12345 }); await expect( @@ -1298,6 +1421,7 @@ describe('auto-sync starter', () => { deps: { isProcessAlive: vi.fn(() => alive), readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), killProcess: vi.fn((pid, signal) => { killProcess(pid, signal); alive = false; @@ -1305,18 +1429,101 @@ describe('auto-sync starter', () => { sleep: vi.fn(async () => {}), }, }), - ).resolves.toBe(true); + ).resolves.toBe('stopped'); expect(killProcess).toHaveBeenCalledWith(12345, 'SIGTERM'); - await expect(fs.access(paths.pidPath)).rejects.toThrow(); - await expect(fs.access(paths.lockPath)).rejects.toThrow(); - await expect(readAutoSyncWatchStatus(paths)).resolves.toMatchObject({ state: 'stopped' }); + await expect(fs.readFile(paths.pidPath, 'utf-8')).resolves.toBe('12345\n'); + await expect(fs.access(paths.ownerPath)).resolves.toBeUndefined(); + await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); + expect(JSON.parse(await fs.readFile(paths.statusPath, 'utf-8'))).toMatchObject({ + state: 'running', + pid: 12345, + }); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } }); - it('does not mark stopped when stop times out waiting for the owner process', async () => { + it('does not delete or overwrite successor ownership after the old owner exits', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); + const paths = getAutoSyncWatchPaths(tempDir); + let oldOwnerAlive = true; + let handedOver = false; + try { + await writeWatchOwner(paths, 12345, 'old-owner'); + + await expect( + stopAutoSyncWatch({ + paths, + stderr: { write: vi.fn() }, + pollMs: 1, + deps: { + isProcessAlive: vi.fn((pid) => (pid === 12345 ? oldOwnerAlive : true)), + readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), + killProcess: vi.fn(), + sleep: vi.fn(async () => { + if (handedOver) return; + handedOver = true; + oldOwnerAlive = false; + await fs.writeFile(paths.pidPath, '54321\n'); + await fs.writeFile( + paths.ownerPath, + `${JSON.stringify({ pid: 54321, ownerId: 'successor', processStartTime: verifiedProcessStartTime, createdAt: '2026-08-04T00:00:00.000Z' })}\n`, + ); + await fs.writeFile( + paths.statusPath, + `${JSON.stringify({ state: 'running', pid: 54321, ownerId: 'successor', updatedAt: '2026-08-04T00:00:00.000Z' })}\n`, + ); + }), + }, + }), + ).resolves.toBe('stopped'); + + await expect(fs.readFile(paths.pidPath, 'utf-8')).resolves.toBe('54321\n'); + expect(JSON.parse(await fs.readFile(paths.ownerPath, 'utf-8'))).toMatchObject({ + pid: 54321, + ownerId: 'successor', + }); + expect(JSON.parse(await fs.readFile(paths.statusPath, 'utf-8'))).toMatchObject({ + state: 'running', + pid: 54321, + ownerId: 'successor', + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('refuses to signal when the process command is unavailable', 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() }, + pollMs: 1, + deps: { + isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => undefined), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), + killProcess, + sleep: vi.fn(async () => {}), + }, + }), + ).resolves.toBe('refused'); + expect(killProcess).not.toHaveBeenCalled(); + await expect(fs.readFile(paths.pidPath, 'utf-8')).resolves.toBe('12345\n'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('does not change owner status when stop times out', async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); const paths = getAutoSyncWatchPaths(tempDir); try { @@ -1331,23 +1538,23 @@ describe('auto-sync starter', () => { deps: { isProcessAlive: vi.fn(() => true), readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), killProcess: vi.fn(), sleep: vi.fn(async () => {}), }, }), - ).resolves.toBe(false); + ).resolves.toBe('timeout'); await expect( readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true), readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), }), - ).resolves.toMatchObject({ - state: 'stopping', - pid: 12345, - message: expect.stringContaining('did not exit'), - }); + ).resolves.toMatchObject({ state: 'running', pid: 12345 }); await expect(fs.readFile(paths.pidPath, 'utf-8')).resolves.toBe('12345\n'); + await expect(fs.access(paths.ownerPath)).resolves.toBeUndefined(); + await expect(fs.access(paths.mutexPath)).resolves.toBeUndefined(); } finally { await fs.rm(tempDir, { recursive: true, force: true }); } @@ -1361,8 +1568,8 @@ describe('auto-sync starter', () => { await fs.mkdir(path.dirname(paths.pidPath), { recursive: true }); await fs.writeFile(paths.pidPath, '12345\n'); await fs.writeFile( - paths.lockPath, - `${JSON.stringify({ pid: 12345, ownerId: 'lock-owner', createdAt: '2026-06-30T00:00:00.000Z' })}\n`, + paths.ownerPath, + `${JSON.stringify({ pid: 12345, ownerId: 'lock-owner', processStartTime: verifiedProcessStartTime, createdAt: '2026-06-30T00:00:00.000Z' })}\n`, ); await fs.writeFile( paths.statusPath, @@ -1375,7 +1582,7 @@ describe('auto-sync starter', () => { stderr: { write: vi.fn() }, deps: { isProcessAlive: vi.fn(() => true), killProcess, sleep: vi.fn(async () => {}) }, }), - ).resolves.toBe(false); + ).resolves.toBe('refused'); expect(killProcess).not.toHaveBeenCalled(); await expect( @@ -1390,6 +1597,44 @@ describe('auto-sync starter', () => { } }); + it('refuses to signal a reused pid even when it is another 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(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => 'Tue Aug 4 13:00:00 2026'), + killProcess, + sleep: vi.fn(async () => {}), + }, + }), + ).resolves.toBe('refused'); + + expect(killProcess).not.toHaveBeenCalled(); + await expect( + readAutoSyncWatchStatus(paths, { + isProcessAlive: vi.fn(() => true), + readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => 'Tue Aug 4 13:00:00 2026'), + }), + ).resolves.toMatchObject({ + state: 'error', + pid: 12345, + message: expect.stringContaining('different process'), + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + 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); @@ -1404,17 +1649,19 @@ describe('auto-sync starter', () => { deps: { isProcessAlive: vi.fn(() => true), readProcessCommand: vi.fn(() => 'node unrelated-service.js'), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), killProcess, sleep: vi.fn(async () => {}), }, }), - ).resolves.toBe(false); + ).resolves.toBe('refused'); expect(killProcess).not.toHaveBeenCalled(); await expect( readAutoSyncWatchStatus(paths, { isProcessAlive: vi.fn(() => true), readProcessCommand: vi.fn(() => 'node unrelated-service.js'), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), }), ).resolves.toMatchObject({ state: 'error', @@ -1426,17 +1673,23 @@ describe('auto-sync starter', () => { } }); - it('restart can start only after stop confirms pid and lock cleanup', async () => { + it('restart starts only after the owner releases its mutex', async () => { const previousHome = process.env.GITNEXUS_HOME; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-watch-')); const paths = getAutoSyncWatchPaths(tempDir); const timer = { unref: vi.fn() }; const setIntervalFn = vi.fn(() => timer) as unknown as typeof setInterval; const clearIntervalFn = vi.fn() as unknown as typeof clearInterval; + const runOnce = vi.fn(async () => ({ + synced: 0, + analyzed: 0, + skippedAnalysis: 0, + failed: 0, + })); let alive = true; + let ownerStop: Promise | undefined; try { process.env.GITNEXUS_HOME = tempDir; - await writeWatchOwner(paths, 12345); await fs.writeFile( path.join(tempDir, 'watch_config.yml'), [ @@ -1448,6 +1701,15 @@ describe('auto-sync starter', () => { ' - git@github.com:team/repo.git', ].join('\n'), ); + const ownerHandle = await startAutoSyncWatch({ + paths, + setIntervalFn, + clearIntervalFn, + runOnce, + keepAlive: false, + deps: { readProcessStartTime: vi.fn(() => verifiedProcessStartTime) }, + }); + expect(ownerHandle).not.toBeNull(); await expect( stopAutoSyncWatch({ @@ -1458,26 +1720,31 @@ describe('auto-sync starter', () => { deps: { isProcessAlive: vi.fn(() => alive), readProcessCommand: vi.fn(() => verifiedWatchCommand), + readProcessStartTime: vi.fn(() => verifiedProcessStartTime), killProcess: vi.fn(() => { - alive = false; + ownerStop = ownerHandle!.stop().then(() => { + alive = false; + }); + }), + sleep: vi.fn(async () => { + await ownerStop; }), - sleep: vi.fn(async () => {}), }, }), - ).resolves.toBe(true); + ).resolves.toBe('stopped'); await expect(fs.access(paths.pidPath)).rejects.toThrow(); - await expect(fs.access(paths.lockPath)).rejects.toThrow(); + await expect(fs.access(paths.ownerPath)).rejects.toThrow(); + await expect(fs.access(paths.mutexPath)).rejects.toThrow(); - const handle = await startAutoSyncWatch({ + const successorHandle = await startAutoSyncWatch({ paths, setIntervalFn, clearIntervalFn, - runOnce: vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 })), + runOnce, keepAlive: false, - deps: { isProcessAlive: vi.fn(() => false) }, }); - expect(handle).not.toBeNull(); - await handle?.stop(); + expect(successorHandle).not.toBeNull(); + await successorHandle?.stop(); } finally { if (previousHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = previousHome; diff --git a/gitnexus/test/unit/auto-sync.test.ts b/gitnexus/test/unit/auto-sync.test.ts index cca2365e7..940f23871 100644 --- a/gitnexus/test/unit/auto-sync.test.ts +++ b/gitnexus/test/unit/auto-sync.test.ts @@ -5,20 +5,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { extractRepoNameFromRemoteUrl, + getAutoSyncMutexPath, getAutoSyncStatePath, getAutoSyncWatchDir, getProjectCommitInfoPath, loadAutoSyncConfig, + parseAutoSyncConfig, parseBranchCandidates, parseDurationMs, + quarantineAutoSyncPartial, resolveConfiguredCloneRoot, loadAutoSyncState, + resetAutoSyncState, saveAutoSyncState, shouldAnalyzeCommit, validateAutoSyncRemoteUrl, validateAutoSyncBranchName, writeProjectCommitInfo, } from '../../src/core/auto-sync/index.js'; +import { acquireFileLock } from '../../src/storage/file-lock.js'; describe('auto-sync', () => { let tempDir: string; @@ -44,6 +49,9 @@ describe('auto-sync', () => { it('places watch runtime artifacts under the watch directory by default', () => { expect(getAutoSyncWatchDir(gitnexusHome)).toBe(path.join(gitnexusHome, 'watch')); + expect(getAutoSyncMutexPath(gitnexusHome)).toBe( + path.join(gitnexusHome, 'watch', 'watch.mutex'), + ); expect(getAutoSyncStatePath(gitnexusHome)).toBe( path.join(gitnexusHome, 'watch', 'auto-sync-state.json'), ); @@ -52,6 +60,38 @@ describe('auto-sync', () => { ); }); + it('refuses to reset state while the watch mutex is held', async () => { + const statePath = getAutoSyncStatePath(gitnexusHome); + const infoPath = getProjectCommitInfoPath(gitnexusHome); + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile(statePath, '{"kept":true}\n'); + await fs.writeFile(infoPath, 'kept\n'); + const release = await acquireFileLock(getAutoSyncMutexPath(gitnexusHome)); + + try { + await expect(resetAutoSyncState(gitnexusHome)).resolves.toBe(false); + await expect(fs.readFile(statePath, 'utf-8')).resolves.toContain('kept'); + await expect(fs.readFile(infoPath, 'utf-8')).resolves.toBe('kept\n'); + } finally { + await release(); + } + }); + + it('resets derived state while holding the watch mutex', async () => { + const statePath = getAutoSyncStatePath(gitnexusHome); + const infoPath = getProjectCommitInfoPath(gitnexusHome); + const mutexPath = getAutoSyncMutexPath(gitnexusHome); + await fs.mkdir(path.dirname(statePath), { recursive: true }); + await fs.writeFile(statePath, '{}\n'); + await fs.writeFile(infoPath, 'derived\n'); + + await expect(resetAutoSyncState(gitnexusHome)).resolves.toBe(true); + + await expect(fs.access(statePath)).rejects.toThrow(); + await expect(fs.access(infoPath)).rejects.toThrow(); + await expect(fs.access(mutexPath)).rejects.toThrow(); + }); + it('loads watch_config.yml from GITNEXUS_HOME and normalizes branch candidates', async () => { await fs.writeFile( path.join(gitnexusHome, 'watch_config.yml'), @@ -116,6 +156,23 @@ describe('auto-sync', () => { expect(loaded.config.projects[0].overwriteLocalChanges).toBe(false); }); + it('rejects repo_git_timeout values above the Node timer limit', () => { + expect(() => + parseAutoSyncConfig( + [ + 'sync_interval_minutes: 10', + 'repo_git_timeout: 2147483648ms', + 'projects:', + ' - local_path: /tmp/repos', + ' branch: main', + ' remote_urls:', + ' - https://github.com/owner/repo.git', + ].join('\n'), + '/tmp/watch_config.yml', + ), + ).toThrow('repo_git_timeout must not exceed 2147483647ms'); + }); + it('rejects analyze_timeout values above half the sync interval', async () => { await fs.writeFile( path.join(gitnexusHome, 'watch_config.yml'), @@ -258,6 +315,60 @@ describe('auto-sync', () => { ); }); + it('removes expired quarantine entries while preserving recent and unrelated files', async () => { + const root = path.join(tempDir, 'repos'); + const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine'); + const expired = path.join(quarantineRoot, 'auto-sync-expired-repo'); + const recent = path.join(quarantineRoot, 'auto-sync-recent-repo'); + const unrelated = path.join(quarantineRoot, 'operator-note.txt'); + await fs.mkdir(expired, { recursive: true }); + await fs.mkdir(recent); + await fs.writeFile(unrelated, 'keep'); + const old = new Date(Date.now() - 15 * 24 * 60 * 60 * 1_000); + await fs.utimes(expired, old, old); + + await resolveConfiguredCloneRoot(root); + + await expect(fs.access(expired)).rejects.toThrow(); + await expect(fs.access(recent)).resolves.toBeUndefined(); + await expect(fs.readFile(unrelated, 'utf-8')).resolves.toBe('keep'); + }); + + it('falls back to copy and remove when quarantine crosses filesystems', async () => { + const target = path.join(tempDir, 'partial-repo'); + const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine'); + await fs.mkdir(target); + await fs.writeFile(path.join(target, 'partial.txt'), 'partial'); + vi.spyOn(fs, 'rename').mockRejectedValueOnce( + Object.assign(new Error('cross-device link'), { code: 'EXDEV' }), + ); + + const destination = await quarantineAutoSyncPartial(target, quarantineRoot); + + await expect(fs.readFile(path.join(destination, 'partial.txt'), 'utf-8')).resolves.toBe( + 'partial', + ); + await expect(fs.access(target)).rejects.toThrow(); + }); + + it('rejects group-writable configured clone roots', async () => { + if (process.platform === 'win32') return; + const root = path.join(tempDir, 'group-writable-repos'); + await fs.mkdir(root, { mode: 0o770 }); + await fs.chmod(root, 0o770); + + await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('group-writable'); + }); + + it('rejects sticky world-writable configured clone roots', async () => { + if (process.platform === 'win32') return; + const root = path.join(tempDir, 'sticky-world-writable-repos'); + await fs.mkdir(root); + await fs.chmod(root, 0o1777); + + await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('world-writable'); + }); + it('creates missing configured clone roots before watch clone work', async () => { const root = path.join(tempDir, 'missing-repos'); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 97ef63567..500db326e 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command, Option } from 'commander'; @@ -251,14 +252,14 @@ describe('CLI help surface', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('gitnexus watch [options] [action]'); - expect(result.stdout).toContain('Actions: init, start (default), restart, stop, status'); + expect(result.stdout).toContain('Actions: init, start (default), restart, stop, status, reset'); expect(result.stdout).toContain('GITNEXUS_HOME/watch_config.yml'); expect(result.stdout).toContain('GITNEXUS_HOME/watch/watch.pid'); expect(result.stdout).toContain('GITNEXUS_HOME/watch/project_commit_info.txt'); }); it('watch init creates the default watch_config.yml and does not overwrite it', () => { - const home = fs.mkdtempSync(path.join(repoRoot, '.tmp-test/gitnexus-watch-init-')); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-init-')); try { const first = runCliArgs(['watch', 'init'], { GITNEXUS_HOME: home }); const configPath = path.join(home, 'watch_config.yml'); @@ -270,9 +271,10 @@ describe('CLI help surface', () => { 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).toContain(`local_path: ${path.join(home, 'repos')}`); expect(config).not.toContain('/abs/path/to/repos'); expect(config).toContain('git@github.com:owner/repo.git'); + expect(config).not.toContain('group_name:'); const second = runCliArgs(['watch', 'init'], { GITNEXUS_HOME: home }); @@ -284,6 +286,52 @@ describe('CLI help surface', () => { } }); + it('watch reset removes only derived auto-sync state files', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-reset-')); + const watchDir = path.join(home, 'watch'); + const cloneMarker = path.join(home, 'repos', 'repo', 'keep.txt'); + try { + fs.mkdirSync(path.dirname(cloneMarker), { recursive: true }); + fs.writeFileSync(cloneMarker, 'keep'); + fs.mkdirSync(watchDir, { recursive: true }); + fs.writeFileSync(path.join(watchDir, 'auto-sync-state.json'), '{}'); + fs.writeFileSync(path.join(watchDir, 'project_commit_info.txt'), 'derived'); + + const result = runCliArgs(['watch', 'reset'], { GITNEXUS_HOME: home }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Reset analysis state'); + expect(fs.existsSync(path.join(watchDir, 'auto-sync-state.json'))).toBe(false); + expect(fs.existsSync(path.join(watchDir, 'project_commit_info.txt'))).toBe(false); + expect(fs.readFileSync(cloneMarker, 'utf8')).toBe('keep'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('watch stop exits non-zero when no watch was stopped', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-stop-')); + try { + const result = runCliArgs(['watch', 'stop'], { GITNEXUS_HOME: home }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Watch is not running'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + it('watch restart starts when the watch is not running', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-restart-')); + try { + const result = runCliArgs(['watch', 'restart'], { GITNEXUS_HOME: home }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Watch is not running'); + expect(result.stderr).toContain('Missing config file'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + it('wiki help shows provider, review, and verbose flags', () => { const result = runHelp('wiki'); diff --git a/gitnexus/test/unit/file-lock.test.ts b/gitnexus/test/unit/file-lock.test.ts new file mode 100644 index 000000000..b02704b10 --- /dev/null +++ b/gitnexus/test/unit/file-lock.test.ts @@ -0,0 +1,154 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { acquireFileLock, FileLockBusyError } from '../../src/storage/file-lock.js'; + +const tempDirs: string[] = []; + +async function tempLockPath(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-file-lock-')); + tempDirs.push(dir); + return path.join(dir, 'locks', 'test.mutex'); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('file lock', () => { + it('rejects a second holder for the same path', async () => { + const lockPath = await tempLockPath(); + const release = await acquireFileLock(lockPath); + + await expect(acquireFileLock(lockPath)).rejects.toBeInstanceOf(FileLockBusyError); + + await release(); + }); + + it('releases idempotently', async () => { + const lockPath = await tempLockPath(); + const release = await acquireFileLock(lockPath); + + await release(); + await expect(release()).resolves.toBeUndefined(); + const nextRelease = await acquireFileLock(lockPath); + await nextRelease(); + }); + + it('reclaims a lock whose owner process exited', async () => { + const lockPath = await tempLockPath(); + const oldRelease = await acquireFileLock(lockPath, { + pid: 111, + processStartTime: 'old-start', + }); + + const nextRelease = await acquireFileLock(lockPath, { + pid: 222, + processStartTime: 'new-start', + isProcessAlive: () => false, + }); + + await oldRelease(); + await expect( + acquireFileLock(lockPath, { + isProcessAlive: (pid) => pid === 222, + readProcessStartTime: () => 'new-start', + }), + ).rejects.toBeInstanceOf(FileLockBusyError); + await nextRelease(); + }); + + it('reclaims a reused pid only when its start time differs', async () => { + const lockPath = await tempLockPath(); + await acquireFileLock(lockPath, { pid: 111, processStartTime: 'old-start' }); + + const nextRelease = await acquireFileLock(lockPath, { + pid: 222, + processStartTime: 'next-start', + isProcessAlive: () => true, + readProcessStartTime: () => 'reused-pid-start', + }); + + await nextRelease(); + }); + + it('fails closed for a legacy or invalid lock without owner metadata', async () => { + const lockPath = await tempLockPath(); + await fs.mkdir(lockPath, { recursive: true }); + + await expect(acquireFileLock(lockPath)).rejects.toBeInstanceOf(FileLockBusyError); + await expect(fs.access(lockPath)).resolves.toBeUndefined(); + }); + + it('waits for the current holder when retries are configured', async () => { + const lockPath = await tempLockPath(); + const release = await acquireFileLock(lockPath); + const next = acquireFileLock(lockPath, { retries: 20, retryDelayMs: 5 }); + + await sleep(10); + await release(); + const nextRelease = await next; + + await nextRelease(); + }); + + it('fails closed while another stale-lock recovery is in progress', async () => { + const lockPath = await tempLockPath(); + const oldRelease = await acquireFileLock(lockPath, { + pid: 999, + processStartTime: 'abandoned', + }); + const reclaimGuardPath = `${lockPath}.reclaim`; + await fs.mkdir(reclaimGuardPath); + + await expect( + acquireFileLock(lockPath, { + pid: 1000, + processStartTime: 'next', + isProcessAlive: () => false, + }), + ).rejects.toBeInstanceOf(FileLockBusyError); + await expect(fs.access(lockPath)).resolves.toBeUndefined(); + + await fs.rmdir(reclaimGuardPath); + const nextRelease = await acquireFileLock(lockPath, { + pid: 1000, + processStartTime: 'next', + isProcessAlive: () => false, + }); + await oldRelease(); + await nextRelease(); + }); + + it('allows only one contender to recover an abandoned lock', async () => { + const lockPath = await tempLockPath(); + await acquireFileLock(lockPath, { pid: 999, processStartTime: 'abandoned' }); + const starts = new Map( + Array.from({ length: 8 }, (_, index) => [1000 + index, `start-${index}`]), + ); + + const results = await Promise.allSettled( + [...starts].map(([pid, processStartTime]) => + acquireFileLock(lockPath, { + pid, + processStartTime, + isProcessAlive: (ownerPid) => ownerPid !== 999, + readProcessStartTime: (ownerPid) => starts.get(ownerPid), + }), + ), + ); + + const acquired = results.filter( + (result): result is PromiseFulfilledResult<() => Promise> => + result.status === 'fulfilled', + ); + expect(acquired).toHaveLength(1); + for (const result of results) { + if (result.status === 'rejected') expect(result.reason).toBeInstanceOf(FileLockBusyError); + } + await acquired[0].value(); + }); +}); diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 1b57b8347..ac3d3b396 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -404,7 +404,7 @@ describe('git-clone', () => { }); }); - describe('buildGitEnv — token injection', () => { + describe('buildGitEnv — managed git environment', () => { // The token MUST travel via GIT_CONFIG_* env vars (git ≥2.31), not via // argv or URL. This keeps it out of `ps`, shell history, and stderr. @@ -428,33 +428,33 @@ describe('git-clone', () => { expect(env.GIT_CURL_VERBOSE).toBeUndefined(); }); - it('does not set GIT_CONFIG_* env vars when no token is provided', () => { + it('disables repository hooks even when no token is provided', () => { const env = buildGitEnv({}); - expect(env.GIT_CONFIG_COUNT).toBeUndefined(); - expect(env.GIT_CONFIG_KEY_0).toBeUndefined(); - expect(env.GIT_CONFIG_VALUE_0).toBeUndefined(); + expect(env.GIT_CONFIG_COUNT).toBe('1'); + expect(env.GIT_CONFIG_KEY_0).toBe('core.hooksPath'); + expect(env.GIT_CONFIG_VALUE_0).toBe(os.devNull); }); - it('also leaves GIT_CONFIG_* unset when token is empty string', () => { + it('only disables repository hooks when token is empty string', () => { const env = buildGitEnv({}, { token: '' }); - expect(env.GIT_CONFIG_COUNT).toBeUndefined(); - expect(env.GIT_CONFIG_KEY_0).toBeUndefined(); - expect(env.GIT_CONFIG_VALUE_0).toBeUndefined(); + expect(env.GIT_CONFIG_COUNT).toBe('1'); + expect(env.GIT_CONFIG_KEY_0).toBe('core.hooksPath'); + expect(env.GIT_CONFIG_VALUE_0).toBe(os.devNull); }); it('injects a host-scoped Basic-auth header when a github.com token is provided', () => { const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/owner/repo' }); - expect(env.GIT_CONFIG_COUNT).toBe('1'); + expect(env.GIT_CONFIG_COUNT).toBe('2'); // Host-scoped key: the header attaches only to this origin's requests. - expect(env.GIT_CONFIG_KEY_0).toBe('http.https://github.com/owner/repo.extraHeader'); + expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/owner/repo.extraHeader'); const expected = 'Authorization: Basic ' + Buffer.from('x-access-token:ghp_secret123').toString('base64'); - expect(env.GIT_CONFIG_VALUE_0).toBe(expected); + expect(env.GIT_CONFIG_VALUE_1).toBe(expected); }); it('does not inject a token for a non-github host (defense-in-depth host bind)', () => { const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://gitlab.com/owner/repo' }); - expect(env.GIT_CONFIG_COUNT).toBeUndefined(); + expect(env.GIT_CONFIG_COUNT).toBe('1'); }); it('never includes the raw token value in any env entry', () => { @@ -463,7 +463,7 @@ describe('git-clone', () => { const token = 'ghp_uniqueRawSecret_98765'; const env = buildGitEnv({ EXISTING: 'value' }, { token, url: 'https://github.com/o/r' }); for (const [key, value] of Object.entries(env)) { - if (key === 'GIT_CONFIG_VALUE_0') continue; + if (key === 'GIT_CONFIG_VALUE_1') continue; expect(String(value)).not.toContain(token); } }); @@ -473,12 +473,12 @@ describe('git-clone', () => { process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz'; try { const env = buildGitEnv({}, { url: 'https://dev.azure.com/org/proj/_git/repo' }); - expect(env.GIT_CONFIG_COUNT).toBe('1'); - expect(env.GIT_CONFIG_KEY_0).toBe( + expect(env.GIT_CONFIG_COUNT).toBe('2'); + expect(env.GIT_CONFIG_KEY_1).toBe( 'http.https://dev.azure.com/org/proj/_git/repo.extraHeader', ); const expected = 'Authorization: Basic ' + Buffer.from(':azure-pat-xyz').toString('base64'); - expect(env.GIT_CONFIG_VALUE_0).toBe(expected); + expect(env.GIT_CONFIG_VALUE_1).toBe(expected); } finally { if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT; else process.env.AZURE_DEVOPS_PAT = prev; @@ -492,8 +492,8 @@ describe('git-clone', () => { process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz'; try { const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/o/r' }); - expect(env.GIT_CONFIG_COUNT).toBe('1'); - expect(env.GIT_CONFIG_VALUE_1).toBeUndefined(); + expect(env.GIT_CONFIG_COUNT).toBe('2'); + expect(env.GIT_CONFIG_VALUE_2).toBeUndefined(); for (const value of Object.values(env)) { expect(String(value)).not.toContain('azure-pat-xyz'); } @@ -508,13 +508,48 @@ describe('git-clone', () => { { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.sslVerify', GIT_CONFIG_VALUE_0: 'true' }, { token: 'ghp_secret123', url: 'https://github.com/o/r' }, ); - expect(env.GIT_CONFIG_COUNT).toBe('2'); + expect(env.GIT_CONFIG_COUNT).toBe('3'); // Operator's pre-existing config is preserved at index 0. expect(env.GIT_CONFIG_KEY_0).toBe('http.sslVerify'); expect(env.GIT_CONFIG_VALUE_0).toBe('true'); - // Our credential is appended at index 1. - expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/o/r.extraHeader'); - expect(env.GIT_CONFIG_VALUE_1).toContain('Authorization: Basic '); + expect(env.GIT_CONFIG_KEY_1).toBe('core.hooksPath'); + expect(env.GIT_CONFIG_VALUE_1).toBe(os.devNull); + expect(env.GIT_CONFIG_KEY_2).toBe('http.https://github.com/o/r.extraHeader'); + expect(env.GIT_CONFIG_VALUE_2).toContain('Authorization: Basic '); + }); + + it('overrides an inherited hooks path with the managed safe value', () => { + const env = buildGitEnv({ + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.hooksPath', + GIT_CONFIG_VALUE_0: '/tmp/untrusted-hooks', + }); + expect(env.GIT_CONFIG_COUNT).toBe('2'); + expect(env.GIT_CONFIG_KEY_1).toBe('core.hooksPath'); + expect(env.GIT_CONFIG_VALUE_1).toBe(os.devNull); + }); + + it('does not execute hooks from an existing repository', async () => { + if (process.platform === 'win32') return; + const root = await mkControlledRoot('gitnexus-managed-git-'); + const marker = path.join(root, 'hook-ran'); + try { + await runGit(['init', '--initial-branch=main'], root); + await runGit(['config', 'user.email', 'test@example.com'], root); + await runGit(['config', 'user.name', 'GitNexus Test'], root); + await fs.writeFile(path.join(root, 'README.md'), 'test\n'); + await runGit(['add', 'README.md'], root); + await runGit(['commit', '-m', 'initial'], root); + const hook = path.join(root, '.git', 'hooks', 'post-checkout'); + await fs.writeFile(hook, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`); + await fs.chmod(hook, 0o700); + + await runGitForTest(['checkout', '-b', 'next'], root); + + await expect(fs.access(marker)).rejects.toThrow(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } }); it('strips control characters from the config key (no key injection)', () => { @@ -522,7 +557,7 @@ describe('git-clone', () => { {}, { token: 'ghp_secret123', url: 'https://github.com/o/r%0Anewline' }, ); - const key = env.GIT_CONFIG_KEY_0 ?? ''; + const key = env.GIT_CONFIG_KEY_1 ?? ''; expect(key).not.toContain('\n'); expect(key).not.toContain('\r'); }); @@ -686,6 +721,69 @@ describe('git-clone', () => { } }); + it('rejects writable existing directories below a controlled clone root', async () => { + if (process.platform === 'win32') return; + const root = await mkControlledRoot('gitnexus-controlled-root-'); + const namespace = path.join(root, 'team'); + try { + await fs.mkdir(namespace); + await fs.chmod(namespace, 0o777); + await expect( + cloneOrPull( + 'https://example.com/team/repo.git', + path.join(namespace, 'repo'), + undefined, + { + allowedCloneRoot: root, + expectedRepoName: 'repo', + }, + ), + ).rejects.toThrow('world-writable'); + } finally { + await fs.chmod(namespace, 0o700).catch(() => {}); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('rejects writable .git metadata in an existing controlled clone', async () => { + if (process.platform === 'win32') return; + const root = await mkControlledRoot('gitnexus-controlled-root-'); + const target = path.join(root, 'repo'); + const gitDir = path.join(target, '.git'); + try { + await fs.mkdir(gitDir, { recursive: true }); + await fs.chmod(gitDir, 0o777); + await expect( + cloneOrPull('https://example.com/team/repo.git', target, undefined, { + allowedCloneRoot: root, + expectedRepoName: 'repo', + }), + ).rejects.toThrow('world-writable'); + } finally { + await fs.chmod(gitDir, 0o700).catch(() => {}); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it('rejects symlinked .git metadata in an existing controlled clone', async () => { + const root = await mkControlledRoot('gitnexus-controlled-root-'); + const outside = await mkControlledRoot('gitnexus-outside-git-dir-'); + const target = path.join(root, 'repo'); + try { + await fs.mkdir(target); + await fs.symlink(outside, path.join(target, '.git')); + await expect( + cloneOrPull('https://example.com/team/repo.git', target, undefined, { + allowedCloneRoot: root, + expectedRepoName: 'repo', + }), + ).rejects.toThrow('symlink'); + } finally { + await fs.rm(root, { recursive: true, force: true }); + await fs.rm(outside, { recursive: true, force: true }); + } + }); + it('rejects existing clones whose remote origin mismatches the requested URL', async () => { const root = await mkControlledRoot('gitnexus-controlled-root-'); const target = path.join(root, 'repo'); @@ -796,6 +894,25 @@ describe('git-clone', () => { } }); + it('clones into a pre-existing empty target directory', async () => { + const root = await mkControlledRoot('gitnexus-controlled-root-'); + const target = path.join(root, 'repo'); + const runGitForTest = vi.fn(async () => ''); + try { + await fs.mkdir(target); + await expect( + cloneOrPull('https://example.com/team/repo.git', target, undefined, { + allowedCloneRoot: root, + expectedRepoName: 'repo', + runGitForTest, + }), + ).resolves.toBe(target); + expect(runGitForTest).toHaveBeenCalled(); + } finally { + 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'); @@ -1179,7 +1296,7 @@ describe('git-clone', () => { }); describe('runGit timeout', () => { - it('waits for close and sends SIGKILL after the grace period before returning timeout', async () => { + it('rejects after SIGKILL even when the child never closes', async () => { vi.useFakeTimers(); try { const child = new EventEmitter() as EventEmitter & { @@ -1209,7 +1326,6 @@ describe('git-clone', () => { await vi.advanceTimersByTimeAsync(25); expect(child.kill).toHaveBeenCalledWith('SIGKILL'); - child.emit('close', null); await expect(promise).rejects.toThrow('timed out after 20ms'); } finally { vi.useRealTimers(); diff --git a/gitnexus/test/unit/process-identity.test.ts b/gitnexus/test/unit/process-identity.test.ts new file mode 100644 index 000000000..9e337fd18 --- /dev/null +++ b/gitnexus/test/unit/process-identity.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { isProcessAlive } from '../../src/utils/process-identity.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('process identity', () => { + it('treats only ESRCH as a dead process', () => { + const kill = vi.spyOn(process, 'kill'); + kill.mockImplementationOnce(() => { + throw Object.assign(new Error('missing'), { code: 'ESRCH' }); + }); + kill.mockImplementationOnce(() => { + throw Object.assign(new Error('not permitted'), { code: 'EPERM' }); + }); + + expect(isProcessAlive(111)).toBe(false); + expect(isProcessAlive(222)).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts index a269daab2..1ae587aea 100644 --- a/gitnexus/test/unit/repo-manager.test.ts +++ b/gitnexus/test/unit/repo-manager.test.ts @@ -771,6 +771,25 @@ describe('registerRepo name override + collision guard (#829)', () => { expect(entries[0].name).not.toBe(path.basename(tmpRepoA.dbPath)); }); + it('preserves every concurrent registration', async () => { + const repoPaths = Array.from({ length: 12 }, (_, index) => + path.join(tmpRepoA.dbPath, `concurrent-${index}`), + ); + await Promise.all(repoPaths.map((repoPath) => fs.mkdir(repoPath, { recursive: true }))); + + await Promise.all( + repoPaths.map((repoPath, index) => + registerRepo(repoPath, meta, { name: `concurrent-${index}` }), + ), + ); + + const entries = await listRegisteredRepos(); + expect(entries).toHaveLength(repoPaths.length); + expect(entries.map((entry) => entry.name).sort()).toEqual( + repoPaths.map((_, index) => `concurrent-${index}`).sort(), + ); + }); + it('re-registerRepo on same path without name preserves an existing alias', async () => { await registerRepo(tmpRepoA.dbPath, meta, { name: 'custom-alias' }); // Second call with no opts should keep the alias, not revert to basename. diff --git a/gitnexus/test/unit/watch-command.test.ts b/gitnexus/test/unit/watch-command.test.ts new file mode 100644 index 000000000..b59fbe33a --- /dev/null +++ b/gitnexus/test/unit/watch-command.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const autoSync = vi.hoisted(() => ({ + startAutoSyncWatch: vi.fn(), +})); + +vi.mock('../../src/core/auto-sync/index.js', () => ({ + getAutoSyncConfigPath: vi.fn(() => '/tmp/watch_config.yml'), + getAutoSyncMutexPath: vi.fn(() => '/tmp/watch.mutex'), + readAutoSyncWatchStatus: vi.fn(), + resetAutoSyncState: vi.fn(), + startAutoSyncWatch: autoSync.startAutoSyncWatch, + stopAutoSyncWatch: vi.fn(), +})); + +import { watchCommand } from '../../src/cli/watch.js'; + +describe('watch command', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.restoreAllMocks()); + + it('reports foreground stop failures and exits non-zero', async () => { + const stop = vi.fn(async () => { + throw new Error('cleanup failed'); + }); + autoSync.startAutoSyncWatch.mockResolvedValue({ stop }); + let signalHandler: (() => void) | undefined; + vi.spyOn(process, 'once').mockImplementation(((event, listener) => { + if (event === 'SIGTERM') signalHandler = listener as () => void; + return process; + }) as typeof process.once); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await watchCommand('start'); + signalHandler?.(); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + + expect(stop).toHaveBeenCalledTimes(1); + expect(stderr).toHaveBeenCalledWith('[auto-sync] Failed to stop watch: cleanup failed\n'); + expect(stderr).not.toHaveBeenCalledWith('[auto-sync] Watch stopped.\n'); + }); +});