Harden workspace sync quarantine and updater recovery
Managed validation / full (pull_request) Successful in 31s
Managed validation / full (pull_request) Successful in 31s
This commit is contained in:
@@ -167,6 +167,48 @@ class GitService {
|
||||
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)
|
||||
@@ -447,6 +489,7 @@ class GitService {
|
||||
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)}`;
|
||||
@@ -454,10 +497,13 @@ class GitService {
|
||||
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||
}
|
||||
if (plan.summary.localFilesToStash > 0) {
|
||||
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`;
|
||||
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) {
|
||||
@@ -486,6 +532,7 @@ class GitService {
|
||||
status,
|
||||
backupBranch,
|
||||
stash,
|
||||
review,
|
||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||
ignoredFilesPreserved: true
|
||||
};
|
||||
@@ -718,6 +765,12 @@ class GitService {
|
||||
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) };
|
||||
@@ -804,7 +857,16 @@ class GitService {
|
||||
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');
|
||||
return { ref, sha, shortSha: sha.slice(0, 7), date, subject };
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -812,6 +874,13 @@ class GitService {
|
||||
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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user