Release ForgeFlow 0.6.0

This commit is contained in:
NuklearRabbit
2026-07-25 05:59:07 +02:00
parent cf1f67a823
commit 9d3933c878
48 changed files with 2208 additions and 466 deletions
+147 -12
View File
@@ -5,6 +5,8 @@ const fs = require('node:fs/promises');
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 {
assertSafeRepositoryPath,
assertRepositoryRelativePath,
@@ -73,23 +75,156 @@ class GitService {
});
}
async getIndexLockInfo(repoPath) {
async gitDirectory(repoPath) {
const root = await this.ensureRepository(repoPath);
const lockPath = path.join(root, '.git', 'index.lock');
const stat = await fs.stat(lockPath).catch(() => null);
return stat ? { exists: true, lockPath, ageMs: Math.max(0, Date.now() - stat.mtimeMs) } : { exists: false, lockPath, ageMs: 0 };
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 removeStaleIndexLock(repoPath, minimumAgeMs = 30_000) {
const info = await this.getIndexLockInfo(repoPath);
if (!info.exists) return { removed: false, reason: 'missing', ...info };
if (info.ageMs < minimumAgeMs) {
const error = new Error('The Git index lock is recent. Close other Git tools and try again before removing it.');
error.code = 'INDEX_LOCK_RECENT';
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;
}
await fs.rm(info.lockPath, { force: true });
return { removed: true, ...info };
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 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 setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {