Files
ForgeFlow/src/main/git-service.cjs
T
ChatGPT MCP bd4240f109
Managed validation / full (pull_request) Successful in 31s
Harden workspace sync quarantine and updater recovery
2026-08-29 21:41:31 +00:00

950 lines
46 KiB
JavaScript

'use strict';
const path = require('node:path');
const fs = require('node:fs/promises');
const crypto = require('node:crypto');
const { run } = require('./process-runner.cjs');
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
const {
assertSafeRepositoryPath,
assertRepositoryRelativePath,
assertRepositoryRelativePaths,
assertCommitMessage,
assertFullCommitSha,
assertCloneRemote
} = require('../shared/validation.cjs');
function parseUnifiedDiff(diffText) {
const text = String(diffText || '').replace(/\r\n/g, '\n');
const firstHunk = text.search(/^@@ /m);
if (firstHunk < 0) return { header: text, hunks: [] };
const header = text.slice(0, firstHunk);
const hunks = text.slice(firstHunk).split(/(?=^@@ )/m).filter(Boolean).map((patch, index) => {
const heading = patch.split('\n', 1)[0];
return { index, heading, patch, additions: (patch.match(/^\+(?!\+\+)/gm) || []).length, deletions: (patch.match(/^-(?!---)/gm) || []).length };
});
return { header, hunks };
}
function parseNameStatus(output) {
const entries = String(output || '').split('\0');
const changes = [];
for (let index = 0; index < entries.length;) {
const rawStatus = entries[index++];
if (!rawStatus) continue;
const code = rawStatus[0];
if (code === 'R' || code === 'C') {
const originalPath = entries[index++] || '';
const filePath = entries[index++] || '';
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
continue;
}
const filePath = entries[index++] || '';
if (!filePath) continue;
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
}
return changes;
}
function parseCompactLog(output) {
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [sha, shortSha, date, subject] = record.split('\x1f');
return { sha, shortSha, date, subject };
});
}
class GitService {
constructor() {
// `git remote get-url` is only re-run when the repository configuration file
// itself changed. Status polling asks for the remote URL of every repository
// every few seconds, and on Windows the child process dominates that cost.
this.remoteUrlCache = new Map();
}
async isAvailable() {
try {
const result = await run('git', ['--version'], { timeout: 10_000 });
return { available: true, version: result.stdout.trim() };
} catch (error) {
return { available: false, version: null, error: error.message };
}
}
async ensureRepository(repoPath) {
const resolved = assertSafeRepositoryPath(repoPath);
const stat = await fs.stat(resolved).catch(() => null);
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
// A directory that carries its own `.git` entry is by definition the top level
// of that working tree, for plain repositories as well as for submodules and
// linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn
// that again is pure overhead, and every status poll passes an already
// resolved repository root back in.
const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null);
if (marker) return resolved;
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
return path.resolve(result.stdout.trim());
}
async status(repoPath) {
const root = await this.ensureRepository(repoPath);
// `--no-optional-locks` keeps a status read from refreshing and rewriting the
// index. Without it every read writes inside .git, which both fights a
// concurrent Git command for the index lock and retriggers the filesystem
// watcher that asked for this read in the first place.
const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
cwd: root,
timeout: 30_000
});
const parsed = parsePorcelainV2(result.stdout);
const remoteUrl = await this.getRemoteUrl(root).catch(() => '');
const head = parsed.branch.oid && parsed.branch.oid !== '(initial)' ? parsed.branch.oid : null;
return { ...parsed, root, remoteUrl, head, shortHead: head ? head.slice(0, 7) : null };
}
statusFingerprint(status) {
return JSON.stringify({
head: status?.head || null,
branch: status?.branch || null,
files: (status?.files || []).map((file) => [file.path, file.originalPath, file.indexCode, file.worktreeCode])
});
}
remoteUrlCacheKey(repoPath, remote) {
return JSON.stringify([path.resolve(repoPath), remote]);
}
async getRemoteUrl(repoPath, remote = 'origin') {
const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null);
const cached = this.remoteUrlCache.get(cacheKey);
if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) {
if (cached.error) throw cached.error;
return cached.url;
}
const remember = (entry) => {
if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size });
else this.remoteUrlCache.delete(cacheKey);
};
try {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
const url = result.stdout.trim();
remember({ url, error: null });
return url;
} catch (error) {
// A repository that has no such remote keeps failing until its configuration
// changes, so the failure is remembered too. Without this, every status poll
// of an unmatched local repository spawns a child process that cannot succeed.
remember({ url: '', error });
throw error;
}
}
pathspecInput(paths) {
const selected = assertRepositoryRelativePaths(paths);
return selected.length ? `${selected.join('\0')}\0` : '';
}
async runWithPathspec(root, args, paths, options = {}) {
const selected = assertRepositoryRelativePaths(paths);
if (!selected.length) return run('git', args, { cwd: root, ...options });
return run('git', [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], {
cwd: root,
input: this.pathspecInput(selected),
...options
});
}
async gitDirectory(repoPath) {
const root = await this.ensureRepository(repoPath);
const result = await run('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd: root, timeout: 15_000 });
return { root, gitDir: path.resolve(result.stdout.trim()) };
}
async writeWorkspaceReviewManifest(repoPath, plan, { backupBranch = null, stash = null } = {}) {
const { root, gitDir } = await this.gitDirectory(repoPath);
const reviewId = String(plan?.id || '').trim();
if (!/^[0-9a-f]{64}$/i.test(reviewId)) throw new Error('Workspace review manifest requires a valid synchronization plan.');
const reviewDirectory = path.join(gitDir, 'forgeflow', 'workspace-reviews');
await fs.mkdir(reviewDirectory, { recursive: true });
const manifestPath = path.join(reviewDirectory, `${reviewId}.json`);
const payload = {
schemaVersion: 1,
kind: 'workspace-sync-quarantine',
id: reviewId,
status: 'pending-codex-review',
createdAt: new Date().toISOString(),
repositoryRoot: root,
branch: plan.branch,
upstream: plan.upstream,
sourceSha: plan.currentSha,
targetSha: plan.targetSha,
recoveryBranch: backupBranch,
stashRef: stash?.ref || null,
stashSha: stash?.sha || null,
files: (plan.localFiles || []).map((file) => ({
path: file.path,
originalPath: file.originalPath || null,
status: file.status,
staged: Boolean(file.staged),
unstaged: Boolean(file.unstaged),
untracked: Boolean(file.untracked)
})),
instructions: [
'Review the recovery branch and quarantine stash with Codex before restoring anything.',
'ForgeFlow recovery branches are local-only and cannot be pushed to Gitea.',
'Restore only files that are still useful; obsolete files can be dropped after review.'
],
manifestPath
};
const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
await fs.rename(temporaryPath, manifestPath);
return payload;
}
isGitLockError(error) {
const message = String(error?.message || error || '');
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
|| COMMON_GIT_LOCK_FILES.some((lockName) => message.toLowerCase().includes(lockName.toLowerCase()));
}
async gitProcessProbe(root) {
if (process.platform !== 'win32') return { available: false, active: [], reason: 'process probe is Windows-only' };
const escaped = root.replace(/'/g, "''");
const script = `$root='${escaped}'; Get-CimInstance Win32_Process -Filter \"Name='git.exe' OR Name='git-remote-https.exe' OR Name='ssh.exe'\" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($root,[System.StringComparison]::OrdinalIgnoreCase) -ge 0 } | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`;
try {
const result = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], { timeout: 15_000, allowExitCodes: [1] });
const text = result.stdout.trim();
const parsed = text ? JSON.parse(text) : [];
return { available: true, active: Array.isArray(parsed) ? parsed : [parsed] };
} catch (error) {
return { available: false, active: [], reason: error.message };
}
}
async listGitLocks(repoPath) {
const { root, gitDir } = await this.gitDirectory(repoPath);
const locks = [];
const walk = async (directory, depth = 0) => {
if (depth > 8) return;
const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const relative = path.relative(gitDir, fullPath).replace(/\\/g, '/');
if (entry.isDirectory()) {
const segments = relative.split('/');
if (segments.includes('objects') || segments.includes('lfs')) continue;
await walk(fullPath, depth + 1);
} else if (entry.isFile() && entry.name.endsWith('.lock')) {
const stat = await fs.stat(fullPath).catch(() => null);
if (stat) locks.push({
name: path.relative(gitDir, fullPath).replace(/\\/g, '/'),
lockPath: fullPath,
ageMs: Math.max(0, Date.now() - stat.mtimeMs),
size: stat.size,
modifiedAt: stat.mtime.toISOString()
});
}
}
};
await walk(gitDir);
const processes = await this.gitProcessProbe(root);
return { root, gitDir, locks: locks.sort((a, b) => a.name.localeCompare(b.name)), processes };
}
async repairStaleGitLocks(repoPath, { minimumAgeMs = 15_000, allowWithoutProcessProbe = false } = {}) {
const report = await this.listGitLocks(repoPath);
if (!report.locks.length) return { ...report, removed: [], skipped: [], repaired: false };
if (report.processes.active.length) {
const error = new Error(`A Git-related process is still using this repository (${report.processes.active.map((item) => `${item.Name || 'process'} ${item.ProcessId || ''}`.trim()).join(', ')}). Close it before repairing locks.`);
error.code = 'GIT_PROCESS_ACTIVE';
error.processes = report.processes.active;
throw error;
}
if (!report.processes.available && !allowWithoutProcessProbe) {
const error = new Error('ForgeFlow could not prove that no Git process is active. Use the explicit force repair only after closing Git tools for this repository.');
error.code = 'GIT_PROCESS_PROBE_UNAVAILABLE';
error.recoverable = true;
throw error;
}
const removed = [];
const skipped = [];
for (const lock of report.locks) {
if (lock.ageMs < minimumAgeMs) { skipped.push({ ...lock, reason: 'recent' }); continue; }
await fs.rm(lock.lockPath, { force: true });
removed.push(lock);
}
if (!removed.length && skipped.length) {
const error = new Error('All Git lock files are recent. Wait a few seconds after closing Git tools, then scan again.');
error.code = 'GIT_LOCKS_RECENT';
error.recoverable = true;
throw error;
}
return { ...report, removed, skipped, repaired: removed.length > 0 };
}
async getIndexLockInfo(repoPath) {
const report = await this.listGitLocks(repoPath);
const lock = report.locks.find((item) => item.name === 'index.lock');
return lock ? { exists: true, ...lock } : { exists: false, lockPath: path.join(report.gitDir, 'index.lock'), ageMs: 0 };
}
async removeStaleIndexLock(repoPath, minimumAgeMs = 15_000) {
const result = await this.repairStaleGitLocks(repoPath, { minimumAgeMs });
const removed = result.removed.find((item) => item.name === 'index.lock');
return removed ? { removed: true, ...removed } : { removed: false, reason: 'missing', ...(await this.getIndexLockInfo(repoPath)) };
}
async reconcile(repoPath) {
const root = await this.ensureRepository(repoPath);
await this.fetch(root).catch(() => null);
const status = await this.status(root);
const upstream = status.branch?.upstream || '';
return {
status,
lockReport: await this.listGitLocks(root),
recommendations: [
{ id: 'fetch', label: 'Fetch and recalculate remote state', action: 'fetch', safe: true },
...(status.branch?.behind > 0 && status.branch?.ahead === 0 && status.clean && upstream ? [{ id: 'pull', label: `Fast-forward from ${upstream}`, action: 'fast-forward', safe: true }] : []),
...(status.branch?.ahead > 0 && status.branch?.behind === 0 && upstream ? [{ id: 'push', label: `Push ${status.branch.ahead} local commit(s)`, action: 'push', safe: true }] : []),
...(status.branch?.ahead > 0 && status.branch?.behind > 0 && upstream ? [
{ id: 'diverged', label: `Branch diverged (${status.branch.ahead} ahead, ${status.branch.behind} behind)`, action: null, safe: false },
{ id: 'backup-reset', label: `Create a safety branch and reset to ${upstream}`, action: 'backup-reset', safe: false }
] : [])
]
};
}
async abortInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
let aborted = null;
if (await exists('rebase-merge') || await exists('rebase-apply')) {
await run('git', ['rebase', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'rebase';
} else if (await exists('MERGE_HEAD')) {
await run('git', ['merge', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'merge';
} else if (await exists('CHERRY_PICK_HEAD')) {
await run('git', ['cherry-pick', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'cherry-pick';
} else if (await exists('REVERT_HEAD')) {
await run('git', ['revert', '--abort'], { cwd: root, timeout: 120_000 });
aborted = 'revert';
}
return { aborted, status: await this.status(root), lockReport: await this.listGitLocks(root) };
}
async detectInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const gitDirResult = await run('git', ['rev-parse', '--git-dir'], { cwd: root, timeout: 30_000 });
const gitDir = path.resolve(root, gitDirResult.stdout.trim());
const exists = async (name) => fs.access(path.join(gitDir, name)).then(() => true).catch(() => false);
if (await exists('rebase-merge') || await exists('rebase-apply')) return 'rebase';
if (await exists('MERGE_HEAD')) return 'merge';
if (await exists('CHERRY_PICK_HEAD')) return 'cherry-pick';
if (await exists('REVERT_HEAD')) return 'revert';
return null;
}
async repairSync(repoPath, strategy) {
const root = await this.ensureRepository(repoPath);
const requested = String(strategy || '').trim();
if (!['fetch', 'fast-forward', 'push', 'backup-reset'].includes(requested)) throw new Error('Unsupported Git synchronization repair strategy.');
await this.fetch(root);
let status = await this.status(root);
const branch = status.branch?.head;
const upstream = status.branch?.upstream;
if (!branch || branch === '(detached)') throw new Error('Synchronization repair requires a named local branch.');
if (!upstream && requested !== 'fetch') throw new Error('The current branch has no upstream branch. Repair origin or publish the branch first.');
if (requested === 'fast-forward') {
if (!status.clean) throw new Error('Fast-forward repair requires a clean working tree. Commit or stash changes first.');
if (status.branch.ahead > 0) throw new Error('Fast-forward repair is only safe when there are no local commits ahead of upstream.');
await run('git', ['merge', '--ff-only', upstream], { cwd: root, timeout: 2 * 60_000 });
} else if (requested === 'push') {
if (status.branch.behind > 0) throw new Error('Push repair is blocked because the remote branch contains commits that are not local.');
await this.push(root);
} else if (requested === 'backup-reset') {
if (!status.clean) throw new Error('Backup-and-reset requires a clean working tree. Commit or stash changes first.');
if (!(status.branch.ahead > 0 && status.branch.behind > 0)) throw new Error('Backup-and-reset is only offered for a diverged branch.');
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
const backupBranch = `forgeflow/backup-${branch.replace(/[^A-Za-z0-9._-]/g, '-')}-${stamp}`;
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
await run('git', ['reset', '--hard', upstream], { cwd: root, timeout: 2 * 60_000 });
status = await this.status(root);
return { strategy: requested, backupBranch, status, lockReport: await this.listGitLocks(root) };
}
status = await this.status(root);
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
}
async previewWorkspaceSync(repoPath) {
const root = await this.ensureRepository(repoPath);
const { status } = await this.fetch(root);
const branch = status.branch?.head;
const upstream = status.branch?.upstream;
if (!status.head || !branch || branch === '(detached)') {
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
throw error;
}
if (!upstream) {
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
throw error;
}
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
const changes = parseNameStatus((await run('git', [
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
this.detectInterruptedOperation(root)
]);
const blockers = [];
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
const summary = {
resultingTrackedChanges: changes.length,
added: changes.filter((item) => item.code === 'A').length,
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
deleted: changes.filter((item) => item.code === 'D').length,
renamed: changes.filter((item) => item.code === 'R').length,
localFilesToStash: status.counts.changed,
untrackedFilesToStash: status.counts.untracked,
localCommitsToProtect: status.branch.ahead,
incomingCommits: status.branch.behind
};
const planId = crypto.createHash('sha256').update(JSON.stringify({
head: status.head,
targetSha,
branch,
upstream,
fingerprint: this.statusFingerprint(status)
})).digest('hex');
return {
id: planId,
repositoryRoot: root,
branch,
upstream,
currentSha: status.head,
targetSha,
needsSync: status.head !== targetSha || !status.clean,
cleanBeforeSync: status.clean,
blockers,
summary,
changes: changes.slice(0, 250),
changesTruncated: changes.length > 250,
localFiles: status.files.slice(0, 250),
localFilesTruncated: status.files.length > 250,
incomingCommits: parseCompactLog(incomingResult.stdout),
localCommits: parseCompactLog(localResult.stdout),
recovery: {
safetyBranch: status.branch.ahead > 0,
stash: status.counts.changed > 0,
untrackedCleanup: status.counts.untracked > 0,
ignoredFilesPreserved: true
}
};
}
async synchronizeWorkspace(repoPath, expectedPlanId) {
const expected = String(expectedPlanId || '').trim();
if (!/^[0-9a-f]{64}$/i.test(expected)) {
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
throw error;
}
const plan = await this.previewWorkspaceSync(repoPath);
if (plan.id !== expected) {
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
error.recoverable = true;
throw error;
}
if (plan.blockers.length) {
const error = new Error(plan.blockers.join(' '));
error.code = 'WORKSPACE_SYNC_BLOCKED';
error.recoverable = true;
throw error;
}
if (!plan.needsSync) {
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
}
const root = plan.repositoryRoot;
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
let backupBranch = null;
let stash = null;
let review = null;
if (plan.summary.localCommitsToProtect > 0) {
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
}
if (plan.summary.localFilesToStash > 0) {
const label = `FORGEFLOW-QUARANTINE:${plan.id} workspace sync ${plan.branch} ${stamp}`;
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
stash = (await this.stashList(root))[0] || null;
}
if (backupBranch || stash) {
review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash });
}
const protectedStatus = await this.status(root);
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
error.recoverable = true;
error.backupBranch = backupBranch;
error.stash = stash;
throw error;
}
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
const status = await this.status(root);
if (status.head !== plan.targetSha || !status.clean) {
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
error.recoverable = true;
error.backupBranch = backupBranch;
error.stash = stash;
throw error;
}
return {
applied: true,
unchanged: false,
plan,
status,
backupBranch,
stash,
review,
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
ignoredFilesPreserved: true
};
}
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
const root = await this.ensureRepository(repoPath);
const safeRemote = assertCloneRemote(remoteUrl);
const name = String(remote || 'origin').trim();
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
return this.status(root);
}
async diff(repoPath, filePath, staged = false) {
const root = await this.ensureRepository(repoPath);
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
const args = ['diff', '--no-ext-diff', '--no-color', '--unified=4'];
if (staged) args.push('--cached');
if (safeFile) args.push('--', safeFile);
const result = await run('git', args, { cwd: root, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
if (!result.stdout && safeFile && !staged) {
const candidate = path.resolve(root, safeFile);
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
const [realRoot, realCandidate, candidateStat] = await Promise.all([
fs.realpath(root).catch(() => root),
fs.realpath(candidate).catch(() => candidate),
fs.stat(candidate).catch(() => null)
]);
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
const normalizedRoot = normalize(realRoot);
const normalizedCandidate = normalize(realCandidate);
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
throw error;
}
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
const error = new Error('The untracked file is too large to render safely as a diff.');
error.code = 'DIFF_FILE_TOO_LARGE';
error.recoverable = true;
throw error;
}
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
}
return result.stdout;
}
async diffHunks(repoPath, filePath) {
const safeFile = assertRepositoryRelativePath(filePath);
const diff = await this.diff(repoPath, safeFile, false);
const parsed = parseUnifiedDiff(diff);
return { filePath: safeFile, partialSupported: parsed.hunks.length > 0, hunks: parsed.hunks.map(({ patch, ...hunk }) => ({ ...hunk, lines: patch.split('\n') })) };
}
async stageHunks(repoPath, filePath, hunkIndexes) {
const root = await this.ensureRepository(repoPath);
const safeFile = assertRepositoryRelativePath(filePath);
const indexes = [...new Set((Array.isArray(hunkIndexes) ? hunkIndexes : []).map(Number))];
if (!indexes.length || indexes.some((index) => !Number.isInteger(index) || index < 0)) throw new Error('Select at least one valid diff hunk.');
const parsed = parseUnifiedDiff(await this.diff(root, safeFile, false));
if (!parsed.hunks.length) throw new Error('Partial staging is unavailable for this file. Stage the complete file instead.');
if (indexes.some((index) => index >= parsed.hunks.length)) throw new Error('The file changed after its diff was loaded. Refresh the diff and try again.');
const patch = `${parsed.header}${indexes.map((index) => parsed.hunks[index].patch).join('')}`;
await run('git', ['apply', '--cached', '--whitespace=nowarn', '-'], { cwd: root, input: patch, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
return this.status(root);
}
async conflictState(repoPath) {
const root = await this.ensureRepository(repoPath);
const operation = await this.detectInterruptedOperation(root);
const result = await run('git', ['diff', '--name-only', '--diff-filter=U', '-z'], { cwd: root, timeout: 30_000 });
const files = result.stdout.split('\0').filter(Boolean).map(assertRepositoryRelativePath);
return { operation, files, canContinue: Boolean(operation) && files.length === 0, status: await this.status(root) };
}
async resolveConflict(repoPath, filePath, resolution) {
const root = await this.ensureRepository(repoPath);
const safeFile = assertRepositoryRelativePath(filePath);
const choice = String(resolution || 'resolved');
if (!['ours', 'theirs', 'resolved'].includes(choice)) throw new Error('Unsupported conflict resolution choice.');
if (choice !== 'resolved') await this.runWithPathspec(root, ['checkout', `--${choice}`], [safeFile], { timeout: 30_000 });
await this.runWithPathspec(root, ['add'], [safeFile], { timeout: 30_000 });
return this.conflictState(root);
}
async continueInterruptedOperation(repoPath) {
const root = await this.ensureRepository(repoPath);
const state = await this.conflictState(root);
if (!state.operation) throw new Error('No interrupted Git operation is active.');
if (state.files.length) throw new Error('Resolve every conflicted file before continuing.');
const commands = { rebase: ['rebase', '--continue'], merge: ['merge', '--continue'], 'cherry-pick': ['cherry-pick', '--continue'], revert: ['revert', '--continue'] };
await run('git', commands[state.operation], { cwd: root, env: { GIT_EDITOR: 'true' }, timeout: 120_000 });
return this.conflictState(root);
}
selectedStatusFiles(status, files) {
const selected = assertRepositoryRelativePaths(files);
if (!selected.length) return { selected, matches: status.files };
const selectedSet = new Set(selected);
const matches = status.files.filter((file) => selectedSet.has(file.path) || (file.originalPath && selectedSet.has(file.originalPath)));
return { selected, matches };
}
expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
const { selected, matches } = this.selectedStatusFiles(status, files);
if (!selected.length) return [];
const expanded = new Set();
for (const file of matches) {
if (unstagedOnly && !file.unstaged) continue;
expanded.add(file.path);
if (file.originalPath) expanded.add(file.originalPath);
}
return [...expanded];
}
async expandSelectedPaths(root, files, options = {}) {
return this.expandStatusPaths(await this.status(root), files, options);
}
// Callers that already read the status pass it in. Reading it again costs a
// child process, and a commit used to pay for four of them.
async applyStage(root, files, knownStatus = null) {
const requested = assertRepositoryRelativePaths(files);
if (!requested.length) {
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
return;
}
// Only stage records that still have a worktree-side change. Re-running
// `git add -A -- deleted-file` after that deletion is already staged makes
// Git fail with "pathspec did not match any files" because the file no
// longer exists in either the worktree or HEAD. Staged-only deletions and
// renames are already ready for commit and must therefore be left alone.
const status = knownStatus || await this.status(root);
const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true });
if (selected.length) {
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
}
}
async stage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
await this.applyStage(root, files);
return this.status(root);
}
async unstage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
const selected = await this.expandSelectedPaths(root, files);
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
if (hasHead.exitCode === 0) {
if (selected.length) await this.runWithPathspec(root, ['restore', '--staged'], selected, { timeout: 120_000 });
else await run('git', ['restore', '--staged', '.'], { cwd: root });
} else {
if (selected.length) await this.runWithPathspec(root, ['rm', '--cached', '--ignore-unmatch'], selected, { timeout: 120_000, allowExitCodes: [1] });
else await run('git', ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
}
return this.status(root);
}
async prepareSelectedStage(root, files) {
const selected = assertRepositoryRelativePaths(files);
let current = null;
if (selected.length) {
current = await this.status(root);
const excludedStaged = current.files
.filter((file) => file.staged)
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
.map((file) => file.path);
if (excludedStaged.length) {
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
}
}
await this.applyStage(root, selected, current);
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
return selected;
}
async commit(repoPath, message, files = []) {
const root = await this.ensureRepository(repoPath);
const commitMessage = assertCommitMessage(message);
await this.prepareSelectedStage(root, files);
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
const status = await this.status(root);
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
}
async commitStaged(repoPath, message) {
const root = await this.ensureRepository(repoPath);
const commitMessage = assertCommitMessage(message);
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
const status = await this.status(root);
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
}
async commitStagedAndPush(repoPath, message) {
const committed = await this.commitStaged(repoPath, message);
try {
const pushed = await this.push(repoPath);
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
} catch (error) {
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED'; wrapped.commitSha = committed.sha; wrapped.recoverable = true;
throw wrapped;
}
}
async commitAndPush(repoPath, message, files = []) {
const committed = await this.commit(repoPath, message, files);
try {
const pushed = await this.push(repoPath);
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
} catch (error) {
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED';
wrapped.commitSha = committed.sha;
wrapped.recoverable = true;
throw wrapped;
}
}
async push(repoPath) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
const branch = status.branch.head;
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
if (/^forgeflow\/recovery-/.test(branch)) {
const error = new Error('ForgeFlow recovery branches are local quarantine references and cannot be pushed to Gitea. Review them with Codex and move only approved work onto a normal branch.');
error.code = 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY';
error.recoverable = true;
throw error;
}
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async fetch(repoPath) {
const root = await this.ensureRepository(repoPath);
const result = await run('git', ['fetch', '--prune'], { cwd: root, timeout: 180_000 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async pullFastForward(repoPath) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before synchronizing.');
if (!status.branch.upstream) throw new Error('This branch has no upstream branch. Publish it first.');
const result = await run('git', ['pull', '--ff-only'], { cwd: root, timeout: 180_000 });
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
}
async history(repoPath, limit = 20) {
const root = await this.ensureRepository(repoPath);
const format = '%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e';
const result = await run('git', ['log', `-${Math.min(Math.max(Number(limit) || 20, 1), 100)}`, `--format=${format}`], { cwd: root, allowExitCodes: [128] });
if (result.exitCode === 128) return [];
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [sha, shortSha, author, email, date, subject] = record.split('\x1f');
return { sha, shortSha, author, email, date, subject };
});
}
async branches(repoPath) {
const root = await this.ensureRepository(repoPath);
const format = '%(refname:short)%x1f%(objectname)%x1f%(HEAD)%x1f%(upstream:short)%x1f%(upstream:track)%x1e';
const result = await run('git', ['for-each-ref', `--format=${format}`, 'refs/heads'], { cwd: root });
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [name, sha, current, upstream, track] = record.split('\x1f');
const ahead = Number(track?.match(/ahead (\d+)/)?.[1] || 0);
const behind = Number(track?.match(/behind (\d+)/)?.[1] || 0);
return { name, sha, shortSha: sha?.slice(0, 7), current: current === '*', upstream: upstream || null, ahead, behind };
});
}
assertBranchName(branch) {
const value = String(branch || '').trim();
if (!value) throw new Error('Branch name is required.');
return value;
}
async checkoutBranch(repoPath, branch) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before switching branches.');
const value = this.assertBranchName(branch);
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
await run('git', ['switch', value], { cwd: root, timeout: 60_000 });
return this.status(root);
}
async createBranch(repoPath, branch) {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (!status.clean) throw new Error('Commit or stash local changes before creating a branch.');
const value = this.assertBranchName(branch);
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
await run('git', ['switch', '-c', value], { cwd: root, timeout: 60_000 });
return this.status(root);
}
async stash(repoPath, message = '') {
const root = await this.ensureRepository(repoPath);
const status = await this.status(root);
if (status.clean) throw new Error('There are no changes to stash.');
const args = ['stash', 'push', '--include-untracked'];
const label = String(message || '').trim();
if (label) args.push('-m', label.slice(0, 200));
const result = await run('git', args, { cwd: root, timeout: 120_000 });
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
}
async stashList(repoPath) {
const root = await this.ensureRepository(repoPath);
const format = '%gd%x1f%H%x1f%aI%x1f%gs%x1e';
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
const [ref, sha, date, subject] = record.split('\x1f');
const quarantine = String(subject || '').match(/FORGEFLOW-QUARANTINE:([0-9a-f]{64})/i);
return {
ref,
sha,
shortSha: sha.slice(0, 7),
date,
subject,
quarantined: Boolean(quarantine),
reviewId: quarantine?.[1] || null
};
});
}
async popStash(repoPath, ref = 'stash@{0}') {
const root = await this.ensureRepository(repoPath);
const value = String(ref || 'stash@{0}');
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
const candidate = (await this.stashList(root)).find((item) => item.ref === value);
if (candidate?.quarantined) {
const error = new Error(`This stash is quarantined for Codex review (${candidate.reviewId}). ForgeFlow will not apply and drop it wholesale; restore only reviewed files manually.`);
error.code = 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED';
error.recoverable = true;
throw error;
}
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
}
async verifyCommitOnRemoteBranch(repoPath, sha, branch) {
const root = await this.ensureRepository(repoPath);
const fullSha = assertFullCommitSha(sha);
const branchName = this.assertBranchName(branch);
await run('git', ['fetch', '--prune', 'origin', branchName], { cwd: root, timeout: 180_000 });
await run('git', ['cat-file', '-e', `${fullSha}^{commit}`], { cwd: root, timeout: 30_000 });
const ancestor = await run('git', ['merge-base', '--is-ancestor', fullSha, `origin/${branchName}`], { cwd: root, allowExitCodes: [1] });
if (ancestor.exitCode !== 0) throw new Error(`Commit ${fullSha.slice(0, 7)} is not contained in origin/${branchName}.`);
return { valid: true, sha: fullSha, branch: branchName };
}
async inspectCloneTarget(remoteUrl, destination) {
const remote = assertCloneRemote(remoteUrl);
const target = assertSafeRepositoryPath(destination);
const existing = await fs.stat(target).catch(() => null);
if (!existing) return { state: 'missing', remote, target };
if (!existing.isDirectory()) {
const error = new Error('The automatic clone target exists and is not a folder.');
error.code = 'CLONE_TARGET_NOT_DIRECTORY';
throw error;
}
const entries = await fs.readdir(target);
if (!entries.length) return { state: 'empty', remote, target };
const existingRemote = await this.getRemoteUrl(target).catch(() => '');
const expected = normalizeRemoteUrl(remote);
const actual = normalizeRemoteUrl(existingRemote);
const sameRepository = Boolean(
expected && actual
&& expected.host === actual.host
&& expected.path === actual.path
);
if (sameRepository) return { state: 'matching-repository', remote, target };
const error = new Error(existingRemote
? 'The automatic clone target already contains a different Git repository.'
: 'The automatic clone target already contains files. Choose another location or link the existing folder.');
error.code = existingRemote ? 'CLONE_TARGET_DIFFERENT_REPOSITORY' : 'CLONE_TARGET_NOT_EMPTY';
throw error;
}
async clone(remoteUrl, destination) {
const assessment = await this.inspectCloneTarget(remoteUrl, destination);
if (assessment.state === 'matching-repository') {
const status = await this.status(assessment.target);
return { ...status, reused: true };
}
if (assessment.state === 'missing') {
await fs.mkdir(path.dirname(assessment.target), { recursive: true });
}
await run('git', ['clone', '--progress', assessment.remote, assessment.target], { timeout: 15 * 60_000, maxBuffer: 32 * 1024 * 1024 });
const status = await this.status(assessment.target);
return { ...status, reused: false };
}
}
module.exports = { GitService, parseUnifiedDiff };