mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
fix(watch): harden auto-sync lifecycle and locking
- validate watch process identity before lifecycle operations\n- serialize registry, analysis, and LadybugDB access with recoverable locks\n- harden clone paths, symlinks, hooks, quarantine, and worker timeouts\n- install procps in the CLI image for reliable Docker watch control\n- add focused regression coverage for lifecycle, locks, clone, and registry behavior
This commit is contained in:
parent
315c1a67a0
commit
e92d845796
32 changed files with 1883 additions and 681 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -128,7 +128,6 @@ local_docs/
|
|||
!.agents/plugins/marketplace.json
|
||||
.context/
|
||||
gitnexus/web/
|
||||
/log/
|
||||
|
||||
# Machine-local skill-evolution evidence (consumed by eval/workflow_bench/evolve.py)
|
||||
eval/workflow_bench/learnings.jsonl
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -434,6 +434,7 @@ gitnexus watch start # `gitnexus watch` is equivalent
|
|||
gitnexus watch status
|
||||
gitnexus watch restart # Required after config changes
|
||||
gitnexus watch stop
|
||||
gitnexus watch reset # Clear failure state; leaves clones and indexes intact
|
||||
```
|
||||
|
||||
`GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration:
|
||||
|
|
@ -449,11 +450,11 @@ projects:
|
|||
- git@github.com:owner/repo.git
|
||||
```
|
||||
|
||||
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path.
|
||||
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
|
||||
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
|
||||
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
|
||||
- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten. Stopping watch cancels an active analysis immediately.
|
||||
- Add `group_name` only after creating that group with `gitnexus group create <name>`.
|
||||
- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timed-out workers are terminated before scheduling resumes. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten. Stopping watch cancels an active analysis immediately.
|
||||
- Add `group_name` only after creating that group with `gitnexus group create <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.
|
||||
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl
|
|||
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus watch [init|start|restart|stop|status] # Control auto-sync from GITNEXUS_HOME/watch_config.yml
|
||||
gitnexus watch [init|start|restart|stop|status|reset] # Control auto-sync from GITNEXUS_HOME/watch_config.yml
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI
|
||||
gitnexus index # Register an existing .gitnexus/ folder into the global registry
|
||||
|
|
@ -277,7 +277,7 @@ gitnexus group impact <name> --target <symbol> --repo <groupPath> # Cross-repo
|
|||
|
||||
### `gitnexus watch`
|
||||
|
||||
`gitnexus watch` is the explicit long-running auto-sync entrypoint. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus watch init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus watch` is the same as `gitnexus watch start`; `restart`, `stop`, and `status` manage the same `GITNEXUS_HOME` instance. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.lock` prevents multiple watch processes for one home, `watch.pid` and `watch.status.json` expose process state, and `quarantine/` stores partial clone output.
|
||||
`gitnexus watch` is the explicit long-running auto-sync entrypoint. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus watch init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus watch` is the same as `gitnexus watch start`; `restart`, `stop`, `status`, and `reset` manage the same `GITNEXUS_HOME` instance. `reset` removes only the derived analysis state and commit snapshot; clones, indexes, and registry entries are untouched. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.mutex` prevents multiple watch processes for one home, `watch.owner.json` records ownership metadata, `watch.pid` plus `watch.status.json` expose process state, and `quarantine/` stores partial clone output before entries are removed after 14 days. Mutexes with verified dead owners are reclaimed automatically after an abnormal exit. Invalid or legacy mutexes fail closed; confirm no watch process is running before manually removing `watch.mutex` and stale `watch.pid` / `watch.owner.json`.
|
||||
|
||||
```yaml
|
||||
sync_interval_minutes: 10
|
||||
|
|
@ -288,7 +288,6 @@ analyze_failure_threshold: 3
|
|||
projects:
|
||||
- local_path: /abs/path/to/repos
|
||||
branches: [master, main]
|
||||
group_name: back_end
|
||||
overwrite_local_changes: false
|
||||
remote_urls:
|
||||
- git@github.com:owner/repo.git
|
||||
|
|
@ -296,7 +295,7 @@ projects:
|
|||
- git@gitee.com:owner/repo.git
|
||||
```
|
||||
|
||||
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. On timeout watch terminates that worker, records the failed attempt, and resumes scheduling only after the worker exits. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes. Stopping watch cancels an active analysis worker immediately. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit resets the failure count and is analyzed again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
|
||||
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. On timeout watch terminates that worker, records the failed attempt, and resumes scheduling only after the worker exits. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes. Stopping watch cancels an active analysis worker immediately. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit or `gitnexus watch reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <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.
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export const en = {
|
|||
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
|
||||
'help.command.watch.description':
|
||||
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
|
||||
'help.watch.details':
|
||||
'\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only git@github.com:owner/repo.git, git@gitlab.com:group/repo.git, and git@gitee.com:owner/repo.git are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.',
|
||||
'help.command.analyze.description': 'Index a repository (full analysis)',
|
||||
'help.command.index.description':
|
||||
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ export const zhCN = {
|
|||
'撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
|
||||
'help.command.watch.description':
|
||||
'控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
|
||||
'help.watch.details':
|
||||
'\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:仅允许 git@github.com:owner/repo.git、git@gitlab.com:group/repo.git 和 git@gitee.com:owner/repo.git。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。',
|
||||
'help.command.analyze.description': '索引仓库(完整分析)',
|
||||
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
|
||||
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
|
||||
|
|
|
|||
|
|
@ -50,18 +50,7 @@ program
|
|||
.description(
|
||||
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
|
||||
)
|
||||
.addHelpText(
|
||||
'after',
|
||||
[
|
||||
'',
|
||||
'Actions: init, start (default), restart, stop, status',
|
||||
'Configuration: GITNEXUS_HOME/watch_config.yml',
|
||||
'Runtime files: GITNEXUS_HOME/watch/watch.pid, watch.lock, watch.status.json, auto-sync-state.json',
|
||||
'Writes: GITNEXUS_HOME/watch/project_commit_info.txt',
|
||||
'Remote URLs: only git@github.com:owner/repo.git, git@gitlab.com:group/repo.git, and git@gitee.com:owner/repo.git are allowed.',
|
||||
'Runs once immediately, then repeats on sync_interval_minutes.',
|
||||
].join('\n'),
|
||||
)
|
||||
.addHelpText('after', () => t('help.watch.details'))
|
||||
.action(createLazyAction(() => import('./watch.js'), 'watchCommand'));
|
||||
|
||||
// Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import fs from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import {
|
||||
getAutoSyncConfigPath,
|
||||
getAutoSyncMutexPath,
|
||||
readAutoSyncWatchStatus,
|
||||
resetAutoSyncState,
|
||||
startAutoSyncWatch,
|
||||
stopAutoSyncWatch,
|
||||
type WatchStatusRecord,
|
||||
|
|
@ -13,17 +15,28 @@ export async function watchCommand(action = 'start'): Promise<void> {
|
|||
await initWatchConfig();
|
||||
return;
|
||||
}
|
||||
if (action === 'reset') {
|
||||
if (!(await resetAutoSyncState())) {
|
||||
process.stderr.write(
|
||||
`[auto-sync] Cannot reset analysis state while the watch mutex is held. Confirm no watch process is running, then remove ${getAutoSyncMutexPath()}.\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.stdout.write('[auto-sync] Reset analysis state.\n');
|
||||
return;
|
||||
}
|
||||
if (action === 'status') {
|
||||
printStatus(await readAutoSyncWatchStatus());
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopAutoSyncWatch();
|
||||
if ((await stopAutoSyncWatch()) !== 'stopped') process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (action === 'restart') {
|
||||
const stopped = await stopAutoSyncWatch();
|
||||
if (!stopped) {
|
||||
const result = await stopAutoSyncWatch();
|
||||
if (result === 'refused' || result === 'timeout') {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
|
@ -46,10 +59,17 @@ async function startWatchProcess(): Promise<void> {
|
|||
}
|
||||
|
||||
const stop = () => {
|
||||
void handle.stop().finally(() => {
|
||||
process.stderr.write('[auto-sync] Watch stopped.\n');
|
||||
process.exit(0);
|
||||
});
|
||||
void handle.stop().then(
|
||||
() => {
|
||||
process.stderr.write('[auto-sync] Watch stopped.\n');
|
||||
process.exit(0);
|
||||
},
|
||||
(error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`[auto-sync] Failed to stop watch: ${message}\n`);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
};
|
||||
process.once('SIGINT', stop);
|
||||
process.once('SIGTERM', stop);
|
||||
|
|
@ -70,7 +90,7 @@ async function initWatchConfig(): Promise<void> {
|
|||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
defaultSyncConfig(path.resolve(path.dirname(configPath), 'repo')),
|
||||
defaultSyncConfig(path.resolve(path.dirname(configPath), 'repos')),
|
||||
{
|
||||
flag: 'wx',
|
||||
},
|
||||
|
|
@ -96,7 +116,6 @@ function defaultSyncConfig(localPath: string): string {
|
|||
'projects:',
|
||||
` - local_path: ${localPath}`,
|
||||
' branches: [master, main]',
|
||||
' group_name: back_end',
|
||||
' overwrite_local_changes: false',
|
||||
' remote_urls:',
|
||||
' - git@github.com:owner/repo.git',
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ export type AutoSyncAnalysisRunner = (
|
|||
signal?: AbortSignal,
|
||||
) => Promise<Pick<AnalyzeResult, 'stats'>>;
|
||||
|
||||
interface AnalysisWorker extends Pick<ChildProcess, 'send' | 'kill' | 'on'> {}
|
||||
interface AnalysisWorker extends Pick<ChildProcess, 'send' | 'kill' | 'on'> {
|
||||
stdout?: Pick<NodeJS.ReadableStream, 'resume'> | null;
|
||||
stderr?: Pick<NodeJS.ReadableStream, 'resume'> | null;
|
||||
}
|
||||
|
||||
export interface AutoSyncAnalysisLaunchDeps {
|
||||
forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker;
|
||||
|
|
@ -59,61 +62,73 @@ export function createAutoSyncAnalysisRunner(
|
|||
? ['--import', pathToFileURL(_require.resolve('tsx/esm')).href, '--max-old-space-size=8192']
|
||||
: ['--max-old-space-size=8192'];
|
||||
const child = deps.forkWorker(workerPath, execArgv);
|
||||
let outcome: WorkerMessage | undefined;
|
||||
let timedOut = false;
|
||||
let cancelled = false;
|
||||
child.stdout?.resume();
|
||||
child.stderr?.resume();
|
||||
|
||||
let terminalOutcome: WorkerMessage | undefined;
|
||||
let terminationGrace: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = deps.setTimeoutFn(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
terminationGrace = deps.setTimeoutFn(() => child.kill('SIGKILL'), TERMINATION_GRACE_MS);
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
cancelled = true;
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
deps.clearTimeoutFn(timeout);
|
||||
if (terminationGrace) deps.clearTimeoutFn(terminationGrace);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const settle = (error?: Error, result?: Pick<AnalyzeResult, 'stats'>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
if (error) reject(error);
|
||||
else resolve(result!);
|
||||
};
|
||||
const timeout = deps.setTimeoutFn(() => {
|
||||
child.kill('SIGTERM');
|
||||
terminationGrace = deps.setTimeoutFn(() => {
|
||||
child.kill('SIGKILL');
|
||||
settle(new Error(`Analysis timed out after ${timeoutMs}ms.`));
|
||||
}, TERMINATION_GRACE_MS);
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
child.kill('SIGKILL');
|
||||
settle(new Error('Analysis cancelled.'));
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
child.on('message', (message: WorkerMessage) => {
|
||||
if (message.type !== 'progress') outcome ??= message;
|
||||
else outcome = message;
|
||||
if (message.type !== 'progress') terminalOutcome ??= message;
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
outcome = { type: 'error', message: `Auto-sync analyze worker error: ${error.message}` };
|
||||
settle(new Error(`Auto-sync analyze worker error: ${error.message}`));
|
||||
});
|
||||
child.on('exit', (code, childSignal) => {
|
||||
deps.clearTimeoutFn(timeout);
|
||||
if (terminationGrace) deps.clearTimeoutFn(terminationGrace);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
if (cancelled) {
|
||||
reject(new Error('Analysis cancelled.'));
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
reject(
|
||||
if (settled) return;
|
||||
if (terminationGrace) {
|
||||
settle(
|
||||
new Error(
|
||||
`Analysis timed out after ${timeoutMs}ms and worker exited (${childSignal ?? code ?? 'unknown'}).`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (outcome?.type === 'complete') {
|
||||
resolve({ stats: outcome.result.stats });
|
||||
if (terminalOutcome?.type === 'complete') {
|
||||
settle(undefined, { stats: terminalOutcome.result.stats });
|
||||
return;
|
||||
}
|
||||
if (outcome?.type === 'error') {
|
||||
reject(new Error(outcome.message));
|
||||
if (terminalOutcome?.type === 'error') {
|
||||
settle(new Error(terminalOutcome.message));
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
settle(
|
||||
new Error(
|
||||
`Auto-sync analyze worker exited before completion (${signal ?? code ?? 'unknown'}).`,
|
||||
`Auto-sync analyze worker exited before completion (${childSignal ?? code ?? 'unknown'}).`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.send({ type: 'start', repoPath, options });
|
||||
try {
|
||||
child.send({ type: 'start', repoPath, options });
|
||||
} catch (error) {
|
||||
child.kill('SIGKILL');
|
||||
settle(new Error(`Failed to start auto-sync analyze worker: ${(error as Error).message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ export function parseAutoSyncConfig(content: string, configPath: string): AutoSy
|
|||
: parseDurationMs(raw.repo_git_timeout);
|
||||
if (!Number.isInteger(repoGitTimeoutMs) || repoGitTimeoutMs <= 0) {
|
||||
errors.push('repo_git_timeout must be a positive duration such as 10s');
|
||||
} else if (repoGitTimeoutMs > MAX_TIMER_DELAY_MS) {
|
||||
errors.push(`repo_git_timeout must not exceed ${MAX_TIMER_DELAY_MS}ms`);
|
||||
}
|
||||
|
||||
const maxAnalyzeTimeoutMs =
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ export {
|
|||
} from './config.js';
|
||||
export {
|
||||
buildStateKey,
|
||||
getAutoSyncMutexPath,
|
||||
getAutoSyncWatchDir,
|
||||
getAutoSyncStatePath,
|
||||
getProjectCommitInfoPath,
|
||||
loadAutoSyncState,
|
||||
resetAutoSyncState,
|
||||
saveAutoSyncState,
|
||||
shouldAnalyzeCommit,
|
||||
writeProjectCommitInfo,
|
||||
|
|
@ -49,6 +51,7 @@ export {
|
|||
startAutoSyncWatch,
|
||||
stopAutoSyncWatch,
|
||||
type AutoSyncStartHandle,
|
||||
type AutoSyncWatchStopResult,
|
||||
type AutoSyncWatchPaths,
|
||||
type WatchStatusRecord,
|
||||
} from './starter.js';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@ import path from 'node:path';
|
|||
import { getGlobalDir } from '../../storage/repo-manager.js';
|
||||
import { getAutoSyncWatchDir } from './state.js';
|
||||
|
||||
const WINDOWS_DANGEROUS_ROOTS =
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
process.env.SystemRoot,
|
||||
process.env.ProgramData,
|
||||
process.env.ProgramFiles,
|
||||
process.env['ProgramFiles(x86)'],
|
||||
].filter((entry): entry is string => Boolean(entry))
|
||||
: [];
|
||||
|
||||
const DANGEROUS_ROOTS = new Set(
|
||||
[
|
||||
'/',
|
||||
|
|
@ -25,6 +35,7 @@ const DANGEROUS_ROOTS = new Set(
|
|||
'/tmp',
|
||||
'/usr',
|
||||
'/var',
|
||||
...WINDOWS_DANGEROUS_ROOTS,
|
||||
].map((entry) => path.resolve(entry)),
|
||||
);
|
||||
|
||||
|
|
@ -47,6 +58,7 @@ const DANGEROUS_PARENT_ROOTS = new Set(
|
|||
'/tmp',
|
||||
'/usr',
|
||||
'/var',
|
||||
...WINDOWS_DANGEROUS_ROOTS,
|
||||
].map((entry) => path.resolve(entry)),
|
||||
);
|
||||
|
||||
|
|
@ -72,10 +84,12 @@ export async function resolveConfiguredCloneRoot(localPath: string): Promise<Aut
|
|||
);
|
||||
assertNotDangerousRoot(realRoot);
|
||||
assertNotGitNexusInternalRoot(realRoot);
|
||||
const quarantineRoot = path.join(getAutoSyncWatchDir(), 'quarantine');
|
||||
await removeExpiredQuarantineEntries(quarantineRoot);
|
||||
|
||||
return {
|
||||
root: realRoot,
|
||||
quarantineRoot: path.join(getAutoSyncWatchDir(), 'quarantine'),
|
||||
quarantineRoot,
|
||||
quarantineRetentionDays: QUARANTINE_RETENTION_DAYS,
|
||||
};
|
||||
}
|
||||
|
|
@ -102,7 +116,13 @@ export async function quarantineAutoSyncPartial(
|
|||
const base = path.basename(targetDir);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const destination = path.join(quarantineRoot, `auto-sync-${stamp}-${process.pid}-${base}`);
|
||||
await fs.rename(targetDir, destination);
|
||||
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`,
|
||||
[
|
||||
|
|
@ -118,7 +138,30 @@ export async function quarantineAutoSyncPartial(
|
|||
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);
|
||||
|
|
@ -167,14 +210,20 @@ async function assertNoSymlinkPath(root: string): Promise<void> {
|
|||
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;
|
||||
const sticky = (stat.mode & 0o1000) !== 0;
|
||||
if (worldWritable && !sticky) {
|
||||
throw new Error(`Refusing world-writable auto-sync clone root without sticky bit: ${root}`);
|
||||
if (worldWritable) {
|
||||
throw new Error(`Refusing world-writable auto-sync clone root: ${root}`);
|
||||
}
|
||||
if (groupWritable) {
|
||||
throw new Error(`Refusing group-writable auto-sync clone root: ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import { createRequire } from 'node:module';
|
||||
import { loadGroupConfig } from '../group/config-parser.js';
|
||||
import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js';
|
||||
import { syncGroup } from '../group/sync.js';
|
||||
import { runFullAnalysis } from '../run-analyze.js';
|
||||
import { getCurrentBranch, getCurrentCommit } from '../../storage/git.js';
|
||||
import { registerRepo, type RepoMeta } from '../../storage/repo-manager.js';
|
||||
import { extractRepoNameFromRemoteUrl } from './repo.js';
|
||||
import { cloneOrPull } from '../../server/git-clone.js';
|
||||
import { cloneOrPull, runGit } from '../../server/git-clone.js';
|
||||
import { resolveConfiguredCloneRoot } from './path-security.js';
|
||||
import {
|
||||
buildStateKey,
|
||||
|
|
@ -32,9 +30,8 @@ export interface AutoSyncLogger {
|
|||
|
||||
export interface AutoSyncRunDeps {
|
||||
cloneOrPull: typeof cloneOrPull;
|
||||
getCurrentBranch: typeof getCurrentBranch;
|
||||
getCurrentCommit: typeof getCurrentCommit;
|
||||
runFullAnalysis?: typeof runFullAnalysis;
|
||||
getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise<string | undefined>;
|
||||
getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise<string>;
|
||||
runAnalysis: AutoSyncAnalysisRunner;
|
||||
registerRepo: typeof registerRepo;
|
||||
loadState: typeof loadAutoSyncState;
|
||||
|
|
@ -53,6 +50,9 @@ export interface AutoSyncRunResult {
|
|||
failed: number;
|
||||
}
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
|
||||
const DEFAULT_LOGGER: AutoSyncLogger = {
|
||||
info: (message) => process.stderr.write(`${message}\n`),
|
||||
warn: (message) => process.stderr.write(`${message}\n`),
|
||||
|
|
@ -61,8 +61,12 @@ const DEFAULT_LOGGER: AutoSyncLogger = {
|
|||
|
||||
const DEFAULT_DEPS: AutoSyncRunDeps = {
|
||||
cloneOrPull,
|
||||
getCurrentBranch,
|
||||
getCurrentCommit,
|
||||
getCurrentBranch: async (repoPath, timeoutMs) => {
|
||||
const branch = (await runGit(['branch', '--show-current'], repoPath, { timeoutMs })).trim();
|
||||
return branch || undefined;
|
||||
},
|
||||
getCurrentCommit: async (repoPath, timeoutMs) =>
|
||||
(await runGit(['rev-parse', 'HEAD'], repoPath, { timeoutMs })).trim(),
|
||||
runAnalysis: runAutoSyncAnalysis,
|
||||
registerRepo,
|
||||
loadState: loadAutoSyncState,
|
||||
|
|
@ -109,9 +113,11 @@ export async function runAutoSyncOnce(
|
|||
const lastSyncTime = now().toISOString();
|
||||
try {
|
||||
throwIfAborted(options.signal);
|
||||
validateAutoSyncRemoteUrl(item.remoteUrl);
|
||||
const repoName = extractRepoNameFromRemoteUrl(item.remoteUrl);
|
||||
const targetDir = getConfiguredRepoPath({ localPath: item.cloneRoot.root }, repoName);
|
||||
if (!item.cloneRoot || !item.repoName || !item.targetDir) {
|
||||
throw new Error(item.error ?? 'Invalid auto-sync work item');
|
||||
}
|
||||
const repoName = item.repoName;
|
||||
const targetDir = item.targetDir;
|
||||
const syncResult = await syncFirstAvailableBranch({
|
||||
item,
|
||||
repoName,
|
||||
|
|
@ -139,7 +145,7 @@ export async function runAutoSyncOnce(
|
|||
|
||||
const currentBranch = syncResult.branch;
|
||||
|
||||
const currentCommit = deps.getCurrentCommit(targetDir);
|
||||
const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs);
|
||||
const stateKey = buildStateKey(targetDir, currentBranch);
|
||||
const previous = state[stateKey];
|
||||
let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped';
|
||||
|
|
@ -166,18 +172,12 @@ export async function runAutoSyncOnce(
|
|||
})
|
||||
) {
|
||||
try {
|
||||
const analysis = deps.runFullAnalysis
|
||||
? await deps.runFullAnalysis(
|
||||
targetDir,
|
||||
{ branch: currentBranch, skipAgentsMd: true, skipSkills: true },
|
||||
{ onProgress: () => {} },
|
||||
)
|
||||
: await deps.runAnalysis(
|
||||
targetDir,
|
||||
{ branch: currentBranch, skipAgentsMd: true, skipSkills: true },
|
||||
config.analyzeTimeoutMs,
|
||||
options.signal,
|
||||
);
|
||||
const analysis = await deps.runAnalysis(
|
||||
targetDir,
|
||||
{ branch: currentBranch, skipAgentsMd: true, skipSkills: true },
|
||||
config.analyzeTimeoutMs,
|
||||
options.signal,
|
||||
);
|
||||
throwIfAborted(options.signal);
|
||||
stats = analysis.stats;
|
||||
analyzeStatus = 'success';
|
||||
|
|
@ -245,16 +245,11 @@ export async function runAutoSyncOnce(
|
|||
}
|
||||
|
||||
result.synced += 1;
|
||||
const stateEntry: AutoSyncCommitStateEntry = {
|
||||
codeCommitId: repoResult.currentCommit,
|
||||
analyzedCommitId: repoResult.analyzedCommitId,
|
||||
lastAnalyzeStatus: repoResult.analyzeStatus,
|
||||
analyzeConsecutiveFailures: repoResult.analyzeConsecutiveFailures,
|
||||
lastAnalyzeError: repoResult.lastAnalyzeError,
|
||||
lastSyncTime: repoResult.lastSyncTime,
|
||||
};
|
||||
state[repoResult.stateKey] = stateEntry;
|
||||
if (repoResult.analyzeStatus === 'success') {
|
||||
let analyzeStatus = repoResult.analyzeStatus;
|
||||
let analyzeConsecutiveFailures = repoResult.analyzeConsecutiveFailures;
|
||||
let lastAnalyzeError = repoResult.lastAnalyzeError;
|
||||
let analyzedCommitId = repoResult.analyzedCommitId;
|
||||
if (analyzeStatus === 'success') {
|
||||
const meta: RepoMeta = {
|
||||
repoPath: repoResult.targetDir,
|
||||
lastCommit: repoResult.currentCommit,
|
||||
|
|
@ -262,28 +257,45 @@ export async function runAutoSyncOnce(
|
|||
stats: repoResult.stats!,
|
||||
branch: repoResult.branch,
|
||||
};
|
||||
await deps.registerRepo(repoResult.targetDir, meta, {
|
||||
name: getAutoSyncRepoIdentity(repoResult.remoteUrl),
|
||||
});
|
||||
result.analyzed += 1;
|
||||
} else if (repoResult.analyzeStatus === 'failed') {
|
||||
try {
|
||||
await deps.registerRepo(repoResult.targetDir, meta, {
|
||||
name: getAutoSyncRepoIdentity(repoResult.remoteUrl),
|
||||
});
|
||||
result.analyzed += 1;
|
||||
} catch (err: unknown) {
|
||||
analyzeStatus = 'failed';
|
||||
analyzedCommitId = undefined;
|
||||
analyzeConsecutiveFailures += 1;
|
||||
lastAnalyzeError = `Repository registration failed: ${shortErrorMessage(err)}`;
|
||||
result.failed += 1;
|
||||
logger.error(`[auto-sync] ${lastAnalyzeError}`);
|
||||
}
|
||||
} else if (analyzeStatus === 'failed') {
|
||||
result.failed += 1;
|
||||
} else if (repoResult.analyzeStatus === 'threshold_skipped') {
|
||||
result.skippedAnalysis += 1;
|
||||
} else {
|
||||
result.skippedAnalysis += 1;
|
||||
}
|
||||
|
||||
const stateEntry: AutoSyncCommitStateEntry = {
|
||||
codeCommitId: repoResult.currentCommit,
|
||||
analyzedCommitId,
|
||||
lastAnalyzeStatus: analyzeStatus,
|
||||
analyzeConsecutiveFailures,
|
||||
lastAnalyzeError,
|
||||
lastSyncTime: repoResult.lastSyncTime,
|
||||
};
|
||||
state[repoResult.stateKey] = stateEntry;
|
||||
|
||||
commitInfoEntries.push({
|
||||
remoteUrl: repoResult.remoteUrl,
|
||||
localPath: repoResult.targetDir,
|
||||
branch: repoResult.branch,
|
||||
codeCommitId: repoResult.currentCommit,
|
||||
analyzedCommitId: repoResult.analyzedCommitId,
|
||||
status: repoResult.analyzeStatus,
|
||||
analyzeConsecutiveFailures: repoResult.analyzeConsecutiveFailures,
|
||||
analyzedCommitId,
|
||||
status: analyzeStatus,
|
||||
analyzeConsecutiveFailures,
|
||||
analyzeFailureThreshold: config.analyzeFailureThreshold,
|
||||
lastAnalyzeError: repoResult.lastAnalyzeError,
|
||||
lastAnalyzeError,
|
||||
lastSyncTime: repoResult.lastSyncTime,
|
||||
});
|
||||
|
||||
|
|
@ -302,7 +314,7 @@ export async function runAutoSyncOnce(
|
|||
`[auto-sync] Group update failed for ${repoResult.project.groupName}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
if (groupMembershipOk && repoResult.analyzeStatus === 'success') {
|
||||
if (groupMembershipOk && analyzeStatus === 'success') {
|
||||
groupsToSync.add(repoResult.project.groupName);
|
||||
}
|
||||
}
|
||||
|
|
@ -329,8 +341,11 @@ function shortErrorMessage(err: unknown): string {
|
|||
export function getConfiguredRepoPath(
|
||||
project: Pick<AutoSyncProjectConfig, 'localPath'>,
|
||||
repoName: string,
|
||||
remoteUrl?: string,
|
||||
): string {
|
||||
return path.resolve(project.localPath, repoName);
|
||||
if (!remoteUrl) return path.resolve(project.localPath, repoName);
|
||||
const identity = getAutoSyncRepoIdentity(remoteUrl);
|
||||
return path.resolve(project.localPath, ...identity.split('/').slice(0, -1), repoName);
|
||||
}
|
||||
|
||||
export async function addRepoToGroup(
|
||||
|
|
@ -380,11 +395,19 @@ async function buildWorkItems(
|
|||
const items: AutoSyncWorkItem[] = [];
|
||||
const targetOwners = new Map<string, string>();
|
||||
for (const project of config.projects) {
|
||||
const cloneRoot = await deps.resolveCloneRoot(project.localPath);
|
||||
let cloneRoot: AutoSyncWorkItem['cloneRoot'];
|
||||
try {
|
||||
cloneRoot = await deps.resolveCloneRoot(project.localPath);
|
||||
} catch (err: unknown) {
|
||||
for (const remoteUrl of project.remoteUrls) {
|
||||
items.push({ project, remoteUrl, error: shortErrorMessage(err) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const remoteUrl of project.remoteUrls) {
|
||||
try {
|
||||
const repoName = extractRepoNameFromRemoteUrl(remoteUrl);
|
||||
const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName);
|
||||
const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName, remoteUrl);
|
||||
const previous = targetOwners.get(targetDir);
|
||||
if (previous !== undefined) {
|
||||
throw new Error(
|
||||
|
|
@ -392,10 +415,10 @@ async function buildWorkItems(
|
|||
);
|
||||
}
|
||||
targetOwners.set(targetDir, remoteUrl);
|
||||
items.push({ project, remoteUrl, cloneRoot, repoName, targetDir });
|
||||
} catch (err: unknown) {
|
||||
if ((err as Error).message.startsWith('Duplicate auto-sync targetDir')) throw err;
|
||||
items.push({ project, remoteUrl, error: shortErrorMessage(err) });
|
||||
}
|
||||
items.push({ project, remoteUrl, cloneRoot });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
|
|
@ -429,7 +452,10 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
|
|||
interface AutoSyncWorkItem {
|
||||
project: AutoSyncProjectConfig;
|
||||
remoteUrl: string;
|
||||
cloneRoot: Awaited<ReturnType<typeof resolveConfiguredCloneRoot>>;
|
||||
cloneRoot?: Awaited<ReturnType<typeof resolveConfiguredCloneRoot>>;
|
||||
repoName?: string;
|
||||
targetDir?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function syncFirstAvailableBranch(input: {
|
||||
|
|
@ -448,15 +474,15 @@ async function syncFirstAvailableBranch(input: {
|
|||
for (const branch of input.item.project.branches) {
|
||||
try {
|
||||
await input.deps.cloneOrPull(input.item.remoteUrl, input.targetDir, undefined, {
|
||||
allowedCloneRoot: input.item.cloneRoot.root,
|
||||
allowedCloneRoot: input.item.cloneRoot!.root,
|
||||
expectedRepoName: input.repoName,
|
||||
quarantineRoot: input.item.cloneRoot.quarantineRoot,
|
||||
quarantineRoot: input.item.cloneRoot!.quarantineRoot,
|
||||
allowAutoSyncSsh: true,
|
||||
timeoutMs: input.timeoutMs,
|
||||
branch,
|
||||
overwriteLocalChanges: input.item.project.overwriteLocalChanges,
|
||||
});
|
||||
const currentBranch = input.deps.getCurrentBranch(input.targetDir);
|
||||
const currentBranch = await input.deps.getCurrentBranch(input.targetDir, input.timeoutMs);
|
||||
if (currentBranch === branch) return { ok: true, branch };
|
||||
failures.push(`${branch}: checked out ${currentBranch ?? '<detached>'}`);
|
||||
input.logger.warn(
|
||||
|
|
|
|||
|
|
@ -2,16 +2,19 @@ import fs from 'node:fs/promises';
|
|||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js';
|
||||
import { getGlobalDir } from '../../storage/repo-manager.js';
|
||||
import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.js';
|
||||
import { loadAutoSyncConfig } from './config.js';
|
||||
import { runAutoSyncOnce } from './runner.js';
|
||||
import { getAutoSyncWatchDir } from './state.js';
|
||||
import { getAutoSyncMutexPath, getAutoSyncWatchDir } from './state.js';
|
||||
|
||||
export interface AutoSyncStartHandle {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export type WatchStatusState = 'running' | 'stopping' | 'stopped' | 'stale' | 'error';
|
||||
export type AutoSyncWatchStopResult = 'stopped' | 'not_running' | 'refused' | 'timeout';
|
||||
|
||||
export interface WatchStatusRecord {
|
||||
state: WatchStatusState;
|
||||
|
|
@ -22,21 +25,24 @@ export interface WatchStatusRecord {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WatchLockRecord {
|
||||
export interface WatchOwnerRecord {
|
||||
pid: number;
|
||||
ownerId: string;
|
||||
processStartTime: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AutoSyncWatchPaths {
|
||||
pidPath: string;
|
||||
lockPath: string;
|
||||
mutexPath: string;
|
||||
ownerPath: string;
|
||||
statusPath: string;
|
||||
}
|
||||
|
||||
export interface AutoSyncWatchControlDeps {
|
||||
isProcessAlive(pid: number): boolean;
|
||||
readProcessCommand(pid: number): string | undefined;
|
||||
readProcessStartTime(pid: number): string | undefined;
|
||||
killProcess(pid: number, signal?: NodeJS.Signals): void;
|
||||
sleep(ms: number): Promise<void>;
|
||||
}
|
||||
|
|
@ -45,7 +51,8 @@ export function getAutoSyncWatchPaths(gitnexusDir = getGlobalDir()): AutoSyncWat
|
|||
const watchDir = getAutoSyncWatchDir(gitnexusDir);
|
||||
return {
|
||||
pidPath: path.join(watchDir, 'watch.pid'),
|
||||
lockPath: path.join(watchDir, 'watch.lock'),
|
||||
mutexPath: getAutoSyncMutexPath(gitnexusDir),
|
||||
ownerPath: path.join(watchDir, 'watch.owner.json'),
|
||||
statusPath: path.join(watchDir, 'watch.status.json'),
|
||||
};
|
||||
}
|
||||
|
|
@ -65,169 +72,152 @@ export async function startAutoSyncWatch(
|
|||
const paths = options.paths ?? getAutoSyncWatchPaths();
|
||||
const deps = resolveWatchDeps(options.deps);
|
||||
const ownerId = crypto.randomUUID();
|
||||
await fs.mkdir(path.dirname(paths.pidPath), { recursive: true });
|
||||
const lockHandle = await acquireWatchLock(paths, deps, stderr);
|
||||
if (!lockHandle) return null;
|
||||
await lockHandle.writeFile(
|
||||
`${JSON.stringify({ pid: process.pid, ownerId, createdAt: new Date().toISOString() })}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
await fs.writeFile(paths.pidPath, `${process.pid}\n`, 'utf-8');
|
||||
|
||||
const loaded = await loadAutoSyncConfig();
|
||||
if (loaded.ok === false) {
|
||||
stderr.write(`${loaded.message}\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'error',
|
||||
pid: process.pid,
|
||||
ownerId,
|
||||
message: loaded.message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await cleanupWatchFiles(paths, lockHandle);
|
||||
const processStartTime = deps.readProcessStartTime(process.pid);
|
||||
if (!processStartTime) {
|
||||
stderr.write('[auto-sync] Unable to verify the watch process start time.\n');
|
||||
return null;
|
||||
}
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'running',
|
||||
pid: process.pid,
|
||||
ownerId,
|
||||
configPath: loaded.config.configPath,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await fs.mkdir(path.dirname(paths.pidPath), { recursive: true });
|
||||
const releaseLock = await acquireWatchLock(paths, deps, stderr, processStartTime);
|
||||
if (!releaseLock) return null;
|
||||
|
||||
const runOnce = options.runOnce ?? runAutoSyncOnce;
|
||||
let activeRun: Promise<void> | undefined;
|
||||
let activeAbortController: AbortController | undefined;
|
||||
const runSafely = () => {
|
||||
if (activeRun) {
|
||||
stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n');
|
||||
return;
|
||||
}
|
||||
const startedAt = new Date();
|
||||
stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`);
|
||||
const abortController = new AbortController();
|
||||
const run = runOnce(loaded.config, { signal: abortController.signal })
|
||||
.then((result) => {
|
||||
stderr.write(
|
||||
`[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`,
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`);
|
||||
stderr.write('[auto-sync] Watch loop finished: failed.\n');
|
||||
});
|
||||
activeRun = run;
|
||||
activeAbortController = abortController;
|
||||
void run.finally(() => {
|
||||
if (activeRun === run) {
|
||||
activeRun = undefined;
|
||||
activeAbortController = undefined;
|
||||
}
|
||||
try {
|
||||
await writeWatchOwner(paths, {
|
||||
pid: process.pid,
|
||||
ownerId,
|
||||
processStartTime,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await writeAtomicText(paths.pidPath, `${process.pid}\n`);
|
||||
|
||||
runSafely();
|
||||
const intervalMs = loaded.config.syncIntervalMinutes * 60_000;
|
||||
const setIntervalFn = options.setIntervalFn ?? setInterval;
|
||||
const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
|
||||
const timer = setIntervalFn(runSafely, intervalMs);
|
||||
if (options.keepAlive === false) timer.unref?.();
|
||||
return {
|
||||
stop: async () => {
|
||||
clearIntervalFn(timer);
|
||||
activeAbortController?.abort();
|
||||
const loaded = await loadAutoSyncConfig();
|
||||
if (loaded.ok === false) {
|
||||
stderr.write(`${loaded.message}\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stopping',
|
||||
state: 'error',
|
||||
pid: process.pid,
|
||||
ownerId,
|
||||
configPath: loaded.config.configPath,
|
||||
message: loaded.message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await activeRun?.catch(() => {});
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stopped',
|
||||
pid: process.pid,
|
||||
ownerId,
|
||||
configPath: loaded.config.configPath,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).finally(() => cleanupWatchFiles(paths, lockHandle));
|
||||
},
|
||||
};
|
||||
await cleanupWatchFiles(paths, ownerId, releaseLock);
|
||||
return null;
|
||||
}
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'running',
|
||||
pid: process.pid,
|
||||
ownerId,
|
||||
configPath: loaded.config.configPath,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const runOnce = options.runOnce ?? runAutoSyncOnce;
|
||||
let activeRun: Promise<void> | undefined;
|
||||
let activeAbortController: AbortController | undefined;
|
||||
const runSafely = () => {
|
||||
if (activeRun) {
|
||||
stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n');
|
||||
return;
|
||||
}
|
||||
const startedAt = new Date();
|
||||
stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`);
|
||||
const abortController = new AbortController();
|
||||
const run = runOnce(loaded.config, { signal: abortController.signal })
|
||||
.then((result) => {
|
||||
stderr.write(
|
||||
`[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`,
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`);
|
||||
stderr.write('[auto-sync] Watch loop finished: failed.\n');
|
||||
});
|
||||
activeRun = run;
|
||||
activeAbortController = abortController;
|
||||
void run.finally(() => {
|
||||
if (activeRun === run) {
|
||||
activeRun = undefined;
|
||||
activeAbortController = undefined;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
runSafely();
|
||||
const intervalMs = loaded.config.syncIntervalMinutes * 60_000;
|
||||
const setIntervalFn = options.setIntervalFn ?? setInterval;
|
||||
const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
|
||||
const timer = setIntervalFn(runSafely, intervalMs);
|
||||
if (options.keepAlive === false) timer.unref?.();
|
||||
let stopPromise: Promise<void> | undefined;
|
||||
return {
|
||||
stop: () =>
|
||||
(stopPromise ??= (async () => {
|
||||
clearIntervalFn(timer);
|
||||
activeAbortController?.abort();
|
||||
try {
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stopping',
|
||||
pid: process.pid,
|
||||
ownerId,
|
||||
configPath: loaded.config.configPath,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await activeRun?.catch(() => {});
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stopped',
|
||||
pid: process.pid,
|
||||
ownerId,
|
||||
configPath: loaded.config.configPath,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
await cleanupWatchFiles(paths, ownerId, releaseLock);
|
||||
}
|
||||
})()),
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanupWatchFiles(paths, ownerId, releaseLock).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireWatchLock(
|
||||
paths: AutoSyncWatchPaths,
|
||||
deps: AutoSyncWatchControlDeps,
|
||||
stderr: Pick<NodeJS.WriteStream, 'write'>,
|
||||
): Promise<fs.FileHandle | null> {
|
||||
processStartTime: string,
|
||||
): Promise<(() => Promise<void>) | null> {
|
||||
try {
|
||||
return await fs.open(paths.lockPath, 'wx');
|
||||
return await acquireFileLock(paths.mutexPath, {
|
||||
pid: process.pid,
|
||||
processStartTime,
|
||||
isProcessAlive: deps.isProcessAlive,
|
||||
readProcessStartTime: deps.readProcessStartTime,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
|
||||
if (!(err instanceof FileLockBusyError)) throw err;
|
||||
}
|
||||
|
||||
const lock = await readLockFile(paths.lockPath);
|
||||
if (!lock) {
|
||||
const message = 'watch lock already exists but has no readable owner; refusing to start';
|
||||
stderr.write(`[auto-sync] ${message}.\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'error',
|
||||
message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
const owner = await readOwnerFile(paths.ownerPath);
|
||||
if (!owner) {
|
||||
stderr.write(
|
||||
`[auto-sync] Watch mutex is held but owner metadata is not ready or invalid. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (deps.isProcessAlive(lock.pid)) {
|
||||
const reason = getWatchProcessIdentityError(lock.pid, deps);
|
||||
if (reason) {
|
||||
stderr.write(`[auto-sync] Refusing to trust existing watch pid ${lock.pid}; ${reason}.\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'error',
|
||||
pid: lock.pid,
|
||||
ownerId: lock.ownerId,
|
||||
message: reason,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
stderr.write(`[auto-sync] Watch is already running with pid ${lock.pid}.\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'running',
|
||||
pid: lock.pid,
|
||||
ownerId: lock.ownerId,
|
||||
message: 'watch already running',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (!deps.isProcessAlive(owner.pid)) {
|
||||
stderr.write(
|
||||
`[auto-sync] Watch mutex remains after owner pid ${owner.pid} exited. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
stderr.write(`[auto-sync] Removing stale watch lock for pid ${lock.pid}.\n`);
|
||||
await removeIfExists(paths.pidPath);
|
||||
await removeIfExists(paths.lockPath);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stale',
|
||||
pid: lock.pid,
|
||||
ownerId: lock.ownerId,
|
||||
message: 'removed stale lock and pid',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
return await fs.open(paths.lockPath, 'wx');
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
const message = 'watch lock was reacquired by another process; refusing to start';
|
||||
stderr.write(`[auto-sync] ${message}.\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'error',
|
||||
message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
const reason = getWatchProcessIdentityError(owner, deps);
|
||||
if (reason) {
|
||||
stderr.write(`[auto-sync] Refusing to trust existing watch pid ${owner.pid}; ${reason}.\n`);
|
||||
return null;
|
||||
}
|
||||
stderr.write(`[auto-sync] Watch is already running with pid ${owner.pid}.\n`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function stopAutoSyncWatch(
|
||||
|
|
@ -238,7 +228,7 @@ export async function stopAutoSyncWatch(
|
|||
timeoutMs?: number;
|
||||
pollMs?: number;
|
||||
} = {},
|
||||
): Promise<boolean> {
|
||||
): Promise<AutoSyncWatchStopResult> {
|
||||
const stderr = options.stderr ?? process.stderr;
|
||||
const paths = options.paths ?? getAutoSyncWatchPaths();
|
||||
const deps = resolveWatchDeps(options.deps);
|
||||
|
|
@ -246,105 +236,54 @@ export async function stopAutoSyncWatch(
|
|||
const pollMs = options.pollMs ?? 100;
|
||||
const pid = await readPid(paths.pidPath);
|
||||
if (!pid) {
|
||||
const lock = await readLockFile(paths.lockPath);
|
||||
if (lock && deps.isProcessAlive(lock.pid)) {
|
||||
const message = `watch appears to be starting with pid ${lock.pid}; pid file is not ready`;
|
||||
stderr.write(`[auto-sync] ${message}.\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'error',
|
||||
pid: lock.pid,
|
||||
ownerId: lock.ownerId,
|
||||
message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
const owner = await readOwnerFile(paths.ownerPath);
|
||||
if (owner && deps.isProcessAlive(owner.pid)) {
|
||||
stderr.write(
|
||||
`[auto-sync] Watch appears to be starting with pid ${owner.pid}; pid file is not ready.\n`,
|
||||
);
|
||||
return 'refused';
|
||||
}
|
||||
if (lock) {
|
||||
stderr.write(`[auto-sync] Removing stale watch lock for pid ${lock.pid}.\n`);
|
||||
await removeIfExists(paths.lockPath);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stale',
|
||||
pid: lock.pid,
|
||||
ownerId: lock.ownerId,
|
||||
message: 'removed stale lock without pid file',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (await fileExists(paths.lockPath)) {
|
||||
const message = 'watch lock exists but has no readable owner; refusing to stop';
|
||||
stderr.write(`[auto-sync] ${message}.\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'error',
|
||||
message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
if (owner || (await fileExists(paths.mutexPath))) {
|
||||
stderr.write(
|
||||
`[auto-sync] Watch ownership is stale or incomplete. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`,
|
||||
);
|
||||
return 'refused';
|
||||
}
|
||||
stderr.write('[auto-sync] Watch is not running.\n');
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stopped',
|
||||
message: 'no pid file',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
return 'not_running';
|
||||
}
|
||||
if (!deps.isProcessAlive(pid)) {
|
||||
stderr.write(`[auto-sync] Removing stale watch pid ${pid}.\n`);
|
||||
await removeIfExists(paths.pidPath);
|
||||
await removeIfExists(paths.lockPath);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stale',
|
||||
pid,
|
||||
message: 'removed stale pid and lock',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
stderr.write(
|
||||
`[auto-sync] Watch pid ${pid} is stale. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`,
|
||||
);
|
||||
return 'refused';
|
||||
}
|
||||
|
||||
const owner = await readVerifiedWatchOwner(paths, pid, deps);
|
||||
if (owner.ok === false) {
|
||||
const message = `refusing to stop pid ${pid}; ${owner.reason}`;
|
||||
stderr.write(`[auto-sync] ${message}.\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'error',
|
||||
pid,
|
||||
message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
stderr.write(`[auto-sync] Refusing to stop pid ${pid}; ${owner.reason}.\n`);
|
||||
return 'refused';
|
||||
}
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stopping',
|
||||
pid,
|
||||
ownerId: owner.owner.ownerId,
|
||||
message: 'stop signal sent; waiting for watch process to exit',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const currentPid = await readPid(paths.pidPath);
|
||||
const currentOwner = await readVerifiedWatchOwner(paths, pid, deps);
|
||||
if (
|
||||
currentPid !== pid ||
|
||||
currentOwner.ok === false ||
|
||||
currentOwner.owner.ownerId !== owner.owner.ownerId
|
||||
) {
|
||||
stderr.write(`[auto-sync] Refusing to stop pid ${pid}; watch ownership changed.\n`);
|
||||
return 'refused';
|
||||
}
|
||||
|
||||
deps.killProcess(pid, 'SIGTERM');
|
||||
stderr.write(`[auto-sync] Stop signal sent to watch pid ${pid}.\n`);
|
||||
const stopped = await waitForProcessExit(pid, { deps, timeoutMs, pollMs });
|
||||
if (!stopped) {
|
||||
const message = `watch pid ${pid} did not exit within ${timeoutMs}ms`;
|
||||
stderr.write(`[auto-sync] ${message}.\n`);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stopping',
|
||||
pid,
|
||||
ownerId: owner.owner.ownerId,
|
||||
message,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
stderr.write(`[auto-sync] Watch pid ${pid} did not exit within ${timeoutMs}ms.\n`);
|
||||
return 'timeout';
|
||||
}
|
||||
await removeIfExists(paths.pidPath);
|
||||
await removeIfExists(paths.lockPath);
|
||||
await writeWatchStatus(paths, {
|
||||
state: 'stopped',
|
||||
pid,
|
||||
ownerId: owner.owner.ownerId,
|
||||
message: 'watch stopped',
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
export async function readAutoSyncWatchStatus(
|
||||
|
|
@ -388,17 +327,19 @@ export async function readAutoSyncWatchStatus(
|
|||
return stored ?? { state: 'stopped', updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
async function readLockFile(lockPath: string): Promise<WatchLockRecord | undefined> {
|
||||
async function readOwnerFile(ownerPath: string): Promise<WatchOwnerRecord | undefined> {
|
||||
try {
|
||||
const raw = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as WatchLockRecord;
|
||||
const raw = await fs.readFile(ownerPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as WatchOwnerRecord;
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
Number.isInteger(parsed.pid) &&
|
||||
parsed.pid > 0 &&
|
||||
typeof parsed.ownerId === 'string' &&
|
||||
parsed.ownerId
|
||||
parsed.ownerId &&
|
||||
typeof parsed.processStartTime === 'string' &&
|
||||
parsed.processStartTime
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
|
|
@ -413,28 +354,31 @@ async function readVerifiedWatchOwner(
|
|||
paths: AutoSyncWatchPaths,
|
||||
pid: number,
|
||||
deps: AutoSyncWatchControlDeps,
|
||||
): Promise<{ ok: true; owner: WatchLockRecord } | { ok: false; reason: string }> {
|
||||
const [status, lock] = await Promise.all([
|
||||
): Promise<{ ok: true; owner: WatchOwnerRecord } | { ok: false; reason: string }> {
|
||||
const [status, owner] = await Promise.all([
|
||||
readStatusFile(paths.statusPath),
|
||||
readLockFile(paths.lockPath),
|
||||
readOwnerFile(paths.ownerPath),
|
||||
]);
|
||||
if (!lock) return { ok: false, reason: 'watch lock is missing or invalid' };
|
||||
if (!owner) return { ok: false, reason: 'watch owner is missing or invalid' };
|
||||
if (!status) return { ok: false, reason: 'watch status is missing or invalid' };
|
||||
if (lock.pid !== pid) return { ok: false, reason: 'watch lock pid does not match pid file' };
|
||||
if (owner.pid !== pid) return { ok: false, reason: 'watch owner pid does not match pid file' };
|
||||
if (status.pid !== pid) return { ok: false, reason: 'watch status pid does not match pid file' };
|
||||
if (!status.ownerId || status.ownerId !== lock.ownerId) {
|
||||
return { ok: false, reason: 'watch status owner does not match lock owner' };
|
||||
if (!status.ownerId || status.ownerId !== owner.ownerId) {
|
||||
return { ok: false, reason: 'watch status owner does not match watch owner' };
|
||||
}
|
||||
const identityError = getWatchProcessIdentityError(pid, deps);
|
||||
const identityError = getWatchProcessIdentityError(owner, deps);
|
||||
if (identityError) return { ok: false, reason: identityError };
|
||||
return { ok: true, owner: lock };
|
||||
return { ok: true, owner };
|
||||
}
|
||||
|
||||
function getWatchProcessIdentityError(
|
||||
pid: number,
|
||||
owner: WatchOwnerRecord,
|
||||
deps: AutoSyncWatchControlDeps,
|
||||
): string | undefined {
|
||||
const command = deps.readProcessCommand(pid);
|
||||
const processStartTime = deps.readProcessStartTime(owner.pid);
|
||||
if (!processStartTime) return 'unable to verify process start time';
|
||||
if (processStartTime !== owner.processStartTime) return 'pid belongs to a different process';
|
||||
const command = deps.readProcessCommand(owner.pid);
|
||||
if (!command) return 'unable to verify process command';
|
||||
if (
|
||||
!/(?:^|\s)watch(?:\s|$)/.test(command) ||
|
||||
|
|
@ -492,13 +436,33 @@ async function writeWatchStatus(
|
|||
await fs.rename(tmpPath, paths.statusPath);
|
||||
}
|
||||
|
||||
async function writeWatchOwner(paths: AutoSyncWatchPaths, record: WatchOwnerRecord): Promise<void> {
|
||||
await writeAtomicText(paths.ownerPath, `${JSON.stringify(record, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function cleanupWatchFiles(
|
||||
paths: AutoSyncWatchPaths,
|
||||
lockHandle?: fs.FileHandle,
|
||||
ownerId: string,
|
||||
releaseLock: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
await lockHandle?.close().catch(() => {});
|
||||
await removeIfExists(paths.pidPath);
|
||||
await removeIfExists(paths.lockPath);
|
||||
try {
|
||||
const owner = await readOwnerFile(paths.ownerPath);
|
||||
if (owner?.ownerId === ownerId) {
|
||||
if ((await readPid(paths.pidPath)) === owner.pid) await removeIfExists(paths.pidPath);
|
||||
if ((await readOwnerFile(paths.ownerPath))?.ownerId === ownerId) {
|
||||
await removeIfExists(paths.ownerPath);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAtomicText(filePath: string, content: string): Promise<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> {
|
||||
|
|
@ -514,29 +478,33 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|||
|
||||
function resolveWatchDeps(deps: Partial<AutoSyncWatchControlDeps> = {}): AutoSyncWatchControlDeps {
|
||||
return {
|
||||
isProcessAlive:
|
||||
deps.isProcessAlive ??
|
||||
((pid) => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
isProcessAlive: deps.isProcessAlive ?? isProcessAlive,
|
||||
readProcessCommand:
|
||||
deps.readProcessCommand ??
|
||||
((pid) => {
|
||||
try {
|
||||
const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
const command =
|
||||
process.platform === 'win32'
|
||||
? execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`(Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\").CommandLine`,
|
||||
],
|
||||
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
||||
).trim()
|
||||
: execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
return command || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
readProcessStartTime: deps.readProcessStartTime ?? readProcessStartTime,
|
||||
killProcess:
|
||||
deps.killProcess ??
|
||||
((pid, signal = 'SIGTERM') => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js';
|
||||
import { getGlobalDir } from '../../storage/repo-manager.js';
|
||||
|
||||
export type AutoSyncAnalyzeStatus = 'success' | 'failed' | 'skipped' | 'threshold_skipped';
|
||||
|
|
@ -19,6 +20,10 @@ export function getAutoSyncWatchDir(gitnexusDir = getGlobalDir()): string {
|
|||
return path.join(gitnexusDir, 'watch');
|
||||
}
|
||||
|
||||
export function getAutoSyncMutexPath(gitnexusDir = getGlobalDir()): string {
|
||||
return path.join(getAutoSyncWatchDir(gitnexusDir), 'watch.mutex');
|
||||
}
|
||||
|
||||
export function getAutoSyncStatePath(gitnexusDir = getGlobalDir()): string {
|
||||
return path.join(getAutoSyncWatchDir(gitnexusDir), 'auto-sync-state.json');
|
||||
}
|
||||
|
|
@ -27,6 +32,26 @@ export function getProjectCommitInfoPath(gitnexusDir = getGlobalDir()): string {
|
|||
return path.join(getAutoSyncWatchDir(gitnexusDir), 'project_commit_info.txt');
|
||||
}
|
||||
|
||||
export async function resetAutoSyncState(gitnexusDir = getGlobalDir()): Promise<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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
shadowSidecarRecoveryMessage,
|
||||
} from './sidecar-recovery.js';
|
||||
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
|
||||
import { isProcessAlive } from '../../utils/process-identity.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -305,20 +306,6 @@ const INIT_LOCK_RETRY_DELAY_MS = 500;
|
|||
|
||||
const initLockPath = (dbPath: string): string => `${dbPath}.init.lock`;
|
||||
|
||||
/**
|
||||
* Returns true when the process identified by `pid` is still running.
|
||||
* Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) —
|
||||
* it throws ESRCH when the process does not exist.
|
||||
*/
|
||||
const isProcessAlive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to break a stale lock whose owning process has exited.
|
||||
* Returns `true` if the stale lock was removed (caller should retry acquire).
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { projectAnalyzeResultForIpc } from './analyze-worker-ipc.js';
|
|||
export interface WorkerAnalysisDeps {
|
||||
runFullAnalysis: typeof import('../core/run-analyze.js').runFullAnalysis;
|
||||
assertAnalysisFinalized: typeof import('../storage/repo-manager.js').assertAnalysisFinalized;
|
||||
acquireAnalysisLock: (repoPath: string) => Promise<() => Promise<void>>;
|
||||
send: (msg: WorkerMessage) => void;
|
||||
/**
|
||||
* Claim the single terminal-outcome slot. Returns `true` for the first caller
|
||||
|
|
@ -44,33 +45,38 @@ export async function runWorkerAnalysis(
|
|||
): Promise<void> {
|
||||
let terminal: WorkerMessage;
|
||||
try {
|
||||
const bootstrapArgs: [] | [AnalyzerRunnerIdentity] = runnerIdentityAtBootstrap
|
||||
? [runnerIdentityAtBootstrap]
|
||||
: [];
|
||||
const result = await deps.runFullAnalysis(
|
||||
repoPath,
|
||||
// This worker force-exits right after reporting, so skip the native close
|
||||
// (it can double-free in LadybugDB's ClientContext destructor after --pdg
|
||||
// writes); flushWAL still persists the index, process.exit reclaims handles.
|
||||
{ ...options, skipNativeCloseOnExit: true },
|
||||
{
|
||||
onProgress: (phase, percent, message) =>
|
||||
deps.send({ type: 'progress', phase, percent, message }),
|
||||
onLog: (message) => deps.send({ type: 'progress', phase: 'log', percent: -1, message }),
|
||||
},
|
||||
...bootstrapArgs,
|
||||
);
|
||||
// P2 (#2264): a half-finalized repo — meta.json written but the global
|
||||
// registry entry missing (e.g. a prior collision-aborted run, or a wiped
|
||||
// registry) — must NOT be reported as a successful analysis. Mirror the CLI's
|
||||
// assertAnalysisFinalized guard so the worker surfaces it as an error instead
|
||||
// of a false `complete` that leaves the repo invisible to list_repos.
|
||||
await deps.assertAnalysisFinalized(repoPath);
|
||||
const releaseAnalysisLock = await deps.acquireAnalysisLock(repoPath);
|
||||
try {
|
||||
const bootstrapArgs: [] | [AnalyzerRunnerIdentity] = runnerIdentityAtBootstrap
|
||||
? [runnerIdentityAtBootstrap]
|
||||
: [];
|
||||
const result = await deps.runFullAnalysis(
|
||||
repoPath,
|
||||
// This worker force-exits right after reporting, so skip the native close
|
||||
// (it can double-free in LadybugDB's ClientContext destructor after --pdg
|
||||
// writes); flushWAL still persists the index, process.exit reclaims handles.
|
||||
{ ...options, skipNativeCloseOnExit: true },
|
||||
{
|
||||
onProgress: (phase, percent, message) =>
|
||||
deps.send({ type: 'progress', phase, percent, message }),
|
||||
onLog: (message) => deps.send({ type: 'progress', phase: 'log', percent: -1, message }),
|
||||
},
|
||||
...bootstrapArgs,
|
||||
);
|
||||
// P2 (#2264): a half-finalized repo — meta.json written but the global
|
||||
// registry entry missing (e.g. a prior collision-aborted run, or a wiped
|
||||
// registry) — must NOT be reported as a successful analysis. Mirror the CLI's
|
||||
// assertAnalysisFinalized guard so the worker surfaces it as an error instead
|
||||
// of a false `complete` that leaves the repo invisible to list_repos.
|
||||
await deps.assertAnalysisFinalized(repoPath);
|
||||
|
||||
// Send a JSON-safe projection, NOT the raw result: the IPC channel is
|
||||
// default-JSON serialization and `result.pipelineResult` carries the live
|
||||
// KnowledgeGraph. See analyze-worker-ipc.ts.
|
||||
terminal = { type: 'complete', result: projectAnalyzeResultForIpc(result) };
|
||||
// Send a JSON-safe projection, NOT the raw result: the IPC channel is
|
||||
// default-JSON serialization and `result.pipelineResult` carries the live
|
||||
// KnowledgeGraph. See analyze-worker-ipc.ts.
|
||||
terminal = { type: 'complete', result: projectAnalyzeResultForIpc(result) };
|
||||
} finally {
|
||||
await releaseAnalysisLock();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Report the failure to the parent over IPC (the parent surfaces the message).
|
||||
const message = err instanceof Error ? err.message : 'Analysis failed';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
* Child -> Parent: { type: 'error', message: string }
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import type { AnalyzeOptions } from '../core/run-analyze.js';
|
||||
import { type AnalyzeResultIpc } from './analyze-worker-ipc.js';
|
||||
import { runWorkerAnalysis, createTerminalClaim } from './analyze-worker-core.js';
|
||||
|
|
@ -123,12 +125,13 @@ process.on('message', async (msg: StartMessage) => {
|
|||
const prepared = await identityModule.captureAnalyzerIdentityBeforeLoad(
|
||||
import.meta.url,
|
||||
async () => {
|
||||
const [analysisModule, repoManager, shutdownHelpers] = await Promise.all([
|
||||
const [analysisModule, repoManager, shutdownHelpers, fileLock] = await Promise.all([
|
||||
import('../core/run-analyze.js'),
|
||||
import('../storage/repo-manager.js'),
|
||||
import('../core/lbug/shutdown-helpers.js'),
|
||||
import('../storage/file-lock.js'),
|
||||
]);
|
||||
return { analysisModule, repoManager, shutdownHelpers };
|
||||
return { analysisModule, repoManager, shutdownHelpers, fileLock };
|
||||
},
|
||||
);
|
||||
boundedCheckpointBeforeExit = prepared.loaded.shutdownHelpers.boundedCheckpointBeforeExit;
|
||||
|
|
@ -142,6 +145,18 @@ process.on('message', async (msg: StartMessage) => {
|
|||
{
|
||||
runFullAnalysis: prepared.loaded.analysisModule.runFullAnalysis,
|
||||
assertAnalysisFinalized: prepared.loaded.repoManager.assertAnalysisFinalized,
|
||||
acquireAnalysisLock: (repoPath) => {
|
||||
const repoKey = createHash('sha256')
|
||||
.update(prepared.loaded.repoManager.canonicalizePath(repoPath))
|
||||
.digest('hex');
|
||||
return prepared.loaded.fileLock.acquireFileLock(
|
||||
path.join(
|
||||
prepared.loaded.repoManager.getGlobalDir(),
|
||||
'locks',
|
||||
`analyze-${repoKey}.lock`,
|
||||
),
|
||||
);
|
||||
},
|
||||
send,
|
||||
claimTerminal,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { spawn } from 'child_process';
|
|||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { isIP } from 'net';
|
||||
import os from 'node:os';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { getGlobalDir } from '../storage/repo-manager.js';
|
||||
import { sanitizeRepoName } from '../storage/git.js';
|
||||
|
|
@ -416,27 +417,14 @@ export function normalizeGitUrlForCompare(url: string): string {
|
|||
* remote means for its threat model — for cloneOrPull, a missing remote
|
||||
* on an existing clone is treated as a refuse-to-pull condition.
|
||||
*/
|
||||
export function getRemoteOriginUrl(cwd: string): Promise<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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -456,8 +444,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`);
|
||||
}
|
||||
|
|
@ -530,7 +519,7 @@ export async function cloneOrPull(
|
|||
if (options?.allowedCloneRoot) {
|
||||
await assertDirectoryOwnerAndPermissions(cloneRoot);
|
||||
}
|
||||
await assertNoSymlinkPath(cloneRoot, safeTarget);
|
||||
await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
|
||||
await assertPreRealpathContainment(cloneRoot, safeTarget);
|
||||
|
||||
const exists = await fs.access(path.join(safeTarget, '.git')).then(
|
||||
|
|
@ -544,11 +533,14 @@ export async function cloneOrPull(
|
|||
);
|
||||
|
||||
if (exists) {
|
||||
if (options?.allowedCloneRoot) {
|
||||
await assertNoSymlinkPath(cloneRoot, path.join(safeTarget, '.git'), true);
|
||||
}
|
||||
await assertPostRealpathContainment(cloneRoot, safeTarget);
|
||||
// Confirm the existing clone is actually the same repository the caller
|
||||
// requested. Without this check, a pull would silently succeed against
|
||||
// whatever remote the dir was originally cloned from.
|
||||
await assertRemoteMatchesRequestedUrl(safeTarget, url);
|
||||
await assertRemoteMatchesRequestedUrl(safeTarget, url, options?.timeoutMs);
|
||||
onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
|
||||
const runGitImpl = options?.runGitForTest ?? runGit;
|
||||
if (options?.branch) {
|
||||
|
|
@ -602,11 +594,11 @@ export async function cloneOrPull(
|
|||
});
|
||||
}
|
||||
} else {
|
||||
if (targetExists) {
|
||||
if (targetExists && (await fs.readdir(safeTarget)).length > 0) {
|
||||
throw new Error(`Clone target already exists but is not a git repository: ${safeTarget}`);
|
||||
}
|
||||
await fs.mkdir(path.dirname(safeTarget), { recursive: true });
|
||||
await assertNoSymlinkPath(cloneRoot, safeTarget);
|
||||
await assertNoSymlinkPath(cloneRoot, safeTarget, Boolean(options?.allowedCloneRoot));
|
||||
await assertPreRealpathContainment(cloneRoot, safeTarget);
|
||||
onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });
|
||||
try {
|
||||
|
|
@ -622,12 +614,20 @@ export async function cloneOrPull(
|
|||
await assertPostRealpathContainment(cloneRoot, safeTarget);
|
||||
} catch (err: unknown) {
|
||||
if (options?.quarantineRoot) {
|
||||
await fs
|
||||
.access(safeTarget)
|
||||
.then(async () => {
|
||||
await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot!);
|
||||
})
|
||||
.catch(() => {});
|
||||
const partialExists = await fs.access(safeTarget).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (partialExists) {
|
||||
try {
|
||||
await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot);
|
||||
} catch (quarantineError) {
|
||||
throw new AggregateError(
|
||||
[err, quarantineError],
|
||||
`Clone failed and partial checkout could not be quarantined: ${safeTarget}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -654,7 +654,11 @@ async function assertPostRealpathContainment(root: string, target: string): Prom
|
|||
}
|
||||
}
|
||||
|
||||
async function assertNoSymlinkPath(root: string, target: string): Promise<void> {
|
||||
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);
|
||||
|
|
@ -672,6 +676,7 @@ async function assertNoSymlinkPath(root: string, target: string): Promise<void>
|
|||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing symlink in clone target path: ${current}`);
|
||||
}
|
||||
if (verifyOwnership) await assertDirectoryOwnerAndPermissions(current);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -776,11 +781,10 @@ function warnIfCleartextCredential(url?: string): void {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the spawn env for `git`. Suppresses credential prompts and, when a
|
||||
* credential resolves (see resolveGitCredential), injects a single
|
||||
* host-scoped Authorization header via the `GIT_CONFIG_*` env protocol
|
||||
* (git ≥2.31) so credentials never appear in argv or the URL. Appends after
|
||||
* any existing `GIT_CONFIG_COUNT` rather than overwriting it. Exported for
|
||||
* Build the spawn env for managed `git` commands. Suppresses credential
|
||||
* prompts, disables repository hooks, and injects at most one host-scoped
|
||||
* Authorization header via the `GIT_CONFIG_*` env protocol (git ≥2.31).
|
||||
* Managed settings append after any existing GIT_CONFIG_COUNT. Exported for
|
||||
* unit tests.
|
||||
*/
|
||||
export function buildGitEnv(
|
||||
|
|
@ -803,18 +807,21 @@ export function buildGitEnv(
|
|||
GIT_CURL_VERBOSE: undefined,
|
||||
};
|
||||
|
||||
const existing = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10);
|
||||
let next = Number.isInteger(existing) && existing > 0 ? existing : 0;
|
||||
env[`GIT_CONFIG_KEY_${next}`] = 'core.hooksPath';
|
||||
env[`GIT_CONFIG_VALUE_${next}`] = os.devNull;
|
||||
next += 1;
|
||||
|
||||
const credential = resolveGitCredential(options);
|
||||
const key = options?.url ? buildExtraHeaderKey(options.url) : undefined;
|
||||
if (credential && key) {
|
||||
// Append after any GIT_CONFIG_* the operator already set, so we never
|
||||
// clobber their git config (e.g. an enforced http.sslVerify).
|
||||
const existing = Number.parseInt(env.GIT_CONFIG_COUNT ?? '', 10);
|
||||
const base = Number.isInteger(existing) && existing > 0 ? existing : 0;
|
||||
env.GIT_CONFIG_COUNT = String(base + 1);
|
||||
env[`GIT_CONFIG_KEY_${base}`] = key;
|
||||
env[`GIT_CONFIG_VALUE_${base}`] = `Authorization: Basic ${credential}`;
|
||||
env[`GIT_CONFIG_KEY_${next}`] = key;
|
||||
env[`GIT_CONFIG_VALUE_${next}`] = `Authorization: Basic ${credential}`;
|
||||
next += 1;
|
||||
warnIfCleartextCredential(options?.url);
|
||||
}
|
||||
env.GIT_CONFIG_COUNT = String(next);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
|
@ -824,7 +831,7 @@ export function buildGitEnv(
|
|||
// host-scoped Authorization header (GitHub PAT for github.com, else the
|
||||
// server's AZURE_DEVOPS_PAT for Azure hosts) via the GIT_CONFIG_* protocol —
|
||||
// never in argv. See resolveGitCredential / buildExtraHeaderKey.
|
||||
function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise<string> {
|
||||
export function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const spawnGit = options?.spawnForTest ?? spawn;
|
||||
const proc = spawnGit('git', args, {
|
||||
|
|
@ -853,6 +860,9 @@ function runGit(args: string[], cwd?: string, options?: RunGitOptions): Promise<
|
|||
proc.kill('SIGTERM');
|
||||
killTimer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
finish(() =>
|
||||
reject(new Error(`git ${args[0]} timed out after ${options.timeoutMs}ms`)),
|
||||
);
|
||||
}, options.timeoutKillGraceMs ?? 1_000);
|
||||
}, options.timeoutMs)
|
||||
: undefined;
|
||||
|
|
|
|||
156
gitnexus/src/storage/file-lock.ts
Normal file
156
gitnexus/src/storage/file-lock.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { isProcessAlive, readProcessStartTime } from '../utils/process-identity.js';
|
||||
|
||||
export interface FileLockOptions {
|
||||
retries?: number;
|
||||
retryDelayMs?: number;
|
||||
pid?: number;
|
||||
processStartTime?: string;
|
||||
isProcessAlive?: (pid: number) => boolean;
|
||||
readProcessStartTime?: (pid: number) => string | undefined;
|
||||
}
|
||||
|
||||
interface FileLockOwner {
|
||||
pid: number;
|
||||
ownerId: string;
|
||||
processStartTime: string;
|
||||
}
|
||||
|
||||
export class FileLockBusyError extends Error {
|
||||
constructor(public readonly lockPath: string) {
|
||||
super(
|
||||
`Lock is already held: ${lockPath}. Confirm no owner process is active, then remove it manually.`,
|
||||
);
|
||||
this.name = 'FileLockBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire a recoverable cross-process mutex using an atomically published owner file. */
|
||||
export async function acquireFileLock(
|
||||
lockPath: string,
|
||||
options: FileLockOptions = {},
|
||||
): Promise<() => Promise<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 (!isLockConflict(error)) throw error;
|
||||
if (
|
||||
await reclaimStaleLock(
|
||||
resolvedPath,
|
||||
options.isProcessAlive ?? isProcessAlive,
|
||||
options.readProcessStartTime ?? readProcessStartTime,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (attempt >= retries) throw new FileLockBusyError(lockPath);
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(pendingPath, { force: true });
|
||||
}
|
||||
|
||||
let releasePromise: Promise<void> | undefined;
|
||||
return () => (releasePromise ??= releaseOwnedLock(resolvedPath, owner.ownerId));
|
||||
}
|
||||
|
||||
async function reclaimStaleLock(
|
||||
lockPath: string,
|
||||
ownerIsAlive: (pid: number) => boolean,
|
||||
getProcessStartTime: (pid: number) => string | undefined,
|
||||
): Promise<boolean> {
|
||||
const reclaimGuardPath = `${lockPath}.reclaim`;
|
||||
try {
|
||||
await fs.mkdir(reclaimGuardPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const owner = await readOwner(lockPath);
|
||||
if (!owner) return false;
|
||||
if (ownerIsAlive(owner.pid)) {
|
||||
const currentStartTime = getProcessStartTime(owner.pid);
|
||||
if (!currentStartTime || currentStartTime === owner.processStartTime) return false;
|
||||
}
|
||||
|
||||
await fs.rm(lockPath, { force: true });
|
||||
return true;
|
||||
} finally {
|
||||
await fs.rmdir(reclaimGuardPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseOwnedLock(lockPath: string, ownerId: string): Promise<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;
|
||||
}
|
||||
|
||||
function isLockConflict(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === 'EEXIST' || code === 'EPERM';
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import os from 'os';
|
|||
import { randomBytes } from 'crypto';
|
||||
import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
|
||||
import { retryRename } from './fs-atomic.js';
|
||||
import { acquireFileLock } from './file-lock.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import {
|
||||
branchSlug,
|
||||
|
|
@ -947,9 +948,25 @@ const writeRegistry = async (entries: RegistryEntry[]): Promise<void> => {
|
|||
// truncated/half-written registry.json that the next load would treat as
|
||||
// empty and silently drop every registered repo (#2106 R9).
|
||||
const target = getGlobalRegistryPath();
|
||||
const tmp = `${target}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf-8');
|
||||
await fs.rename(tmp, target);
|
||||
const tmp = `${target}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf-8');
|
||||
await fs.rename(tmp, target);
|
||||
} finally {
|
||||
await fs.unlink(tmp).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const withRegistryLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const release = await acquireFileLock(`${getGlobalRegistryPath()}.lock`, {
|
||||
retries: 400,
|
||||
retryDelayMs: 25,
|
||||
});
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1070,7 +1087,7 @@ const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): bool
|
|||
* caller can re-use it to keep AGENTS.md / skill files aligned with the
|
||||
* MCP-visible repo name (#979).
|
||||
*/
|
||||
export const registerRepo = async (
|
||||
const registerRepoUnlocked = async (
|
||||
repoPath: string,
|
||||
meta: RepoMeta,
|
||||
opts?: RegisterRepoOptions,
|
||||
|
|
@ -1237,21 +1254,28 @@ export const registerRepo = async (
|
|||
return name;
|
||||
};
|
||||
|
||||
export const registerRepo = async (
|
||||
repoPath: string,
|
||||
meta: RepoMeta,
|
||||
opts?: RegisterRepoOptions,
|
||||
): Promise<string> => withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts));
|
||||
|
||||
/**
|
||||
* Remove a repo from the global registry.
|
||||
* Called after `gitnexus clean`.
|
||||
*/
|
||||
export const unregisterRepo = async (repoPath: string): Promise<void> => {
|
||||
// Canonicalise BOTH sides so an unregister call issued with the
|
||||
// symlink form (`/var/folders/.../repo`) still matches an entry
|
||||
// written with the realpath form (`/private/var/folders/.../repo`),
|
||||
// and vice versa. Matches the semantics of `registerRepo` and
|
||||
// `resolveRegistryEntry` post-#1003 review.
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const entries = await readRegistry();
|
||||
const filtered = entries.filter((e) => !registryPathEquals(canonicalizePath(e.path), resolved));
|
||||
await writeRegistry(filtered);
|
||||
};
|
||||
export const unregisterRepo = async (repoPath: string): Promise<void> =>
|
||||
withRegistryLock(async () => {
|
||||
// Canonicalise BOTH sides so an unregister call issued with the
|
||||
// symlink form (`/var/folders/.../repo`) still matches an entry
|
||||
// written with the realpath form (`/private/var/folders/.../repo`),
|
||||
// and vice versa. Matches the semantics of `registerRepo` and
|
||||
// `resolveRegistryEntry` post-#1003 review.
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const entries = await readRegistry();
|
||||
const filtered = entries.filter((e) => !registryPathEquals(canonicalizePath(e.path), resolved));
|
||||
await writeRegistry(filtered);
|
||||
});
|
||||
|
||||
/**
|
||||
* Remove a single non-primary branch's summary from a repo's registry entry
|
||||
|
|
@ -1261,22 +1285,23 @@ export const unregisterRepo = async (repoPath: string): Promise<void> => {
|
|||
* primary entry is left intact; an empty `branches[]` is dropped to keep the
|
||||
* registry shape legacy-clean.
|
||||
*/
|
||||
export const removeBranchIndex = async (repoPath: string, branch: string): Promise<boolean> => {
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const entries = await readRegistry();
|
||||
const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved));
|
||||
if (idx < 0) return false;
|
||||
const entry = entries[idx];
|
||||
const before = entry.branches?.length ?? 0;
|
||||
if (!entry.branches || before === 0) return false;
|
||||
const remaining = entry.branches.filter((b) => b.branch !== branch);
|
||||
if (remaining.length === before) return false; // branch not recorded
|
||||
if (remaining.length > 0) entry.branches = remaining;
|
||||
else delete entry.branches;
|
||||
entries[idx] = entry;
|
||||
await writeRegistry(entries);
|
||||
return true;
|
||||
};
|
||||
export const removeBranchIndex = async (repoPath: string, branch: string): Promise<boolean> =>
|
||||
withRegistryLock(async () => {
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const entries = await readRegistry();
|
||||
const idx = entries.findIndex((e) => registryPathEquals(canonicalizePath(e.path), resolved));
|
||||
if (idx < 0) return false;
|
||||
const entry = entries[idx];
|
||||
const before = entry.branches?.length ?? 0;
|
||||
if (!entry.branches || before === 0) return false;
|
||||
const remaining = entry.branches.filter((b) => b.branch !== branch);
|
||||
if (remaining.length === before) return false; // branch not recorded
|
||||
if (remaining.length > 0) entry.branches = remaining;
|
||||
else delete entry.branches;
|
||||
entries[idx] = entry;
|
||||
await writeRegistry(entries);
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Record that the flat workspace slot now serves `branch` (#2354).
|
||||
|
|
@ -1348,18 +1373,20 @@ export const adoptFlatBranchLabel = async (repoPath: string, branch: string): Pr
|
|||
// multi-writer whole-file overwrite, and writing a pre-rm snapshot would
|
||||
// silently clobber concurrent registerRepo/removeBranchIndex writers —
|
||||
// the #2106 R9 re-read-before-write discipline registerRepo follows.
|
||||
const entries = await readRegistry();
|
||||
const idx = isRegistered(entries);
|
||||
if (idx < 0) return; // unregistered concurrently → still a no-op
|
||||
const entry = entries[idx];
|
||||
const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
|
||||
const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
|
||||
if (entry.branch === branch && !droppedSummary) return; // already coherent
|
||||
entry.branch = branch;
|
||||
if (remaining && remaining.length > 0) entry.branches = remaining;
|
||||
else delete entry.branches;
|
||||
entries[idx] = entry;
|
||||
await writeRegistry(entries);
|
||||
await withRegistryLock(async () => {
|
||||
const entries = await readRegistry();
|
||||
const idx = isRegistered(entries);
|
||||
if (idx < 0) return; // unregistered concurrently → still a no-op
|
||||
const entry = entries[idx];
|
||||
const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
|
||||
const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
|
||||
if (entry.branch === branch && !droppedSummary) return; // already coherent
|
||||
entry.branch = branch;
|
||||
if (remaining && remaining.length > 0) entry.branches = remaining;
|
||||
else delete entry.branches;
|
||||
entries[idx] = entry;
|
||||
await writeRegistry(entries);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1651,6 +1678,7 @@ export const listRegisteredRepos = async (opts?: {
|
|||
|
||||
// Validate each entry still has a .gitnexus/ directory with metadata
|
||||
const valid: RegistryEntry[] = [];
|
||||
const prunedPaths: string[] = [];
|
||||
for (const entry of entries) {
|
||||
// Named to avoid shadowing the exported `hasIndex` function above.
|
||||
let indexFound = false;
|
||||
|
|
@ -1681,6 +1709,7 @@ export const listRegisteredRepos = async (opts?: {
|
|||
valid.push(entry);
|
||||
} else if (!firstNonMissingError && lastMissingError) {
|
||||
// Index genuinely removed — safe to prune
|
||||
prunedPaths.push(canonicalizePath(entry.path));
|
||||
} else {
|
||||
// Not provably absent — keep entry to prevent mass registry wipe.
|
||||
// Warn so an I/O storm becomes observable instead of silently
|
||||
|
|
@ -1693,9 +1722,17 @@ export const listRegisteredRepos = async (opts?: {
|
|||
}
|
||||
}
|
||||
|
||||
// If we pruned any entries, save the cleaned registry
|
||||
if (valid.length !== entries.length) {
|
||||
await writeRegistry(valid);
|
||||
// Re-apply only the confirmed removals to a fresh snapshot while holding
|
||||
// the registry lock; concurrent registrations must survive validation cleanup.
|
||||
if (prunedPaths.length > 0) {
|
||||
await withRegistryLock(async () => {
|
||||
const fresh = await readRegistry();
|
||||
const cleaned = fresh.filter(
|
||||
(entry) =>
|
||||
!prunedPaths.some((pruned) => registryPathEquals(canonicalizePath(entry.path), pruned)),
|
||||
);
|
||||
if (cleaned.length !== fresh.length) await writeRegistry(cleaned);
|
||||
});
|
||||
}
|
||||
|
||||
return valid;
|
||||
|
|
|
|||
34
gitnexus/src/utils/process-identity.ts
Normal file
34
gitnexus/src/utils/process-identity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ const baseResult: AnalyzeResult = {
|
|||
|
||||
const okRun: WorkerAnalysisDeps['runFullAnalysis'] = vi.fn(async () => baseResult);
|
||||
const okFinalize: WorkerAnalysisDeps['assertAnalysisFinalized'] = vi.fn(async () => undefined);
|
||||
const okLock: WorkerAnalysisDeps['acquireAnalysisLock'] = vi.fn(async () => async () => undefined);
|
||||
const alwaysClaim: WorkerAnalysisDeps['claimTerminal'] = () => true;
|
||||
|
||||
describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => {
|
||||
|
|
@ -48,6 +49,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => {
|
|||
{
|
||||
runFullAnalysis: okRun,
|
||||
assertAnalysisFinalized,
|
||||
acquireAnalysisLock: okLock,
|
||||
send,
|
||||
claimTerminal: alwaysClaim,
|
||||
},
|
||||
|
|
@ -69,6 +71,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => {
|
|||
{
|
||||
runFullAnalysis: okRun,
|
||||
assertAnalysisFinalized: okFinalize,
|
||||
acquireAnalysisLock: okLock,
|
||||
send,
|
||||
claimTerminal: alwaysClaim,
|
||||
},
|
||||
|
|
@ -89,6 +92,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => {
|
|||
{
|
||||
runFullAnalysis: run,
|
||||
assertAnalysisFinalized: okFinalize,
|
||||
acquireAnalysisLock: okLock,
|
||||
send,
|
||||
claimTerminal: alwaysClaim,
|
||||
},
|
||||
|
|
@ -98,6 +102,31 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => {
|
|||
expect(run.mock.calls[0]?.[3]).toBe(receipt);
|
||||
});
|
||||
|
||||
it('does not enter analysis when another worker holds the repo lock', async () => {
|
||||
const send = vi.fn<(msg: WorkerMessage) => void>();
|
||||
const run = vi.fn<WorkerAnalysisDeps['runFullAnalysis']>(async () => baseResult);
|
||||
|
||||
await runWorkerAnalysis(
|
||||
'/repo',
|
||||
{},
|
||||
{
|
||||
runFullAnalysis: run,
|
||||
assertAnalysisFinalized: okFinalize,
|
||||
acquireAnalysisLock: vi.fn(async () => {
|
||||
throw new Error('Lock is already held for /repo');
|
||||
}),
|
||||
send,
|
||||
claimTerminal: alwaysClaim,
|
||||
},
|
||||
);
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Lock is already held for /repo',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports error when finalization passes but the analysis itself throws', async () => {
|
||||
const send = vi.fn<(msg: WorkerMessage) => void>();
|
||||
const failingRun: WorkerAnalysisDeps['runFullAnalysis'] = vi.fn(async () => {
|
||||
|
|
@ -113,6 +142,7 @@ describe('runWorkerAnalysis — finalize guard (#2264 P2)', () => {
|
|||
{
|
||||
runFullAnalysis: failingRun,
|
||||
assertAnalysisFinalized: finalize,
|
||||
acquireAnalysisLock: okLock,
|
||||
send,
|
||||
claimTerminal: alwaysClaim,
|
||||
},
|
||||
|
|
@ -134,6 +164,7 @@ describe('runWorkerAnalysis — terminal-claim coordination (#2264 P3)', () => {
|
|||
{
|
||||
runFullAnalysis: okRun,
|
||||
assertAnalysisFinalized: okFinalize,
|
||||
acquireAnalysisLock: okLock,
|
||||
send,
|
||||
claimTerminal: alreadyClaimed,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,52 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import { createAutoSyncAnalysisRunner } from '../../src/core/auto-sync/analysis-worker-launch.js';
|
||||
|
||||
describe('auto-sync analysis worker', () => {
|
||||
it('ignores progress and resolves from the terminal complete message', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
stdout: { resume: vi.fn() },
|
||||
stderr: { resume: vi.fn() },
|
||||
});
|
||||
const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) });
|
||||
|
||||
const result = run('/tmp/repo', { branch: 'main' }, 50);
|
||||
child.emit('message', { type: 'progress', phase: 'parsing', progress: 20 });
|
||||
child.emit('message', { type: 'complete', result: { stats: { files: 3 } } });
|
||||
child.emit('exit', 0, null);
|
||||
|
||||
await expect(result).resolves.toEqual({ stats: { files: 3 } });
|
||||
expect(child.stdout.resume).toHaveBeenCalled();
|
||||
expect(child.stderr.resume).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects immediately when the worker emits an error without exiting', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
});
|
||||
const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) });
|
||||
|
||||
const result = run('/tmp/repo', { branch: 'main' }, 50);
|
||||
child.emit('error', new Error('spawn failed'));
|
||||
|
||||
await expect(result).rejects.toThrow('Auto-sync analyze worker error: spawn failed');
|
||||
});
|
||||
|
||||
it('preserves a worker error after progress messages', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
});
|
||||
const run = createAutoSyncAnalysisRunner({ forkWorker: vi.fn(() => child as any) });
|
||||
|
||||
const result = run('/tmp/repo', { branch: 'main' }, 50);
|
||||
child.emit('message', { type: 'progress', phase: 'parsing', progress: 20 });
|
||||
child.emit('message', { type: 'error', message: 'parser crashed' });
|
||||
child.emit('exit', 1, null);
|
||||
|
||||
await expect(result).rejects.toThrow('parser crashed');
|
||||
});
|
||||
it('waits for timed-out worker exit before releasing the scheduled run', async () => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
send: vi.fn(),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,20 +5,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import {
|
||||
extractRepoNameFromRemoteUrl,
|
||||
getAutoSyncMutexPath,
|
||||
getAutoSyncStatePath,
|
||||
getAutoSyncWatchDir,
|
||||
getProjectCommitInfoPath,
|
||||
loadAutoSyncConfig,
|
||||
parseAutoSyncConfig,
|
||||
parseBranchCandidates,
|
||||
parseDurationMs,
|
||||
quarantineAutoSyncPartial,
|
||||
resolveConfiguredCloneRoot,
|
||||
loadAutoSyncState,
|
||||
resetAutoSyncState,
|
||||
saveAutoSyncState,
|
||||
shouldAnalyzeCommit,
|
||||
validateAutoSyncRemoteUrl,
|
||||
validateAutoSyncBranchName,
|
||||
writeProjectCommitInfo,
|
||||
} from '../../src/core/auto-sync/index.js';
|
||||
import { acquireFileLock } from '../../src/storage/file-lock.js';
|
||||
|
||||
describe('auto-sync', () => {
|
||||
let tempDir: string;
|
||||
|
|
@ -44,6 +49,9 @@ describe('auto-sync', () => {
|
|||
|
||||
it('places watch runtime artifacts under the watch directory by default', () => {
|
||||
expect(getAutoSyncWatchDir(gitnexusHome)).toBe(path.join(gitnexusHome, 'watch'));
|
||||
expect(getAutoSyncMutexPath(gitnexusHome)).toBe(
|
||||
path.join(gitnexusHome, 'watch', 'watch.mutex'),
|
||||
);
|
||||
expect(getAutoSyncStatePath(gitnexusHome)).toBe(
|
||||
path.join(gitnexusHome, 'watch', 'auto-sync-state.json'),
|
||||
);
|
||||
|
|
@ -52,6 +60,38 @@ describe('auto-sync', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('refuses to reset state while the watch mutex is held', async () => {
|
||||
const statePath = getAutoSyncStatePath(gitnexusHome);
|
||||
const infoPath = getProjectCommitInfoPath(gitnexusHome);
|
||||
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
||||
await fs.writeFile(statePath, '{"kept":true}\n');
|
||||
await fs.writeFile(infoPath, 'kept\n');
|
||||
const release = await acquireFileLock(getAutoSyncMutexPath(gitnexusHome));
|
||||
|
||||
try {
|
||||
await expect(resetAutoSyncState(gitnexusHome)).resolves.toBe(false);
|
||||
await expect(fs.readFile(statePath, 'utf-8')).resolves.toContain('kept');
|
||||
await expect(fs.readFile(infoPath, 'utf-8')).resolves.toBe('kept\n');
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it('resets derived state while holding the watch mutex', async () => {
|
||||
const statePath = getAutoSyncStatePath(gitnexusHome);
|
||||
const infoPath = getProjectCommitInfoPath(gitnexusHome);
|
||||
const mutexPath = getAutoSyncMutexPath(gitnexusHome);
|
||||
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
||||
await fs.writeFile(statePath, '{}\n');
|
||||
await fs.writeFile(infoPath, 'derived\n');
|
||||
|
||||
await expect(resetAutoSyncState(gitnexusHome)).resolves.toBe(true);
|
||||
|
||||
await expect(fs.access(statePath)).rejects.toThrow();
|
||||
await expect(fs.access(infoPath)).rejects.toThrow();
|
||||
await expect(fs.access(mutexPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('loads watch_config.yml from GITNEXUS_HOME and normalizes branch candidates', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(gitnexusHome, 'watch_config.yml'),
|
||||
|
|
@ -116,6 +156,23 @@ describe('auto-sync', () => {
|
|||
expect(loaded.config.projects[0].overwriteLocalChanges).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects repo_git_timeout values above the Node timer limit', () => {
|
||||
expect(() =>
|
||||
parseAutoSyncConfig(
|
||||
[
|
||||
'sync_interval_minutes: 10',
|
||||
'repo_git_timeout: 2147483648ms',
|
||||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' branch: main',
|
||||
' remote_urls:',
|
||||
' - https://github.com/owner/repo.git',
|
||||
].join('\n'),
|
||||
'/tmp/watch_config.yml',
|
||||
),
|
||||
).toThrow('repo_git_timeout must not exceed 2147483647ms');
|
||||
});
|
||||
|
||||
it('rejects analyze_timeout values above half the sync interval', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(gitnexusHome, 'watch_config.yml'),
|
||||
|
|
@ -258,6 +315,60 @@ describe('auto-sync', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('removes expired quarantine entries while preserving recent and unrelated files', async () => {
|
||||
const root = path.join(tempDir, 'repos');
|
||||
const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine');
|
||||
const expired = path.join(quarantineRoot, 'auto-sync-expired-repo');
|
||||
const recent = path.join(quarantineRoot, 'auto-sync-recent-repo');
|
||||
const unrelated = path.join(quarantineRoot, 'operator-note.txt');
|
||||
await fs.mkdir(expired, { recursive: true });
|
||||
await fs.mkdir(recent);
|
||||
await fs.writeFile(unrelated, 'keep');
|
||||
const old = new Date(Date.now() - 15 * 24 * 60 * 60 * 1_000);
|
||||
await fs.utimes(expired, old, old);
|
||||
|
||||
await resolveConfiguredCloneRoot(root);
|
||||
|
||||
await expect(fs.access(expired)).rejects.toThrow();
|
||||
await expect(fs.access(recent)).resolves.toBeUndefined();
|
||||
await expect(fs.readFile(unrelated, 'utf-8')).resolves.toBe('keep');
|
||||
});
|
||||
|
||||
it('falls back to copy and remove when quarantine crosses filesystems', async () => {
|
||||
const target = path.join(tempDir, 'partial-repo');
|
||||
const quarantineRoot = path.join(gitnexusHome, 'watch', 'quarantine');
|
||||
await fs.mkdir(target);
|
||||
await fs.writeFile(path.join(target, 'partial.txt'), 'partial');
|
||||
vi.spyOn(fs, 'rename').mockRejectedValueOnce(
|
||||
Object.assign(new Error('cross-device link'), { code: 'EXDEV' }),
|
||||
);
|
||||
|
||||
const destination = await quarantineAutoSyncPartial(target, quarantineRoot);
|
||||
|
||||
await expect(fs.readFile(path.join(destination, 'partial.txt'), 'utf-8')).resolves.toBe(
|
||||
'partial',
|
||||
);
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects group-writable configured clone roots', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const root = path.join(tempDir, 'group-writable-repos');
|
||||
await fs.mkdir(root, { mode: 0o770 });
|
||||
await fs.chmod(root, 0o770);
|
||||
|
||||
await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('group-writable');
|
||||
});
|
||||
|
||||
it('rejects sticky world-writable configured clone roots', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const root = path.join(tempDir, 'sticky-world-writable-repos');
|
||||
await fs.mkdir(root);
|
||||
await fs.chmod(root, 0o1777);
|
||||
|
||||
await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('world-writable');
|
||||
});
|
||||
|
||||
it('creates missing configured clone roots before watch clone work', async () => {
|
||||
const root = path.join(tempDir, 'missing-repos');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Command, Option } from 'commander';
|
||||
|
|
@ -251,14 +252,14 @@ describe('CLI help surface', () => {
|
|||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('gitnexus watch [options] [action]');
|
||||
expect(result.stdout).toContain('Actions: init, start (default), restart, stop, status');
|
||||
expect(result.stdout).toContain('Actions: init, start (default), restart, stop, status, reset');
|
||||
expect(result.stdout).toContain('GITNEXUS_HOME/watch_config.yml');
|
||||
expect(result.stdout).toContain('GITNEXUS_HOME/watch/watch.pid');
|
||||
expect(result.stdout).toContain('GITNEXUS_HOME/watch/project_commit_info.txt');
|
||||
});
|
||||
|
||||
it('watch init creates the default watch_config.yml and does not overwrite it', () => {
|
||||
const home = fs.mkdtempSync(path.join(repoRoot, '.tmp-test/gitnexus-watch-init-'));
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-init-'));
|
||||
try {
|
||||
const first = runCliArgs(['watch', 'init'], { GITNEXUS_HOME: home });
|
||||
const configPath = path.join(home, 'watch_config.yml');
|
||||
|
|
@ -270,9 +271,10 @@ describe('CLI help surface', () => {
|
|||
expect(config).toContain('analyze_failure_threshold: 3');
|
||||
expect(config).toContain('analyze_timeout: 5m');
|
||||
expect(config).toContain('overwrite_local_changes: false');
|
||||
expect(config).toContain(`local_path: ${path.join(home, 'repo')}`);
|
||||
expect(config).toContain(`local_path: ${path.join(home, 'repos')}`);
|
||||
expect(config).not.toContain('/abs/path/to/repos');
|
||||
expect(config).toContain('git@github.com:owner/repo.git');
|
||||
expect(config).not.toContain('group_name:');
|
||||
|
||||
const second = runCliArgs(['watch', 'init'], { GITNEXUS_HOME: home });
|
||||
|
||||
|
|
@ -284,6 +286,52 @@ describe('CLI help surface', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('watch reset removes only derived auto-sync state files', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-reset-'));
|
||||
const watchDir = path.join(home, 'watch');
|
||||
const cloneMarker = path.join(home, 'repos', 'repo', 'keep.txt');
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(cloneMarker), { recursive: true });
|
||||
fs.writeFileSync(cloneMarker, 'keep');
|
||||
fs.mkdirSync(watchDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(watchDir, 'auto-sync-state.json'), '{}');
|
||||
fs.writeFileSync(path.join(watchDir, 'project_commit_info.txt'), 'derived');
|
||||
|
||||
const result = runCliArgs(['watch', 'reset'], { GITNEXUS_HOME: home });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Reset analysis state');
|
||||
expect(fs.existsSync(path.join(watchDir, 'auto-sync-state.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(watchDir, 'project_commit_info.txt'))).toBe(false);
|
||||
expect(fs.readFileSync(cloneMarker, 'utf8')).toBe('keep');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('watch stop exits non-zero when no watch was stopped', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-stop-'));
|
||||
try {
|
||||
const result = runCliArgs(['watch', 'stop'], { GITNEXUS_HOME: home });
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('Watch is not running');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('watch restart starts when the watch is not running', () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-restart-'));
|
||||
try {
|
||||
const result = runCliArgs(['watch', 'restart'], { GITNEXUS_HOME: home });
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain('Watch is not running');
|
||||
expect(result.stderr).toContain('Missing config file');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('wiki help shows provider, review, and verbose flags', () => {
|
||||
const result = runHelp('wiki');
|
||||
|
||||
|
|
|
|||
154
gitnexus/test/unit/file-lock.test.ts
Normal file
154
gitnexus/test/unit/file-lock.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { acquireFileLock, FileLockBusyError } from '../../src/storage/file-lock.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function tempLockPath(): Promise<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('releases idempotently', async () => {
|
||||
const lockPath = await tempLockPath();
|
||||
const release = await acquireFileLock(lockPath);
|
||||
|
||||
await release();
|
||||
await expect(release()).resolves.toBeUndefined();
|
||||
const nextRelease = await acquireFileLock(lockPath);
|
||||
await nextRelease();
|
||||
});
|
||||
|
||||
it('reclaims a lock whose owner process exited', async () => {
|
||||
const lockPath = await tempLockPath();
|
||||
const oldRelease = await acquireFileLock(lockPath, {
|
||||
pid: 111,
|
||||
processStartTime: 'old-start',
|
||||
});
|
||||
|
||||
const nextRelease = await acquireFileLock(lockPath, {
|
||||
pid: 222,
|
||||
processStartTime: 'new-start',
|
||||
isProcessAlive: () => false,
|
||||
});
|
||||
|
||||
await oldRelease();
|
||||
await expect(
|
||||
acquireFileLock(lockPath, {
|
||||
isProcessAlive: (pid) => pid === 222,
|
||||
readProcessStartTime: () => 'new-start',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FileLockBusyError);
|
||||
await nextRelease();
|
||||
});
|
||||
|
||||
it('reclaims a reused pid only when its start time differs', async () => {
|
||||
const lockPath = await tempLockPath();
|
||||
await acquireFileLock(lockPath, { pid: 111, processStartTime: 'old-start' });
|
||||
|
||||
const nextRelease = await acquireFileLock(lockPath, {
|
||||
pid: 222,
|
||||
processStartTime: 'next-start',
|
||||
isProcessAlive: () => true,
|
||||
readProcessStartTime: () => 'reused-pid-start',
|
||||
});
|
||||
|
||||
await nextRelease();
|
||||
});
|
||||
|
||||
it('fails closed for a legacy or invalid lock without owner metadata', async () => {
|
||||
const lockPath = await tempLockPath();
|
||||
await fs.mkdir(lockPath, { recursive: true });
|
||||
|
||||
await expect(acquireFileLock(lockPath)).rejects.toBeInstanceOf(FileLockBusyError);
|
||||
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('waits for the current holder when retries are configured', async () => {
|
||||
const lockPath = await tempLockPath();
|
||||
const release = await acquireFileLock(lockPath);
|
||||
const next = acquireFileLock(lockPath, { retries: 20, retryDelayMs: 5 });
|
||||
|
||||
await sleep(10);
|
||||
await release();
|
||||
const nextRelease = await next;
|
||||
|
||||
await nextRelease();
|
||||
});
|
||||
|
||||
it('fails closed while another stale-lock recovery is in progress', async () => {
|
||||
const lockPath = await tempLockPath();
|
||||
const oldRelease = await acquireFileLock(lockPath, {
|
||||
pid: 999,
|
||||
processStartTime: 'abandoned',
|
||||
});
|
||||
const reclaimGuardPath = `${lockPath}.reclaim`;
|
||||
await fs.mkdir(reclaimGuardPath);
|
||||
|
||||
await expect(
|
||||
acquireFileLock(lockPath, {
|
||||
pid: 1000,
|
||||
processStartTime: 'next',
|
||||
isProcessAlive: () => false,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FileLockBusyError);
|
||||
await expect(fs.access(lockPath)).resolves.toBeUndefined();
|
||||
|
||||
await fs.rmdir(reclaimGuardPath);
|
||||
const nextRelease = await acquireFileLock(lockPath, {
|
||||
pid: 1000,
|
||||
processStartTime: 'next',
|
||||
isProcessAlive: () => false,
|
||||
});
|
||||
await oldRelease();
|
||||
await nextRelease();
|
||||
});
|
||||
|
||||
it('allows only one contender to recover an abandoned lock', async () => {
|
||||
const lockPath = await tempLockPath();
|
||||
await acquireFileLock(lockPath, { pid: 999, processStartTime: 'abandoned' });
|
||||
const starts = new Map(
|
||||
Array.from({ length: 8 }, (_, index) => [1000 + index, `start-${index}`]),
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
[...starts].map(([pid, processStartTime]) =>
|
||||
acquireFileLock(lockPath, {
|
||||
pid,
|
||||
processStartTime,
|
||||
isProcessAlive: (ownerPid) => ownerPid !== 999,
|
||||
readProcessStartTime: (ownerPid) => starts.get(ownerPid),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const acquired = results.filter(
|
||||
(result): result is PromiseFulfilledResult<() => Promise<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();
|
||||
});
|
||||
});
|
||||
|
|
@ -404,7 +404,7 @@ describe('git-clone', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('buildGitEnv — token injection', () => {
|
||||
describe('buildGitEnv — managed git environment', () => {
|
||||
// The token MUST travel via GIT_CONFIG_* env vars (git ≥2.31), not via
|
||||
// argv or URL. This keeps it out of `ps`, shell history, and stderr.
|
||||
|
||||
|
|
@ -428,33 +428,33 @@ describe('git-clone', () => {
|
|||
expect(env.GIT_CURL_VERBOSE).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set GIT_CONFIG_* env vars when no token is provided', () => {
|
||||
it('disables repository hooks even when no token is provided', () => {
|
||||
const env = buildGitEnv({});
|
||||
expect(env.GIT_CONFIG_COUNT).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_KEY_0).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('core.hooksPath');
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe(os.devNull);
|
||||
});
|
||||
|
||||
it('also leaves GIT_CONFIG_* unset when token is empty string', () => {
|
||||
it('only disables repository hooks when token is empty string', () => {
|
||||
const env = buildGitEnv({}, { token: '' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_KEY_0).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('core.hooksPath');
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe(os.devNull);
|
||||
});
|
||||
|
||||
it('injects a host-scoped Basic-auth header when a github.com token is provided', () => {
|
||||
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/owner/repo' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
||||
// Host-scoped key: the header attaches only to this origin's requests.
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('http.https://github.com/owner/repo.extraHeader');
|
||||
expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/owner/repo.extraHeader');
|
||||
const expected =
|
||||
'Authorization: Basic ' + Buffer.from('x-access-token:ghp_secret123').toString('base64');
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe(expected);
|
||||
expect(env.GIT_CONFIG_VALUE_1).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not inject a token for a non-github host (defense-in-depth host bind)', () => {
|
||||
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://gitlab.com/owner/repo' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
});
|
||||
|
||||
it('never includes the raw token value in any env entry', () => {
|
||||
|
|
@ -463,7 +463,7 @@ describe('git-clone', () => {
|
|||
const token = 'ghp_uniqueRawSecret_98765';
|
||||
const env = buildGitEnv({ EXISTING: 'value' }, { token, url: 'https://github.com/o/r' });
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key === 'GIT_CONFIG_VALUE_0') continue;
|
||||
if (key === 'GIT_CONFIG_VALUE_1') continue;
|
||||
expect(String(value)).not.toContain(token);
|
||||
}
|
||||
});
|
||||
|
|
@ -473,12 +473,12 @@ describe('git-clone', () => {
|
|||
process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz';
|
||||
try {
|
||||
const env = buildGitEnv({}, { url: 'https://dev.azure.com/org/proj/_git/repo' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe(
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
||||
expect(env.GIT_CONFIG_KEY_1).toBe(
|
||||
'http.https://dev.azure.com/org/proj/_git/repo.extraHeader',
|
||||
);
|
||||
const expected = 'Authorization: Basic ' + Buffer.from(':azure-pat-xyz').toString('base64');
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe(expected);
|
||||
expect(env.GIT_CONFIG_VALUE_1).toBe(expected);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.AZURE_DEVOPS_PAT;
|
||||
else process.env.AZURE_DEVOPS_PAT = prev;
|
||||
|
|
@ -492,8 +492,8 @@ describe('git-clone', () => {
|
|||
process.env.AZURE_DEVOPS_PAT = 'azure-pat-xyz';
|
||||
try {
|
||||
const env = buildGitEnv({}, { token: 'ghp_secret123', url: 'https://github.com/o/r' });
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('1');
|
||||
expect(env.GIT_CONFIG_VALUE_1).toBeUndefined();
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
||||
expect(env.GIT_CONFIG_VALUE_2).toBeUndefined();
|
||||
for (const value of Object.values(env)) {
|
||||
expect(String(value)).not.toContain('azure-pat-xyz');
|
||||
}
|
||||
|
|
@ -508,13 +508,48 @@ describe('git-clone', () => {
|
|||
{ GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.sslVerify', GIT_CONFIG_VALUE_0: 'true' },
|
||||
{ token: 'ghp_secret123', url: 'https://github.com/o/r' },
|
||||
);
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('3');
|
||||
// Operator's pre-existing config is preserved at index 0.
|
||||
expect(env.GIT_CONFIG_KEY_0).toBe('http.sslVerify');
|
||||
expect(env.GIT_CONFIG_VALUE_0).toBe('true');
|
||||
// Our credential is appended at index 1.
|
||||
expect(env.GIT_CONFIG_KEY_1).toBe('http.https://github.com/o/r.extraHeader');
|
||||
expect(env.GIT_CONFIG_VALUE_1).toContain('Authorization: Basic ');
|
||||
expect(env.GIT_CONFIG_KEY_1).toBe('core.hooksPath');
|
||||
expect(env.GIT_CONFIG_VALUE_1).toBe(os.devNull);
|
||||
expect(env.GIT_CONFIG_KEY_2).toBe('http.https://github.com/o/r.extraHeader');
|
||||
expect(env.GIT_CONFIG_VALUE_2).toContain('Authorization: Basic ');
|
||||
});
|
||||
|
||||
it('overrides an inherited hooks path with the managed safe value', () => {
|
||||
const env = buildGitEnv({
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.hooksPath',
|
||||
GIT_CONFIG_VALUE_0: '/tmp/untrusted-hooks',
|
||||
});
|
||||
expect(env.GIT_CONFIG_COUNT).toBe('2');
|
||||
expect(env.GIT_CONFIG_KEY_1).toBe('core.hooksPath');
|
||||
expect(env.GIT_CONFIG_VALUE_1).toBe(os.devNull);
|
||||
});
|
||||
|
||||
it('does not execute hooks from an existing repository', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const root = await mkControlledRoot('gitnexus-managed-git-');
|
||||
const marker = path.join(root, 'hook-ran');
|
||||
try {
|
||||
await runGit(['init', '--initial-branch=main'], root);
|
||||
await runGit(['config', 'user.email', 'test@example.com'], root);
|
||||
await runGit(['config', 'user.name', 'GitNexus Test'], root);
|
||||
await fs.writeFile(path.join(root, 'README.md'), 'test\n');
|
||||
await runGit(['add', 'README.md'], root);
|
||||
await runGit(['commit', '-m', 'initial'], root);
|
||||
const hook = path.join(root, '.git', 'hooks', 'post-checkout');
|
||||
await fs.writeFile(hook, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\n`);
|
||||
await fs.chmod(hook, 0o700);
|
||||
|
||||
await runGitForTest(['checkout', '-b', 'next'], root);
|
||||
|
||||
await expect(fs.access(marker)).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('strips control characters from the config key (no key injection)', () => {
|
||||
|
|
@ -522,7 +557,7 @@ describe('git-clone', () => {
|
|||
{},
|
||||
{ token: 'ghp_secret123', url: 'https://github.com/o/r%0Anewline' },
|
||||
);
|
||||
const key = env.GIT_CONFIG_KEY_0 ?? '';
|
||||
const key = env.GIT_CONFIG_KEY_1 ?? '';
|
||||
expect(key).not.toContain('\n');
|
||||
expect(key).not.toContain('\r');
|
||||
});
|
||||
|
|
@ -686,6 +721,69 @@ describe('git-clone', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rejects writable existing directories below a controlled clone root', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const namespace = path.join(root, 'team');
|
||||
try {
|
||||
await fs.mkdir(namespace);
|
||||
await fs.chmod(namespace, 0o777);
|
||||
await expect(
|
||||
cloneOrPull(
|
||||
'https://example.com/team/repo.git',
|
||||
path.join(namespace, 'repo'),
|
||||
undefined,
|
||||
{
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('world-writable');
|
||||
} finally {
|
||||
await fs.chmod(namespace, 0o700).catch(() => {});
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects writable .git metadata in an existing controlled clone', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const target = path.join(root, 'repo');
|
||||
const gitDir = path.join(target, '.git');
|
||||
try {
|
||||
await fs.mkdir(gitDir, { recursive: true });
|
||||
await fs.chmod(gitDir, 0o777);
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
}),
|
||||
).rejects.toThrow('world-writable');
|
||||
} finally {
|
||||
await fs.chmod(gitDir, 0o700).catch(() => {});
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects symlinked .git metadata in an existing controlled clone', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const outside = await mkControlledRoot('gitnexus-outside-git-dir-');
|
||||
const target = path.join(root, 'repo');
|
||||
try {
|
||||
await fs.mkdir(target);
|
||||
await fs.symlink(outside, path.join(target, '.git'));
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
}),
|
||||
).rejects.toThrow('symlink');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects existing clones whose remote origin mismatches the requested URL', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const target = path.join(root, 'repo');
|
||||
|
|
@ -796,6 +894,25 @@ describe('git-clone', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('clones into a pre-existing empty target directory', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const target = path.join(root, 'repo');
|
||||
const runGitForTest = vi.fn(async () => '');
|
||||
try {
|
||||
await fs.mkdir(target);
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
runGitForTest,
|
||||
}),
|
||||
).resolves.toBe(target);
|
||||
expect(runGitForTest).toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('quarantines partial auto-sync clone output on clone failure', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const quarantineRoot = path.join(root, 'quarantine');
|
||||
|
|
@ -1179,7 +1296,7 @@ describe('git-clone', () => {
|
|||
});
|
||||
|
||||
describe('runGit timeout', () => {
|
||||
it('waits for close and sends SIGKILL after the grace period before returning timeout', async () => {
|
||||
it('rejects after SIGKILL even when the child never closes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
|
|
@ -1209,7 +1326,6 @@ describe('git-clone', () => {
|
|||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
child.emit('close', null);
|
||||
await expect(promise).rejects.toThrow('timed out after 20ms');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
|
|
|||
22
gitnexus/test/unit/process-identity.test.ts
Normal file
22
gitnexus/test/unit/process-identity.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -771,6 +771,25 @@ describe('registerRepo name override + collision guard (#829)', () => {
|
|||
expect(entries[0].name).not.toBe(path.basename(tmpRepoA.dbPath));
|
||||
});
|
||||
|
||||
it('preserves every concurrent registration', async () => {
|
||||
const repoPaths = Array.from({ length: 12 }, (_, index) =>
|
||||
path.join(tmpRepoA.dbPath, `concurrent-${index}`),
|
||||
);
|
||||
await Promise.all(repoPaths.map((repoPath) => fs.mkdir(repoPath, { recursive: true })));
|
||||
|
||||
await Promise.all(
|
||||
repoPaths.map((repoPath, index) =>
|
||||
registerRepo(repoPath, meta, { name: `concurrent-${index}` }),
|
||||
),
|
||||
);
|
||||
|
||||
const entries = await listRegisteredRepos();
|
||||
expect(entries).toHaveLength(repoPaths.length);
|
||||
expect(entries.map((entry) => entry.name).sort()).toEqual(
|
||||
repoPaths.map((_, index) => `concurrent-${index}`).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-registerRepo on same path without name preserves an existing alias', async () => {
|
||||
await registerRepo(tmpRepoA.dbPath, meta, { name: 'custom-alias' });
|
||||
// Second call with no opts should keep the alias, not revert to basename.
|
||||
|
|
|
|||
43
gitnexus/test/unit/watch-command.test.ts
Normal file
43
gitnexus/test/unit/watch-command.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue