singularity-forge/scripts/recover-gsd-1668.ps1
ace-pm 6b0ac484ba refactor: update log prefixes and string values from gsd- to sf- namespace
Updates channel prefixes, log messages, comments, and configuration values
across daemon, mcp-server, and related packages to complete the rebrand from
gsd to sf-run naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:37:12 +02:00

339 lines
14 KiB
PowerShell

# recover-sf-1668.ps1 — Recovery script for issue #1668 (Windows)
#
# SF v2.39.x deleted the milestone branch and worktree directory when a
# merge failed due to the repo using `master` as its default branch (not
# `main`). The commits were never merged — they are orphaned in the git
# object store and can be recovered via git reflog or git fsck.
#
# This script:
# 1. Searches git reflog for the deleted milestone branch (fastest path)
# 2. Falls back to git fsck --unreachable to find orphaned commits
# 3. Ranks candidates by recency and SF commit message patterns
# 4. Creates a recovery branch at the identified commit
# 5. Reports what was found and how to complete the merge manually
#
# Usage:
# powershell -ExecutionPolicy Bypass -File scripts\recover-sf-1668.ps1 [-MilestoneId <ID>] [-DryRun] [-Auto]
#
# Options:
# -MilestoneId <ID> SF milestone ID (e.g. M001-g2nalq).
# -DryRun Show what would be done without making any changes.
# -Auto Pick best candidate automatically (no prompts).
#
# Requirements: git >= 2.23, PowerShell >= 5.1, Git for Windows
#
# Affected versions: SF.39.x
# Fixed in: SF.40.1 (PR #1669)
[CmdletBinding()]
param(
[string]$MilestoneId = "",
[switch]$DryRun,
[switch]$Auto
)
$ErrorActionPreference = 'Stop'
# ── Helpers ───────────────────────────────────────────────────────────────────
function Info { param($msg) Write-Host "[info] $msg" -ForegroundColor Cyan }
function Ok { param($msg) Write-Host "[ok] $msg" -ForegroundColor Green }
function Warn { param($msg) Write-Host "[warn] $msg" -ForegroundColor Yellow }
function Err { param($msg) Write-Host "[error] $msg" -ForegroundColor Red }
function Section { param($msg) Write-Host "`n$msg" -ForegroundColor White }
function Dim { param($msg) Write-Host " $msg" -ForegroundColor DarkGray }
function Run {
param($cmd)
if ($DryRun) {
Write-Host " (dry-run) $cmd" -ForegroundColor Yellow
} else {
Invoke-Expression $cmd
}
}
function Git {
param([string[]]$args)
$output = & git @args 2>&1
if ($LASTEXITCODE -ne 0) { return "" }
return $output -join "`n"
}
function Die {
param($msg)
Err $msg
exit 1
}
# ── Preflight ─────────────────────────────────────────────────────────────────
Section "── Preflight ───────────────────────────────────────────────────────────"
$gitDir = & git rev-parse --git-dir 2>&1
if ($LASTEXITCODE -ne 0) {
Die "Not inside a git repository. Run this from your project root."
}
$repoRoot = (& git rev-parse --show-toplevel).Trim()
Set-Location $repoRoot
Info "Repo root: $repoRoot"
if ($DryRun) { Warn "DRY-RUN mode — no changes will be made." }
# ── Step 1: Check live milestone branches ────────────────────────────────────
Section "── Step 1: Verify milestone branch is missing ───────────────────────────"
$branchPattern = if ($MilestoneId) { "milestone/$MilestoneId" } else { "milestone/" }
$liveBranches = & git branch 2>/dev/null | Where-Object { $_ -match [regex]::Escape($branchPattern) } | ForEach-Object { $_.Trim().TrimStart('* ') }
if ($liveBranches) {
Ok "Found live milestone branch(es):"
$liveBranches | ForEach-Object { Write-Host " $_" }
Warn "The branch still exists — are you sure it was lost?"
Write-Host " git checkout $($liveBranches[0])"
if (-not $MilestoneId) { exit 0 }
}
if ($MilestoneId -and -not $liveBranches) {
Info "Confirmed: milestone/$MilestoneId branch is gone."
} elseif (-not $MilestoneId) {
Info "No live milestone/ branches found — scanning for orphaned commits."
}
# ── Step 2: Search git reflog ─────────────────────────────────────────────────
Section "── Step 2: Search git reflog for deleted branch ────────────────────────"
$reflogFoundSha = ""
$reflogFoundBranch = ""
if ($MilestoneId) {
$reflogPath = Join-Path $repoRoot ".git\logs\refs\heads\milestone\$MilestoneId"
if (Test-Path $reflogPath) {
$lines = Get-Content $reflogPath
if ($lines) {
$lastLine = $lines[-1]
$reflogFoundSha = ($lastLine -split '\s+')[1]
$reflogFoundBranch = "milestone/$MilestoneId"
Ok "Reflog entry found for milestone/$MilestoneId — commit: $($reflogFoundSha.Substring(0,12))"
}
} else {
Info "No reflog file at .git\logs\refs\heads\milestone\$MilestoneId"
}
}
if (-not $reflogFoundSha) {
Info "Scanning git reflog for milestone/ commits..."
$reflogAll = & git reflog --all --format="%H %gs" 2>/dev/null | Where-Object { $_ -match "milestone/" } | Select-Object -First 20
if ($reflogAll) {
Info "Found milestone-related reflog entries:"
$reflogAll | ForEach-Object { Dim $_ }
$match = if ($MilestoneId) {
$reflogAll | Where-Object { $_ -match "milestone/$([regex]::Escape($MilestoneId))" } | Select-Object -First 1
} else {
$reflogAll | Select-Object -First 1
}
if ($match) {
$reflogFoundSha = ($match -split '\s+')[0]
if ($match -match 'milestone/(\S+)') { $reflogFoundBranch = "milestone/$($Matches[1])" }
else { $reflogFoundBranch = "milestone/unknown" }
}
} else {
Info "No milestone/ entries in reflog."
}
}
# ── Step 3: Fall back to git fsck ─────────────────────────────────────────────
Section "── Step 3: Scan for orphaned (unreachable) commits ───────────────────"
$sortedCandidates = @()
if (-not $reflogFoundSha) {
Info "Running git fsck --unreachable (this may take a moment)..."
$fsckOutput = & git fsck --unreachable --no-reflogs 2>/dev/null | Where-Object { $_ -match '^unreachable commit' }
if (-not $fsckOutput) {
$fsckOutput = & git fsck --unreachable 2>/dev/null | Where-Object { $_ -match '^unreachable commit' }
}
$unreachableCommits = $fsckOutput | ForEach-Object { ($_ -split '\s+')[2] } | Where-Object { $_ }
$total = @($unreachableCommits).Count
Info "Found $total unreachable commit object(s)."
if ($total -eq 0) {
Err "No unreachable commits found."
Write-Host ""
Write-Host "This means one of:"
Write-Host " 1. git gc has already pruned the objects (default: 14 days)"
Write-Host " 2. The commits were never written to the object store"
Write-Host " 3. The wrong repository is being scanned"
exit 1
}
$cutoff = (Get-Date).AddDays(-30).ToUnixTimeSeconds()
$candidates = @()
foreach ($sha in $unreachableCommits) {
if (-not $sha) { continue }
$commitDate = [long](& git show -s --format="%ct" $sha 2>/dev/null)
if (-not $commitDate -or $commitDate -lt $cutoff) { continue }
$commitMsg = (& git show -s --format="%s" $sha 2>/dev/null) -join ""
$commitBody = (& git show -s --format="%b" $sha 2>/dev/null) -join " "
$commitDateHr = (& git show -s --format="%ci" $sha 2>/dev/null) -join ""
$score = 0
if ($MilestoneId -and ($commitMsg + $commitBody) -match [regex]::Escape($MilestoneId)) { $score += 100 }
if ($commitMsg -match '^feat\([A-Z][0-9]+') { $score += 50 }
if (($commitMsg + $commitBody) -match 'milestone/|complete-milestone|SF|slice') { $score += 20 }
$weekAgo = (Get-Date).AddDays(-7).ToUnixTimeSeconds()
if ($commitDate -gt $weekAgo) { $score += 10 }
$fileCount = (& git show --stat --format="" $sha 2>/dev/null | Select-Object -Last 1) -replace '.*?(\d+) file.*','$1'
$candidates += [PSCustomObject]@{
SHA = $sha
Score = $score
Message = $commitMsg
Date = $commitDateHr
FileCount = $fileCount
}
}
if ($candidates.Count -eq 0) {
Err "No recent unreachable commits found within the last 30 days."
Write-Host "Objects may have been pruned by git gc."
exit 1
}
$sortedCandidates = $candidates | Sort-Object -Property Score -Descending | Select-Object -First 10
Info "Top candidates (scored by recency and SF message patterns):"
Write-Host ""
$num = 1
foreach ($c in $sortedCandidates) {
Write-Host " $num) $($c.SHA.Substring(0,12)) $($c.Message)" -ForegroundColor Green
Dim "$($c.Date)$($c.FileCount) file(s)"
$num++
}
Write-Host ""
}
# ── Step 4: Select recovery commit ───────────────────────────────────────────
Section "── Step 4: Select recovery commit ──────────────────────────────────────"
$recoverySha = ""
$recoverySource = ""
if ($reflogFoundSha) {
$recoverySha = $reflogFoundSha
$recoverySource = "reflog ($reflogFoundBranch)"
Info "Using reflog candidate: $($recoverySha.Substring(0,12))"
Dim (& git show -s --format="%s %ci" $recoverySha 2>/dev/null)
} elseif ($sortedCandidates.Count -eq 1 -or $Auto) {
$recoverySha = $sortedCandidates[0].SHA
$recoverySource = "fsck (auto-selected)"
Info "Auto-selecting best candidate: $($recoverySha.Substring(0,12))"
} else {
$selection = Read-Host "Select a candidate to recover [1-$($sortedCandidates.Count), or q to quit]"
if ($selection -eq 'q') { Info "Aborted."; exit 0 }
$selIdx = [int]$selection - 1
if ($selIdx -lt 0 -or $selIdx -ge $sortedCandidates.Count) { Die "Invalid selection: $selection" }
$recoverySha = $sortedCandidates[$selIdx].SHA
$recoverySource = "fsck (user-selected #$selection)"
}
if (-not $recoverySha) { Die "Could not determine a recovery commit." }
Ok "Recovery commit: $($recoverySha.Substring(0,16)) (source: $recoverySource)"
Write-Host ""
Info "Commit details:"
& git show -s --format=" Message: %s`n Author: %an <%ae>`n Date: %ci`n Full SHA: %H" $recoverySha
Write-Host ""
Info "Files at this commit (first 30):"
& git show --stat --format="" $recoverySha 2>/dev/null | Select-Object -First 30
Write-Host ""
# ── Step 5: Create recovery branch ───────────────────────────────────────────
Section "── Step 5: Create recovery branch ──────────────────────────────────────"
$recoveryBranch = if ($MilestoneId) {
"recovery/1668/$MilestoneId"
} elseif ($reflogFoundBranch) {
"recovery/1668/$($reflogFoundBranch -replace '/','-')"
} else {
"recovery/1668/commit-$($recoverySha.Substring(0,8))"
}
$branchExists = & git show-ref --verify --quiet "refs/heads/$recoveryBranch" 2>/dev/null; $exists = $LASTEXITCODE -eq 0
if ($exists) {
Warn "Branch $recoveryBranch already exists."
if (-not $Auto) {
$answer = Read-Host "Overwrite it? [y/N]"
if ($answer -notin @('y','Y')) { Info "Aborted."; exit 0 }
}
Run "git branch -D `"$recoveryBranch`""
}
Run "git branch `"$recoveryBranch`" `"$recoverySha`""
if (-not $DryRun) {
Ok "Recovery branch created: $recoveryBranch"
} else {
Ok "(dry-run) Would create branch: $recoveryBranch -> $($recoverySha.Substring(0,12))"
}
# ── Step 6: Verify ────────────────────────────────────────────────────────────
if (-not $DryRun) {
Section "── Step 6: Verify recovery branch ──────────────────────────────────────"
$fileList = & git ls-tree -r --name-only $recoveryBranch 2>/dev/null | Where-Object { $_ -notmatch '^\.sf/' }
$fileCount = @($fileList).Count
Info "Files recoverable (excluding .sf/ state files): $fileCount"
$fileList | Select-Object -First 30 | ForEach-Object { Write-Host " $_" }
if ($fileCount -gt 30) { Dim " ... and $($fileCount - 30) more" }
}
# ── Summary ───────────────────────────────────────────────────────────────────
Section "── Recovery Summary ─────────────────────────────────────────────────────"
if ($DryRun) {
Write-Host "Dry-run complete. Re-run without -DryRun to apply." -ForegroundColor Yellow
exit 0
}
$defaultBranch = (& git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null) -replace 'refs/remotes/origin/',''
if (-not $defaultBranch) { $defaultBranch = (& git branch --show-current) }
Write-Host "Recovery branch ready: " -NoNewline
Write-Host $recoveryBranch -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:"
Write-Host ""
Write-Host " 1. Inspect the recovered files:"
Write-Host " git checkout $recoveryBranch"
Write-Host " dir"
Write-Host ""
Write-Host " 2. Verify your code is intact:"
Write-Host " git log --oneline $recoveryBranch | head -20"
Write-Host ""
Write-Host " 3. Merge to your default branch ($defaultBranch):"
Write-Host " git checkout $defaultBranch"
Write-Host " git merge --squash $recoveryBranch"
Write-Host " git commit -m `"feat: recover milestone from #1668`""
Write-Host ""
Write-Host " 4. Clean up after verifying:"
Write-Host " git branch -D $recoveryBranch"
Write-Host ""
Write-Host "Note: update SF to v2.40.1+ to prevent this from recurring." -ForegroundColor DarkGray
Write-Host " PR: https://github.com/singularity-forge/sf-run/pull/1669" -ForegroundColor DarkGray
Write-Host ""