This commit is contained in:
Yayler 2026-08-28 09:04:54 +08:00 committed by GitHub
commit ff7898be21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 6248 additions and 160 deletions

2
.gitignore vendored
View file

@ -31,6 +31,8 @@ npm-debug.log*
# Testing
coverage/
.tmp-test/
gitnexus/.tmp-test/
# Misc
*.local

View file

@ -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

View file

@ -444,6 +444,46 @@ If embeddings are skipped on a large repository, the indexed graph likely exceed
</details>
<details>
<summary><strong>Keep remote repositories indexed with <code>gitnexus watch</code></strong></summary>
`gitnexus watch` clones or pulls configured repositories, analyzes new commits, and optionally syncs their group. It runs once immediately, then repeats on the configured interval. It runs in the foreground; use your process manager if it must survive a shell session.
```bash
# 1. Create the config once. It never overwrites an existing file.
gitnexus watch init
# 2. Edit $GITNEXUS_HOME/watch_config.yml, then start it.
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:
```yaml
sync_interval_minutes: 10
analyze_timeout: 5m
projects:
- local_path: /absolute/path/to/clones
branches: [main, master]
overwrite_local_changes: false
remote_urls:
- git@github.com:owner/repo.git
```
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `watch stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, watch reports `cancelling` or `stopping` and retains ownership so another watch cannot take over. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten.
- Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days.
See the [full watch configuration and runtime reference](gitnexus/README.md#gitnexus-watch) for concurrency, timeouts, failure thresholds, and runtime files.
</details>
<details>
<summary><strong>Repository groups</strong> (multi-repo / monorepo service tracking)</summary>

View file

@ -19,14 +19,14 @@
*
* False-positive suppression:
* - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
* `URL`, `marked`, `Number`).
* `URL`, `marked`, `Number`, `path`).
* - Skips calls whose first argument is a string-literal (grammar-load smoke
* tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
* - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
* - Skips the `safe-parse.ts` helper itself.
*/
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math', 'path']);
export default {
meta: {
@ -74,7 +74,7 @@ export default {
// Receiver-text-shape skip: anything matching well-known JS APIs that
// happen to have a `.parse(<expr>)` shape but aren't tree-sitter.
if (
/^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
/^(JSON|URL|marked|Number|Math|Date|path|globalThis\.JSON)\b/.test(receiverText) ||
/\bjson\.parse\b/i.test(receiverText)
) {
return;

View file

@ -247,6 +247,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|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
@ -281,6 +282,28 @@ gitnexus group status <name> # Check staleness of repos in a group
gitnexus group impact <name> --target <symbol> --repo <groupPath> # Cross-repo blast radius
```
### `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`, `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, `watch.stop.<ownerId>.json` is a temporary owner-fenced stop request, 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
max_concurrency: 1
repo_git_timeout: 10s
analyze_timeout: 5m
analyze_failure_threshold: 3
projects:
- local_path: /abs/path/to/repos
branches: [master, main]
overwrite_local_changes: false
remote_urls:
- git@github.com:owner/repo.git
- git@gitlab.com:group/repo.git
- git@gitee.com:owner/repo.git
```
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; 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. Timeout and `watch stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, watch reports `cancelling` or `stopping` and keeps its ownership files so another watch cannot take over. `watch stop` uses this same control path on macOS and Windows. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes. `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 <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
> **`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.
## Remote Embeddings

View file

@ -13,6 +13,7 @@ const COMMAND_DESCRIPTION_KEYS = {
'': 'help.description.root',
setup: 'help.command.setup.description',
uninstall: 'help.command.uninstall.description',
watch: 'help.command.watch.description',
analyze: 'help.command.analyze.description',
index: 'help.command.index.description',
serve: 'help.command.serve.description',

View file

@ -132,6 +132,10 @@ export const en = {
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
'help.command.uninstall.description':
'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 SSH URLs on github.com, gitlab.com, and gitee.com 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)',

View file

@ -133,6 +133,10 @@ export const zhCN = {
'一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
'help.command.uninstall.description':
'撤销 `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远程地址仅允许 github.com、gitlab.com 和 gitee.com 上的 SSH 地址。\n启动后立即运行一次之后按 sync_interval_minutes 重复。',
'help.command.analyze.description': '索引仓库(完整分析)',
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',

View file

@ -45,6 +45,14 @@ program
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
program
.command('watch [action]')
.description(
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
)
.addHelpText('after', () => t('help.watch.details'))
.action(createLazyAction(() => import('./watch.js'), 'watchCommand'));
// Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook
// before it overwrites the var, so the postAction hook can restore it. The
// analyzeCommand env snapshot is taken AFTER this hook runs, so it cannot undo

124
gitnexus/src/cli/watch.ts Normal file
View file

@ -0,0 +1,124 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {
getAutoSyncConfigPath,
getAutoSyncMutexPath,
readAutoSyncWatchStatus,
resetAutoSyncState,
startAutoSyncWatch,
stopAutoSyncWatch,
type WatchStatusRecord,
} from '../core/auto-sync/index.js';
export async function watchCommand(action = 'start'): Promise<void> {
if (action === 'init') {
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') {
if ((await stopAutoSyncWatch()) !== 'stopped') process.exitCode = 1;
return;
}
if (action === 'restart') {
const result = await stopAutoSyncWatch();
if (result === 'refused' || result === 'timeout') {
process.exitCode = 1;
return;
}
await startWatchProcess();
return;
}
if (action !== 'start') {
process.stderr.write(`[auto-sync] Unknown watch action: ${action}\n`);
process.exitCode = 1;
return;
}
await startWatchProcess();
}
async function startWatchProcess(): Promise<void> {
const handle = await startAutoSyncWatch();
if (!handle) {
process.exitCode = 1;
return;
}
const stop = () => {
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);
}
function printStatus(status: WatchStatusRecord): void {
const parts = [`state=${status.state}`];
if (status.pid) parts.push(`pid=${status.pid}`);
if (status.configPath) parts.push(`config=${status.configPath}`);
if (status.message) parts.push(`message=${status.message}`);
parts.push(`updated_at=${status.updatedAt}`);
process.stdout.write(`${parts.join(' ')}\n`);
}
async function initWatchConfig(): Promise<void> {
const configPath = getAutoSyncConfigPath();
try {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
defaultSyncConfig(path.resolve(path.dirname(configPath), 'repos')),
{
flag: 'wx',
},
);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
process.stderr.write(`[auto-sync] Config already exists: ${configPath}\n`);
process.exitCode = 1;
return;
}
throw err;
}
process.stdout.write(`[auto-sync] Created ${configPath}\n`);
}
function defaultSyncConfig(localPath: string): string {
return [
'sync_interval_minutes: 10',
'max_concurrency: 1',
'repo_git_timeout: 10s',
'analyze_timeout: 5m',
'analyze_failure_threshold: 3',
'projects:',
` - local_path: ${localPath}`,
' branches: [master, main]',
' overwrite_local_changes: false',
' remote_urls:',
' - git@github.com:owner/repo.git',
'',
].join('\n');
}

View file

@ -0,0 +1,148 @@
import { fork, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { AnalyzeOptions, AnalyzeResult } from '../run-analyze.js';
import type { WorkerMessage } from '../../server/analyze-worker-protocol.js';
import { autoHeapCapMb } from '../ingestion/utils/effective-ram.js';
const _require = createRequire(import.meta.url);
export type AutoSyncAnalysisRunner = (
repoPath: string,
options: AnalyzeOptions,
timeoutMs: number,
signal?: AbortSignal,
onCancellationRequested?: () => void,
) => Promise<Pick<AnalyzeResult, 'stats'>>;
interface AnalysisWorker extends Pick<ChildProcess, 'send' | 'on'> {
stdout?: Pick<NodeJS.ReadableStream, 'resume'> | null;
stderr?: Pick<NodeJS.ReadableStream, 'resume'> | null;
}
export interface AutoSyncAnalysisLaunchDeps {
forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker;
setTimeoutFn: typeof setTimeout;
clearTimeoutFn: typeof clearTimeout;
}
const DEFAULT_DEPS: AutoSyncAnalysisLaunchDeps = {
forkWorker: (workerPath, execArgv) =>
fork(workerPath, [], {
execArgv,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
}),
setTimeoutFn: setTimeout,
clearTimeoutFn: clearTimeout,
};
export function createAutoSyncAnalysisRunner(
overrides: Partial<AutoSyncAnalysisLaunchDeps> = {},
): AutoSyncAnalysisRunner {
const deps = { ...DEFAULT_DEPS, ...overrides };
return (repoPath, options, timeoutMs, signal, onCancellationRequested) =>
new Promise<Pick<AnalyzeResult, 'stats'>>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('Analysis cancelled.'));
return;
}
const callerPath = fileURLToPath(import.meta.url);
const isDev = callerPath.endsWith('.ts');
const workerPath = path.join(
path.dirname(callerPath),
'../../server',
isDev ? 'analyze-worker.ts' : 'analyze-worker.js',
);
if (!existsSync(workerPath)) {
reject(new Error(`Auto-sync analyze worker is missing: ${workerPath}`));
return;
}
const workerHeapMb = Math.min(8192, autoHeapCapMb());
const execArgv = isDev
? [
'--import',
pathToFileURL(_require.resolve('tsx/esm')).href,
`--max-old-space-size=${workerHeapMb}`,
]
: [`--max-old-space-size=${workerHeapMb}`];
const child = deps.forkWorker(workerPath, execArgv);
child.stdout?.resume();
child.stderr?.resume();
let terminalOutcome: WorkerMessage | undefined;
let terminationError: Error | undefined;
let settled = false;
const cleanup = () => {
deps.clearTimeoutFn(timeout);
signal?.removeEventListener('abort', onAbort);
};
const settle = (error?: Error, result?: Pick<AnalyzeResult, 'stats'>) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve(result!);
};
const requestCancellation = (error: Error) => {
if (settled || terminationError) return;
terminationError = error;
deps.clearTimeoutFn(timeout);
onCancellationRequested?.();
// IPC has the same semantics on macOS and Windows. The worker exits only
// after reaching a JS-visible safe point; this parent keeps ownership until then.
try {
child.send({ type: 'cancel' });
} catch {
// A closed IPC channel still has an exit/error path. Do not force-kill a
// worker that may be inside native code.
}
};
const timeout = deps.setTimeoutFn(
() => requestCancellation(new Error(`Analysis timed out after ${timeoutMs}ms.`)),
timeoutMs,
);
const onAbort = () => requestCancellation(new Error('Analysis cancelled.'));
signal?.addEventListener('abort', onAbort, { once: true });
child.on('message', (message: WorkerMessage) => {
// Once timeout/cancellation requested shutdown, its reason owns the
// result. A terminal IPC can already be queued behind cancellation.
if (message.type === 'progress' || terminalOutcome || terminationError) return;
terminalOutcome = message;
deps.clearTimeoutFn(timeout);
});
child.on('error', (error) => {
requestCancellation(new Error(`Auto-sync analyze worker error: ${error.message}`));
});
child.on('exit', (code, childSignal) => {
if (settled) return;
if (terminationError) {
settle(terminationError);
return;
}
if (terminalOutcome?.type === 'complete') {
settle(undefined, { stats: terminalOutcome.result.stats });
return;
}
if (terminalOutcome?.type === 'error') {
settle(new Error(terminalOutcome.message));
return;
}
settle(
new Error(
`Auto-sync analyze worker exited before completion (${childSignal ?? code ?? 'unknown'}).`,
),
);
});
try {
child.send({ type: 'start', repoPath, options });
} catch (error) {
requestCancellation(
new Error(`Failed to start auto-sync analyze worker: ${(error as Error).message}`),
);
}
});
}
export const runAutoSyncAnalysis = createAutoSyncAnalysisRunner();

View file

@ -0,0 +1,301 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { getGlobalDir } from '../../storage/repo-manager.js';
import { normalizeConfiguredCloneRoot } from './path-security.js';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
export const AUTO_SYNC_CONFIG_FILE = 'watch_config.yml';
const GROUP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
const MIN_SYNC_INTERVAL_MINUTES = 5;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const MAX_SYNC_INTERVAL_MINUTES = Math.floor(MAX_TIMER_DELAY_MS / 60_000);
const DEFAULT_REPO_GIT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_CONCURRENCY = 1;
export const DEFAULT_ANALYZE_FAILURE_THRESHOLD = 3;
const MIN_ANALYZE_FAILURE_THRESHOLD = 2;
const ALLOWED_REMOTE_HOSTS = new Set(['github.com', 'gitlab.com', 'gitee.com']);
export interface AutoSyncProjectConfig {
localPath: string;
groupName?: string;
overwriteLocalChanges: boolean;
branches: string[];
remoteUrls: string[];
}
export interface AutoSyncConfig {
configPath: string;
syncIntervalMinutes: number;
repoGitTimeoutMs: number;
analyzeTimeoutMs: number;
maxConcurrency: number;
analyzeFailureThreshold: number;
projects: AutoSyncProjectConfig[];
}
export type AutoSyncConfigLoadResult =
| { ok: true; config: AutoSyncConfig }
| { ok: false; reason: 'missing' | 'unreadable' | 'invalid'; message: string };
export function getAutoSyncConfigPath(gitnexusDir = getGlobalDir()): string {
return path.join(gitnexusDir, AUTO_SYNC_CONFIG_FILE);
}
export function parseBranchCandidates(branchValue: unknown): string[] {
const rawItems = Array.isArray(branchValue)
? branchValue.flatMap((item) => String(item).split(','))
: String(branchValue ?? '').split(',');
const branches: string[] = [];
const seen = new Set<string>();
for (const item of rawItems) {
const branch = item.trim();
if (!branch || seen.has(branch)) continue;
seen.add(branch);
branches.push(branch);
}
return branches;
}
export async function loadAutoSyncConfig(
configPath = getAutoSyncConfigPath(),
): Promise<AutoSyncConfigLoadResult> {
let content: string;
try {
content = await fs.readFile(configPath, 'utf-8');
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {
ok: false,
reason: 'missing',
message: `[auto-sync] Missing config file: ${configPath}. Auto sync is skipped.`,
};
}
return {
ok: false,
reason: 'unreadable',
message: `[auto-sync] Unable to read config file: ${configPath}. Auto sync is skipped.`,
};
}
try {
return { ok: true, config: parseAutoSyncConfig(content, configPath) };
} catch (err: unknown) {
return {
ok: false,
reason: 'invalid',
message: `[auto-sync] Invalid watch_config.yml: ${(err as Error).message}. Auto sync is skipped.`,
};
}
}
export function parseAutoSyncConfig(content: string, configPath: string): AutoSyncConfig {
const raw = yaml.load(content, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('expected a YAML object');
}
const errors: string[] = [];
const interval = Number(raw.sync_interval_minutes);
if (!Number.isInteger(interval) || interval <= 0) {
errors.push('sync_interval_minutes must be a positive integer');
} else if (interval < MIN_SYNC_INTERVAL_MINUTES) {
errors.push(`sync_interval_minutes must be at least ${MIN_SYNC_INTERVAL_MINUTES}`);
} else if (interval > MAX_SYNC_INTERVAL_MINUTES) {
errors.push(`sync_interval_minutes must not exceed ${MAX_SYNC_INTERVAL_MINUTES}`);
}
const maxConcurrency =
raw.max_concurrency === undefined ? DEFAULT_MAX_CONCURRENCY : Number(raw.max_concurrency);
if (!Number.isInteger(maxConcurrency) || maxConcurrency <= 0) {
errors.push('max_concurrency must be a positive integer');
}
const repoGitTimeoutMs =
raw.repo_git_timeout === undefined
? DEFAULT_REPO_GIT_TIMEOUT_MS
: 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 =
Number.isInteger(interval) &&
interval >= MIN_SYNC_INTERVAL_MINUTES &&
interval <= MAX_SYNC_INTERVAL_MINUTES
? interval * 30_000
: undefined;
const analyzeTimeoutMs =
raw.analyze_timeout === undefined
? (maxAnalyzeTimeoutMs ?? 0)
: parseDurationMs(raw.analyze_timeout);
if (!Number.isInteger(analyzeTimeoutMs) || analyzeTimeoutMs <= 0) {
errors.push('analyze_timeout must be a positive duration such as 30m');
} else if (maxAnalyzeTimeoutMs !== undefined && analyzeTimeoutMs > maxAnalyzeTimeoutMs) {
errors.push(
`analyze_timeout must not exceed half of sync_interval_minutes (${maxAnalyzeTimeoutMs / 60_000}m)`,
);
}
const analyzeFailureThreshold =
raw.analyze_failure_threshold === undefined
? DEFAULT_ANALYZE_FAILURE_THRESHOLD
: Number(raw.analyze_failure_threshold);
if (
!Number.isInteger(analyzeFailureThreshold) ||
analyzeFailureThreshold < MIN_ANALYZE_FAILURE_THRESHOLD
) {
errors.push(`analyze_failure_threshold must be an integer >= ${MIN_ANALYZE_FAILURE_THRESHOLD}`);
}
const rawProjects = raw.projects;
if (!Array.isArray(rawProjects) || rawProjects.length === 0) {
errors.push('projects must contain at least one project');
}
const projects: AutoSyncProjectConfig[] = [];
if (Array.isArray(rawProjects)) {
rawProjects.forEach((projectValue, index) => {
const project = projectValue as Record<string, unknown>;
if (!project || typeof project !== 'object' || Array.isArray(project)) {
errors.push(`projects[${index}] must be an object`);
return;
}
const localPath = typeof project.local_path === 'string' ? project.local_path.trim() : '';
if (!localPath) {
errors.push(`projects[${index}].local_path is required`);
} else {
try {
normalizeConfiguredCloneRoot(localPath);
} catch (err: unknown) {
errors.push(`projects[${index}].local_path ${(err as Error).message}`);
}
}
const remoteUrls = Array.isArray(project.remote_urls)
? project.remote_urls.map((url) => String(url).trim()).filter(Boolean)
: [];
if (remoteUrls.length === 0) {
errors.push(`projects[${index}].remote_urls must contain at least one URL`);
}
for (let urlIndex = 0; urlIndex < remoteUrls.length; urlIndex += 1) {
try {
validateAutoSyncRemoteUrl(remoteUrls[urlIndex]);
} catch (err: unknown) {
errors.push(`projects[${index}].remote_urls[${urlIndex}] ${(err as Error).message}`);
}
}
if (project.branch !== undefined && project.branches !== undefined) {
errors.push(`projects[${index}] must not set both branch and branches`);
}
const branches = parseBranchCandidates(
project.branches !== undefined ? project.branches : project.branch,
);
if (branches.length === 0) errors.push(`projects[${index}].branches is required`);
for (let branchIndex = 0; branchIndex < branches.length; branchIndex += 1) {
try {
validateAutoSyncBranchName(branches[branchIndex]);
} catch (err: unknown) {
errors.push(`projects[${index}].branches[${branchIndex}] ${(err as Error).message}`);
}
}
const groupName =
typeof project.group_name === 'string' && project.group_name.trim()
? project.group_name.trim()
: undefined;
if (groupName && !GROUP_NAME_PATTERN.test(groupName)) {
errors.push(`projects[${index}].group_name is invalid`);
}
const overwriteLocalChanges =
project.overwrite_local_changes === undefined ? false : project.overwrite_local_changes;
if (typeof overwriteLocalChanges !== 'boolean') {
errors.push(`projects[${index}].overwrite_local_changes must be a boolean`);
}
if (localPath && remoteUrls.length > 0 && branches.length > 0) {
projects.push({
localPath,
groupName,
overwriteLocalChanges: overwriteLocalChanges === true,
branches,
remoteUrls,
});
}
});
}
if (errors.length > 0) throw new Error(errors.join('; '));
return {
configPath,
syncIntervalMinutes: interval,
repoGitTimeoutMs,
analyzeTimeoutMs,
maxConcurrency,
analyzeFailureThreshold,
projects,
};
}
export function validateAutoSyncRemoteUrl(remoteUrl: string): void {
const trimmed = remoteUrl.trim();
if (trimmed.includes('?') || trimmed.includes('#')) {
throw new Error('must not include query strings or fragments');
}
const match = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed);
if (!match) {
throw new Error(
'must use an SSH URL on github.com, gitlab.com, or gitee.com',
);
}
const host = match[1].toLowerCase();
const repoPath = match[2];
if (!ALLOWED_REMOTE_HOSTS.has(host)) {
throw new Error('host must be one of github.com, gitlab.com, or gitee.com');
}
const pathParts = repoPath.split('/');
if (
repoPath.startsWith('/') ||
repoPath.includes('..') ||
pathParts.length < 2 ||
pathParts.some((part) => !part)
) {
throw new Error('path must include owner/repo without traversal');
}
}
export function validateAutoSyncBranchName(branch: string): void {
if (!branch.trim()) throw new Error('must not be empty');
if (/[\s\0-\x1f\x7f]/.test(branch))
throw new Error('must not contain whitespace or control characters');
if (/[~^:?*[\\]/.test(branch)) throw new Error('contains characters not allowed in a git ref');
if (branch.startsWith('-')) throw new Error('must not start with "-"');
if (branch.includes('..')) throw new Error('must not contain ".."');
if (branch.includes('`')) throw new Error('must not contain backticks');
if (branch.endsWith('/') || branch.endsWith('.'))
throw new Error('must not end with "/" or "."');
if (branch.includes('//')) throw new Error('must not contain consecutive slashes');
if (branch.includes('@{')) throw new Error('must not contain "@{"');
if (branch.split('/').some((component) => component.startsWith('.') || component.endsWith('.lock')))
throw new Error('must not contain hidden or .lock path components');
}
export function parseDurationMs(value: unknown): number {
if (typeof value === 'number') return value * 1_000;
const raw = String(value ?? '').trim();
const match = /^(\d+)(ms|s|m)?$/.exec(raw);
if (!match) return Number.NaN;
const amount = Number(match[1]);
const unit = match[2] ?? 's';
if (unit === 'ms') return amount;
if (unit === 's') return amount * 1_000;
return amount * 60_000;
}

View file

@ -0,0 +1,57 @@
export {
AUTO_SYNC_CONFIG_FILE,
getAutoSyncConfigPath,
loadAutoSyncConfig,
parseAutoSyncConfig,
parseBranchCandidates,
parseDurationMs,
validateAutoSyncBranchName,
validateAutoSyncRemoteUrl,
type AutoSyncConfig,
type AutoSyncConfigLoadResult,
type AutoSyncProjectConfig,
} from './config.js';
export {
buildStateKey,
getAutoSyncMutexPath,
getAutoSyncWatchDir,
getAutoSyncStatePath,
getProjectCommitInfoPath,
loadAutoSyncState,
resetAutoSyncState,
saveAutoSyncState,
shouldAnalyzeCommit,
writeProjectCommitInfo,
type AutoSyncAnalyzeStatus,
type AutoSyncCommitState,
type AutoSyncCommitStateEntry,
type ProjectCommitInfoEntry,
} from './state.js';
export { extractRepoNameFromRemoteUrl } from './repo.js';
export {
normalizeConfiguredCloneRoot,
quarantineAutoSyncPartial,
resolveConfiguredCloneRoot,
type AutoSyncCloneRoot,
} from './path-security.js';
export {
addRepoToGroup,
getAutoSyncRepoIdentity,
getConfiguredRepoPath,
resolveActualConcurrency,
runAutoSyncOnce,
syncGroupByName,
type AutoSyncLogger,
type AutoSyncRunDeps,
type AutoSyncRunResult,
} from './runner.js';
export {
getAutoSyncWatchPaths,
readAutoSyncWatchStatus,
startAutoSyncWatch,
stopAutoSyncWatch,
type AutoSyncStartHandle,
type AutoSyncWatchStopResult,
type AutoSyncWatchPaths,
type WatchStatusRecord,
} from './starter.js';

View file

@ -0,0 +1,234 @@
import fs from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import os from 'node:os';
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(
[
'/',
os.homedir(),
os.tmpdir(),
'/bin',
'/boot',
'/dev',
'/etc',
'/lib',
'/lib64',
'/opt',
'/proc',
'/private/tmp',
'/private/var',
'/root',
'/sbin',
'/sys',
'/tmp',
'/usr',
'/var',
...WINDOWS_DANGEROUS_ROOTS,
].map((entry) => path.resolve(entry)),
);
const DANGEROUS_PARENT_ROOTS = new Set(
[
os.tmpdir(),
'/bin',
'/boot',
'/dev',
'/etc',
'/lib',
'/lib64',
'/opt',
'/proc',
'/private/tmp',
'/private/var',
'/root',
'/sbin',
'/sys',
'/tmp',
'/usr',
'/var',
...WINDOWS_DANGEROUS_ROOTS,
].map((entry) => path.resolve(entry)),
);
const QUARANTINE_RETENTION_DAYS = 14;
export interface AutoSyncCloneRoot {
root: string;
quarantineRoot: string;
quarantineRetentionDays: number;
}
export async function resolveConfiguredCloneRoot(localPath: string): Promise<AutoSyncCloneRoot> {
const root = normalizeConfiguredCloneRoot(localPath);
assertNotDangerousRoot(root);
await assertNoSymlinkPath(root);
await fs.mkdir(root, { recursive: true });
await assertDirectoryOwnerAndPermissions(root);
const realRoot = await fs.realpath(root);
assertContainedOrSame(
root,
realRoot,
'Configured clone root realpath escaped its normalized path',
);
assertNotDangerousRoot(realRoot);
assertNotGitNexusInternalRoot(realRoot);
const quarantineRoot = path.join(getAutoSyncWatchDir(), 'quarantine');
await removeExpiredQuarantineEntries(quarantineRoot);
return {
root: realRoot,
quarantineRoot,
quarantineRetentionDays: QUARANTINE_RETENTION_DAYS,
};
}
export function normalizeConfiguredCloneRoot(localPath: string): string {
const value = localPath.trim();
if (!value) throw new Error('local_path is required');
if (!path.isAbsolute(value)) throw new Error('local_path must be an absolute path');
if (value.split(path.sep).includes('..')) {
throw new Error('local_path must be normalized and must not contain traversal segments');
}
const resolved = path.resolve(value);
if (resolved !== path.normalize(value)) {
throw new Error('local_path must be normalized and must not contain traversal segments');
}
return resolved;
}
export async function quarantineAutoSyncPartial(
targetDir: string,
quarantineRoot: string,
): Promise<string> {
await fs.mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
const base = path.basename(targetDir);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const destination = path.join(quarantineRoot, `auto-sync-${stamp}-${process.pid}-${randomUUID()}-${base}`);
try {
await fs.rename(targetDir, destination);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err;
await fs.cp(targetDir, destination, { recursive: true });
await fs.rm(targetDir, { recursive: true, force: true });
}
await fs.writeFile(
`${destination}.README.txt`,
[
'GitNexus auto-sync isolated a partial or unsafe clone result.',
`Created at: ${new Date().toISOString()}`,
`Original path: ${targetDir}`,
`Retention: keep for ${QUARANTINE_RETENTION_DAYS} days unless an operator reviews and removes it earlier.`,
'Cleanup: verify the original path and remote before manual deletion.',
'',
].join('\n'),
'utf-8',
);
return destination;
}
async function removeExpiredQuarantineEntries(quarantineRoot: string): Promise<void> {
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);
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
throw new Error(`Refusing unsafe auto-sync clone root under ${dangerousRoot}: ${root}`);
}
}
if (path.parse(root).root === root)
throw new Error(`Refusing filesystem root as clone root: ${root}`);
}
function assertNotGitNexusInternalRoot(root: string): void {
const gitnexusDir = path.resolve(getGlobalDir());
const blocked = [
path.join(gitnexusDir, 'groups'),
path.join(gitnexusDir, 'indexes'),
path.join(gitnexusDir, 'quarantine'),
path.join(getAutoSyncWatchDir(gitnexusDir), 'quarantine'),
];
for (const blockedRoot of blocked) {
const rel = path.relative(blockedRoot, root);
if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
throw new Error(`Refusing GitNexus internal directory as auto-sync clone root: ${root}`);
}
}
}
async function assertNoSymlinkPath(root: string): Promise<void> {
const parsed = path.parse(root);
let current = parsed.root;
const parts = root.slice(parsed.root.length).split(path.sep).filter(Boolean);
for (const part of parts) {
current = path.join(current, part);
let stat;
try {
stat = await fs.lstat(current);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') break;
throw err;
}
if (stat.isSymbolicLink())
throw new Error(`Refusing symlink in auto-sync clone root path: ${current}`);
}
}
export async function assertDirectoryOwnerAndPermissions(root: string): Promise<void> {
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;
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}`);
}
}
function assertContainedOrSame(root: string, child: string, message: string): void {
const rel = path.relative(root, child);
if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(message);
}

View file

@ -0,0 +1,7 @@
import { extractRepoName } from '../../server/git-clone.js';
import { validateAutoSyncRemoteUrl } from './config.js';
export function extractRepoNameFromRemoteUrl(remoteUrl: string): string {
validateAutoSyncRemoteUrl(remoteUrl);
return extractRepoName(remoteUrl);
}

View file

@ -0,0 +1,518 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { loadGroupConfig } from '../group/config-parser.js';
import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js';
import { syncGroup } from '../group/sync.js';
import { registerRepo, type RepoMeta } from '../../storage/repo-manager.js';
import { extractRepoNameFromRemoteUrl } from './repo.js';
import { cloneOrPull, runGit } from '../../server/git-clone.js';
import { resolveConfiguredCloneRoot } from './path-security.js';
import {
buildStateKey,
loadAutoSyncState,
saveAutoSyncState,
shouldAnalyzeCommit,
writeProjectCommitInfo,
type AutoSyncAnalyzeStatus,
type AutoSyncCommitStateEntry,
type ProjectCommitInfoEntry,
} from './state.js';
import type { AutoSyncConfig, AutoSyncProjectConfig } from './config.js';
import { validateAutoSyncRemoteUrl } from './config.js';
import { runAutoSyncAnalysis, type AutoSyncAnalysisRunner } from './analysis-worker-launch.js';
export interface AutoSyncLogger {
info(message: string): void;
warn(message: string): void;
error(message: string): void;
}
export interface AutoSyncRunDeps {
cloneOrPull: typeof cloneOrPull;
getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise<string | undefined>;
getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise<string>;
runAnalysis: AutoSyncAnalysisRunner;
registerRepo: typeof registerRepo;
loadState: typeof loadAutoSyncState;
saveState: typeof saveAutoSyncState;
writeCommitInfo: typeof writeProjectCommitInfo;
addRepoToGroup: typeof addRepoToGroup;
syncGroupByName: typeof syncGroupByName;
resolveCloneRoot: typeof resolveConfiguredCloneRoot;
getAvailableMemoryGB: () => number;
}
export interface AutoSyncRunResult {
synced: number;
analyzed: number;
skippedAnalysis: number;
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`),
error: (message) => process.stderr.write(`${message}\n`),
};
const DEFAULT_DEPS: AutoSyncRunDeps = {
cloneOrPull,
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,
saveState: saveAutoSyncState,
writeCommitInfo: writeProjectCommitInfo,
addRepoToGroup,
syncGroupByName,
resolveCloneRoot: resolveConfiguredCloneRoot,
getAvailableMemoryGB: () => Math.floor(process.availableMemory?.() ?? 0) / 1024 / 1024 / 1024,
};
export async function runAutoSyncOnce(
config: AutoSyncConfig,
options: {
deps?: Partial<AutoSyncRunDeps>;
logger?: AutoSyncLogger;
now?: () => Date;
signal?: AbortSignal;
onAnalysisCancellationRequested?: () => void;
} = {},
): Promise<AutoSyncRunResult> {
const deps = { ...DEFAULT_DEPS, ...options.deps };
const logger = options.logger ?? DEFAULT_LOGGER;
const now = options.now ?? (() => new Date());
throwIfAborted(options.signal);
const state = await deps.loadState();
throwIfAborted(options.signal);
const groupsToSync = new Set<string>();
const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 };
const commitInfoEntries: ProjectCommitInfoEntry[] = [];
const actualConcurrency = resolveActualConcurrency(
config.maxConcurrency,
deps.getAvailableMemoryGB(),
);
logger.info(
`[auto-sync] Starting sync loop with max_concurrency=${actualConcurrency} analyze_failure_threshold=${config.analyzeFailureThreshold}.`,
);
const workItems = await buildWorkItems(config, deps);
const repoResults = await mapWithConcurrency(
workItems,
actualConcurrency,
options.signal,
async (item) => {
const lastSyncTime = now().toISOString();
try {
throwIfAborted(options.signal);
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,
targetDir,
timeoutMs: config.repoGitTimeoutMs,
deps,
logger,
});
throwIfAborted(options.signal);
if (syncResult.ok === false) {
logger.error(
`[auto-sync] Repository sync failed for ${item.remoteUrl}; no configured branch could be pulled: ${syncResult.message}`,
);
return {
kind: 'failed' as const,
project: item.project,
remoteUrl: item.remoteUrl,
targetDir,
branch: item.project.branches[0],
status: syncResult.status,
analyzeConsecutiveFailures: 0,
lastSyncTime,
};
}
const currentBranch = syncResult.branch;
const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs);
const stateKey = buildStateKey(targetDir, currentBranch);
const previous = state[stateKey];
let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped';
let analyzedCommitId = previous?.analyzedCommitId;
let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0;
let lastAnalyzeError = previous?.lastAnalyzeError;
let stats: RepoMeta['stats'] | undefined;
if (previous && previous.codeCommitId !== currentCommit) {
analyzeConsecutiveFailures = 0;
lastAnalyzeError = undefined;
}
if (analyzeConsecutiveFailures >= config.analyzeFailureThreshold) {
analyzeStatus = 'threshold_skipped';
logger.error(
`[auto-sync] Skip analysis for ${targetDir}; analyze consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold} reached threshold. Fix the repository or clear auto-sync state before retrying.`,
);
} else if (
shouldAnalyzeCommit({
currentCommit,
previousAnalyzedCommit: previous?.analyzedCommitId,
previousStatus: previous?.lastAnalyzeStatus,
})
) {
try {
const analysis = await (options.onAnalysisCancellationRequested
? deps.runAnalysis(
targetDir,
{ branch: currentBranch, skipAgentsMd: true, skipSkills: true },
config.analyzeTimeoutMs,
options.signal,
options.onAnalysisCancellationRequested,
)
: deps.runAnalysis(
targetDir,
{ branch: currentBranch, skipAgentsMd: true, skipSkills: true },
config.analyzeTimeoutMs,
options.signal,
));
throwIfAborted(options.signal);
stats = analysis.stats;
analyzeStatus = 'success';
analyzedCommitId = currentCommit;
analyzeConsecutiveFailures = 0;
lastAnalyzeError = undefined;
} catch (err: unknown) {
if (options.signal?.aborted) throw err;
analyzeStatus = 'failed';
analyzeConsecutiveFailures += 1;
lastAnalyzeError = shortErrorMessage(err);
logger.error(
`[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`,
);
}
} else {
logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`);
}
throwIfAborted(options.signal);
return {
kind: 'synced' as const,
project: item.project,
repoName,
remoteUrl: item.remoteUrl,
targetDir,
branch: currentBranch,
currentCommit,
analyzedCommitId,
analyzeStatus,
analyzeConsecutiveFailures,
lastAnalyzeError,
stats,
stateKey,
lastSyncTime,
};
} catch (err: unknown) {
if (options.signal?.aborted) throw err;
logger.error(
`[auto-sync] Repository sync failed for ${item.remoteUrl}: ${(err as Error).message}`,
);
return {
kind: 'failed' as const,
project: item.project,
remoteUrl: item.remoteUrl,
targetDir: item.targetDir ?? '',
status: 'sync_failed' as const,
lastSyncTime,
};
}
},
);
for (const repoResult of repoResults) {
if (repoResult.kind === 'failed') {
result.failed += 1;
commitInfoEntries.push({
remoteUrl: repoResult.remoteUrl,
localPath: repoResult.targetDir,
branch: repoResult.branch,
status: repoResult.status,
lastSyncTime: repoResult.lastSyncTime,
});
continue;
}
result.synced += 1;
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,
indexedAt: repoResult.lastSyncTime,
stats: repoResult.stats!,
branch: repoResult.branch,
};
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 {
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,
status: analyzeStatus,
analyzeConsecutiveFailures,
analyzeFailureThreshold: config.analyzeFailureThreshold,
lastAnalyzeError,
lastSyncTime: repoResult.lastSyncTime,
});
if (repoResult.project.groupName) {
let groupMembershipOk = false;
let membershipAdded = false;
try {
membershipAdded = await deps.addRepoToGroup(
repoResult.project,
getAutoSyncRepoIdentity(repoResult.remoteUrl),
getAutoSyncRepoIdentity(repoResult.remoteUrl),
);
groupMembershipOk = true;
} catch (err: unknown) {
result.failed += 1;
logger.error(
`[auto-sync] Group update failed for ${repoResult.project.groupName}: ${(err as Error).message}`,
);
}
if (
groupMembershipOk &&
(analyzeStatus === 'success' || (membershipAdded && analyzeStatus === 'skipped'))
) {
groupsToSync.add(repoResult.project.groupName);
}
}
}
await deps.saveState(state);
await deps.writeCommitInfo(commitInfoEntries);
for (const groupName of groupsToSync) {
try {
await deps.syncGroupByName(groupName);
} catch (err: unknown) {
result.failed += 1;
logger.error(`[auto-sync] Group sync failed for ${groupName}: ${(err as Error).message}`);
}
}
return result;
}
function shortErrorMessage(err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
return message.replace(/\s+/g, ' ').slice(0, 240);
}
export function getConfiguredRepoPath(
project: Pick<AutoSyncProjectConfig, 'localPath'>,
repoName: string,
remoteUrl?: string,
): string {
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(
project: Pick<AutoSyncProjectConfig, 'groupName'>,
groupPath: string,
registryName = groupPath,
): Promise<boolean> {
if (!project.groupName) return false;
const groupDir = getGroupDir(getDefaultGitnexusDir(), project.groupName);
const config = await loadGroupConfig(groupDir);
if (config.repos[groupPath] === registryName) return false;
if (config.repos[groupPath] !== undefined) {
throw new Error(`group path ${groupPath} is already mapped to ${config.repos[groupPath]}`);
}
config.repos[groupPath] = registryName;
await writeGroupConfigAtomic(path.join(groupDir, 'group.yaml'), config);
return true;
}
export function getAutoSyncRepoIdentity(remoteUrl: string): string {
validateAutoSyncRemoteUrl(remoteUrl);
const [, host, remotePath] = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim())!;
return `${host.toLowerCase()}/${remotePath.replace(/\.git$/i, '')}`;
}
export async function syncGroupByName(groupName: string): Promise<void> {
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
await syncGroup(config, { groupDir, allowStale: true });
}
async function writeGroupConfigAtomic(filePath: string, config: unknown): Promise<void> {
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, yaml.dump(config), 'utf-8');
await fs.rename(tmpPath, filePath);
}
export function resolveActualConcurrency(configured: number, availableMemoryGB: number): number {
const memoryLimit = Math.max(1, Math.floor(availableMemoryGB / 2));
return Math.max(1, Math.min(configured, memoryLimit));
}
async function buildWorkItems(
config: AutoSyncConfig,
deps: AutoSyncRunDeps,
): Promise<AutoSyncWorkItem[]> {
const items: AutoSyncWorkItem[] = [];
const targetOwners = new Map<string, string>();
for (const project of config.projects) {
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, remoteUrl);
const previous = targetOwners.get(targetDir);
if (previous !== undefined) {
throw new Error(
`Duplicate auto-sync targetDir ${targetDir} for ${previous} and ${remoteUrl}`,
);
}
targetOwners.set(targetDir, remoteUrl);
items.push({ project, remoteUrl, cloneRoot, repoName, targetDir });
} catch (err: unknown) {
items.push({ project, remoteUrl, error: shortErrorMessage(err) });
}
}
}
return items;
}
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
signal: AbortSignal | undefined,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let nextIndex = 0;
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
throwIfAborted(signal);
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await worker(items[currentIndex]);
throwIfAborted(signal);
}
});
await Promise.all(runners);
return results;
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw new Error('Auto-sync run cancelled.');
}
interface AutoSyncWorkItem {
project: AutoSyncProjectConfig;
remoteUrl: string;
cloneRoot?: Awaited<ReturnType<typeof resolveConfiguredCloneRoot>>;
repoName?: string;
targetDir?: string;
error?: string;
}
async function syncFirstAvailableBranch(input: {
item: AutoSyncWorkItem;
repoName: string;
targetDir: string;
timeoutMs: number;
deps: AutoSyncRunDeps;
logger: AutoSyncLogger;
}): Promise<
| { ok: true; branch: string }
| { ok: false; status: 'branch_unavailable' | 'sync_timeout'; message: string }
> {
const failures: string[] = [];
let sawTimeout = false;
for (const branch of input.item.project.branches) {
try {
await input.deps.cloneOrPull(input.item.remoteUrl, input.targetDir, undefined, {
allowedCloneRoot: input.item.cloneRoot!.root,
expectedRepoName: input.repoName,
quarantineRoot: input.item.cloneRoot!.quarantineRoot,
allowAutoSyncSsh: true,
timeoutMs: input.timeoutMs,
branch,
overwriteLocalChanges: input.item.project.overwriteLocalChanges,
});
const currentBranch = await input.deps.getCurrentBranch(input.targetDir, input.timeoutMs);
if (currentBranch === branch) return { ok: true, branch };
failures.push(`${branch}: checked out ${currentBranch ?? '<detached>'}`);
input.logger.warn(
`[auto-sync] Branch ${branch} for ${input.item.remoteUrl} synced but current branch is ${currentBranch ?? '<detached>'}; trying next branch.`,
);
} catch (err: unknown) {
const message = (err as Error).message;
if (message.includes('timed out')) sawTimeout = true;
failures.push(`${branch}: ${message}`);
input.logger.warn(
`[auto-sync] Branch ${branch} unavailable for ${input.item.remoteUrl}: ${message}`,
);
}
}
return {
ok: false,
status: sawTimeout ? 'sync_timeout' : 'branch_unavailable',
message: failures.join('; '),
};
}

View file

@ -0,0 +1,596 @@
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 { getAutoSyncMutexPath, getAutoSyncWatchDir } from './state.js';
export interface AutoSyncStartHandle {
stop(): Promise<void>;
}
export type WatchStatusState = 'running' | 'cancelling' | 'stopping' | 'stopped' | 'stale' | 'error';
export type AutoSyncWatchStopResult = 'stopped' | 'not_running' | 'refused' | 'timeout';
export interface WatchStatusRecord {
state: WatchStatusState;
pid?: number;
ownerId?: string;
configPath?: string;
message?: string;
updatedAt: string;
}
export interface WatchOwnerRecord {
pid: number;
ownerId: string;
processStartTime: string;
createdAt: string;
}
interface WatchStopRequestRecord {
pid: number;
ownerId: string;
processStartTime: string;
requestedAt: string;
}
const WATCH_STOP_POLL_MS = 250;
export interface AutoSyncWatchPaths {
pidPath: string;
mutexPath: string;
ownerPath: string;
statusPath: string;
}
export interface AutoSyncWatchControlDeps {
isProcessAlive(pid: number): boolean;
readProcessCommand(pid: number): string | undefined;
readProcessStartTime(pid: number): string | undefined;
sleep(ms: number): Promise<void>;
}
export function getAutoSyncWatchPaths(gitnexusDir = getGlobalDir()): AutoSyncWatchPaths {
const watchDir = getAutoSyncWatchDir(gitnexusDir);
return {
pidPath: path.join(watchDir, 'watch.pid'),
mutexPath: getAutoSyncMutexPath(gitnexusDir),
ownerPath: path.join(watchDir, 'watch.owner.json'),
statusPath: path.join(watchDir, 'watch.status.json'),
};
}
export async function startAutoSyncWatch(
options: {
setIntervalFn?: typeof setInterval;
clearIntervalFn?: typeof clearInterval;
runOnce?: typeof runAutoSyncOnce;
stderr?: Pick<NodeJS.WriteStream, 'write'>;
keepAlive?: boolean;
paths?: AutoSyncWatchPaths;
deps?: Partial<AutoSyncWatchControlDeps>;
} = {},
): Promise<AutoSyncStartHandle | null> {
const stderr = options.stderr ?? process.stderr;
const paths = options.paths ?? getAutoSyncWatchPaths();
const deps = resolveWatchDeps(options.deps);
const ownerId = crypto.randomUUID();
const processStartTime = deps.readProcessStartTime(process.pid);
if (!processStartTime) {
stderr.write('[auto-sync] Unable to verify the watch process start time.\n');
return null;
}
await fs.mkdir(path.dirname(paths.pidPath), { recursive: true });
const releaseLock = await acquireWatchLock(paths, deps, stderr, processStartTime);
if (!releaseLock) return null;
try {
await writeWatchOwner(paths, {
pid: process.pid,
ownerId,
processStartTime,
createdAt: new Date().toISOString(),
});
await writeAtomicText(paths.pidPath, `${process.pid}\n`);
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, 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;
const setIntervalFn = options.setIntervalFn ?? setInterval;
const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
let activeRun: Promise<void> | undefined;
let activeAbortController: AbortController | undefined;
let stopping = false;
let statusWrite = Promise.resolve();
const updateStatus = (state: WatchStatusState, message?: string) => {
const write = statusWrite.then(() =>
writeWatchStatus(paths, {
state,
pid: process.pid,
ownerId,
configPath: loaded.config.configPath,
message,
updatedAt: new Date().toISOString(),
}),
);
statusWrite = write.catch(() => {});
return write;
};
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,
onAnalysisCancellationRequested: () => {
if (!stopping) {
void updateStatus(
'cancelling',
'Analysis cancellation requested; waiting for the worker to reach a safe shutdown point.',
);
}
},
})
.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');
})
.finally(async () => {
if (activeRun === run) {
activeRun = undefined;
activeAbortController = undefined;
}
if (!stopping) await updateStatus('running');
});
activeRun = run;
activeAbortController = abortController;
};
let timer: ReturnType<typeof setInterval> | undefined;
let controlTimer: ReturnType<typeof setInterval> | undefined;
let stopPromise: Promise<void> | undefined;
const stop = () =>
(stopPromise ??= (async () => {
stopping = true;
if (timer) clearIntervalFn(timer);
if (controlTimer) clearIntervalFn(controlTimer);
activeAbortController?.abort();
try {
await updateStatus('stopping');
await activeRun?.catch(() => {});
await updateStatus('stopped');
} finally {
await cleanupWatchFiles(paths, ownerId, releaseLock);
}
})());
const checkStopRequest = async () => {
const request = await readStopRequest(stopRequestPath(paths, ownerId));
if (
request?.pid === process.pid &&
request.ownerId === ownerId &&
request.processStartTime === processStartTime
) {
void stop().catch((error: unknown) => {
stderr.write(`[auto-sync] Failed to stop watch: ${(error as Error).message}\n`);
});
}
};
runSafely();
controlTimer = setIntervalFn(() => void checkStopRequest(), WATCH_STOP_POLL_MS);
timer = setIntervalFn(runSafely, loaded.config.syncIntervalMinutes * 60_000);
if (options.keepAlive === false) {
controlTimer.unref?.();
timer.unref?.();
}
return { stop };
} catch (error) {
await cleanupWatchFiles(paths, ownerId, releaseLock).catch(() => {});
throw error;
}
}
async function acquireWatchLock(
paths: AutoSyncWatchPaths,
deps: AutoSyncWatchControlDeps,
stderr: Pick<NodeJS.WriteStream, 'write'>,
processStartTime: string,
): Promise<(() => Promise<void>) | null> {
try {
return await acquireFileLock(paths.mutexPath, {
pid: process.pid,
processStartTime,
isProcessAlive: deps.isProcessAlive,
readProcessStartTime: deps.readProcessStartTime,
});
} catch (err: unknown) {
if (!(err instanceof FileLockBusyError)) throw err;
}
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(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;
}
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(
options: {
paths?: AutoSyncWatchPaths;
stderr?: Pick<NodeJS.WriteStream, 'write'>;
deps?: Partial<AutoSyncWatchControlDeps>;
timeoutMs?: number;
pollMs?: number;
} = {},
): Promise<AutoSyncWatchStopResult> {
const stderr = options.stderr ?? process.stderr;
const paths = options.paths ?? getAutoSyncWatchPaths();
const deps = resolveWatchDeps(options.deps);
const timeoutMs = options.timeoutMs ?? 10_000;
const pollMs = options.pollMs ?? 100;
const pid = await readPid(paths.pidPath);
if (!pid) {
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 (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');
return 'not_running';
}
if (!deps.isProcessAlive(pid)) {
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) {
stderr.write(`[auto-sync] Refusing to stop pid ${pid}; ${owner.reason}.\n`);
return 'refused';
}
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';
}
await writeAtomicText(
stopRequestPath(paths, owner.owner.ownerId),
`${JSON.stringify({
pid,
ownerId: owner.owner.ownerId,
processStartTime: owner.owner.processStartTime,
requestedAt: new Date().toISOString(),
} satisfies WatchStopRequestRecord)}\n`,
);
stderr.write(`[auto-sync] Stop requested for watch pid ${pid}.\n`);
const stopped = await waitForProcessExit(pid, { deps, timeoutMs, pollMs });
if (!stopped) {
stderr.write(`[auto-sync] Watch pid ${pid} did not exit within ${timeoutMs}ms.\n`);
return 'timeout';
}
return 'stopped';
}
export async function readAutoSyncWatchStatus(
paths = getAutoSyncWatchPaths(),
deps: Partial<AutoSyncWatchControlDeps> = {},
): Promise<WatchStatusRecord> {
const resolvedDeps = resolveWatchDeps(deps);
const pid = await readPid(paths.pidPath);
if (pid && !resolvedDeps.isProcessAlive(pid)) {
return {
state: 'stale',
pid,
message: 'pid file exists but process is not running',
updatedAt: new Date().toISOString(),
};
}
if (pid) {
const stored = await readStatusFile(paths.statusPath);
if (stored?.state === 'error') {
return { ...stored, pid, updatedAt: new Date().toISOString() };
}
const owner = await readVerifiedWatchOwner(paths, pid, resolvedDeps);
if (owner.ok === false) {
return {
...stored,
state: 'error',
pid,
message: owner.reason,
updatedAt: new Date().toISOString(),
};
}
return {
...stored,
state:
stored?.state === 'cancelling' || stored?.state === 'stopping'
? stored.state
: 'running',
pid,
ownerId: owner.owner.ownerId,
updatedAt: new Date().toISOString(),
};
}
const stored = await readStatusFile(paths.statusPath);
return stored ?? { state: 'stopped', updatedAt: new Date().toISOString() };
}
async function readOwnerFile(ownerPath: string): Promise<WatchOwnerRecord | undefined> {
try {
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 &&
typeof parsed.processStartTime === 'string' &&
parsed.processStartTime
) {
return parsed;
}
return undefined;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
return undefined;
}
}
async function readVerifiedWatchOwner(
paths: AutoSyncWatchPaths,
pid: number,
deps: AutoSyncWatchControlDeps,
): Promise<{ ok: true; owner: WatchOwnerRecord } | { ok: false; reason: string }> {
const [status, owner] = await Promise.all([
readStatusFile(paths.statusPath),
readOwnerFile(paths.ownerPath),
]);
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 (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 !== owner.ownerId) {
return { ok: false, reason: 'watch status owner does not match watch owner' };
}
const identityError = getWatchProcessIdentityError(owner, deps);
if (identityError) return { ok: false, reason: identityError };
return { ok: true, owner };
}
function getWatchProcessIdentityError(
owner: WatchOwnerRecord,
deps: AutoSyncWatchControlDeps,
): string | undefined {
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) ||
!/(?:gitnexus|[\\/]cli[\\/]index\.(?:ts|[cm]?js))/.test(command)
) {
return 'pid command is not a GitNexus watch process';
}
return undefined;
}
async function waitForProcessExit(
pid: number,
options: { deps: AutoSyncWatchControlDeps; timeoutMs: number; pollMs: number },
): Promise<boolean> {
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline) {
if (!options.deps.isProcessAlive(pid)) return true;
await options.deps.sleep(options.pollMs);
}
return !options.deps.isProcessAlive(pid);
}
async function readPid(pidPath: string): Promise<number | undefined> {
try {
const raw = await fs.readFile(pidPath, 'utf-8');
const pid = Number(raw.trim());
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw err;
}
}
async function readStatusFile(statusPath: string): Promise<WatchStatusRecord | undefined> {
try {
const parsed = JSON.parse(await fs.readFile(statusPath, 'utf-8')) as WatchStatusRecord;
return parsed && typeof parsed === 'object' ? parsed : undefined;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
return {
state: 'error',
message: `unable to read status file: ${(err as Error).message}`,
updatedAt: new Date().toISOString(),
};
}
}
function stopRequestPath(paths: AutoSyncWatchPaths, ownerId: string): string {
return path.join(path.dirname(paths.pidPath), `watch.stop.${ownerId}.json`);
}
async function readStopRequest(filePath: string): Promise<WatchStopRequestRecord | undefined> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8')) as WatchStopRequestRecord;
if (
parsed &&
typeof parsed === 'object' &&
Number.isInteger(parsed.pid) &&
parsed.pid > 0 &&
typeof parsed.ownerId === 'string' &&
parsed.ownerId &&
typeof parsed.processStartTime === 'string' &&
parsed.processStartTime &&
typeof parsed.requestedAt === 'string' &&
parsed.requestedAt
) {
return parsed;
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
}
return undefined;
}
async function writeWatchStatus(
paths: AutoSyncWatchPaths,
record: WatchStatusRecord,
): Promise<void> {
await fs.mkdir(path.dirname(paths.statusPath), { recursive: true });
const tmpPath = `${paths.statusPath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf-8');
await fs.rename(tmpPath, paths.statusPath);
}
async function writeWatchOwner(paths: AutoSyncWatchPaths, record: WatchOwnerRecord): Promise<void> {
await writeAtomicText(paths.ownerPath, `${JSON.stringify(record, null, 2)}\n`);
}
async function cleanupWatchFiles(
paths: AutoSyncWatchPaths,
ownerId: string,
releaseLock: () => Promise<void>,
): Promise<void> {
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);
}
await removeIfExists(stopRequestPath(paths, ownerId));
}
} finally {
await releaseLock();
}
}
async function writeAtomicText(filePath: string, content: string): Promise<void> {
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<void> {
await fs.rm(filePath, { force: true });
}
async function fileExists(filePath: string): Promise<boolean> {
return fs.access(filePath).then(
() => true,
() => false,
);
}
function resolveWatchDeps(deps: Partial<AutoSyncWatchControlDeps> = {}): AutoSyncWatchControlDeps {
return {
isProcessAlive: deps.isProcessAlive ?? isProcessAlive,
readProcessCommand:
deps.readProcessCommand ??
((pid) => {
try {
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,
sleep:
deps.sleep ??
((ms) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
})),
};
}

View file

@ -0,0 +1,167 @@
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';
export interface AutoSyncCommitStateEntry {
codeCommitId: string;
analyzedCommitId?: string;
lastAnalyzeStatus?: AutoSyncAnalyzeStatus;
analyzeConsecutiveFailures?: number;
lastAnalyzeError?: string;
lastSyncTime: string;
}
export type AutoSyncCommitState = Record<string, AutoSyncCommitStateEntry>;
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');
}
export function getProjectCommitInfoPath(gitnexusDir = getGlobalDir()): string {
return path.join(getAutoSyncWatchDir(gitnexusDir), 'project_commit_info.txt');
}
export async function resetAutoSyncState(gitnexusDir = getGlobalDir()): Promise<boolean> {
let releaseLock: () => Promise<void>;
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}`;
}
export function shouldAnalyzeCommit(input: {
currentCommit: string;
previousAnalyzedCommit?: string;
previousStatus?: AutoSyncAnalyzeStatus;
}): boolean {
if (!input.currentCommit) return false;
if (input.previousStatus === 'failed') return true;
return input.currentCommit !== input.previousAnalyzedCommit;
}
export async function loadAutoSyncState(
statePath = getAutoSyncStatePath(),
): Promise<AutoSyncCommitState> {
try {
const raw = await fs.readFile(statePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, AutoSyncCommitStateEntry] =>
isAutoSyncCommitStateEntry(entry[1]),
),
);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
process.stderr.write(
`[auto-sync] Ignoring unreadable or corrupt state file: ${statePath}. State will be rebuilt.\n`,
);
}
return {};
}
}
function isAutoSyncCommitStateEntry(value: unknown): value is AutoSyncCommitStateEntry {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const entry = value as Record<string, unknown>;
return (
typeof entry.codeCommitId === 'string' &&
typeof entry.lastSyncTime === 'string' &&
(entry.analyzedCommitId === undefined || typeof entry.analyzedCommitId === 'string') &&
(entry.lastAnalyzeStatus === undefined ||
entry.lastAnalyzeStatus === 'success' ||
entry.lastAnalyzeStatus === 'failed' ||
entry.lastAnalyzeStatus === 'skipped' ||
entry.lastAnalyzeStatus === 'threshold_skipped') &&
(entry.analyzeConsecutiveFailures === undefined ||
(typeof entry.analyzeConsecutiveFailures === 'number' &&
Number.isInteger(entry.analyzeConsecutiveFailures) &&
entry.analyzeConsecutiveFailures >= 0)) &&
(entry.lastAnalyzeError === undefined || typeof entry.lastAnalyzeError === 'string')
);
}
export async function saveAutoSyncState(
state: AutoSyncCommitState,
statePath = getAutoSyncStatePath(),
): Promise<void> {
await fs.mkdir(path.dirname(statePath), { recursive: true });
const tmpPath = `${statePath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
await fs.rename(tmpPath, statePath);
}
export async function writeProjectCommitInfo(
entries: ProjectCommitInfoEntry[],
infoPath = getProjectCommitInfoPath(),
): Promise<void> {
await fs.mkdir(path.dirname(infoPath), { recursive: true });
const lines = [
'# GitNexus auto-sync project commit info',
`updated_at: ${new Date().toISOString()}`,
'',
...entries.flatMap((entry) => [
`remote: ${entry.remoteUrl}`,
`local_path: ${entry.localPath}`,
`branch: ${entry.branch ?? ''}`,
`code_commit: ${entry.codeCommitId ?? ''}`,
`analyzed_commit: ${entry.analyzedCommitId ?? ''}`,
`status: ${entry.status}`,
`analyze_consecutive_failures: ${entry.analyzeConsecutiveFailures ?? 0}`,
...(entry.analyzeFailureThreshold === undefined
? []
: [`analyze_failure_threshold: ${entry.analyzeFailureThreshold}`]),
...(entry.lastAnalyzeError ? [`last_analyze_error: ${entry.lastAnalyzeError}`] : []),
`last_sync_time: ${entry.lastSyncTime}`,
'',
]),
];
const tmpPath = `${infoPath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, `${lines.join('\n')}\n`, 'utf-8');
await fs.rename(tmpPath, infoPath);
}
export interface ProjectCommitInfoEntry {
remoteUrl: string;
localPath: string;
branch?: string;
codeCommitId?: string;
analyzedCommitId?: string;
status:
| AutoSyncAnalyzeStatus
| 'sync_failed'
| 'branch_skipped'
| 'branch_unavailable'
| 'sync_timeout';
analyzeConsecutiveFailures?: number;
analyzeFailureThreshold?: number;
lastAnalyzeError?: string;
lastSyncTime: string;
}

View file

@ -72,6 +72,7 @@ import {
shadowSidecarRecoveryMessage,
sidecarPreflightDisabled,
} from './sidecar-recovery.js';
import { isProcessAlive } from '../../utils/process-identity.js';
import { logger } from '../logger.js';
import {
@ -330,20 +331,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).

View file

@ -26,13 +26,20 @@
import type { AnalyzeOptions } from '../core/run-analyze.js';
import type { AnalyzeResultIpc } from './analyze-worker-ipc.js';
/** Parent → child: the single command that starts an analysis run. */
/** Parent → child: start one analysis run. */
export interface StartMessage {
type: 'start';
repoPath: string;
options: AnalyzeOptions;
}
/** Parent → child: request safe cancellation at the next JS-visible checkpoint. */
export interface CancelMessage {
type: 'cancel';
}
export type ParentMessage = StartMessage | CancelMessage;
export interface ProgressMessage {
type: 'progress';
phase: string;

View file

@ -6,12 +6,13 @@
*
* IPC Protocol:
* Parent -> Child: { type: 'start', repoPath: string, options: AnalyzeOptions }
* Parent -> Child: { type: 'cancel' }
* Child -> Parent: { type: 'progress', phase: string, percent: number, message: string }
* Child -> Parent: { type: 'complete', result: AnalyzeResult }
* Child -> Parent: { type: 'error', message: string }
*/
import type { StartMessage, WorkerMessage } from './analyze-worker-protocol.js';
import type { ParentMessage, WorkerMessage } from './analyze-worker-protocol.js';
import { runWorkerAnalysis, createTerminalClaim } from './analyze-worker-core.js';
type BoundedCheckpointBeforeExit =
typeof import('../core/lbug/shutdown-helpers.js').boundedCheckpointBeforeExit;
@ -62,19 +63,24 @@ process.on('unhandledRejection', (reason: unknown) => {
}
});
// Handle cancellation / timeout shutdown (analyze-job.ts `cancelJob` sends
// SIGTERM). Bounded CHECKPOINT-then-exit shared with the CLI SIGINT path (#2264):
// skip the native close (the LadybugDB destructor can double-free after --pdg
// writes), but don't block behind the in-flight COPY's connection lock — so a
// single cancel can't abort or hang the worker. A CHECKPOINT failure is reported
// to the parent over IPC, not swallowed; the exit always fires.
process.on('SIGTERM', () => {
// Only report the cancellation if the analysis hasn't already reported a
// terminal outcome (#2264 P3) — otherwise this would flip an already-complete
// job to failed. The cleanup + exit below run regardless.
// IPC cancellation is the cross-platform control path. It only records the
// request while analysis is active; cleanup waits until the analysis promise has
// returned to JS. SIGTERM is retained only for local process shutdown.
let cancellationRequested = false;
let started = false;
function requestWorkerCancellation(source: string): void {
if (cancellationRequested) return;
cancellationRequested = true;
if (claimTerminal()) {
send({ type: 'error', message: 'Analysis cancelled (worker received SIGTERM)' });
send({ type: 'error', message: `Analysis cancelled (${source})` });
}
if (!started) {
// No analysis has started, so no native work needs a safe-point handshake.
process.exit(0);
}
}
function exitAfterCancellation(): void {
if (!boundedCheckpointBeforeExit) {
process.exit(0);
return;
@ -82,17 +88,21 @@ process.on('SIGTERM', () => {
void boundedCheckpointBeforeExit({
exitCode: 0,
onFlushError: (err: unknown) => {
const message =
err instanceof Error ? err.message : 'Worker checkpoint failed during SIGTERM';
const message = err instanceof Error ? err.message : 'Worker checkpoint failed during cancellation';
send({ type: 'error', message });
},
});
});
}
// Listen for start command from parent — guarded against re-entry
let started = false;
process.on('message', async (msg: StartMessage) => {
if (msg.type !== 'start' || started) return;
process.on('SIGTERM', () => requestWorkerCancellation('worker received SIGTERM'));
// Listen for parent commands — guarded against re-entry.
process.on('message', async (msg: ParentMessage) => {
if (msg.type === 'cancel') {
requestWorkerCancellation('parent requested cancellation');
return;
}
if (started) return;
started = true;
try {
@ -112,6 +122,9 @@ process.on('message', async (msg: StartMessage) => {
},
);
boundedCheckpointBeforeExit = prepared.loaded.shutdownHelpers.boundedCheckpointBeforeExit;
// A cancel can arrive while the dynamic imports are resolving. Do not begin
// a new analysis after that request; the finally block performs safe cleanup.
if (cancellationRequested) return;
// The run → finalize → report contract lives in the side-effect-free
// analyze-worker-core seam (unit-testable without this entry module's
// process.on side effects). It reports exactly one terminal message and
@ -135,9 +148,11 @@ process.on('message', async (msg: StartMessage) => {
});
}
} finally {
// LadybugDB's native module prevents clean exit — force it (same reason the
// CLI uses process.exit(0)). In `finally` so the exit still fires even if the
// report above throws on a closed IPC channel (#2264 review P3).
setTimeout(() => process.exit(0), 500);
// A cancel must not end the process while runFullAnalysis may still be in
// native code. This continuation runs only after that promise has settled.
if (cancellationRequested) exitAfterCancellation();
// Normal terminal outcomes still need the existing process exit because
// LadybugDB stays live.
else setTimeout(() => process.exit(0), 500);
}
});

View file

@ -56,7 +56,7 @@ import {
} from '../core/embedding-count.js';
import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
import {
extractRepoName,
extractWebRepoName,
getCloneDir,
cloneOrPull,
warnIfInsecureAzureConfig,
@ -1568,7 +1568,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
try {
// Clone if URL provided
if (repoUrl && !repoLocalPath) {
const repoName = extractRepoName(repoUrl);
const repoName = extractWebRepoName(repoUrl);
targetPath = getCloneDir(repoName);
jobManager.updateJob(job.id, {

View file

@ -9,9 +9,15 @@ 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 { parseRepoNameFromUrl, stripUrlCredentials } from '../storage/git.js';
import { getGlobalDir } from '../storage/repo-manager.js';
import { sanitizeRepoName, stripUrlCredentials } from '../storage/git.js';
import {
assertDirectoryOwnerAndPermissions,
quarantineAutoSyncPartial,
} from '../core/auto-sync/path-security.js';
import { validateAutoSyncRemoteUrl } from '../core/auto-sync/config.js';
/**
* Root directory for all cloned repositories. Targets must resolve inside this.
@ -39,12 +45,16 @@ export const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
* clone root via path traversal.
*/
export function extractRepoName(url: string): string {
const name = parseRepoNameFromUrl(url);
let trimmed = url.trim();
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
const withoutGit = trimmed.toLowerCase().endsWith('.git') ? trimmed.slice(0, -4) : trimmed;
const name = withoutGit.split(/[/:]/).filter(Boolean).pop() ?? '';
if (
!name ||
name === '.' ||
name === '..' ||
name === 'unknown' ||
name.startsWith('-') ||
!REPO_NAME_PATTERN.test(name)
) {
throw new Error('Could not extract a valid repository name from URL');
@ -52,6 +62,26 @@ export function extractRepoName(url: string): string {
return name;
}
/**
* Derive a clone directory name for the web `/api/analyze` boundary.
*
* The API historically accepted Azure DevOps and similar URLs whose repo
* segment contains spaces or other directory-unsafe characters by sanitizing
* the final segment. Keep that compatibility at the web boundary while leaving
* `extractRepoName()` strict for internal/security-sensitive callers.
*/
export function extractWebRepoName(url: string): string {
let trimmed = url.trim();
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
const withoutGit = trimmed.toLowerCase().endsWith('.git') ? trimmed.slice(0, -4) : trimmed;
const rawName = withoutGit.split(/[/:]/).filter(Boolean).pop() ?? '';
const safeName = sanitizeRepoName(rawName);
if (!rawName || safeName === 'unknown') {
throw new Error('Could not extract a valid repository name from URL');
}
return safeName;
}
/** Get the clone target directory for a repo name. */
export function getCloneDir(repoName: string): string {
// Re-validate at the boundary even though extractRepoName already checked —
@ -87,6 +117,10 @@ export function validateGitUrl(url: string): void {
throw new Error('Only https:// and http:// git URLs are allowed');
}
if (parsed.search || parsed.hash) {
throw new Error('Git URLs must not include query strings or fragments');
}
const host = parsed.hostname.toLowerCase();
// Block known dangerous hostnames (cloud metadata services)
@ -235,6 +269,26 @@ export interface CloneProgress {
message: string;
}
export interface CloneOrPullOptions {
token?: string;
allowedCloneRoot?: string;
expectedRepoName?: string;
quarantineRoot?: string;
allowAutoSyncSsh?: boolean;
timeoutMs?: number;
branch?: string;
overwriteLocalChanges?: boolean;
runGitForTest?: typeof runGit;
}
type RunGitOptions = {
token?: string;
url?: string;
timeoutMs?: number;
timeoutKillGraceMs?: number;
spawnForTest?: typeof spawn;
};
/**
* Build the `git clone` argument list for a given URL and target directory.
*
@ -304,6 +358,10 @@ export function buildCloneArgs(url: string, targetDir: string): string[] {
return ['clone', '--depth', '1', '--', url, targetDir];
}
export function buildBranchCloneArgs(url: string, targetDir: string, branch: string): string[] {
return ['clone', '--depth', '1', '--branch', branch, '--', url, targetDir];
}
/**
* Normalize a git URL into a comparable form.
*
@ -363,27 +421,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<string | null> {
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<string | null> {
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;
}
}
/**
@ -403,8 +448,9 @@ export function getRemoteOriginUrl(cwd: string): Promise<string | null> {
export async function assertRemoteMatchesRequestedUrl(
targetDir: string,
requestedUrl: string,
timeoutMs?: number,
): Promise<void> {
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`);
}
@ -446,51 +492,200 @@ export async function cloneOrPull(
url: string,
targetDir: string,
onProgress?: (progress: CloneProgress) => void,
options?: { token?: string },
options?: CloneOrPullOptions,
): Promise<string> {
// Containment barrier — inline with the canonical path.relative idiom so
// CodeQL recognizes the sanitizer at every following filesystem and
// subprocess sink. The same `safeTarget` is used for every downstream
// path operation — no reassignment that the analyzer could lose track of.
//
// Limitation: this is a lexical containment check, not a realpath check.
// If an attacker can place a symlink under CLONE_ROOT pointing outside it,
// the lexical check passes but the clone lands at the symlink target. That
// requires pre-existing local write access to CLONE_ROOT, so the threat
// model considers it out of scope; CodeQL js/path-injection accepts the
// lexical form. Tracked as a follow-up if defense-in-depth is needed.
// The lexical check runs before filesystem creation; realpath and symlink
// checks below run before pull/clone and again after clone completes.
const cloneRoot = path.resolve(options?.allowedCloneRoot ?? CLONE_ROOT);
const expectedRepoName = options?.expectedRepoName;
if (expectedRepoName !== undefined && expectedRepoName !== extractRepoName(url)) {
throw new Error(`Clone target repo name ${expectedRepoName} does not match requested URL`);
}
const safeTarget = path.resolve(targetDir);
const rel = path.relative(CLONE_ROOT, safeTarget);
if (expectedRepoName !== undefined && path.basename(safeTarget) !== expectedRepoName) {
throw new Error(`Clone target basename must match repository name ${expectedRepoName}`);
}
const rel = path.relative(cloneRoot, safeTarget);
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Clone target must be a subdirectory of ${CLONE_ROOT}`);
throw new Error(`Clone target must be a subdirectory of ${cloneRoot}`);
}
// Always validate the requested URL — the prior shape only ran this in
// the code path where the repo was cloned. Now it runs unconditionally,
// preventing SSRF / blocked-host bypasses even when targetDir already exists.
validateGitUrl(url);
if (options?.allowAutoSyncSsh) validateAutoSyncRemoteUrl(url);
else validateGitUrl(url);
await fs.mkdir(cloneRoot, { recursive: true });
if (options?.allowedCloneRoot) {
await assertDirectoryOwnerAndPermissions(cloneRoot);
}
await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
await fs.mkdir(path.dirname(safeTarget), { recursive: true });
await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
await assertPreRealpathContainment(cloneRoot, safeTarget);
const exists = await fs.access(path.join(safeTarget, '.git')).then(
() => true,
() => false,
);
const targetExists = await fs.access(safeTarget).then(
() => true,
() => false,
);
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...' });
await runGit(['pull', '--ff-only'], safeTarget, { token: options?.token, url });
const runGitImpl = options?.runGitForTest ?? runGit;
if (options?.branch) {
if (!options.overwriteLocalChanges) {
const status = await runGitImpl(['status', '--porcelain'], safeTarget, {
token: options?.token,
url,
timeoutMs: options?.timeoutMs,
});
if (status.trim()) {
throw new Error(
`Refusing to update ${safeTarget}: local changes detected. Set overwrite_local_changes: true to overwrite them.`,
);
}
}
await runGitImpl(
[
'fetch',
'--depth',
'1',
'origin',
`refs/heads/${options.branch}:refs/remotes/origin/${options.branch}`,
],
safeTarget,
{
token: options?.token,
url,
timeoutMs: options?.timeoutMs,
},
);
await runGitImpl(
[
'checkout',
...(options.overwriteLocalChanges ? ['--force'] : []),
'-B',
options.branch,
`origin/${options.branch}`,
],
safeTarget,
{
token: options?.token,
url,
timeoutMs: options?.timeoutMs,
},
);
} else {
await runGitImpl(['pull', '--ff-only'], safeTarget, {
token: options?.token,
url,
timeoutMs: options?.timeoutMs,
});
}
} else {
await fs.mkdir(path.dirname(safeTarget), { recursive: true });
if (targetExists && (await fs.readdir(safeTarget)).length > 0) {
throw new Error(`Clone target already exists but is not a git repository: ${safeTarget}`);
}
onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });
await runGit(buildCloneArgs(url, safeTarget), undefined, { token: options?.token, url });
try {
const runGitImpl = options?.runGitForTest ?? runGit;
const cloneArgs = options?.branch
? buildBranchCloneArgs(url, safeTarget, options.branch)
: buildCloneArgs(url, safeTarget);
await runGitImpl(cloneArgs, undefined, {
token: options?.token,
url,
timeoutMs: options?.timeoutMs,
});
await assertPostRealpathContainment(cloneRoot, safeTarget);
} catch (err: unknown) {
if (options?.quarantineRoot) {
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;
}
}
return safeTarget;
}
async function assertPreRealpathContainment(root: string, target: string): Promise<void> {
const realRoot = await fs.realpath(root);
const realParent = await fs.realpath(path.dirname(target));
const parentRel = path.relative(realRoot, realParent);
if (parentRel.startsWith('..') || path.isAbsolute(parentRel)) {
throw new Error(`Clone target parent must resolve inside ${root}`);
}
}
async function assertPostRealpathContainment(root: string, target: string): Promise<void> {
const realRoot = await fs.realpath(root);
const realTarget = await fs.realpath(target);
const rel = path.relative(realRoot, realTarget);
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Clone target must resolve inside ${root}`);
}
}
async function assertNoSymlinkPath(
root: string,
target: string,
verifyOwnership = false,
): Promise<void> {
const resolvedRoot = path.resolve(root);
const resolvedTarget = path.resolve(target);
const relativeTarget = path.relative(resolvedRoot, resolvedTarget);
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) return;
let current = resolvedRoot;
for (const segment of relativeTarget.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
let stat;
try {
stat = await fs.lstat(current);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') break;
throw err;
}
if (stat.isSymbolicLink()) {
throw new Error(`Refusing symlink in clone target path: ${current}`);
}
if (verifyOwnership) await assertDirectoryOwnerAndPermissions(current);
}
}
/**
* Hosts the per-request GitHub PAT may be sent to. Exported so the
* /api/analyze boundary check and this injection-site check share one
@ -592,11 +787,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(
@ -619,18 +813,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;
}
@ -640,35 +837,65 @@ 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?: { token?: string; url?: string },
): Promise<void> {
export function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('git', args, {
const spawnGit = options?.spawnForTest ?? spawn;
const proc = spawnGit('git', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: buildGitEnv(process.env, options),
});
let stdout = '';
let stderr = '';
let settled = false;
let timedOut = false;
let killTimer: NodeJS.Timeout | undefined;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
fn();
};
const timer =
options?.timeoutMs && options.timeoutMs > 0
? setTimeout(() => {
timedOut = true;
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;
proc.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk;
});
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk;
});
proc.on('close', (code) => {
if (code === 0) resolve();
if (timedOut) {
finish(() => reject(new Error(`git ${args[0]} timed out after ${options?.timeoutMs}ms`)));
return;
}
if (code === 0) finish(() => resolve(stdout));
else {
// Log full stderr internally but don't expose it to API callers (SSRF mitigation)
if (stderr.trim()) logger.error(`git ${args[0]} stderr: ${stderr.trim()}`);
reject(new Error(`git ${args[0]} failed (exit code ${code})`));
finish(() => reject(new Error(`git ${args[0]} failed (exit code ${code})`)));
}
});
proc.on('error', (err) => {
reject(new Error(`Failed to spawn git: ${err.message}`));
finish(() => reject(new Error(`Failed to spawn git: ${err.message}`)));
});
});
}
export const runGitForTest = runGit;

View file

@ -0,0 +1,171 @@
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<void>> {
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 (!(await isLockConflict(error, resolvedPath))) throw error;
if (
await reclaimStaleLock(
resolvedPath,
owner,
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<void> | undefined;
return () => (releasePromise ??= releaseOwnedLock(resolvedPath, owner.ownerId));
}
async function reclaimStaleLock(
lockPath: string,
guardOwner: FileLockOwner,
ownerIsAlive: (pid: number) => boolean,
getProcessStartTime: (pid: number) => string | undefined,
): Promise<boolean> {
const reclaimGuardPath = `${lockPath}.reclaim`;
let releaseReclaimGuard: () => Promise<void>;
try {
releaseReclaimGuard = await acquireFileLock(reclaimGuardPath, {
pid: guardOwner.pid,
processStartTime: guardOwner.processStartTime,
isProcessAlive: ownerIsAlive,
readProcessStartTime: getProcessStartTime,
});
} catch (error) {
if (error instanceof FileLockBusyError) 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 releaseReclaimGuard();
}
}
async function releaseOwnedLock(lockPath: string, ownerId: string): Promise<void> {
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<boolean> {
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<FileLockOwner | undefined> {
try {
const parsed = JSON.parse(await fs.readFile(lockPath, 'utf-8')) as Partial<FileLockOwner>;
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;
}
async function isLockConflict(error: unknown, lockPath: string): Promise<boolean> {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'EEXIST') return true;
if (code !== 'EPERM') return false;
try {
await fs.access(lockPath);
return true;
} catch {
return false;
}
}

View file

@ -757,11 +757,10 @@ export const readRegistryStrict = async (): Promise<RegistryEntry[]> => readRegi
* registry is the one file every gitnexus process on the machine writes, and
* `withRegistryLock` degrades to unlocked on timeout, so the write cannot rely
* on the lock to keep two writers off one staging path (#2888).
*
* `attempts` is forwarded to the rename retry; best-effort callers pass `1`.
*/
const writeRegistry = async (entries: RegistryEntry[], attempts?: number): Promise<void> => {
await fs.mkdir(getGlobalDir(), { recursive: true });
const dir = getGlobalDir();
await fs.mkdir(dir, { recursive: true });
await writeFileAtomic(
getGlobalRegistryPath(),
JSON.stringify(sanitizeEntries(entries), null, 2),
@ -1545,12 +1544,7 @@ export const listRegisteredRepos = async (opts?: {
try {
await withRegistryLock(async () => {
const fresh = await readRegistry();
// attempts: 1 — the catch below discards a failure, so the rename
// backoff would only make every other process wait out this lock.
await writeRegistry(
fresh.filter((entry) => !pruned.has(entry.path)),
1,
);
await writeRegistry(fresh.filter((entry) => !pruned.has(entry.path)), 1);
});
} catch (err) {
// Best-effort housekeeping: callers consume the returned view, and the

View file

@ -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;
}
}

View file

@ -0,0 +1,162 @@
import { EventEmitter } from 'node:events';
import { describe, expect, it, vi } from 'vitest';
const { autoHeapCapMbMock } = vi.hoisted(() => ({ autoHeapCapMbMock: vi.fn(() => 512) }));
vi.mock('../../src/core/ingestion/utils/effective-ram.js', () => ({
autoHeapCapMb: autoHeapCapMbMock,
}));
import { createAutoSyncAnalysisRunner } from '../../src/core/auto-sync/analysis-worker-launch.js';
function createChild() {
return Object.assign(new EventEmitter(), {
send: vi.fn(),
stdout: { resume: vi.fn() },
stderr: { resume: vi.fn() },
});
}
describe('auto-sync analysis worker', () => {
it('ignores progress and resolves from the terminal complete message', async () => {
const child = createChild();
const forkWorker = vi.fn(() => child as any);
const run = createAutoSyncAnalysisRunner({ forkWorker });
const result = run('/tmp/repo', { branch: 'main' }, 50);
expect(forkWorker).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining(['--max-old-space-size=512']),
);
child.emit('message', { type: 'progress', phase: 'parsing', percent: 20, message: 'Parsing' });
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('requests cancellation on a worker error but waits for its safe exit', async () => {
const child = createChild();
const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) });
const result = run('/tmp/repo', { branch: 'main' }, 50);
let settled = false;
void result.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
child.emit('error', new Error('IPC disconnected'));
await Promise.resolve();
expect(settled).toBe(false);
expect(child.send).toHaveBeenLastCalledWith({ type: 'cancel' });
child.emit('exit', 1, null);
await expect(result).rejects.toThrow('Auto-sync analyze worker error: IPC disconnected');
});
it('preserves a worker terminal error', async () => {
const child = createChild();
const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) });
const result = run('/tmp/repo', { branch: 'main' }, 50);
child.emit('message', { type: 'progress', phase: 'parsing', percent: 20, message: 'Parsing' });
child.emit('message', { type: 'error', message: 'parser crashed' });
child.emit('exit', 1, null);
await expect(result).rejects.toThrow('parser crashed');
});
it('requests cancellation after timeout, reports it, and waits for exit', async () => {
const child = createChild();
const timers: Array<() => void> = [];
const onCancellationRequested = vi.fn();
const run = createAutoSyncAnalysisRunner({
forkWorker: vi.fn(() => child as any),
setTimeoutFn: vi.fn((callback: () => void) => {
timers.push(callback);
return timers.length as any;
}) as any,
clearTimeoutFn: vi.fn() as any,
});
const result = run('/tmp/repo', { branch: 'main' }, 50, undefined, onCancellationRequested);
timers[0]!();
expect(onCancellationRequested).toHaveBeenCalledOnce();
expect(child.send).toHaveBeenLastCalledWith({ type: 'cancel' });
let settled = false;
void result.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await Promise.resolve();
expect(settled).toBe(false);
child.emit('exit', 0, null);
await expect(result).rejects.toThrow('Analysis timed out after 50ms');
});
it('keeps the timeout outcome when complete arrives after cancellation begins', async () => {
const child = createChild();
const timers: Array<() => void> = [];
const run = createAutoSyncAnalysisRunner({
forkWorker: vi.fn(() => child as any),
setTimeoutFn: vi.fn((callback: () => void) => {
timers.push(callback);
return timers.length as any;
}) as any,
clearTimeoutFn: vi.fn() as any,
});
const result = run('/tmp/repo', { branch: 'main' }, 50);
timers[0]!();
child.emit('message', { type: 'complete', result: { stats: { files: 3 } } });
child.emit('exit', 0, null);
await expect(result).rejects.toThrow('Analysis timed out after 50ms');
});
it('does not send cancellation after a terminal complete message', async () => {
const child = createChild();
const timers: Array<() => void> = [];
const run = createAutoSyncAnalysisRunner({
forkWorker: vi.fn(() => child as any),
setTimeoutFn: vi.fn((callback: () => void) => {
timers.push(callback);
return timers.length as any;
}) as any,
clearTimeoutFn: vi.fn() as any,
});
const result = run('/tmp/repo', { branch: 'main' }, 50);
child.emit('message', { type: 'complete', result: { stats: { files: 3 } } });
child.emit('exit', 0, null);
await expect(result).resolves.toEqual({ stats: { files: 3 } });
expect(child.send).toHaveBeenCalledTimes(1);
expect(timers).toHaveLength(1);
});
it('uses the same cancellation request for an aborted watch run', async () => {
const child = createChild();
const controller = new AbortController();
const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) });
const result = run('/tmp/repo', { branch: 'main' }, 50, controller.signal);
controller.abort();
expect(child.send).toHaveBeenLastCalledWith({ type: 'cancel' });
child.emit('exit', 0, null);
await expect(result).rejects.toThrow('Analysis cancelled');
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,628 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
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;
let gitnexusHome: string;
let oldHome: string | undefined;
beforeEach(async () => {
const base = path.join(process.cwd(), '.tmp-test');
await fs.mkdir(base, { recursive: true });
tempDir = await fs.realpath(await fs.mkdtemp(path.join(base, 'gitnexus-auto-sync-')));
gitnexusHome = path.join(tempDir, '.gitnexus');
await fs.mkdir(gitnexusHome);
oldHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = gitnexusHome;
});
afterEach(async () => {
if (oldHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = oldHome;
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
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'),
);
expect(getProjectCommitInfoPath(gitnexusHome)).toBe(
path.join(gitnexusHome, 'watch', 'project_commit_info.txt'),
);
});
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'),
[
'sync_interval_minutes: 120',
'max_concurrency: 3',
'repo_git_timeout: 12s',
'analyze_timeout: 45m',
'analyze_failure_threshold: 2',
'projects:',
' - local_path: /tmp/repos',
' group_name: back_end',
' overwrite_local_changes: true',
' branches: [test, master, test]',
' remote_urls:',
' - git@gitee.com:qts_server/qts_account.git',
].join('\n'),
);
const loaded = await loadAutoSyncConfig();
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error('expected config to load');
expect(loaded.config.configPath).toBe(path.join(gitnexusHome, 'watch_config.yml'));
expect(loaded.config.syncIntervalMinutes).toBe(120);
expect(loaded.config.maxConcurrency).toBe(3);
expect(loaded.config.repoGitTimeoutMs).toBe(12_000);
expect(loaded.config.analyzeTimeoutMs).toBe(2_700_000);
expect(loaded.config.analyzeFailureThreshold).toBe(2);
expect(loaded.config.projects[0]).toMatchObject({
localPath: '/tmp/repos',
groupName: 'back_end',
overwriteLocalChanges: true,
branches: ['test', 'master'],
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
});
});
it('defaults repo_git_timeout and max_concurrency and allows empty group_name', async () => {
await fs.writeFile(
path.join(gitnexusHome, 'watch_config.yml'),
[
'sync_interval_minutes: 10',
'projects:',
' - local_path: /tmp/repos',
' group_name: ""',
' branch: master',
' remote_urls:',
' - git@github.com:owner/repo.git',
].join('\n'),
);
const loaded = await loadAutoSyncConfig();
expect(loaded.ok).toBe(true);
if (!loaded.ok) throw new Error('expected config');
expect(loaded.config.repoGitTimeoutMs).toBe(10_000);
expect(loaded.config.analyzeTimeoutMs).toBe(300_000);
expect(loaded.config.maxConcurrency).toBe(1);
expect(loaded.config.analyzeFailureThreshold).toBe(3);
expect(loaded.config.projects[0].groupName).toBeUndefined();
expect(loaded.config.projects[0].overwriteLocalChanges).toBe(false);
});
it('rejects 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'),
[
'sync_interval_minutes: 10',
'analyze_timeout: 6m',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:owner/repo.git',
].join('\n'),
);
const loaded = await loadAutoSyncConfig();
expect(loaded.ok).toBe(false);
if (loaded.ok) throw new Error('expected invalid config');
expect(loaded.message).toContain(
'analyze_timeout must not exceed half of sync_interval_minutes (5m)',
);
});
it('rejects invalid analyze_failure_threshold values', async () => {
await fs.writeFile(
path.join(gitnexusHome, 'watch_config.yml'),
[
'sync_interval_minutes: 10',
'analyze_failure_threshold: 1',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' remote_urls:',
' - git@github.com:owner/repo.git',
].join('\n'),
);
const loaded = await loadAutoSyncConfig();
expect(loaded.ok).toBe(false);
if (loaded.ok) throw new Error('expected invalid config');
expect(loaded.message).toContain('analyze_failure_threshold must be an integer >= 2');
});
it('reports missing config without throwing', async () => {
const loaded = await loadAutoSyncConfig();
expect(loaded).toEqual({
ok: false,
reason: 'missing',
message: `[auto-sync] Missing config file: ${path.join(gitnexusHome, 'watch_config.yml')}. Auto sync is skipped.`,
});
});
it('reports invalid config without throwing', async () => {
await fs.writeFile(path.join(gitnexusHome, 'watch_config.yml'), 'projects: []\n');
const loaded = await loadAutoSyncConfig();
expect(loaded.ok).toBe(false);
if (loaded.ok) throw new Error('expected invalid config');
expect(loaded.reason).toBe('invalid');
expect(loaded.message).toContain('[auto-sync] Invalid watch_config.yml:');
expect(loaded.message).toContain('sync_interval_minutes must be a positive integer');
expect(loaded.message).toContain('projects must contain at least one project');
});
it('rejects missing, relative, and traversal local_path values at config load', async () => {
await fs.writeFile(
path.join(gitnexusHome, 'watch_config.yml'),
[
'sync_interval_minutes: 10',
'projects:',
' - local_path: ../repos',
' branch: master',
' remote_urls:',
' - git@github.com:team/repo.git',
].join('\n'),
);
const loaded = await loadAutoSyncConfig();
expect(loaded.ok).toBe(false);
if (loaded.ok) throw new Error('expected invalid config');
expect(loaded.message).toContain('local_path must be an absolute path');
});
it('hard-fails unsafe configured clone roots', async () => {
await expect(resolveConfiguredCloneRoot('/')).rejects.toThrow('unsafe auto-sync clone root');
await expect(resolveConfiguredCloneRoot(os.homedir())).rejects.toThrow(
'unsafe auto-sync clone root',
);
await expect(
resolveConfiguredCloneRoot(path.join(await fs.realpath(os.tmpdir()), 'repos')),
).rejects.toThrow('unsafe auto-sync clone root');
const root = path.join(tempDir, 'repos');
await expect(resolveConfiguredCloneRoot(`${root}/../repos`)).rejects.toThrow('normalized');
});
it('rejects GitNexus internal directory descendants as clone roots', async () => {
for (const internalDir of ['groups', 'indexes', 'quarantine']) {
const root = path.join(gitnexusHome, internalDir, 'repo-root');
await fs.mkdir(root, { recursive: true });
await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('GitNexus internal directory');
}
});
it('allows the default GitNexus repos directory as an auto-sync clone root', async () => {
const root = path.join(gitnexusHome, 'repos');
await fs.mkdir(root, { recursive: true });
await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual(
expect.objectContaining({
root,
quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'),
}),
);
});
it('rejects symlinks in configured clone root paths', async () => {
const realRoot = path.join(tempDir, 'real-root');
const linkRoot = path.join(tempDir, 'link-root');
await fs.mkdir(realRoot);
await fs.symlink(realRoot, linkRoot);
await expect(resolveConfiguredCloneRoot(linkRoot)).rejects.toThrow('symlink');
});
it('resolves safe configured clone roots and reports quarantine retention', async () => {
const root = path.join(tempDir, 'repos');
await fs.mkdir(root);
await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual(
expect.objectContaining({
root,
quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'),
quarantineRetentionDays: 14,
}),
);
});
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('gives concurrent partial clone quarantines unique destinations', async () => {
const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine');
const first = path.join(tempDir, 'one', 'partial-repo');
const second = path.join(tempDir, 'two', 'partial-repo');
await Promise.all([fs.mkdir(first, { recursive: true }), fs.mkdir(second, { recursive: true })]);
const [firstDestination, secondDestination] = await Promise.all([
quarantineAutoSyncPartial(first, quarantineRoot),
quarantineAutoSyncPartial(second, quarantineRoot),
]);
expect(firstDestination).not.toBe(secondDestination);
await expect(fs.access(firstDestination)).resolves.toBeUndefined();
await expect(fs.access(secondDestination)).resolves.toBeUndefined();
await expect(fs.access(first)).rejects.toThrow();
await expect(fs.access(second)).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');
await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual(
expect.objectContaining({
root,
quarantineRoot: path.join(gitnexusHome, 'watch', 'quarantine'),
}),
);
expect((await fs.stat(root)).isDirectory()).toBe(true);
});
it('parses branch strings and arrays with trimming and de-duplication', () => {
expect(parseBranchCandidates('test, master, test')).toEqual(['test', 'master']);
expect(parseBranchCandidates(['develop,master', 'develop'])).toEqual(['develop', 'master']);
});
it('rejects unsafe auto-sync branch names', () => {
expect(() => validateAutoSyncBranchName('feature/good-branch')).not.toThrow();
expect(() => validateAutoSyncBranchName('foo./bar')).not.toThrow();
expect(() => validateAutoSyncBranchName('-upload-pack=evil')).toThrow('must not start');
expect(() => validateAutoSyncBranchName('feature bad')).toThrow('whitespace');
expect(() => validateAutoSyncBranchName('feature..bad')).toThrow('must not contain ".."');
expect(() => validateAutoSyncBranchName('bad:ref')).toThrow('not allowed');
expect(() => validateAutoSyncBranchName('feature.')).toThrow('must not end');
expect(() => validateAutoSyncBranchName('feature/')).toThrow('must not end');
expect(() => validateAutoSyncBranchName('feature//branch')).toThrow('consecutive');
expect(() => validateAutoSyncBranchName('feature@{x')).toThrow('must not contain "@{"');
expect(() => validateAutoSyncBranchName('.hidden')).toThrow('hidden');
expect(() => validateAutoSyncBranchName('foo/bar.lock')).toThrow('hidden or .lock');
});
it('extracts safe repository names from remote URLs', () => {
expect(extractRepoNameFromRemoteUrl('git@gitee.com:qts_server/qts_account.git')).toBe(
'qts_account',
);
expect(extractRepoNameFromRemoteUrl('git@gitlab.com:team/subgroup/repo-name.git')).toBe(
'repo-name',
);
});
it('rejects unsafe repository names without sanitizing them', () => {
expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/repo$name.git')).toThrow(
'valid repository name',
);
expect(() => extractRepoNameFromRemoteUrl('git@github.com:team/..')).toThrow('traversal');
});
it('allows only github, gitlab, and gitee SSH SCP remote URLs', () => {
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo')).not.toThrow();
expect(() => validateAutoSyncRemoteUrl('git@github.com:im-fan/multica.git')).not.toThrow();
expect(() => validateAutoSyncRemoteUrl('git@gitlab.com:group/subgroup/repo.git')).not.toThrow();
expect(() =>
validateAutoSyncRemoteUrl('git@gitee.com:qts-ops/qts-code-engineering.git'),
).not.toThrow();
expect(() => validateAutoSyncRemoteUrl('https://github.com/owner/repo.git')).toThrow(
'must use',
);
expect(() => validateAutoSyncRemoteUrl('ssh://git@github.com/owner/repo.git')).toThrow(
'must use',
);
expect(() => validateAutoSyncRemoteUrl('user@github.com:owner/repo.git')).toThrow('must use');
expect(() => validateAutoSyncRemoteUrl('git@example.com:owner/repo.git')).toThrow(
'host must be',
);
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo.git?ref=main')).toThrow(
'must not include query strings or fragments',
);
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/repo.git#main')).toThrow(
'must not include query strings or fragments',
);
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner/')).toThrow('path must include');
expect(() => validateAutoSyncRemoteUrl('git@github.com:owner//repo')).toThrow(
'path must include',
);
});
it('parses repo git timeout durations', () => {
expect(parseDurationMs('10s')).toBe(10_000);
expect(parseDurationMs('2m')).toBe(120_000);
expect(parseDurationMs('5000ms')).toBe(5000);
expect(parseDurationMs('10')).toBe(10_000);
expect(parseDurationMs(10)).toBe(10_000);
});
it('keeps branch compatibility but rejects branch and branches together', async () => {
await fs.writeFile(
path.join(gitnexusHome, 'watch_config.yml'),
[
'sync_interval_minutes: 10',
'projects:',
' - local_path: /tmp/repos',
' branch: master',
' branches: [develop]',
' remote_urls:',
' - git@github.com:owner/repo.git',
].join('\n'),
);
const loaded = await loadAutoSyncConfig();
expect(loaded.ok).toBe(false);
if (loaded.ok) throw new Error('expected invalid config');
expect(loaded.message).toContain('must not set both branch and branches');
});
it('uses commit ids to skip unchanged analyses and retry failed prior analyses', () => {
expect(shouldAnalyzeCommit({ currentCommit: 'abc', previousAnalyzedCommit: 'abc' })).toBe(
false,
);
expect(
shouldAnalyzeCommit({
currentCommit: 'abc',
previousAnalyzedCommit: 'abc',
previousStatus: 'failed',
}),
).toBe(true);
expect(shouldAnalyzeCommit({ currentCommit: 'def', previousAnalyzedCommit: 'abc' })).toBe(true);
});
it('saves state atomically and reloads it', async () => {
const statePath = path.join(tempDir, 'auto-sync-state.json');
await saveAutoSyncState(
{
'/tmp/repos/qts_account|master': {
codeCommitId: 'abc',
analyzedCommitId: 'abc',
lastAnalyzeStatus: 'success',
analyzeConsecutiveFailures: 2,
lastAnalyzeError: 'old error',
lastSyncTime: '2026-06-30T00:00:00.000Z',
},
},
statePath,
);
await expect(fs.readdir(tempDir)).resolves.not.toContain(
expect.stringContaining('auto-sync-state.json.tmp'),
);
await expect(loadAutoSyncState(statePath)).resolves.toEqual({
'/tmp/repos/qts_account|master': {
codeCommitId: 'abc',
analyzedCommitId: 'abc',
lastAnalyzeStatus: 'success',
analyzeConsecutiveFailures: 2,
lastAnalyzeError: 'old error',
lastSyncTime: '2026-06-30T00:00:00.000Z',
},
});
});
it('returns empty state and reports corrupt state files', async () => {
const statePath = path.join(tempDir, 'auto-sync-state.json');
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
await fs.writeFile(statePath, '{not-json', 'utf-8');
await expect(loadAutoSyncState(statePath)).resolves.toEqual({});
expect(stderr).toHaveBeenCalledWith(
`[auto-sync] Ignoring unreadable or corrupt state file: ${statePath}. State will be rebuilt.\n`,
);
});
it('drops malformed state entries while preserving valid entries', async () => {
const statePath = path.join(tempDir, 'auto-sync-state.json');
await fs.writeFile(
statePath,
JSON.stringify({
'/tmp/repos/valid|main': {
codeCommitId: 'abc',
analyzedCommitId: 'abc',
lastAnalyzeStatus: 'success',
analyzeConsecutiveFailures: 0,
lastSyncTime: '2026-06-30T00:00:00.000Z',
},
'/tmp/repos/invalid|main': {
codeCommitId: 123,
analyzeConsecutiveFailures: -1,
lastSyncTime: null,
},
}),
);
await expect(loadAutoSyncState(statePath)).resolves.toEqual({
'/tmp/repos/valid|main': {
codeCommitId: 'abc',
analyzedCommitId: 'abc',
lastAnalyzeStatus: 'success',
analyzeConsecutiveFailures: 0,
lastSyncTime: '2026-06-30T00:00:00.000Z',
},
});
});
it('writes project_commit_info.txt atomically', async () => {
const infoPath = path.join(tempDir, 'project_commit_info.txt');
await writeProjectCommitInfo(
[
{
remoteUrl: 'git@github.com:owner/repo.git',
localPath: '/tmp/repos/repo',
branch: 'master',
codeCommitId: 'abc',
analyzedCommitId: 'abc',
status: 'success',
analyzeConsecutiveFailures: 0,
analyzeFailureThreshold: 3,
lastSyncTime: '2026-06-30T00:00:00.000Z',
},
{
remoteUrl: 'git@github.com:owner/bad.git',
localPath: '/tmp/repos/bad',
branch: 'master',
codeCommitId: 'def',
analyzedCommitId: 'abc',
status: 'threshold_skipped',
analyzeConsecutiveFailures: 3,
analyzeFailureThreshold: 3,
lastAnalyzeError: 'parser crashed',
lastSyncTime: '2026-06-30T00:00:00.000Z',
},
],
infoPath,
);
const content = await fs.readFile(infoPath, 'utf-8');
expect(content).toContain('remote: git@github.com:owner/repo.git');
expect(content).toContain('code_commit: abc');
expect(content).toContain('analyze_consecutive_failures: 0');
expect(content).toContain('analyze_failure_threshold: 3');
expect(content).toContain('status: threshold_skipped');
expect(content).toContain('last_analyze_error: parser crashed');
await expect(fs.readdir(tempDir)).resolves.not.toContain(
expect.stringContaining('project_commit_info.txt.tmp'),
);
});
});

View file

@ -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';
@ -16,7 +17,11 @@ function runHelp(command: string, env: NodeJS.ProcessEnv = {}) {
}
function runHelpArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args, '--help'], {
return runCliArgs([...args, '--help'], env);
}
function runCliArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, ...env },
@ -242,6 +247,91 @@ describe('CLI help surface', () => {
}
});
it('watch help exposes lifecycle actions and state files', () => {
const result = runHelp('watch');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus watch [options] [action]');
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(os.tmpdir(), 'gitnexus-watch-init-'));
try {
const first = runCliArgs(['watch', 'init'], { GITNEXUS_HOME: home });
const configPath = path.join(home, 'watch_config.yml');
expect(first.status).toBe(0);
expect(first.stdout).toContain(`Created ${configPath}`);
const config = fs.readFileSync(configPath, 'utf8');
expect(config).toContain('sync_interval_minutes: 10');
expect(config).toContain('analyze_failure_threshold: 3');
expect(config).toContain('analyze_timeout: 5m');
expect(config).toContain('overwrite_local_changes: false');
expect(config).toContain(`local_path: ${path.join(home, '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 });
expect(second.status).toBe(1);
expect(second.stderr).toContain(`Config already exists: ${configPath}`);
expect(fs.readFileSync(configPath, 'utf8')).toBe(config);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
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');

View file

@ -0,0 +1,205 @@
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, vi } from 'vitest';
import { acquireFileLock, FileLockBusyError } from '../../src/storage/file-lock.js';
const tempDirs: string[] = [];
async function tempLockPath(): Promise<string> {
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('propagates hard-link EPERM when no lock exists', async () => {
const lockPath = await tempLockPath();
const error = Object.assign(new Error('hard links unavailable'), { code: 'EPERM' });
const link = vi.spyOn(fs, 'link').mockRejectedValueOnce(error);
try {
await expect(acquireFileLock(lockPath)).rejects.toBe(error);
} finally {
link.mockRestore();
}
});
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' });
await expect(
acquireFileLock(lockPath, {
pid: 222,
processStartTime: 'next-start',
isProcessAlive: () => true,
readProcessStartTime: () => 'old-start',
}),
).rejects.toBeInstanceOf(FileLockBusyError);
const nextRelease = await acquireFileLock(lockPath, {
pid: 222,
processStartTime: 'next-start',
isProcessAlive: () => true,
readProcessStartTime: () => 'reused-pid-start',
});
await nextRelease();
});
it('fails closed for legacy or invalid lock contents without owner metadata', async () => {
const lockPath = await tempLockPath();
const invalidContents = ['legacy lock', '{not json', JSON.stringify({ pid: 123 })];
await fs.mkdir(path.dirname(lockPath), { recursive: true });
for (const content of invalidContents) {
await fs.writeFile(lockPath, content, 'utf-8');
await expect(
acquireFileLock(lockPath, { pid: 456, processStartTime: 'next-start' }),
).rejects.toBeInstanceOf(FileLockBusyError);
await expect(fs.readFile(lockPath, 'utf-8')).resolves.toBe(content);
await fs.rm(lockPath);
}
await fs.mkdir(lockPath, { recursive: true });
await expect(
acquireFileLock(lockPath, { pid: 456, processStartTime: 'next-start' }),
).rejects.toBeInstanceOf(FileLockBusyError);
await expect(fs.access(lockPath)).resolves.toBeUndefined();
});
it('recovers when a stale reclaim guard was left by a crashed contender', async () => {
const lockPath = await tempLockPath();
await acquireFileLock(lockPath, { pid: 999, processStartTime: 'abandoned' });
await acquireFileLock(`${lockPath}.reclaim`, {
pid: 998,
processStartTime: 'abandoned-reclaimer',
});
const release = await acquireFileLock(lockPath, {
pid: 1000,
processStartTime: 'next',
isProcessAlive: () => false,
});
await release();
});
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<void>> =>
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();
});
});

View file

@ -16,20 +16,24 @@ vi.mock('../../src/core/logger.js', () => ({
import {
extractRepoName,
extractWebRepoName,
getCloneDir,
validateGitUrl,
cloneOrPull,
buildCloneArgs,
buildBranchCloneArgs,
buildGitEnv,
normalizeGitUrlForCompare,
assertRemoteMatchesRequestedUrl,
isAzureDevOpsUrl,
warnIfInsecureAzureConfig,
runGitForTest,
} from '../../src/server/git-clone.js';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { getRemoteOriginUrl } from '../../src/storage/git.js';
import { getGlobalDir } from '../../src/storage/repo-manager.js';
@ -42,6 +46,31 @@ import { getGlobalDir } from '../../src/storage/repo-manager.js';
// load, the same point CLONE_ROOT is frozen, so the two always agree.
const EXPECTED_CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos'));
async function mkControlledRoot(prefix: string): Promise<string> {
const base = path.join(process.cwd(), '.tmp-test');
await fs.mkdir(base, { recursive: true });
return fs.realpath(await fs.mkdtemp(path.join(base, prefix)));
}
function runGit(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (chunk: Buffer) => {
stdout += chunk;
});
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk;
});
proc.on('close', (code) => {
if (code === 0) resolve(stdout);
else reject(new Error(`git ${args.join(' ')} failed (${code}): ${stderr}`));
});
proc.on('error', reject);
});
}
describe('git-clone', () => {
describe('extractRepoName', () => {
it('extracts name from HTTPS URL', () => {
@ -95,29 +124,39 @@ describe('git-clone', () => {
expect(elapsedMs).toBeLessThan(500);
});
it('strips leading dashes to prevent argument injection', () => {
expect(extractRepoName('https://github.com/user/--upload-pack=payload.git')).toBe(
'upload-pack_payload',
it('rejects leading dashes to prevent argument injection', () => {
expect(() => extractRepoName('https://github.com/user/--upload-pack=payload.git')).toThrow(
'valid repository name',
);
expect(() => extractRepoName('https://github.com/user/-repo')).toThrow(
'valid repository name',
);
expect(extractRepoName('https://github.com/user/-repo')).toBe('repo');
});
it('sanitizes unsafe directory characters', () => {
// sanitizeRepoName turns <tag> into _tag_
expect(extractRepoName('https://github.com/user/repo<tag>.git')).toBe('repo_tag_');
it('rejects unsafe directory characters instead of sanitizing them', () => {
expect(() => extractRepoName('https://github.com/user/repo<tag>.git')).toThrow(
'valid repository name',
);
});
it('sanitizes shell metacharacters in URL segments', () => {
it('rejects shell metacharacters in URL segments', () => {
// The split on /[/:]/ does not split on backslashes or other shell chars,
// so a name like `repo;rm -rf /` would slip through without the pattern.
// After fix/sanitize-repo-name, these are sanitized to underscores.
expect(extractRepoName('https://example.com/foo:repo;rm')).toBe('repo_rm');
expect(extractRepoName('https://example.com/foo:repo$x')).toBe('repo_x');
// so a name like `repo;rm -rf /` must fail instead of being rewritten.
expect(() => extractRepoName('https://example.com/foo:repo;rm')).toThrow(
'valid repository name',
);
expect(() => extractRepoName('https://example.com/foo:repo$x')).toThrow(
'valid repository name',
);
});
it('sanitizes whitespace and backslashes', () => {
expect(extractRepoName('https://example.com/foo:repo name')).toBe('repo_name');
expect(extractRepoName('https://example.com/foo:repo\\name')).toBe('repo_name');
it('rejects whitespace and backslashes', () => {
expect(() => extractRepoName('https://example.com/foo:repo name')).toThrow(
'valid repository name',
);
expect(() => extractRepoName('https://example.com/foo:repo\\name')).toThrow(
'valid repository name',
);
});
});
@ -158,6 +197,15 @@ describe('git-clone', () => {
expect(() => validateGitUrl('http://gitlab.com/user/repo.git')).not.toThrow();
});
it('rejects query strings and fragments instead of reinterpreting clone remotes', () => {
expect(() => validateGitUrl('https://github.com/user/repo.git?ref=main')).toThrow(
'must not include query strings or fragments',
);
expect(() => validateGitUrl('https://github.com/user/repo.git#main')).toThrow(
'must not include query strings or fragments',
);
});
it('blocks SSH protocol', () => {
expect(() => validateGitUrl('ssh://git@github.com/user/repo.git')).toThrow(
'Only https:// and http://',
@ -349,9 +397,23 @@ describe('git-clone', () => {
expect(args.some((a) => a.toLowerCase().includes('authorization'))).toBe(false);
expect(args.some((a) => a.includes('extraHeader'))).toBe(false);
});
it('adds --branch before the URL separator for branch-specific clones', () => {
const args = buildBranchCloneArgs('git@github.com:owner/repo.git', '/safe/target', 'develop');
expect(args).toEqual([
'clone',
'--depth',
'1',
'--branch',
'develop',
'--',
'git@github.com:owner/repo.git',
'/safe/target',
]);
});
});
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.
@ -375,33 +437,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', () => {
@ -410,7 +472,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);
}
});
@ -420,12 +482,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;
@ -439,8 +501,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');
}
@ -455,13 +517,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)', () => {
@ -469,7 +566,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');
});
@ -534,6 +631,387 @@ describe('git-clone', () => {
'Only https:// and http://',
);
});
it('keeps regular cloneOrPull restricted to http and https URLs', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
try {
await expect(
cloneOrPull('git@github.com:owner/repo.git', path.join(root, 'repo'), undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
}),
).rejects.toThrow('Invalid URL');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('creates missing nested parents before checking controlled clone containment', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
const target = path.join(root, 'github.com', 'owner', 'repo');
const runGitForTest = vi.fn(async () => {
await fs.mkdir(path.join(target, '.git'), { recursive: true });
return '';
});
try {
await expect(
cloneOrPull('git@github.com:owner/repo', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
allowAutoSyncSsh: true,
runGitForTest,
}),
).resolves.toBe(target);
expect(runGitForTest).toHaveBeenCalledOnce();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('allows auto-sync SSH SCP clone URLs with a per-repo timeout', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
const target = path.join(root, 'repo');
const runGitForTest = vi.fn(async () => {
await fs.mkdir(target);
return '';
});
try {
await expect(
cloneOrPull('git@gitlab.com:group/subgroup/repo.git', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
allowAutoSyncSsh: true,
timeoutMs: 10_000,
branch: 'develop',
runGitForTest,
}),
).resolves.toBe(target);
expect(runGitForTest).toHaveBeenCalledWith(
[
'clone',
'--depth',
'1',
'--branch',
'develop',
'--',
'git@gitlab.com:group/subgroup/repo.git',
target,
],
undefined,
{ token: undefined, url: 'git@gitlab.com:group/subgroup/repo.git', timeoutMs: 10_000 },
);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('allows an explicitly controlled auto-sync clone root outside the default root', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
try {
const target = path.join(root, 'repo');
await expect(
cloneOrPull('http://127.0.0.1/repo.git', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
}),
).rejects.toThrow('private/internal');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('rejects controlled-root target names that do not match the remote repo name', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
try {
await expect(
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'other'), undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
}),
).rejects.toThrow('basename must match');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('rejects symlink children before clone or pull', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
const outside = await mkControlledRoot('gitnexus-outside-');
try {
await fs.symlink(outside, path.join(root, 'repo'));
await expect(
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'repo'), 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 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');
try {
await new Promise<void>((resolve, reject) => {
const proc = spawn('git', ['init'], { cwd: root, stdio: 'ignore' });
proc.on('close', (code) =>
code === 0 ? resolve() : reject(new Error(`git init ${code}`)),
);
proc.on('error', reject);
});
await fs.rename(path.join(root, '.git'), path.join(target, '.git')).catch(async () => {
await fs.mkdir(target);
await fs.rename(path.join(root, '.git'), path.join(target, '.git'));
});
await fs.writeFile(
path.join(target, '.git', 'config'),
[
'[remote "origin"]',
'\turl = https://example.com/other/repo.git',
'\tfetch = +refs/heads/*:refs/remotes/origin/*',
'',
].join('\n'),
);
await expect(
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
}),
).rejects.toThrow('not the requested URL');
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('switches a shallow single-branch clone to a fallback branch', async () => {
const root = await mkControlledRoot('gitnexus-shallow-fallback-');
const source = path.join(root, 'source');
const remote = path.join(root, 'remote.git');
const target = path.join(root, 'repo');
const remoteUrl = 'git@github.com:team/repo.git';
const gitConfig = path.join(root, 'gitconfig');
const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL;
const previousNoSystemConfig = process.env.GIT_CONFIG_NOSYSTEM;
try {
await runGit(['init', '--bare', remote], root);
await runGit(['init', '--initial-branch=master', source], root);
await runGit(['config', 'user.email', 'test@example.com'], source);
await runGit(['config', 'user.name', 'GitNexus Test'], source);
await fs.writeFile(path.join(source, 'branch.txt'), 'master\n');
await runGit(['add', 'branch.txt'], source);
await runGit(['commit', '-m', 'master'], source);
await runGit(['checkout', '-b', 'main'], source);
await fs.writeFile(path.join(source, 'branch.txt'), 'main\n');
await runGit(['commit', '-am', 'main'], source);
await runGit(['remote', 'add', 'origin', `file://${remote}`], source);
await runGit(['push', 'origin', 'master', 'main'], source);
await fs.writeFile(
gitConfig,
`[protocol "file"]\n\tallow = always\n[url "file://${remote}"]\n\tinsteadOf = ${remoteUrl}\n`,
);
process.env.GIT_CONFIG_GLOBAL = gitConfig;
process.env.GIT_CONFIG_NOSYSTEM = '1';
await runGit(['clone', '--depth', '1', '--branch', 'master', remoteUrl, target], root);
await expect(
runGit(['show-ref', '--verify', '--quiet', 'refs/remotes/origin/main'], target),
).rejects.toThrow();
await expect(runGit(['rev-parse', '--is-shallow-repository'], target)).resolves.toBe(
'true\n',
);
await fs.writeFile(path.join(target, 'branch.txt'), 'local changes\n');
await expect(
cloneOrPull(remoteUrl, target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
allowAutoSyncSsh: true,
branch: 'main',
}),
).rejects.toThrow();
await expect(fs.readFile(path.join(target, 'branch.txt'), 'utf8')).resolves.toBe(
'local changes\n',
);
await cloneOrPull(remoteUrl, target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
allowAutoSyncSsh: true,
branch: 'main',
overwriteLocalChanges: true,
});
await expect(runGit(['branch', '--show-current'], target)).resolves.toBe('main\n');
await expect(fs.readFile(path.join(target, 'branch.txt'), 'utf8')).resolves.toBe('main\n');
await expect(runGit(['rev-parse', 'main'], target)).resolves.toBe(
await runGit(['rev-parse', 'origin/main'], target),
);
} finally {
if (previousGlobalConfig === undefined) delete process.env.GIT_CONFIG_GLOBAL;
else process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;
if (previousNoSystemConfig === undefined) delete process.env.GIT_CONFIG_NOSYSTEM;
else process.env.GIT_CONFIG_NOSYSTEM = previousNoSystemConfig;
await fs.rm(root, { recursive: true, force: true });
}
});
it('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');
const target = path.join(root, 'repo');
try {
await expect(
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
quarantineRoot,
runGitForTest: async () => {
await fs.mkdir(target);
await fs.writeFile(path.join(target, 'partial.txt'), 'partial', 'utf-8');
throw new Error('git clone failed (exit code 128)');
},
}),
).rejects.toThrow('git clone failed');
const entries = await fs.readdir(quarantineRoot);
expect(
entries.some((entry) => entry.startsWith('auto-sync-') && entry.endsWith('-repo')),
).toBe(true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('does not quarantine an existing non-git directory on clone failure', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
const quarantineRoot = path.join(root, 'quarantine');
const target = path.join(root, 'repo');
try {
await fs.mkdir(target);
await fs.writeFile(path.join(target, 'user-file.txt'), 'keep me', 'utf-8');
await expect(
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
quarantineRoot,
}),
).rejects.toThrow('already exists but is not a git repository');
await expect(fs.readFile(path.join(target, 'user-file.txt'), 'utf-8')).resolves.toBe(
'keep me',
);
await expect(fs.access(quarantineRoot)).rejects.toThrow();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it('rejects controlled clone roots with unsafe permissions inside cloneOrPull', async () => {
const root = await mkControlledRoot('gitnexus-controlled-root-');
try {
await fs.chmod(root, 0o777);
await expect(
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'repo'), undefined, {
allowedCloneRoot: root,
expectedRepoName: 'repo',
}),
).rejects.toThrow('world-writable');
} finally {
await fs.chmod(root, 0o700).catch(() => {});
await fs.rm(root, { recursive: true, force: true });
}
});
});
describe('isAzureDevOpsUrl', () => {
@ -646,6 +1124,30 @@ describe('git-clone', () => {
});
});
describe('extractWebRepoName — API clone compatibility', () => {
it('sanitizes repo names with spaces and unsafe directory characters at the web boundary', () => {
expect(extractWebRepoName('https://dev.azure.com/org/project/_git/My Repo With Spaces')).toBe(
'My_Repo_With_Spaces',
);
expect(extractWebRepoName('https://example.com/team/repo$name.git')).toBe('repo_name');
});
it('keeps Windows reserved names from becoming clone directories', () => {
expect(() => extractWebRepoName('https://example.com/team/CON.git')).toThrow(
'valid repository name',
);
expect(() => extractWebRepoName('https://example.com/team/NUL.txt')).toThrow(
'valid repository name',
);
});
it('leaves strict extractRepoName behavior unchanged for internal callers', () => {
expect(() => extractRepoName('https://example.com/team/repo$name.git')).toThrow(
'valid repository name',
);
});
});
describe('validateGitUrl — Azure DevOps URLs', () => {
it('allows self-hosted Azure DevOps Server URLs', () => {
expect(() =>
@ -824,4 +1326,42 @@ describe('git-clone', () => {
}
});
});
describe('runGit timeout', () => {
it('rejects after SIGKILL even when the child never closes', async () => {
vi.useFakeTimers();
try {
const child = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
child.stderr = new EventEmitter();
child.kill = vi.fn();
const spawnForTest = vi.fn(() => child) as unknown as typeof spawn;
const promise = runGitForTest(['clone'], undefined, {
timeoutMs: 20,
timeoutKillGraceMs: 20,
spawnForTest,
});
await vi.advanceTimersByTimeAsync(25);
let settled = false;
promise
.catch(() => {})
.finally(() => {
settled = true;
});
await vi.runAllTicks();
expect(child.kill).toHaveBeenCalledWith('SIGTERM');
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(25);
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
await expect(promise).rejects.toThrow('timed out after 20ms');
} finally {
vi.useRealTimers();
}
});
});
});

View file

@ -413,14 +413,32 @@ describe('windowsHide regression', () => {
/**
* Count spawn-family invocations. The regex matches ``spawn(``,
* ``spawnSync(``, ``execFile(``, ``execFileSync(``,
* ``execFileAsync(``, ``execSync(`` as function calls not
* destructures (``const { spawn } = ...``), not method calls
* (``.exec(``), not bare ``exec()`` (which collides with regex
* ``.exec()``; we explicitly drop it).
* ``execFileAsync(``, ``execSync(`` and simple local aliases that
* point at one of those functions as function calls not destructures
* (``const { spawn } = ...``), not method calls (``.exec(``), not bare
* ``exec()`` (which collides with regex ``.exec()``; we explicitly
* drop it).
*/
function countSpawnCalls(codeSource: string): number {
const re =
/(^|[^a-zA-Z0-9_$.])(spawn|spawnSync|execFile|execFileSync|execFileAsync|execSync)\s*\(/gm;
const spawnFunctions = [
'spawn',
'spawnSync',
'execFile',
'execFileSync',
'execFileAsync',
'execSync',
];
const spawnNames = new Set(spawnFunctions);
const aliasRe = new RegExp(
`\\bconst\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*[^;\\n]*\\b(?:${spawnFunctions.join('|')})\\b`,
'g',
);
let aliasMatch: RegExpExecArray | null;
while ((aliasMatch = aliasRe.exec(codeSource)) !== null) {
spawnNames.add(aliasMatch[1]);
}
const re = new RegExp(`(^|[^a-zA-Z0-9_$.])(${[...spawnNames].join('|')})\\s*\\(`, 'gm');
let count = 0;
while (re.exec(codeSource) !== null) {
count++;

View file

@ -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);
});
});

View file

@ -171,8 +171,10 @@ describe('writeRegistry — private tmp path per transaction (#2888)', () => {
it('keeps serving a validating read when the prune write fails', async () => {
await registerRepo(tmpRepoA.dbPath, meta, { name: 'gone' });
fsCtx.renameMock.mockClear();
// EBUSY is normally retryable, but prune persistence is best-effort and
// must not hold the registry lock through retry backoff.
fsCtx.renameMock.mockImplementationOnce(() =>
Promise.reject(Object.assign(new Error('mock read-only home'), { code: 'EROFS' })),
Promise.reject(Object.assign(new Error('mock busy registry'), { code: 'EBUSY' })),
);
const cap = _captureLogger();

View file

@ -816,6 +816,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.

View file

@ -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');
});
});