Harden workspace sync quarantine and updater recovery #6
@@ -63,16 +63,24 @@ function Get-Sha256([string]$Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Start-ForgeFlowAndVerify([string]$Executable) {
|
||||||
|
$process = Start-Process -FilePath $Executable -WorkingDirectory (Split-Path -Parent $Executable) -PassThru
|
||||||
|
Start-Sleep -Milliseconds 1500
|
||||||
|
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application could stay running." }
|
||||||
|
return $process
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
|
||||||
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
|
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
|
||||||
if ($HandshakeOnly) {
|
if ($HandshakeOnly) {
|
||||||
|
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||||
Write-Log "Handshake-only verification completed successfully."
|
Write-Log "Handshake-only verification completed successfully."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
$actualSha256 = Get-Sha256 -Path $BinaryPath
|
$actualSha256 = Get-Sha256 -Path $BinaryPath
|
||||||
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
|
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
|
||||||
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
|
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
|
||||||
|
Write-UpdateState -State "started" -Message "Binary preflight passed; updater owns the update request."
|
||||||
if ($VerifyOnly) {
|
if ($VerifyOnly) {
|
||||||
Write-Log "Verification-only SHA-256 check completed successfully."
|
Write-Log "Verification-only SHA-256 check completed successfully."
|
||||||
exit 0
|
exit 0
|
||||||
@@ -90,9 +98,16 @@ try {
|
|||||||
try {
|
try {
|
||||||
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
|
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
|
||||||
} catch {
|
} catch {
|
||||||
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
|
$copyFailure = $_.Exception.Message
|
||||||
Write-UpdateState -State "rolled-back" -Message $_.Exception.Message
|
try {
|
||||||
throw
|
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
|
||||||
|
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
|
||||||
|
Write-Log "Portable replacement failed; previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
|
||||||
|
Write-UpdateState -State "rolled-back" -Message $copyFailure -RestartLaunched $true
|
||||||
|
} catch {
|
||||||
|
Write-UpdateState -State "failed" -Message "$copyFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
|
||||||
|
}
|
||||||
|
throw $copyFailure
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer."
|
Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer."
|
||||||
@@ -100,13 +115,31 @@ try {
|
|||||||
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
|
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
|
||||||
}
|
}
|
||||||
|
|
||||||
$restart = Start-Process -FilePath $CurrentExecutable -WorkingDirectory (Split-Path -Parent $CurrentExecutable) -PassThru
|
try {
|
||||||
Write-Log "ForgeFlow $ExpectedVersion installed; restart PID $($restart.Id)."
|
$restart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
|
||||||
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
|
Write-Log "ForgeFlow $ExpectedVersion installed; verified restart PID $($restart.Id)."
|
||||||
|
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
|
||||||
|
} catch {
|
||||||
|
$restartFailure = $_.Exception.Message
|
||||||
|
if ($isPortable -and $backupPath -and (Test-Path -LiteralPath $backupPath -PathType Leaf)) {
|
||||||
|
Write-Log "Updated portable executable failed its restart probe; restoring the previous executable."
|
||||||
|
try {
|
||||||
|
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
|
||||||
|
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
|
||||||
|
Write-Log "Previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
|
||||||
|
Write-UpdateState -State "rolled-back" -Message $restartFailure -RestartLaunched $true
|
||||||
|
} catch {
|
||||||
|
Write-UpdateState -State "failed" -Message "$restartFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Log "ForgeFlow $ExpectedVersion installed, but automatic restart failed: $restartFailure"
|
||||||
|
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully, but must be started manually." -RestartLaunched $false
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Log $_.Exception.Message
|
Write-Log $_.Exception.Message
|
||||||
$current = $null
|
$current = $null
|
||||||
try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {}
|
try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {}
|
||||||
if ($current.state -ne "rolled-back") { Write-UpdateState -State "failed" -Message $_.Exception.Message }
|
if ($current.state -notin @("rolled-back", "failed")) { Write-UpdateState -State "failed" -Message $_.Exception.Message }
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,13 +117,20 @@ function Start-ForgeFlow {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
|
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
|
||||||
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
|
||||||
|
|
||||||
if ($HandshakeOnly) {
|
if ($HandshakeOnly) {
|
||||||
|
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
||||||
Write-UpdateLog "Handshake-only verification completed successfully."
|
Write-UpdateLog "Handshake-only verification completed successfully."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath (Join-Path $SourcePath ".git")) {
|
||||||
|
throw "Integrated source update refuses to overwrite a Git working tree. Use normal Git/ForgeFlow workspace sync so local commits and dirty files remain reviewable."
|
||||||
|
}
|
||||||
|
$actualHash = Get-Sha256 -Path $ArchivePath
|
||||||
|
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
||||||
|
Write-UpdateState -State "started" -Message "Source update preflight passed; the external helper owns the request." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
||||||
|
|
||||||
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit."
|
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit."
|
||||||
$deadline = (Get-Date).AddMinutes(2)
|
$deadline = (Get-Date).AddMinutes(2)
|
||||||
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
|
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
|
||||||
@@ -131,9 +138,6 @@ try {
|
|||||||
Start-Sleep -Milliseconds 500
|
Start-Sleep -Milliseconds 500
|
||||||
}
|
}
|
||||||
|
|
||||||
$actualHash = Get-Sha256 -Path $ArchivePath
|
|
||||||
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
|
||||||
|
|
||||||
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
||||||
$extract = Join-Path $working "extract"
|
$extract = Join-Path $working "extract"
|
||||||
$backup = Join-Path $working "backup"
|
$backup = Join-Path $working "backup"
|
||||||
|
|||||||
@@ -167,6 +167,48 @@ class GitService {
|
|||||||
return { root, gitDir: path.resolve(result.stdout.trim()) };
|
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) {
|
isGitLockError(error) {
|
||||||
const message = String(error?.message || error || '');
|
const message = String(error?.message || error || '');
|
||||||
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
|
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', '-');
|
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||||
let backupBranch = null;
|
let backupBranch = null;
|
||||||
let stash = null;
|
let stash = null;
|
||||||
|
let review = null;
|
||||||
if (plan.summary.localCommitsToProtect > 0) {
|
if (plan.summary.localCommitsToProtect > 0) {
|
||||||
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||||
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
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 });
|
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||||
}
|
}
|
||||||
if (plan.summary.localFilesToStash > 0) {
|
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 });
|
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
||||||
stash = (await this.stashList(root))[0] || null;
|
stash = (await this.stashList(root))[0] || null;
|
||||||
}
|
}
|
||||||
|
if (backupBranch || stash) {
|
||||||
|
review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash });
|
||||||
|
}
|
||||||
|
|
||||||
const protectedStatus = await this.status(root);
|
const protectedStatus = await this.status(root);
|
||||||
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||||
@@ -486,6 +532,7 @@ class GitService {
|
|||||||
status,
|
status,
|
||||||
backupBranch,
|
backupBranch,
|
||||||
stash,
|
stash,
|
||||||
|
review,
|
||||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||||
ignoredFilesPreserved: true
|
ignoredFilesPreserved: true
|
||||||
};
|
};
|
||||||
@@ -718,6 +765,12 @@ class GitService {
|
|||||||
const status = await this.status(root);
|
const status = await this.status(root);
|
||||||
const branch = status.branch.head;
|
const branch = status.branch.head;
|
||||||
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached 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 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 });
|
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) };
|
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 });
|
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
|
||||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||||
const [ref, sha, date, subject] = record.split('\x1f');
|
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 root = await this.ensureRepository(repoPath);
|
||||||
const value = String(ref || 'stash@{0}');
|
const value = String(ref || 'stash@{0}');
|
||||||
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
|
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 });
|
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) };
|
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,12 +161,13 @@ Force repair after you have closed all Git tools for this repository?`)
|
|||||||
]);
|
]);
|
||||||
const recovery = [
|
const recovery = [
|
||||||
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
|
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
|
||||||
result.stash ? `stash ${result.stash.ref}` : null,
|
result.stash ? `quarantine stash ${result.stash.ref}` : null,
|
||||||
|
result.review ? `Codex review manifest ${result.review.manifestPath}` : null,
|
||||||
].filter(Boolean).join(" and ");
|
].filter(Boolean).join(" and ");
|
||||||
showToast(
|
showToast(
|
||||||
"Workspace synchronized with Gitea",
|
"Workspace synchronized with Gitea",
|
||||||
recovery
|
recovery
|
||||||
? `Local work is preserved in ${recovery}. Ignored runtime files were retained.`
|
? `Local work is quarantined in ${recovery}. Review it before restoring anything; ignored runtime files were retained.`
|
||||||
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
|
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
|
||||||
"success",
|
"success",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ function renderGitTools(repository) {
|
|||||||
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
|
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
|
||||||
: '<div class="empty-state compact"><p>Load branch information.</p></div>';
|
: '<div class="empty-state compact"><p>Load branch information.</p></div>';
|
||||||
const stashRows = ui.stashes.length
|
const stashRows = ui.stashes.length
|
||||||
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("")
|
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div>${stash.quarantined ? `<span class="status-pill warning" title="Workspace review ${attr(stash.reviewId || "")}">Codex review required</span>` : `<button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button>`}</div>`).join("")
|
||||||
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
|
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
|
||||||
const recoveryBody = recovery
|
const recoveryBody = recovery
|
||||||
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
|
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
|
||||||
|
|||||||
@@ -295,6 +295,16 @@ test('previews and safely mirrors a workspace to Gitea while preserving every cl
|
|||||||
assert.equal(result.status.head, reviewedPlan.targetSha);
|
assert.equal(result.status.head, reviewedPlan.targetSha);
|
||||||
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
|
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
|
||||||
assert.ok(result.stash?.sha);
|
assert.ok(result.stash?.sha);
|
||||||
|
assert.equal(result.stash.quarantined, true);
|
||||||
|
assert.equal(result.review.id, reviewedPlan.id);
|
||||||
|
assert.equal(result.review.status, 'pending-codex-review');
|
||||||
|
const reviewManifest = JSON.parse(await fs.readFile(result.review.manifestPath, 'utf8'));
|
||||||
|
assert.equal(reviewManifest.recoveryBranch, result.backupBranch);
|
||||||
|
assert.equal(reviewManifest.stashSha, result.stash.sha);
|
||||||
|
assert.deepEqual(
|
||||||
|
new Set(reviewManifest.files.map((file) => file.path)),
|
||||||
|
new Set(['README.md', 'local-notes.txt', 'changed-after-preview.txt'])
|
||||||
|
);
|
||||||
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
|
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
|
||||||
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
|
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
|
||||||
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
|
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
|
||||||
@@ -306,6 +316,15 @@ test('previews and safely mirrors a workspace to Gitea while preserving every cl
|
|||||||
assert.match(stashedPaths, /README\.md/);
|
assert.match(stashedPaths, /README\.md/);
|
||||||
assert.match(stashedPaths, /local-notes\.txt/);
|
assert.match(stashedPaths, /local-notes\.txt/);
|
||||||
assert.match(stashedPaths, /changed-after-preview\.txt/);
|
assert.match(stashedPaths, /changed-after-preview\.txt/);
|
||||||
|
await assert.rejects(
|
||||||
|
service.popStash(working, result.stash.ref),
|
||||||
|
(error) => error.code === 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED'
|
||||||
|
);
|
||||||
|
await git(['switch', result.backupBranch], working);
|
||||||
|
await assert.rejects(
|
||||||
|
service.push(working),
|
||||||
|
(error) => error.code === 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
|
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
|
||||||
|
|||||||
@@ -268,6 +268,15 @@ test("PowerShell update helper starts with param and has no BOM or stray leading
|
|||||||
assert.match(text, /npm ci --no-audit --no-fund/);
|
assert.match(text, /npm ci --no-audit --no-fund/);
|
||||||
assert.match(text, /package-lock\.json/);
|
assert.match(text, /package-lock\.json/);
|
||||||
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
|
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
|
||||||
|
assert.match(text, /Integrated source update refuses to overwrite a Git working tree/);
|
||||||
|
assert.ok(
|
||||||
|
text.indexOf("Handshake-only verification completed successfully") <
|
||||||
|
text.indexOf("Integrated source update refuses to overwrite a Git working tree"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
text.indexOf("$actualHash = Get-Sha256") <
|
||||||
|
text.indexOf("Source update preflight passed"),
|
||||||
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
text.indexOf('Write-UpdateState -State "success"') <
|
text.indexOf('Write-UpdateState -State "success"') <
|
||||||
text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"),
|
text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"),
|
||||||
@@ -764,4 +773,15 @@ test("binary update helper verifies, waits, applies and records restart state",
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert.doesNotMatch(helper, /Get-FileHash/);
|
assert.doesNotMatch(helper, /Get-FileHash/);
|
||||||
|
assert.match(helper, /function Start-ForgeFlowAndVerify/);
|
||||||
|
assert.match(helper, /Start-Sleep -Milliseconds 1500/);
|
||||||
|
assert.match(helper, /Updated portable executable failed its restart probe/);
|
||||||
|
assert.ok(
|
||||||
|
helper.indexOf("$actualSha256 = Get-Sha256") <
|
||||||
|
helper.indexOf("Binary preflight passed"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
helper.indexOf("Binary preflight passed") <
|
||||||
|
helper.indexOf('Write-UpdateState -State "waiting-for-exit"'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user