diff --git a/scripts/apply-binary-update.ps1 b/scripts/apply-binary-update.ps1 index bd129ef..0a8de52 100644 --- a/scripts/apply-binary-update.ps1 +++ b/scripts/apply-binary-update.ps1 @@ -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 { - Write-UpdateState -State "started" -Message "Binary updater owns the update request." Write-Log "Validating ForgeFlow $ExpectedVersion binary update." if ($HandshakeOnly) { + Write-UpdateState -State "started" -Message "Binary updater owns the update request." Write-Log "Handshake-only verification completed successfully." exit 0 } $actualSha256 = Get-Sha256 -Path $BinaryPath 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." } + Write-UpdateState -State "started" -Message "Binary preflight passed; updater owns the update request." if ($VerifyOnly) { Write-Log "Verification-only SHA-256 check completed successfully." exit 0 @@ -90,9 +98,16 @@ try { try { Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force } catch { - Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force - Write-UpdateState -State "rolled-back" -Message $_.Exception.Message - throw + $copyFailure = $_.Exception.Message + try { + 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 { 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)." } } - $restart = Start-Process -FilePath $CurrentExecutable -WorkingDirectory (Split-Path -Parent $CurrentExecutable) -PassThru - Write-Log "ForgeFlow $ExpectedVersion installed; restart PID $($restart.Id)." - Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true + try { + $restart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable + 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 { Write-Log $_.Exception.Message $current = $null 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 } diff --git a/scripts/apply-source-update.ps1 b/scripts/apply-source-update.ps1 index ce442ef..1a139ba 100644 --- a/scripts/apply-source-update.ps1 +++ b/scripts/apply-source-update.ps1 @@ -117,13 +117,20 @@ function Start-ForgeFlow { try { 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) { + 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." 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." $deadline = (Get-Date).AddMinutes(2) while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { @@ -131,9 +138,6 @@ try { 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")) $extract = Join-Path $working "extract" $backup = Join-Path $working "backup" diff --git a/src/main/git-service.cjs b/src/main/git-service.cjs index d1912ae..c5c1d90 100644 --- a/src/main/git-service.cjs +++ b/src/main/git-service.cjs @@ -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) }; } diff --git a/src/renderer/actions/recovery.js b/src/renderer/actions/recovery.js index 58dde35..ed98546 100644 --- a/src/renderer/actions/recovery.js +++ b/src/renderer/actions/recovery.js @@ -161,12 +161,13 @@ Force repair after you have closed all Git tools for this repository?`) ]); const recovery = [ 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 "); showToast( "Workspace synchronized with Gitea", 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.`, "success", ); diff --git a/src/renderer/views.js b/src/renderer/views.js index 374839b..7edc808 100644 --- a/src/renderer/views.js +++ b/src/renderer/views.js @@ -367,7 +367,7 @@ function renderGitTools(repository) { ? ui.branches.map((branch) => `
Load branch information.
No stashes, or Git tools have not been loaded.