Release ForgeFlow 0.6.0
This commit is contained in:
+147
-12
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user