* fix(worktree): detect default branch instead of hardcoding "main" on milestone merge (#1668) Repos using `master` (or any non-`main` default branch) without a GSD preferences file and without a milestone META.json would have `mergeMilestoneToMain` fall back to the hardcoded string `"main"`, causing `git checkout main` to fail. The worktree and milestone branch were left in an indeterminate state with only a terse error message. Two targeted fixes: 1. **auto-worktree.ts** — Replace `?? "main"` fallback with `?? nativeDetectMainBranch(originalBasePath_)`. This function already exists and is used in 9 other locations; it probes origin/HEAD, then checks for `main`, `master`, and finally falls back to the current branch. The resolution order is unchanged for the common case (integration branch → prefs.main_branch → detected). 2. **worktree-resolver.ts** — Improve the merge-failure warning from a bare "Milestone merge failed: <reason>" to an actionable message that explicitly tells the user their worktree and milestone branch are preserved, and what to do next (retry /complete-milestone or merge manually). This prevents the panic of "is my code gone?" described in the issue. Tests added: - `auto-worktree-milestone-merge.test.ts`: Test 7 creates a real git repo with `master` as the default branch, no META.json, and no prefs, then verifies the squash-merge succeeds and lands on `master`. - `worktree-resolver.test.ts`: Asserts the failure message includes the original error, the word "preserved", and a recovery suggestion. * fix(recovery): add recover-gsd-1668 script for orphaned milestone commits Users who hit the #1668 bug (milestone branch deleted before merge succeeded) can use this script to recover their code from git's object store before git gc prunes the orphaned commits (default: 14–90 days). The script has two search strategies: 1. Git reflog — checks .git/logs/refs/heads/milestone/<ID> first. Reflogs survive branch deletion for up to 90 days. This is the fastest path and requires zero scanning. 2. Git fsck fallback — runs git fsck --unreachable --no-reflogs to find all orphaned commit objects, then scores them in a single git log --no-walk batch call (not per-commit git show, which would be O(n) process launches). Scores by: - Milestone ID match in subject (+100) - GSD conventional commit pattern feat(M<id>...) (+50) - Milestone-related keywords in subject (+20) - Committed within last 7 days (+10) Once a commit is selected (interactively or via --auto), the script creates recovery/<1668>/<milestone-id> branch and prints the exact commands to inspect, merge, and clean up. Supports: --milestone <ID>, --dry-run, --auto Platforms: bash (Linux/macOS) and PowerShell (Windows)
339 lines
14 KiB
PowerShell
339 lines
14 KiB
PowerShell
# recover-gsd-1668.ps1 — Recovery script for issue #1668 (Windows)
|
|
#
|
|
# GSD 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 GSD 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-gsd-1668.ps1 [-MilestoneId <ID>] [-DryRun] [-Auto]
|
|
#
|
|
# Options:
|
|
# -MilestoneId <ID> GSD 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: GSD 2.39.x
|
|
# Fixed in: GSD 2.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|GSD|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 GSD 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 '^\.gsd/' }
|
|
$fileCount = @($fileList).Count
|
|
Info "Files recoverable (excluding .gsd/ 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 GSD to v2.40.1+ to prevent this from recurring." -ForegroundColor DarkGray
|
|
Write-Host " PR: https://github.com/gsd-build/gsd-2/pull/1669" -ForegroundColor DarkGray
|
|
Write-Host ""
|