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