mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
adds an opt-in auto sync and analysis loop for GitNexus
This commit is contained in:
parent
8ad4469e96
commit
bf1f38b9aa
13 changed files with 1656 additions and 27 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -31,6 +31,8 @@ npm-debug.log*
|
|||
|
||||
# Testing
|
||||
coverage/
|
||||
.tmp-test/
|
||||
gitnexus/.tmp-test/
|
||||
|
||||
# Misc
|
||||
*.local
|
||||
|
|
@ -110,3 +112,4 @@ local_docs/
|
|||
.agents/
|
||||
.context/
|
||||
gitnexus/web/
|
||||
/log/
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ const _require = createRequire(import.meta.url);
|
|||
const pkg = _require('../../package.json');
|
||||
const program = new Command();
|
||||
|
||||
if (process.env.AUTO_UPDATE_AND_ANALYZE_FLAG?.trim() === '1') {
|
||||
void import('../core/auto-sync/index.js').then(({ maybeStartAutoSyncFromEnv }) =>
|
||||
maybeStartAutoSyncFromEnv(),
|
||||
);
|
||||
}
|
||||
|
||||
function collectCodingAgents(value: string, previous: string[] | undefined): string[] {
|
||||
return [...(previous ?? []), ...value.split(',')];
|
||||
}
|
||||
|
|
|
|||
163
gitnexus/src/core/auto-sync/config.ts
Normal file
163
gitnexus/src/core/auto-sync/config.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { getGlobalDir } from '../../storage/repo-manager.js';
|
||||
import { normalizeConfiguredCloneRoot } from './path-security.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
|
||||
export const AUTO_SYNC_FLAG = 'AUTO_UPDATE_AND_ANALYZE_FLAG';
|
||||
export const AUTO_SYNC_CONFIG_FILE = 'sync_config.yml';
|
||||
const GROUP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
||||
const MIN_SYNC_INTERVAL_MINUTES = 5;
|
||||
|
||||
export interface AutoSyncProjectConfig {
|
||||
localPath: string;
|
||||
gitnexusGroup?: string;
|
||||
branches: string[];
|
||||
remoteUrls: string[];
|
||||
}
|
||||
|
||||
export interface AutoSyncConfig {
|
||||
configPath: string;
|
||||
syncIntervalMinutes: number;
|
||||
projects: AutoSyncProjectConfig[];
|
||||
}
|
||||
|
||||
export type AutoSyncFlagDecision =
|
||||
| { enabled: true }
|
||||
| { enabled: false; reason: 'unset' | 'disabled' | 'invalid'; message?: string };
|
||||
|
||||
export type AutoSyncConfigLoadResult =
|
||||
| { ok: true; config: AutoSyncConfig }
|
||||
| { ok: false; reason: 'missing' | 'unreadable' | 'invalid'; message: string };
|
||||
|
||||
export function parseAutoSyncFlag(raw = process.env[AUTO_SYNC_FLAG]): AutoSyncFlagDecision {
|
||||
if (raw === undefined || raw.trim() === '') return { enabled: false, reason: 'unset' };
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '0') return { enabled: false, reason: 'disabled' };
|
||||
if (trimmed === '1') return { enabled: true };
|
||||
return {
|
||||
enabled: false,
|
||||
reason: 'invalid',
|
||||
message: `[auto-sync] ${AUTO_SYNC_FLAG} must be 0 or 1; got "${trimmed}". Auto sync is disabled.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAutoSyncConfigPath(gitnexusDir = getGlobalDir()): string {
|
||||
return path.join(gitnexusDir, AUTO_SYNC_CONFIG_FILE);
|
||||
}
|
||||
|
||||
export function parseBranchCandidates(branchValue: unknown): string[] {
|
||||
const rawItems = Array.isArray(branchValue)
|
||||
? branchValue.flatMap((item) => String(item).split(','))
|
||||
: String(branchValue ?? '').split(',');
|
||||
const branches: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of rawItems) {
|
||||
const branch = item.trim();
|
||||
if (!branch || seen.has(branch)) continue;
|
||||
seen.add(branch);
|
||||
branches.push(branch);
|
||||
}
|
||||
return branches;
|
||||
}
|
||||
|
||||
export async function loadAutoSyncConfig(
|
||||
configPath = getAutoSyncConfigPath(),
|
||||
): Promise<AutoSyncConfigLoadResult> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(configPath, 'utf-8');
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing',
|
||||
message: `[auto-sync] Missing config file: ${configPath}. Auto sync is skipped.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'unreadable',
|
||||
message: `[auto-sync] Unable to read config file: ${configPath}. Auto sync is skipped.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return { ok: true, config: parseAutoSyncConfig(content, configPath) };
|
||||
} catch (err: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid',
|
||||
message: `[auto-sync] Invalid sync_config.yml: ${(err as Error).message}. Auto sync is skipped.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAutoSyncConfig(content: string, configPath: string): AutoSyncConfig {
|
||||
const raw = yaml.load(content, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('expected a YAML object');
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const interval = Number(raw.sync_interval_minutes);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
errors.push('sync_interval_minutes must be a positive integer');
|
||||
} else if (interval < MIN_SYNC_INTERVAL_MINUTES) {
|
||||
errors.push(`sync_interval_minutes must be at least ${MIN_SYNC_INTERVAL_MINUTES}`);
|
||||
}
|
||||
|
||||
const rawProjects = raw.projects;
|
||||
if (!Array.isArray(rawProjects) || rawProjects.length === 0) {
|
||||
errors.push('projects must contain at least one project');
|
||||
}
|
||||
|
||||
const projects: AutoSyncProjectConfig[] = [];
|
||||
if (Array.isArray(rawProjects)) {
|
||||
rawProjects.forEach((projectValue, index) => {
|
||||
const project = projectValue as Record<string, unknown>;
|
||||
if (!project || typeof project !== 'object' || Array.isArray(project)) {
|
||||
errors.push(`projects[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const localPath = typeof project.local_path === 'string' ? project.local_path.trim() : '';
|
||||
if (!localPath) {
|
||||
errors.push(`projects[${index}].local_path is required`);
|
||||
} else {
|
||||
try {
|
||||
normalizeConfiguredCloneRoot(localPath);
|
||||
} catch (err: unknown) {
|
||||
errors.push(`projects[${index}].local_path ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const remoteUrls = Array.isArray(project.remote_urls)
|
||||
? project.remote_urls.map((url) => String(url).trim()).filter(Boolean)
|
||||
: [];
|
||||
if (remoteUrls.length === 0) {
|
||||
errors.push(`projects[${index}].remote_urls must contain at least one URL`);
|
||||
}
|
||||
|
||||
const branches = parseBranchCandidates(project.branch);
|
||||
if (branches.length === 0) errors.push(`projects[${index}].branch is required`);
|
||||
|
||||
const gitnexusGroup =
|
||||
typeof project.gitnexus_group === 'string' ? project.gitnexus_group.trim() : undefined;
|
||||
if (gitnexusGroup && !GROUP_NAME_PATTERN.test(gitnexusGroup)) {
|
||||
errors.push(`projects[${index}].gitnexus_group is invalid`);
|
||||
}
|
||||
|
||||
if (localPath && remoteUrls.length > 0 && branches.length > 0) {
|
||||
projects.push({ localPath, gitnexusGroup, branches, remoteUrls });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||
return { configPath, syncIntervalMinutes: interval, projects };
|
||||
}
|
||||
40
gitnexus/src/core/auto-sync/index.ts
Normal file
40
gitnexus/src/core/auto-sync/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export {
|
||||
AUTO_SYNC_CONFIG_FILE,
|
||||
AUTO_SYNC_FLAG,
|
||||
getAutoSyncConfigPath,
|
||||
loadAutoSyncConfig,
|
||||
parseAutoSyncConfig,
|
||||
parseAutoSyncFlag,
|
||||
parseBranchCandidates,
|
||||
type AutoSyncConfig,
|
||||
type AutoSyncConfigLoadResult,
|
||||
type AutoSyncFlagDecision,
|
||||
type AutoSyncProjectConfig,
|
||||
} from './config.js';
|
||||
export {
|
||||
buildStateKey,
|
||||
getAutoSyncStatePath,
|
||||
loadAutoSyncState,
|
||||
saveAutoSyncState,
|
||||
shouldAnalyzeCommit,
|
||||
type AutoSyncAnalyzeStatus,
|
||||
type AutoSyncCommitState,
|
||||
type AutoSyncCommitStateEntry,
|
||||
} from './state.js';
|
||||
export { extractRepoNameFromRemoteUrl } from './repo.js';
|
||||
export {
|
||||
normalizeConfiguredCloneRoot,
|
||||
quarantineAutoSyncPartial,
|
||||
resolveConfiguredCloneRoot,
|
||||
type AutoSyncCloneRoot,
|
||||
} from './path-security.js';
|
||||
export {
|
||||
addRepoToGroup,
|
||||
getConfiguredRepoPath,
|
||||
runAutoSyncOnce,
|
||||
syncGroupByName,
|
||||
type AutoSyncLogger,
|
||||
type AutoSyncRunDeps,
|
||||
type AutoSyncRunResult,
|
||||
} from './runner.js';
|
||||
export { maybeStartAutoSyncFromEnv, type AutoSyncStartHandle } from './starter.js';
|
||||
173
gitnexus/src/core/auto-sync/path-security.ts
Normal file
173
gitnexus/src/core/auto-sync/path-security.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getGlobalDir } from '../../storage/repo-manager.js';
|
||||
|
||||
const DANGEROUS_ROOTS = new Set(
|
||||
[
|
||||
'/',
|
||||
os.homedir(),
|
||||
os.tmpdir(),
|
||||
'/bin',
|
||||
'/boot',
|
||||
'/dev',
|
||||
'/etc',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/proc',
|
||||
'/private/tmp',
|
||||
'/private/var',
|
||||
'/root',
|
||||
'/sbin',
|
||||
'/sys',
|
||||
'/tmp',
|
||||
'/usr',
|
||||
'/var',
|
||||
].map((entry) => path.resolve(entry)),
|
||||
);
|
||||
|
||||
const DANGEROUS_PARENT_ROOTS = new Set(
|
||||
[
|
||||
os.tmpdir(),
|
||||
'/bin',
|
||||
'/boot',
|
||||
'/dev',
|
||||
'/etc',
|
||||
'/lib',
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/proc',
|
||||
'/private/tmp',
|
||||
'/private/var',
|
||||
'/root',
|
||||
'/sbin',
|
||||
'/sys',
|
||||
'/tmp',
|
||||
'/usr',
|
||||
'/var',
|
||||
].map((entry) => path.resolve(entry)),
|
||||
);
|
||||
|
||||
const QUARANTINE_RETENTION_DAYS = 14;
|
||||
|
||||
export interface AutoSyncCloneRoot {
|
||||
root: string;
|
||||
quarantineRoot: string;
|
||||
quarantineRetentionDays: number;
|
||||
}
|
||||
|
||||
export async function resolveConfiguredCloneRoot(localPath: string): Promise<AutoSyncCloneRoot> {
|
||||
const root = normalizeConfiguredCloneRoot(localPath);
|
||||
assertNotDangerousRoot(root);
|
||||
await assertNoSymlinkPath(root);
|
||||
await assertDirectoryOwnerAndPermissions(root);
|
||||
const realRoot = await fs.realpath(root);
|
||||
assertContainedOrSame(root, realRoot, 'Configured clone root realpath escaped its normalized path');
|
||||
assertNotDangerousRoot(realRoot);
|
||||
assertNotGitNexusInternalRoot(realRoot);
|
||||
|
||||
return {
|
||||
root: realRoot,
|
||||
quarantineRoot: path.join(getGlobalDir(), 'quarantine'),
|
||||
quarantineRetentionDays: QUARANTINE_RETENTION_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeConfiguredCloneRoot(localPath: string): string {
|
||||
const value = localPath.trim();
|
||||
if (!value) throw new Error('local_path is required');
|
||||
if (!path.isAbsolute(value)) throw new Error('local_path must be an absolute path');
|
||||
if (value.split(path.sep).includes('..')) {
|
||||
throw new Error('local_path must be normalized and must not contain traversal segments');
|
||||
}
|
||||
const resolved = path.resolve(value);
|
||||
if (resolved !== path.normalize(value)) {
|
||||
throw new Error('local_path must be normalized and must not contain traversal segments');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function quarantineAutoSyncPartial(targetDir: string, quarantineRoot: string): Promise<string> {
|
||||
await fs.mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
|
||||
const base = path.basename(targetDir);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const destination = path.join(quarantineRoot, `auto-sync-${stamp}-${process.pid}-${base}`);
|
||||
await fs.rename(targetDir, destination);
|
||||
await fs.writeFile(
|
||||
`${destination}.README.txt`,
|
||||
[
|
||||
'GitNexus auto-sync isolated a partial or unsafe clone result.',
|
||||
`Created at: ${new Date().toISOString()}`,
|
||||
`Original path: ${targetDir}`,
|
||||
`Retention: keep for ${QUARANTINE_RETENTION_DAYS} days unless an operator reviews and removes it earlier.`,
|
||||
'Cleanup: verify the original path and remote before manual deletion.',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
return destination;
|
||||
}
|
||||
|
||||
function assertNotDangerousRoot(root: string): void {
|
||||
if (DANGEROUS_ROOTS.has(root)) throw new Error(`Refusing unsafe auto-sync clone root: ${root}`);
|
||||
for (const dangerousRoot of DANGEROUS_PARENT_ROOTS) {
|
||||
const rel = path.relative(dangerousRoot, root);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
throw new Error(`Refusing unsafe auto-sync clone root under ${dangerousRoot}: ${root}`);
|
||||
}
|
||||
}
|
||||
if (path.parse(root).root === root) throw new Error(`Refusing filesystem root as clone root: ${root}`);
|
||||
}
|
||||
|
||||
function assertNotGitNexusInternalRoot(root: string): void {
|
||||
const gitnexusDir = path.resolve(getGlobalDir());
|
||||
const blocked = [
|
||||
gitnexusDir,
|
||||
path.join(gitnexusDir, 'groups'),
|
||||
path.join(gitnexusDir, 'indexes'),
|
||||
path.join(gitnexusDir, 'quarantine'),
|
||||
];
|
||||
for (const blockedRoot of blocked) {
|
||||
const rel = path.relative(blockedRoot, root);
|
||||
if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
|
||||
throw new Error(`Refusing GitNexus internal directory as auto-sync clone root: ${root}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoSymlinkPath(root: string): Promise<void> {
|
||||
const parsed = path.parse(root);
|
||||
let current = parsed.root;
|
||||
const parts = root.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
current = path.join(current, part);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.lstat(current);
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') break;
|
||||
throw err;
|
||||
}
|
||||
if (stat.isSymbolicLink()) throw new Error(`Refusing symlink in auto-sync clone root path: ${current}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertDirectoryOwnerAndPermissions(root: string): Promise<void> {
|
||||
const stat = await fs.stat(root);
|
||||
if (!stat.isDirectory()) throw new Error(`auto-sync clone root is not a directory: ${root}`);
|
||||
if (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 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertContainedOrSame(root: string, child: string, message: string): void {
|
||||
const rel = path.relative(root, child);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(message);
|
||||
}
|
||||
5
gitnexus/src/core/auto-sync/repo.ts
Normal file
5
gitnexus/src/core/auto-sync/repo.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { extractRepoName } from '../../server/git-clone.js';
|
||||
|
||||
export function extractRepoNameFromRemoteUrl(remoteUrl: string): string {
|
||||
return extractRepoName(remoteUrl);
|
||||
}
|
||||
201
gitnexus/src/core/auto-sync/runner.ts
Normal file
201
gitnexus/src/core/auto-sync/runner.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
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 { resolveConfiguredCloneRoot } from './path-security.js';
|
||||
import {
|
||||
buildStateKey,
|
||||
loadAutoSyncState,
|
||||
saveAutoSyncState,
|
||||
shouldAnalyzeCommit,
|
||||
type AutoSyncAnalyzeStatus,
|
||||
} from './state.js';
|
||||
import type { AutoSyncConfig, AutoSyncProjectConfig } from './config.js';
|
||||
|
||||
export interface AutoSyncLogger {
|
||||
info(message: string): void;
|
||||
warn(message: string): void;
|
||||
error(message: string): void;
|
||||
}
|
||||
|
||||
export interface AutoSyncRunDeps {
|
||||
cloneOrPull: typeof cloneOrPull;
|
||||
getCurrentBranch: typeof getCurrentBranch;
|
||||
getCurrentCommit: typeof getCurrentCommit;
|
||||
runFullAnalysis: typeof runFullAnalysis;
|
||||
registerRepo: typeof registerRepo;
|
||||
loadState: typeof loadAutoSyncState;
|
||||
saveState: typeof saveAutoSyncState;
|
||||
addRepoToGroup: typeof addRepoToGroup;
|
||||
syncGroupByName: typeof syncGroupByName;
|
||||
resolveCloneRoot: typeof resolveConfiguredCloneRoot;
|
||||
}
|
||||
|
||||
export interface AutoSyncRunResult {
|
||||
synced: number;
|
||||
analyzed: number;
|
||||
skippedAnalysis: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGGER: AutoSyncLogger = {
|
||||
info: (message) => process.stderr.write(`${message}\n`),
|
||||
warn: (message) => process.stderr.write(`${message}\n`),
|
||||
error: (message) => process.stderr.write(`${message}\n`),
|
||||
};
|
||||
|
||||
const DEFAULT_DEPS: AutoSyncRunDeps = {
|
||||
cloneOrPull,
|
||||
getCurrentBranch,
|
||||
getCurrentCommit,
|
||||
runFullAnalysis,
|
||||
registerRepo,
|
||||
loadState: loadAutoSyncState,
|
||||
saveState: saveAutoSyncState,
|
||||
addRepoToGroup,
|
||||
syncGroupByName,
|
||||
resolveCloneRoot: resolveConfiguredCloneRoot,
|
||||
};
|
||||
|
||||
export async function runAutoSyncOnce(
|
||||
config: AutoSyncConfig,
|
||||
options: { deps?: Partial<AutoSyncRunDeps>; logger?: AutoSyncLogger; now?: () => Date } = {},
|
||||
): Promise<AutoSyncRunResult> {
|
||||
const deps = { ...DEFAULT_DEPS, ...options.deps };
|
||||
const logger = options.logger ?? DEFAULT_LOGGER;
|
||||
const now = options.now ?? (() => new Date());
|
||||
const state = await deps.loadState();
|
||||
const groupsToSync = new Set<string>();
|
||||
const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 };
|
||||
|
||||
for (const project of config.projects) {
|
||||
for (const remoteUrl of project.remoteUrls) {
|
||||
try {
|
||||
const repoName = extractRepoNameFromRemoteUrl(remoteUrl);
|
||||
const cloneRoot = await deps.resolveCloneRoot(project.localPath);
|
||||
const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName);
|
||||
await deps.cloneOrPull(remoteUrl, targetDir, undefined, {
|
||||
allowedCloneRoot: cloneRoot.root,
|
||||
expectedRepoName: repoName,
|
||||
quarantineRoot: cloneRoot.quarantineRoot,
|
||||
});
|
||||
result.synced += 1;
|
||||
|
||||
const currentBranch = deps.getCurrentBranch(targetDir);
|
||||
if (!currentBranch || !project.branches.includes(currentBranch)) {
|
||||
result.skippedAnalysis += 1;
|
||||
logger.warn(
|
||||
`[auto-sync] Skip analysis for ${targetDir}; current branch ${currentBranch ?? '<detached>'} is not in configured branches: ${project.branches.join(', ')}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const branch = currentBranch;
|
||||
const currentCommit = deps.getCurrentCommit(targetDir);
|
||||
const stateKey = buildStateKey(targetDir, branch);
|
||||
const previous = state[stateKey];
|
||||
let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped';
|
||||
let analyzedCommitId = previous?.analyzedCommitId;
|
||||
|
||||
if (
|
||||
shouldAnalyzeCommit({
|
||||
currentCommit,
|
||||
previousAnalyzedCommit: previous?.analyzedCommitId,
|
||||
previousStatus: previous?.lastAnalyzeStatus,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
const analysis = await deps.runFullAnalysis(
|
||||
targetDir,
|
||||
{ branch, skipAgentsMd: true, skipSkills: true },
|
||||
{ onProgress: () => {} },
|
||||
);
|
||||
const meta: RepoMeta = {
|
||||
repoPath: targetDir,
|
||||
lastCommit: currentCommit,
|
||||
indexedAt: now().toISOString(),
|
||||
stats: analysis.stats,
|
||||
branch,
|
||||
};
|
||||
await deps.registerRepo(targetDir, meta, { name: repoName, allowDuplicateName: true });
|
||||
analyzeStatus = 'success';
|
||||
analyzedCommitId = currentCommit;
|
||||
result.analyzed += 1;
|
||||
} catch (err: unknown) {
|
||||
analyzeStatus = 'failed';
|
||||
result.failed += 1;
|
||||
logger.error(`[auto-sync] Analysis failed for ${targetDir}: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
result.skippedAnalysis += 1;
|
||||
logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`);
|
||||
}
|
||||
|
||||
state[stateKey] = {
|
||||
codeCommitId: currentCommit,
|
||||
analyzedCommitId,
|
||||
lastAnalyzeStatus: analyzeStatus,
|
||||
lastSyncTime: now().toISOString(),
|
||||
};
|
||||
|
||||
if (project.gitnexusGroup) {
|
||||
const added = await deps.addRepoToGroup(project, repoName);
|
||||
if (added) groupsToSync.add(project.gitnexusGroup);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
result.failed += 1;
|
||||
logger.error(`[auto-sync] Repository sync failed for ${remoteUrl}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await deps.saveState(state);
|
||||
for (const groupName of groupsToSync) {
|
||||
try {
|
||||
await deps.syncGroupByName(groupName);
|
||||
} catch (err: unknown) {
|
||||
result.failed += 1;
|
||||
logger.error(`[auto-sync] Group sync failed for ${groupName}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getConfiguredRepoPath(
|
||||
project: Pick<AutoSyncProjectConfig, 'localPath'>,
|
||||
repoName: string,
|
||||
): string {
|
||||
return path.resolve(project.localPath, repoName);
|
||||
}
|
||||
|
||||
export async function addRepoToGroup(
|
||||
project: Pick<AutoSyncProjectConfig, 'gitnexusGroup'>,
|
||||
repoName: string,
|
||||
): Promise<boolean> {
|
||||
if (!project.gitnexusGroup) return false;
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), project.gitnexusGroup);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
if (Object.values(config.repos).includes(repoName)) return false;
|
||||
config.repos[repoName] = repoName;
|
||||
await writeGroupConfigAtomic(path.join(groupDir, 'group.yaml'), config);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function syncGroupByName(groupName: string): Promise<void> {
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
await syncGroup(config, { groupDir, allowStale: true });
|
||||
}
|
||||
|
||||
async function writeGroupConfigAtomic(filePath: string, config: unknown): Promise<void> {
|
||||
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
||||
await fs.writeFile(tmpPath, yaml.dump(config), 'utf-8');
|
||||
await fs.rename(tmpPath, filePath);
|
||||
}
|
||||
51
gitnexus/src/core/auto-sync/starter.ts
Normal file
51
gitnexus/src/core/auto-sync/starter.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { loadAutoSyncConfig, parseAutoSyncFlag } from './config.js';
|
||||
import { runAutoSyncOnce } from './runner.js';
|
||||
|
||||
export interface AutoSyncStartHandle {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export async function maybeStartAutoSyncFromEnv(options: {
|
||||
setIntervalFn?: typeof setInterval;
|
||||
clearIntervalFn?: typeof clearInterval;
|
||||
runOnce?: typeof runAutoSyncOnce;
|
||||
stderr?: Pick<NodeJS.WriteStream, 'write'>;
|
||||
} = {}): Promise<AutoSyncStartHandle | null> {
|
||||
const stderr = options.stderr ?? process.stderr;
|
||||
const flag = parseAutoSyncFlag();
|
||||
if (flag.enabled === false) {
|
||||
if (flag.message) stderr.write(`${flag.message}\n`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const loaded = await loadAutoSyncConfig();
|
||||
if (loaded.ok === false) {
|
||||
stderr.write(`${loaded.message}\n`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const runOnce = options.runOnce ?? runAutoSyncOnce;
|
||||
let running = false;
|
||||
const runSafely = () => {
|
||||
if (running) {
|
||||
stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n');
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
void runOnce(loaded.config)
|
||||
.catch((err: unknown) => {
|
||||
stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`);
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
};
|
||||
|
||||
runSafely();
|
||||
const intervalMs = loaded.config.syncIntervalMinutes * 60_000;
|
||||
const setIntervalFn = options.setIntervalFn ?? setInterval;
|
||||
const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
|
||||
const timer = setIntervalFn(runSafely, intervalMs);
|
||||
timer.unref?.();
|
||||
return { stop: () => clearIntervalFn(timer) };
|
||||
}
|
||||
61
gitnexus/src/core/auto-sync/state.ts
Normal file
61
gitnexus/src/core/auto-sync/state.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getGlobalDir } from '../../storage/repo-manager.js';
|
||||
|
||||
export type AutoSyncAnalyzeStatus = 'success' | 'failed' | 'skipped';
|
||||
|
||||
export interface AutoSyncCommitStateEntry {
|
||||
codeCommitId: string;
|
||||
analyzedCommitId?: string;
|
||||
lastAnalyzeStatus?: AutoSyncAnalyzeStatus;
|
||||
lastSyncTime: string;
|
||||
}
|
||||
|
||||
export type AutoSyncCommitState = Record<string, AutoSyncCommitStateEntry>;
|
||||
|
||||
export function getAutoSyncStatePath(gitnexusDir = getGlobalDir()): string {
|
||||
return path.join(gitnexusDir, 'auto-sync-state.json');
|
||||
}
|
||||
|
||||
export function buildStateKey(repoPath: string, branch: string): string {
|
||||
return `${path.resolve(repoPath)}|${branch}`;
|
||||
}
|
||||
|
||||
export function shouldAnalyzeCommit(input: {
|
||||
currentCommit: string;
|
||||
previousAnalyzedCommit?: string;
|
||||
previousStatus?: AutoSyncAnalyzeStatus;
|
||||
}): boolean {
|
||||
if (!input.currentCommit) return false;
|
||||
if (input.previousStatus === 'failed') return true;
|
||||
return input.currentCommit !== input.previousAnalyzedCommit;
|
||||
}
|
||||
|
||||
export async function loadAutoSyncState(
|
||||
statePath = getAutoSyncStatePath(),
|
||||
): Promise<AutoSyncCommitState> {
|
||||
try {
|
||||
const raw = await fs.readFile(statePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as AutoSyncCommitState)
|
||||
: {};
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
process.stderr.write(
|
||||
`[auto-sync] Ignoring unreadable or corrupt state file: ${statePath}. State will be rebuilt.\n`,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAutoSyncState(
|
||||
state: AutoSyncCommitState,
|
||||
statePath = getAutoSyncStatePath(),
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
||||
const tmpPath = `${statePath}.tmp.${process.pid}.${Date.now()}`;
|
||||
await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
|
||||
await fs.rename(tmpPath, statePath);
|
||||
}
|
||||
|
|
@ -10,8 +10,11 @@ import path from 'path';
|
|||
import fs from 'fs/promises';
|
||||
import { isIP } from 'net';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { parseRepoNameFromUrl } from '../storage/git.js';
|
||||
import { getGlobalDir } from '../storage/repo-manager.js';
|
||||
import {
|
||||
assertDirectoryOwnerAndPermissions,
|
||||
quarantineAutoSyncPartial,
|
||||
} from '../core/auto-sync/path-security.js';
|
||||
|
||||
/**
|
||||
* Root directory for all cloned repositories. Targets must resolve inside this.
|
||||
|
|
@ -39,12 +42,16 @@ export const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|||
* clone root via path traversal.
|
||||
*/
|
||||
export function extractRepoName(url: string): string {
|
||||
const name = parseRepoNameFromUrl(url);
|
||||
let trimmed = url.trim();
|
||||
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
|
||||
const withoutGit = trimmed.toLowerCase().endsWith('.git') ? trimmed.slice(0, -4) : trimmed;
|
||||
const name = withoutGit.split(/[/:]/).filter(Boolean).pop() ?? '';
|
||||
if (
|
||||
!name ||
|
||||
name === '.' ||
|
||||
name === '..' ||
|
||||
name === 'unknown' ||
|
||||
name.startsWith('-') ||
|
||||
!REPO_NAME_PATTERN.test(name)
|
||||
) {
|
||||
throw new Error('Could not extract a valid repository name from URL');
|
||||
|
|
@ -235,6 +242,14 @@ export interface CloneProgress {
|
|||
message: string;
|
||||
}
|
||||
|
||||
export interface CloneOrPullOptions {
|
||||
token?: string;
|
||||
allowedCloneRoot?: string;
|
||||
expectedRepoName?: string;
|
||||
quarantineRoot?: string;
|
||||
runGitForTest?: typeof runGit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `git clone` argument list for a given URL and target directory.
|
||||
*
|
||||
|
|
@ -443,36 +458,54 @@ export async function cloneOrPull(
|
|||
url: string,
|
||||
targetDir: string,
|
||||
onProgress?: (progress: CloneProgress) => void,
|
||||
options?: { token?: string },
|
||||
options?: CloneOrPullOptions,
|
||||
): Promise<string> {
|
||||
// Containment barrier — inline with the canonical path.relative idiom so
|
||||
// CodeQL recognizes the sanitizer at every following filesystem and
|
||||
// subprocess sink. The same `safeTarget` is used for every downstream
|
||||
// path operation — no reassignment that the analyzer could lose track of.
|
||||
//
|
||||
// Limitation: this is a lexical containment check, not a realpath check.
|
||||
// If an attacker can place a symlink under CLONE_ROOT pointing outside it,
|
||||
// the lexical check passes but the clone lands at the symlink target. That
|
||||
// requires pre-existing local write access to CLONE_ROOT, so the threat
|
||||
// model considers it out of scope; CodeQL js/path-injection accepts the
|
||||
// lexical form. Tracked as a follow-up if defense-in-depth is needed.
|
||||
// The lexical check runs before filesystem creation; realpath and symlink
|
||||
// checks below run before pull/clone and again after clone completes.
|
||||
const cloneRoot = path.resolve(options?.allowedCloneRoot ?? CLONE_ROOT);
|
||||
const expectedRepoName = options?.expectedRepoName;
|
||||
if (expectedRepoName !== undefined && expectedRepoName !== extractRepoName(url)) {
|
||||
throw new Error(`Clone target repo name ${expectedRepoName} does not match requested URL`);
|
||||
}
|
||||
|
||||
const safeTarget = path.resolve(targetDir);
|
||||
const rel = path.relative(CLONE_ROOT, safeTarget);
|
||||
if (expectedRepoName !== undefined && path.basename(safeTarget) !== expectedRepoName) {
|
||||
throw new Error(`Clone target basename must match repository name ${expectedRepoName}`);
|
||||
}
|
||||
|
||||
const rel = path.relative(cloneRoot, safeTarget);
|
||||
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error(`Clone target must be a subdirectory of ${CLONE_ROOT}`);
|
||||
throw new Error(`Clone target must be a subdirectory of ${cloneRoot}`);
|
||||
}
|
||||
|
||||
// Always validate the requested URL — the prior shape only ran this in
|
||||
// the code path where the repo was cloned. Now it runs unconditionally,
|
||||
// preventing SSRF / blocked-host bypasses even when targetDir already exists.
|
||||
validateGitUrl(url);
|
||||
await fs.mkdir(cloneRoot, { recursive: true });
|
||||
if (options?.allowedCloneRoot) {
|
||||
await assertDirectoryOwnerAndPermissions(cloneRoot);
|
||||
}
|
||||
await assertNoSymlinkPath(cloneRoot, safeTarget);
|
||||
await assertPreRealpathContainment(cloneRoot, safeTarget);
|
||||
|
||||
const exists = await fs.access(path.join(safeTarget, '.git')).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const targetExists = await fs.access(safeTarget).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
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.
|
||||
|
|
@ -480,14 +513,72 @@ export async function cloneOrPull(
|
|||
onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
|
||||
await runGit(['pull', '--ff-only'], safeTarget, { token: options?.token, url });
|
||||
} else {
|
||||
if (targetExists) {
|
||||
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 assertPreRealpathContainment(cloneRoot, safeTarget);
|
||||
onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });
|
||||
await runGit(buildCloneArgs(url, safeTarget), undefined, { token: options?.token, url });
|
||||
try {
|
||||
const runGitImpl = options?.runGitForTest ?? runGit;
|
||||
await runGitImpl(buildCloneArgs(url, safeTarget), undefined, { token: options?.token, url });
|
||||
await assertPostRealpathContainment(cloneRoot, safeTarget);
|
||||
} catch (err: unknown) {
|
||||
if (options?.quarantineRoot) {
|
||||
await fs
|
||||
.access(safeTarget)
|
||||
.then(async () => {
|
||||
await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot!);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return safeTarget;
|
||||
}
|
||||
|
||||
async function assertPreRealpathContainment(root: string, target: string): Promise<void> {
|
||||
const realRoot = await fs.realpath(root);
|
||||
const realParent = await fs.realpath(path.dirname(target));
|
||||
const parentRel = path.relative(realRoot, realParent);
|
||||
if (parentRel.startsWith('..') || path.isAbsolute(parentRel)) {
|
||||
throw new Error(`Clone target parent must resolve inside ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPostRealpathContainment(root: string, target: string): Promise<void> {
|
||||
const realRoot = await fs.realpath(root);
|
||||
const realTarget = await fs.realpath(target);
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error(`Clone target must resolve inside ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoSymlinkPath(root: string, target: string): Promise<void> {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolvedTarget = path.resolve(target);
|
||||
const relativeTarget = path.relative(resolvedRoot, resolvedTarget);
|
||||
if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) return;
|
||||
let current = resolvedRoot;
|
||||
for (const segment of relativeTarget.split(path.sep).filter(Boolean)) {
|
||||
current = path.join(current, segment);
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.lstat(current);
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') break;
|
||||
throw err;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing symlink in clone target path: ${current}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the per-request GitHub PAT may be sent to. Exported so the
|
||||
* /api/analyze boundary check and this injection-site check share one
|
||||
|
|
|
|||
421
gitnexus/test/unit/auto-sync-runner.test.ts
Normal file
421
gitnexus/test/unit/auto-sync-runner.test.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
addRepoToGroup,
|
||||
getConfiguredRepoPath,
|
||||
maybeStartAutoSyncFromEnv,
|
||||
runAutoSyncOnce,
|
||||
} from '../../src/core/auto-sync/index.js';
|
||||
import type { AutoSyncConfig, AutoSyncRunDeps } from '../../src/core/auto-sync/index.js';
|
||||
|
||||
const config: AutoSyncConfig = {
|
||||
configPath: '/tmp/.gitnexus/sync_config.yml',
|
||||
syncIntervalMinutes: 10,
|
||||
projects: [
|
||||
{
|
||||
localPath: '/tmp/repos',
|
||||
gitnexusGroup: 'back_end',
|
||||
branches: ['master'],
|
||||
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const cloneRoot = {
|
||||
root: '/tmp/repos',
|
||||
quarantineRoot: '/tmp/.gitnexus/quarantine',
|
||||
quarantineRetentionDays: 14,
|
||||
};
|
||||
|
||||
function withCloneRoot(deps: Partial<AutoSyncRunDeps>): Partial<AutoSyncRunDeps> {
|
||||
return {
|
||||
resolveCloneRoot: vi.fn(async () => cloneRoot),
|
||||
...deps,
|
||||
};
|
||||
}
|
||||
|
||||
describe('auto-sync runner', () => {
|
||||
it('runs clone, analyzes changed commits, registers the repo, and syncs changed groups', async () => {
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-2'),
|
||||
runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
||||
registerRepo: vi.fn(async () => 'qts_account'),
|
||||
loadState: vi.fn(async () => ({
|
||||
'/tmp/repos/qts_account|master': {
|
||||
codeCommitId: 'commit-1',
|
||||
analyzedCommitId: 'commit-1',
|
||||
lastAnalyzeStatus: 'success',
|
||||
lastSyncTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => true),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
now: () => new Date('2026-06-30T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, analyzed: 1, skippedAnalysis: 0, failed: 0 });
|
||||
expect(deps.cloneOrPull).toHaveBeenCalledWith(
|
||||
'git@gitee.com:qts_server/qts_account.git',
|
||||
'/tmp/repos/qts_account',
|
||||
undefined,
|
||||
{
|
||||
allowedCloneRoot: '/tmp/repos',
|
||||
expectedRepoName: 'qts_account',
|
||||
quarantineRoot: '/tmp/.gitnexus/quarantine',
|
||||
},
|
||||
);
|
||||
expect(deps.getCurrentBranch).toHaveBeenCalledWith('/tmp/repos/qts_account');
|
||||
expect(deps.runFullAnalysis).toHaveBeenCalledWith(
|
||||
'/tmp/repos/qts_account',
|
||||
{ branch: 'master', skipAgentsMd: true, skipSkills: true },
|
||||
{ onProgress: expect.any(Function) },
|
||||
);
|
||||
expect(deps.registerRepo).toHaveBeenCalledWith(
|
||||
'/tmp/repos/qts_account',
|
||||
expect.objectContaining({ lastCommit: 'commit-2', branch: 'master' }),
|
||||
{ name: 'qts_account', allowDuplicateName: true },
|
||||
);
|
||||
expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end');
|
||||
});
|
||||
|
||||
it('skips analysis when commit id has not changed', async () => {
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-1'),
|
||||
runFullAnalysis: vi.fn(),
|
||||
registerRepo: vi.fn(),
|
||||
loadState: vi.fn(async () => ({
|
||||
'/tmp/repos/qts_account|master': {
|
||||
codeCommitId: 'commit-1',
|
||||
analyzedCommitId: 'commit-1',
|
||||
lastAnalyzeStatus: 'success',
|
||||
lastSyncTime: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result.analyzed).toBe(0);
|
||||
expect(result.skippedAnalysis).toBe(1);
|
||||
expect(deps.runFullAnalysis).not.toHaveBeenCalled();
|
||||
expect(deps.syncGroupByName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses local_path plus repo name as the clone target', async () => {
|
||||
expect(getConfiguredRepoPath(config.projects[0], 'qts_account')).toBe('/tmp/repos/qts_account');
|
||||
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-2'),
|
||||
runFullAnalysis: vi.fn(async () => ({ stats: { files: 1 } }) as any),
|
||||
registerRepo: vi.fn(async () => 'qts_account'),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => false),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
expect(deps.cloneOrPull).toHaveBeenCalledWith(
|
||||
'git@gitee.com:qts_server/qts_account.git',
|
||||
'/tmp/repos/qts_account',
|
||||
undefined,
|
||||
{
|
||||
allowedCloneRoot: '/tmp/repos',
|
||||
expectedRepoName: 'qts_account',
|
||||
quarantineRoot: '/tmp/.gitnexus/quarantine',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('skips analysis when the checked out branch is not configured', async () => {
|
||||
const warnLogger = vi.fn();
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => 'develop'),
|
||||
getCurrentCommit: vi.fn(),
|
||||
runFullAnalysis: vi.fn(),
|
||||
registerRepo: vi.fn(),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => true),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: warnLogger, error: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 1, failed: 0 });
|
||||
expect(deps.getCurrentCommit).not.toHaveBeenCalled();
|
||||
expect(deps.runFullAnalysis).not.toHaveBeenCalled();
|
||||
expect(deps.addRepoToGroup).not.toHaveBeenCalled();
|
||||
expect(warnLogger).toHaveBeenCalledWith(
|
||||
'[auto-sync] Skip analysis for /tmp/repos/qts_account; current branch develop is not in configured branches: master.',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips analysis when the checked out repository is detached', async () => {
|
||||
const warnLogger = vi.fn();
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async () => '/tmp/repos/qts_account'),
|
||||
getCurrentBranch: vi.fn(() => undefined),
|
||||
getCurrentCommit: vi.fn(),
|
||||
runFullAnalysis: vi.fn(),
|
||||
registerRepo: vi.fn(),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => true),
|
||||
syncGroupByName: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await runAutoSyncOnce(config, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: warnLogger, error: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 1, failed: 0 });
|
||||
expect(deps.getCurrentCommit).not.toHaveBeenCalled();
|
||||
expect(deps.runFullAnalysis).not.toHaveBeenCalled();
|
||||
expect(deps.addRepoToGroup).not.toHaveBeenCalled();
|
||||
expect(warnLogger).toHaveBeenCalledWith(
|
||||
'[auto-sync] Skip analysis for /tmp/repos/qts_account; current branch <detached> is not in configured branches: master.',
|
||||
);
|
||||
});
|
||||
|
||||
it('isolates repository, analysis, and group sync failures', async () => {
|
||||
const errorLogger = vi.fn();
|
||||
const failingConfig: AutoSyncConfig = {
|
||||
...config,
|
||||
projects: [
|
||||
{
|
||||
...config.projects[0],
|
||||
remoteUrls: [
|
||||
'git@gitee.com:qts_server/failing_sync.git',
|
||||
'git@gitee.com:qts_server/qts_account.git',
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const deps: Partial<AutoSyncRunDeps> = withCloneRoot({
|
||||
cloneOrPull: vi.fn(async (remoteUrl) => {
|
||||
if (remoteUrl.includes('failing_sync')) throw new Error('sync failed');
|
||||
return '/tmp/repos/qts_account';
|
||||
}),
|
||||
getCurrentBranch: vi.fn(() => 'master'),
|
||||
getCurrentCommit: vi.fn(() => 'commit-2'),
|
||||
runFullAnalysis: vi.fn(async () => {
|
||||
throw new Error('analysis failed');
|
||||
}),
|
||||
registerRepo: vi.fn(),
|
||||
loadState: vi.fn(async () => ({})),
|
||||
saveState: vi.fn(async () => {}),
|
||||
addRepoToGroup: vi.fn(async () => true),
|
||||
syncGroupByName: vi.fn(async () => {
|
||||
throw new Error('group sync failed');
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runAutoSyncOnce(failingConfig, {
|
||||
deps,
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: errorLogger },
|
||||
now: () => new Date('2026-06-30T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, analyzed: 0, skippedAnalysis: 0, failed: 3 });
|
||||
expect(deps.cloneOrPull).toHaveBeenCalledTimes(2);
|
||||
expect(deps.registerRepo).not.toHaveBeenCalled();
|
||||
expect(deps.addRepoToGroup).toHaveBeenCalledWith(failingConfig.projects[0], 'qts_account');
|
||||
expect(deps.syncGroupByName).toHaveBeenCalledWith('back_end');
|
||||
expect(deps.saveState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
'/tmp/repos/qts_account|master': expect.objectContaining({
|
||||
codeCommitId: 'commit-2',
|
||||
lastAnalyzeStatus: 'failed',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(errorLogger).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Repository sync failed for git@gitee.com:qts_server/failing_sync.git'),
|
||||
);
|
||||
expect(errorLogger).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Analysis failed for /tmp/repos/qts_account'),
|
||||
);
|
||||
expect(errorLogger).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Group sync failed for back_end'),
|
||||
);
|
||||
});
|
||||
|
||||
it('detects existing groupPath to registryName mappings as already joined', async () => {
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-group-'));
|
||||
try {
|
||||
process.env.GITNEXUS_HOME = tempDir;
|
||||
const groupDir = path.join(tempDir, 'groups', 'back_end');
|
||||
await fs.mkdir(groupDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(groupDir, 'group.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'name: back_end',
|
||||
'repos:',
|
||||
' hr/hiring/backend: qts_account',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await expect(addRepoToGroup({ gitnexusGroup: 'back_end' }, 'qts_account')).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await expect(fs.readFile(path.join(groupDir, 'group.yaml'), 'utf-8')).resolves.toContain(
|
||||
'hr/hiring/backend: qts_account',
|
||||
);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousHome;
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-sync starter', () => {
|
||||
it('does not read config or register timers when the flag is disabled', async () => {
|
||||
const previous = process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
process.env.AUTO_UPDATE_AND_ANALYZE_FLAG = '0';
|
||||
const setIntervalFn = vi.fn() as unknown as typeof setInterval;
|
||||
|
||||
try {
|
||||
const handle = await maybeStartAutoSyncFromEnv({ setIntervalFn });
|
||||
expect(handle).toBeNull();
|
||||
expect(setIntervalFn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
else process.env.AUTO_UPDATE_AND_ANALYZE_FLAG = previous;
|
||||
}
|
||||
});
|
||||
|
||||
it('registers a clearable timer when enabled with a valid config', async () => {
|
||||
const previousFlag = process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
|
||||
const timer = { unref: vi.fn() };
|
||||
const setIntervalFn = vi.fn(() => timer) as unknown as typeof setInterval;
|
||||
const clearIntervalFn = vi.fn() as unknown as typeof clearInterval;
|
||||
const runOnce = vi.fn(async () => ({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 }));
|
||||
|
||||
try {
|
||||
process.env.AUTO_UPDATE_AND_ANALYZE_FLAG = '1';
|
||||
process.env.GITNEXUS_HOME = tempDir;
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'sync_config.yml'),
|
||||
[
|
||||
'sync_interval_minutes: 5',
|
||||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' gitnexus_group: back_end',
|
||||
' branch: master',
|
||||
' remote_urls:',
|
||||
' - git@gitee.com:qts_server/qts_account.git',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const handle = await maybeStartAutoSyncFromEnv({ setIntervalFn, clearIntervalFn, runOnce });
|
||||
|
||||
expect(handle).not.toBeNull();
|
||||
expect(runOnce).toHaveBeenCalledTimes(1);
|
||||
expect(setIntervalFn).toHaveBeenCalledWith(expect.any(Function), 300_000);
|
||||
expect(timer.unref).toHaveBeenCalled();
|
||||
|
||||
handle?.stop();
|
||||
|
||||
expect(clearIntervalFn).toHaveBeenCalledWith(timer);
|
||||
} finally {
|
||||
if (previousFlag === undefined) delete process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
else process.env.AUTO_UPDATE_AND_ANALYZE_FLAG = previousFlag;
|
||||
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousHome;
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('skips overlapping scheduled runs while a previous run is active', async () => {
|
||||
const previousFlag = process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
const previousHome = process.env.GITNEXUS_HOME;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-auto-sync-starter-'));
|
||||
const timer = { unref: vi.fn() };
|
||||
let scheduled: (() => void) | undefined;
|
||||
const setIntervalFn = vi.fn((fn: () => void) => {
|
||||
scheduled = fn;
|
||||
return timer;
|
||||
}) as unknown as typeof setInterval;
|
||||
const stderr = { write: vi.fn() };
|
||||
let releaseRun: (() => void) | undefined;
|
||||
const runOnce = vi.fn(
|
||||
() =>
|
||||
new Promise<any>((resolve) => {
|
||||
releaseRun = () => resolve({ synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 });
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
process.env.AUTO_UPDATE_AND_ANALYZE_FLAG = '1';
|
||||
process.env.GITNEXUS_HOME = tempDir;
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'sync_config.yml'),
|
||||
[
|
||||
'sync_interval_minutes: 5',
|
||||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' branch: master',
|
||||
' remote_urls:',
|
||||
' - https://example.com/team/repo.git',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await maybeStartAutoSyncFromEnv({ setIntervalFn, runOnce, stderr });
|
||||
scheduled?.();
|
||||
|
||||
expect(runOnce).toHaveBeenCalledTimes(1);
|
||||
expect(stderr.write).toHaveBeenCalledWith(
|
||||
'[auto-sync] Previous run is still active; skipping overlapping run.\n',
|
||||
);
|
||||
|
||||
releaseRun?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
scheduled?.();
|
||||
|
||||
expect(runOnce).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
if (previousFlag === undefined) delete process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
else process.env.AUTO_UPDATE_AND_ANALYZE_FLAG = previousFlag;
|
||||
if (previousHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousHome;
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
250
gitnexus/test/unit/auto-sync.test.ts
Normal file
250
gitnexus/test/unit/auto-sync.test.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
extractRepoNameFromRemoteUrl,
|
||||
loadAutoSyncConfig,
|
||||
parseAutoSyncFlag,
|
||||
parseBranchCandidates,
|
||||
resolveConfiguredCloneRoot,
|
||||
loadAutoSyncState,
|
||||
saveAutoSyncState,
|
||||
shouldAnalyzeCommit,
|
||||
} from '../../src/core/auto-sync/index.js';
|
||||
|
||||
describe('auto-sync', () => {
|
||||
let tempDir: string;
|
||||
let gitnexusHome: string;
|
||||
let oldHome: string | undefined;
|
||||
let oldFlag: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
const base = path.join(process.cwd(), '.tmp-test');
|
||||
await fs.mkdir(base, { recursive: true });
|
||||
tempDir = await fs.realpath(await fs.mkdtemp(path.join(base, 'gitnexus-auto-sync-')));
|
||||
gitnexusHome = path.join(tempDir, '.gitnexus');
|
||||
await fs.mkdir(gitnexusHome);
|
||||
oldHome = process.env.GITNEXUS_HOME;
|
||||
oldFlag = process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
process.env.GITNEXUS_HOME = gitnexusHome;
|
||||
delete process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (oldHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = oldHome;
|
||||
if (oldFlag === undefined) delete process.env.AUTO_UPDATE_AND_ANALYZE_FLAG;
|
||||
else process.env.AUTO_UPDATE_AND_ANALYZE_FLAG = oldFlag;
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('keeps auto sync disabled when the flag is unset or 0', () => {
|
||||
expect(parseAutoSyncFlag(undefined)).toEqual({ enabled: false, reason: 'unset' });
|
||||
expect(parseAutoSyncFlag('0')).toEqual({ enabled: false, reason: 'disabled' });
|
||||
});
|
||||
|
||||
it('enables auto sync only for the explicit value 1', () => {
|
||||
expect(parseAutoSyncFlag('1')).toEqual({ enabled: true });
|
||||
expect(parseAutoSyncFlag('true')).toEqual({
|
||||
enabled: false,
|
||||
reason: 'invalid',
|
||||
message: '[auto-sync] AUTO_UPDATE_AND_ANALYZE_FLAG must be 0 or 1; got "true". Auto sync is disabled.',
|
||||
});
|
||||
});
|
||||
|
||||
it('loads sync_config.yml from GITNEXUS_HOME and normalizes branch candidates', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(gitnexusHome, 'sync_config.yml'),
|
||||
[
|
||||
'sync_interval_minutes: 10',
|
||||
'projects:',
|
||||
' - local_path: /tmp/repos',
|
||||
' gitnexus_group: back_end',
|
||||
' branch: test, master, test',
|
||||
' remote_urls:',
|
||||
' - git@gitee.com:qts_server/qts_account.git',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const loaded = await loadAutoSyncConfig();
|
||||
|
||||
expect(loaded.ok).toBe(true);
|
||||
if (!loaded.ok) throw new Error('expected config to load');
|
||||
expect(loaded.config.configPath).toBe(path.join(gitnexusHome, 'sync_config.yml'));
|
||||
expect(loaded.config.syncIntervalMinutes).toBe(10);
|
||||
expect(loaded.config.projects[0]).toMatchObject({
|
||||
localPath: '/tmp/repos',
|
||||
gitnexusGroup: 'back_end',
|
||||
branches: ['test', 'master'],
|
||||
remoteUrls: ['git@gitee.com:qts_server/qts_account.git'],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing config without throwing', async () => {
|
||||
const loaded = await loadAutoSyncConfig();
|
||||
|
||||
expect(loaded).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing',
|
||||
message: `[auto-sync] Missing config file: ${path.join(gitnexusHome, 'sync_config.yml')}. Auto sync is skipped.`,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports invalid config without throwing', async () => {
|
||||
await fs.writeFile(path.join(gitnexusHome, 'sync_config.yml'), 'projects: []\n');
|
||||
|
||||
const loaded = await loadAutoSyncConfig();
|
||||
|
||||
expect(loaded.ok).toBe(false);
|
||||
if (loaded.ok) throw new Error('expected invalid config');
|
||||
expect(loaded.reason).toBe('invalid');
|
||||
expect(loaded.message).toContain('[auto-sync] Invalid sync_config.yml:');
|
||||
expect(loaded.message).toContain('sync_interval_minutes must be a positive integer');
|
||||
expect(loaded.message).toContain('projects must contain at least one project');
|
||||
});
|
||||
|
||||
it('rejects missing, relative, and traversal local_path values at config load', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(gitnexusHome, 'sync_config.yml'),
|
||||
[
|
||||
'sync_interval_minutes: 10',
|
||||
'projects:',
|
||||
' - local_path: ../repos',
|
||||
' branch: master',
|
||||
' remote_urls:',
|
||||
' - https://example.com/team/repo.git',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const loaded = await loadAutoSyncConfig();
|
||||
|
||||
expect(loaded.ok).toBe(false);
|
||||
if (loaded.ok) throw new Error('expected invalid config');
|
||||
expect(loaded.message).toContain('local_path must be an absolute path');
|
||||
});
|
||||
|
||||
it('hard-fails unsafe configured clone roots', async () => {
|
||||
await expect(resolveConfiguredCloneRoot('/')).rejects.toThrow('unsafe auto-sync clone root');
|
||||
await expect(resolveConfiguredCloneRoot(os.homedir())).rejects.toThrow('unsafe auto-sync clone root');
|
||||
await expect(resolveConfiguredCloneRoot(path.join(await fs.realpath(os.tmpdir()), 'repos'))).rejects.toThrow(
|
||||
'unsafe auto-sync clone root',
|
||||
);
|
||||
const root = path.join(tempDir, 'repos');
|
||||
await expect(resolveConfiguredCloneRoot(`${root}/../repos`)).rejects.toThrow(
|
||||
'normalized',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects GitNexus internal directory descendants as clone roots', async () => {
|
||||
for (const internalDir of ['groups', 'indexes', 'quarantine']) {
|
||||
const root = path.join(gitnexusHome, internalDir, 'repo-root');
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
|
||||
await expect(resolveConfiguredCloneRoot(root)).rejects.toThrow('GitNexus internal directory');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects symlinks in configured clone root paths', async () => {
|
||||
const realRoot = path.join(tempDir, 'real-root');
|
||||
const linkRoot = path.join(tempDir, 'link-root');
|
||||
await fs.mkdir(realRoot);
|
||||
await fs.symlink(realRoot, linkRoot);
|
||||
|
||||
await expect(resolveConfiguredCloneRoot(linkRoot)).rejects.toThrow('symlink');
|
||||
});
|
||||
|
||||
it('resolves safe configured clone roots and reports quarantine retention', async () => {
|
||||
const root = path.join(tempDir, 'repos');
|
||||
await fs.mkdir(root);
|
||||
|
||||
await expect(resolveConfiguredCloneRoot(root)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
root,
|
||||
quarantineRoot: path.join(gitnexusHome, 'quarantine'),
|
||||
quarantineRetentionDays: 14,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses branch strings and arrays with trimming and de-duplication', () => {
|
||||
expect(parseBranchCandidates('test, master, test')).toEqual(['test', 'master']);
|
||||
expect(parseBranchCandidates(['develop,master', 'develop'])).toEqual(['develop', 'master']);
|
||||
});
|
||||
|
||||
it('extracts safe repository names from remote URLs', () => {
|
||||
expect(extractRepoNameFromRemoteUrl('git@gitee.com:qts_server/qts_account.git')).toBe(
|
||||
'qts_account',
|
||||
);
|
||||
expect(extractRepoNameFromRemoteUrl('https://example.com/team/repo-name.git')).toBe(
|
||||
'repo-name',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsafe repository names without sanitizing them', () => {
|
||||
expect(() => extractRepoNameFromRemoteUrl('https://example.com/team/repo$name.git')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
expect(() => extractRepoNameFromRemoteUrl('https://example.com/team/..')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses commit ids to skip unchanged analyses and retry failed prior analyses', () => {
|
||||
expect(shouldAnalyzeCommit({ currentCommit: 'abc', previousAnalyzedCommit: 'abc' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
shouldAnalyzeCommit({
|
||||
currentCommit: 'abc',
|
||||
previousAnalyzedCommit: 'abc',
|
||||
previousStatus: 'failed',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(shouldAnalyzeCommit({ currentCommit: 'def', previousAnalyzedCommit: 'abc' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('saves state atomically and reloads it', async () => {
|
||||
const statePath = path.join(tempDir, 'auto-sync-state.json');
|
||||
|
||||
await saveAutoSyncState(
|
||||
{
|
||||
'/tmp/repos/qts_account|master': {
|
||||
codeCommitId: 'abc',
|
||||
analyzedCommitId: 'abc',
|
||||
lastAnalyzeStatus: 'success',
|
||||
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
statePath,
|
||||
);
|
||||
|
||||
await expect(fs.readdir(tempDir)).resolves.not.toContain(
|
||||
expect.stringContaining('auto-sync-state.json.tmp'),
|
||||
);
|
||||
await expect(loadAutoSyncState(statePath)).resolves.toEqual({
|
||||
'/tmp/repos/qts_account|master': {
|
||||
codeCommitId: 'abc',
|
||||
analyzedCommitId: 'abc',
|
||||
lastAnalyzeStatus: 'success',
|
||||
lastSyncTime: '2026-06-30T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty state and reports corrupt state files', async () => {
|
||||
const statePath = path.join(tempDir, 'auto-sync-state.json');
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
await fs.writeFile(statePath, '{not-json', 'utf-8');
|
||||
|
||||
await expect(loadAutoSyncState(statePath)).resolves.toEqual({});
|
||||
|
||||
expect(stderr).toHaveBeenCalledWith(
|
||||
`[auto-sync] Ignoring unreadable or corrupt state file: ${statePath}. State will be rebuilt.\n`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,12 @@ import { getGlobalDir } from '../../src/storage/repo-manager.js';
|
|||
// load, the same point CLONE_ROOT is frozen, so the two always agree.
|
||||
const EXPECTED_CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos'));
|
||||
|
||||
async function mkControlledRoot(prefix: string): Promise<string> {
|
||||
const base = path.join(process.cwd(), '.tmp-test');
|
||||
await fs.mkdir(base, { recursive: true });
|
||||
return fs.realpath(await fs.mkdtemp(path.join(base, prefix)));
|
||||
}
|
||||
|
||||
describe('git-clone', () => {
|
||||
describe('extractRepoName', () => {
|
||||
it('extracts name from HTTPS URL', () => {
|
||||
|
|
@ -95,29 +101,39 @@ describe('git-clone', () => {
|
|||
expect(elapsedMs).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('strips leading dashes to prevent argument injection', () => {
|
||||
expect(extractRepoName('https://github.com/user/--upload-pack=payload.git')).toBe(
|
||||
'upload-pack_payload',
|
||||
it('rejects leading dashes to prevent argument injection', () => {
|
||||
expect(() => extractRepoName('https://github.com/user/--upload-pack=payload.git')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
expect(() => extractRepoName('https://github.com/user/-repo')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
expect(extractRepoName('https://github.com/user/-repo')).toBe('repo');
|
||||
});
|
||||
|
||||
it('sanitizes unsafe directory characters', () => {
|
||||
// sanitizeRepoName turns <tag> into _tag_
|
||||
expect(extractRepoName('https://github.com/user/repo<tag>.git')).toBe('repo_tag_');
|
||||
it('rejects unsafe directory characters instead of sanitizing them', () => {
|
||||
expect(() => extractRepoName('https://github.com/user/repo<tag>.git')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes shell metacharacters in URL segments', () => {
|
||||
it('rejects shell metacharacters in URL segments', () => {
|
||||
// The split on /[/:]/ does not split on backslashes or other shell chars,
|
||||
// so a name like `repo;rm -rf /` would slip through without the pattern.
|
||||
// After fix/sanitize-repo-name, these are sanitized to underscores.
|
||||
expect(extractRepoName('https://example.com/foo:repo;rm')).toBe('repo_rm');
|
||||
expect(extractRepoName('https://example.com/foo:repo$x')).toBe('repo_x');
|
||||
// so a name like `repo;rm -rf /` must fail instead of being rewritten.
|
||||
expect(() => extractRepoName('https://example.com/foo:repo;rm')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
expect(() => extractRepoName('https://example.com/foo:repo$x')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes whitespace and backslashes', () => {
|
||||
expect(extractRepoName('https://example.com/foo:repo name')).toBe('repo_name');
|
||||
expect(extractRepoName('https://example.com/foo:repo\\name')).toBe('repo_name');
|
||||
it('rejects whitespace and backslashes', () => {
|
||||
expect(() => extractRepoName('https://example.com/foo:repo name')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
expect(() => extractRepoName('https://example.com/foo:repo\\name')).toThrow(
|
||||
'valid repository name',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -534,6 +550,154 @@ describe('git-clone', () => {
|
|||
'Only https:// and http://',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows an explicitly controlled auto-sync clone root outside the default root', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
try {
|
||||
const target = path.join(root, 'repo');
|
||||
await expect(
|
||||
cloneOrPull('http://127.0.0.1/repo.git', target, undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
}),
|
||||
).rejects.toThrow('private/internal');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects controlled-root target names that do not match the remote repo name', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
try {
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'other'), undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
}),
|
||||
).rejects.toThrow('basename must match');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects symlink children before clone or pull', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const outside = await mkControlledRoot('gitnexus-outside-');
|
||||
try {
|
||||
await fs.symlink(outside, path.join(root, 'repo'));
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'repo'), undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
}),
|
||||
).rejects.toThrow('symlink');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects existing clones whose remote origin mismatches the requested URL', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const target = path.join(root, 'repo');
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn('git', ['init'], { cwd: root, stdio: 'ignore' });
|
||||
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`git init ${code}`))));
|
||||
proc.on('error', reject);
|
||||
});
|
||||
await fs.rename(path.join(root, '.git'), path.join(target, '.git')).catch(async () => {
|
||||
await fs.mkdir(target);
|
||||
await fs.rename(path.join(root, '.git'), path.join(target, '.git'));
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(target, '.git', 'config'),
|
||||
[
|
||||
'[remote "origin"]',
|
||||
'\turl = https://example.com/other/repo.git',
|
||||
'\tfetch = +refs/heads/*:refs/remotes/origin/*',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
}),
|
||||
).rejects.toThrow('not the requested URL');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('quarantines partial auto-sync clone output on clone failure', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const quarantineRoot = path.join(root, 'quarantine');
|
||||
const target = path.join(root, 'repo');
|
||||
try {
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
quarantineRoot,
|
||||
runGitForTest: async () => {
|
||||
await fs.mkdir(target);
|
||||
await fs.writeFile(path.join(target, 'partial.txt'), 'partial', 'utf-8');
|
||||
throw new Error('git clone failed (exit code 128)');
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('git clone failed');
|
||||
|
||||
const entries = await fs.readdir(quarantineRoot);
|
||||
expect(entries.some((entry) => entry.startsWith('auto-sync-') && entry.endsWith('-repo'))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not quarantine an existing non-git directory on clone failure', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
const quarantineRoot = path.join(root, 'quarantine');
|
||||
const target = path.join(root, 'repo');
|
||||
try {
|
||||
await fs.mkdir(target);
|
||||
await fs.writeFile(path.join(target, 'user-file.txt'), 'keep me', 'utf-8');
|
||||
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', target, undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
quarantineRoot,
|
||||
}),
|
||||
).rejects.toThrow('already exists but is not a git repository');
|
||||
|
||||
await expect(fs.readFile(path.join(target, 'user-file.txt'), 'utf-8')).resolves.toBe(
|
||||
'keep me',
|
||||
);
|
||||
await expect(fs.access(quarantineRoot)).rejects.toThrow();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects controlled clone roots with unsafe permissions inside cloneOrPull', async () => {
|
||||
const root = await mkControlledRoot('gitnexus-controlled-root-');
|
||||
try {
|
||||
await fs.chmod(root, 0o777);
|
||||
await expect(
|
||||
cloneOrPull('https://example.com/team/repo.git', path.join(root, 'repo'), undefined, {
|
||||
allowedCloneRoot: root,
|
||||
expectedRepoName: 'repo',
|
||||
}),
|
||||
).rejects.toThrow('world-writable');
|
||||
} finally {
|
||||
await fs.chmod(root, 0o700).catch(() => {});
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAzureDevOpsUrl', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue