Harden workspace sync quarantine and updater recovery
Managed validation / full (pull_request) Successful in 31s

This commit is contained in:
ChatGPT MCP
2026-08-29 21:41:31 +00:00
parent d926007dae
commit bd4240f109
7 changed files with 163 additions and 17 deletions
+41 -8
View File
@@ -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
} }
+8 -4
View File
@@ -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"
+71 -2
View File
@@ -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) };
} }
+3 -2
View File
@@ -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",
); );
+1 -1
View File
@@ -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>` : ""}`
+19
View File
@@ -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) => {
+20
View File
@@ -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"'),
);
}); });