fix(worktree): detect default branch instead of hardcoding "main" on milestone merge (#1668) (#1669)

* 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)
This commit is contained in:
Jeremy McSpadden 2026-03-21 09:34:55 -05:00 committed by GitHub
parent 3e8cf4ba8f
commit 74b97bdcdb
6 changed files with 901 additions and 3 deletions

View file

@ -0,0 +1,339 @@
# 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 ""

446
scripts/recover-gsd-1668.sh Executable file
View file

@ -0,0 +1,446 @@
#!/usr/bin/env bash
# recover-gsd-1668.sh — Recovery script for issue #1668 (Linux / macOS)
#
# 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:
# bash scripts/recover-gsd-1668.sh [--milestone <ID>] [--dry-run] [--auto]
#
# Options:
# --milestone <ID> GSD milestone ID (e.g. M001-g2nalq).
# When omitted the script scans all recent orphans.
# --dry-run Show what would be done without making any changes.
# --auto Pick the best candidate automatically (no prompts).
#
# Requirements: git >= 2.23, bash >= 4.x
#
# Affected versions: GSD 2.39.x
# Fixed in: GSD 2.40.1 (PR #1669)
set -euo pipefail
# ─── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
# ─── Args ─────────────────────────────────────────────────────────────────────
DRY_RUN=false
AUTO=false
MILESTONE_ID=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--auto) AUTO=true; shift ;;
--milestone)
[[ $# -lt 2 ]] && { echo "Error: --milestone requires an argument" >&2; exit 1; }
MILESTONE_ID="$2"; shift 2 ;;
--milestone=*)
MILESTONE_ID="${1#--milestone=}"; shift ;;
-h|--help)
sed -n '2,/^set -/p' "$0" | grep '^#' | sed 's/^# \{0,1\}//'
exit 0 ;;
*)
echo "Unknown argument: $1" >&2
echo "Usage: $0 [--milestone <ID>] [--dry-run] [--auto]" >&2
exit 1 ;;
esac
done
# ─── Helpers ──────────────────────────────────────────────────────────────────
info() { echo -e "${CYAN}[info]${RESET} $*"; }
ok() { echo -e "${GREEN}[ok]${RESET} $*"; }
warn() { echo -e "${YELLOW}[warn]${RESET} $*"; }
error() { echo -e "${RED}[error]${RESET} $*" >&2; }
section() { echo -e "\n${BOLD}$*${RESET}"; }
dim() { echo -e "${DIM}$*${RESET}"; }
die() {
error "$*"
exit 1
}
run() {
if $DRY_RUN; then
echo -e " ${YELLOW}(dry-run)${RESET} $*"
else
eval "$*"
fi
}
# ─── Preflight ────────────────────────────────────────────────────────────────
section "── Preflight ───────────────────────────────────────────────────────────"
if ! git rev-parse --git-dir > /dev/null 2>&1; then
die "Not inside a git repository. Run this from your project root."
fi
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
info "Repo root: $REPO_ROOT"
$DRY_RUN && warn "DRY-RUN mode — no changes will be made."
# ─── Step 1: Confirm the milestone branch is gone ─────────────────────────────
section "── Step 1: Verify milestone branch is missing ───────────────────────────"
BRANCH_PATTERN="milestone/"
if [[ -n "$MILESTONE_ID" ]]; then
BRANCH_PATTERN="milestone/${MILESTONE_ID}"
fi
LIVE_BRANCHES="$(git branch | grep "$BRANCH_PATTERN" 2>/dev/null | tr -d '* ' || true)"
if [[ -n "$LIVE_BRANCHES" ]]; then
ok "Found live milestone branch(es):"
echo "$LIVE_BRANCHES" | while IFS= read -r b; do echo " $b"; done
echo ""
warn "The branch still exists — are you sure it was lost?"
echo " If you want to check out existing work: git checkout ${LIVE_BRANCHES%%$'\n'*}"
echo " To merge it manually: git checkout master && git merge --squash ${LIVE_BRANCHES%%$'\n'*}"
echo ""
echo "Re-run with --milestone <ID> to force scanning for a specific orphaned commit."
if [[ -z "$MILESTONE_ID" ]]; then
exit 0
fi
fi
if [[ -n "$MILESTONE_ID" && -n "$LIVE_BRANCHES" ]]; then
warn "Milestone branch milestone/${MILESTONE_ID} is still live — continuing scan anyway."
elif [[ -n "$MILESTONE_ID" ]]; then
info "Confirmed: milestone/${MILESTONE_ID} branch is gone."
else
info "No live milestone/ branches found — scanning for orphaned commits."
fi
# ─── Step 2: Search git reflog (fastest, most reliable) ───────────────────────
section "── Step 2: Search git reflog for deleted branch ────────────────────────"
# git reflog stores branch moves and deletions in .git/logs/refs/heads/
# It is retained for 90 days by default (gc.reflogExpire).
REFLOG_FOUND_SHA=""
REFLOG_FOUND_BRANCH=""
if [[ -n "$MILESTONE_ID" ]]; then
REFLOG_PATH="${REPO_ROOT}/.git/logs/refs/heads/milestone/${MILESTONE_ID}"
if [[ -f "$REFLOG_PATH" ]]; then
# Last line of the reflog for this branch is the most recent tip
REFLOG_FOUND_SHA="$(tail -1 "$REFLOG_PATH" | awk '{print $2}')"
REFLOG_FOUND_BRANCH="milestone/${MILESTONE_ID}"
ok "Reflog entry found for milestone/${MILESTONE_ID} — commit: ${REFLOG_FOUND_SHA:0:12}"
else
info "No reflog file at .git/logs/refs/heads/milestone/${MILESTONE_ID}"
fi
fi
# Also try git reflog (in-memory index, works without the raw file)
if [[ -z "$REFLOG_FOUND_SHA" ]]; then
info "Scanning git reflog for milestone/ commits..."
REFLOG_MILESTONES="$(git reflog --all --format="%H %gs" 2>/dev/null \
| grep -E "(checkout|commit|merge).*milestone/" \
| head -20 || true)"
if [[ -n "$REFLOG_MILESTONES" ]]; then
info "Found milestone-related reflog entries:"
echo "$REFLOG_MILESTONES" | while IFS= read -r line; do
dim " $line"
done
# Extract the most recent SHA from the most relevant entry
if [[ -n "$MILESTONE_ID" ]]; then
MATCH="$(echo "$REFLOG_MILESTONES" | grep "milestone/${MILESTONE_ID}" | head -1 || true)"
else
MATCH="$(echo "$REFLOG_MILESTONES" | head -1 || true)"
fi
if [[ -n "$MATCH" ]]; then
REFLOG_FOUND_SHA="$(echo "$MATCH" | awk '{print $1}')"
REFLOG_FOUND_BRANCH="$(echo "$MATCH" | grep -oE 'milestone/[^ ]+' | head -1 || echo "milestone/unknown")"
fi
else
info "No milestone/ entries in reflog."
fi
fi
# ─── Step 3: Fall back to git fsck if reflog didn't find it ───────────────────
section "── Step 3: Scan for orphaned (unreachable) commits ───────────────────"
FSCK_CANDIDATES=()
FSCK_CANDIDATE_MSGS=()
FSCK_CANDIDATE_DATES=()
FSCK_CANDIDATE_FILES=()
if [[ -z "$REFLOG_FOUND_SHA" ]]; then
info "Running git fsck --unreachable (this may take a moment)..."
# Collect all unreachable commit hashes
UNREACHABLE_COMMITS="$(git fsck --unreachable --no-reflogs 2>/dev/null \
| grep '^unreachable commit' \
| awk '{print $3}' || true)"
if [[ -z "$UNREACHABLE_COMMITS" ]]; then
# Try without --no-reflogs as a fallback (less conservative)
UNREACHABLE_COMMITS="$(git fsck --unreachable 2>/dev/null \
| grep '^unreachable commit' \
| awk '{print $3}' || true)"
fi
TOTAL="$(echo "$UNREACHABLE_COMMITS" | grep -c . || true)"
info "Found ${TOTAL} unreachable commit object(s)."
if [[ -z "$UNREACHABLE_COMMITS" || "$TOTAL" -eq 0 ]]; then
error "No unreachable commits found."
echo ""
echo "This means one of:"
echo " 1. git gc has already been run and the objects were pruned"
echo " (objects are pruned after 14 days by default)"
echo " 2. The commits were never written to the object store"
echo " 3. The wrong repository is being scanned"
echo ""
echo "If git gc ran, the objects may be unrecoverable without a backup."
echo "Try: git reflog --all | grep milestone"
exit 1
fi
# Score each unreachable commit — rank by recency and GSD message patterns.
# GSD milestone commits look like: "feat(M001-g2nalq): <title>"
# Slice merges look like: "feat(M001-g2nalq/S01): <slice>"
#
# Performance: use a single `git log --no-walk=unsorted --stdin` call to
# read all commit metadata in one pass instead of one `git show` per commit.
CUTOFF="$(date -d '30 days ago' '+%s' 2>/dev/null || date -v-30d '+%s' 2>/dev/null || echo 0)"
WEEK_AGO="$(date -d '7 days ago' '+%s' 2>/dev/null || date -v-7d '+%s' 2>/dev/null || echo 0)"
# Batch-read all commits: output format per commit is:
# HASH<TAB>UNIX_TIMESTAMP<TAB>ISO_DATE<TAB>SUBJECT
# separated by NUL so multi-line subjects don't break parsing.
BATCH_LOG="$(echo "$UNREACHABLE_COMMITS" \
| git log --no-walk=unsorted --stdin --format=$'%H\t%ct\t%ci\t%s' 2>/dev/null || true)"
while IFS=$'\t' read -r sha commit_ts commit_date_hr commit_msg; do
[[ -z "$sha" ]] && continue
[[ -z "$commit_ts" || "$commit_ts" -lt "$CUTOFF" ]] && continue
# Score: milestone pattern in subject is highest signal
SCORE=0
if [[ -n "$MILESTONE_ID" ]] && echo "$commit_msg" | grep -qiE "(milestone[/ ])?${MILESTONE_ID}"; then
SCORE=$((SCORE + 100))
fi
if echo "$commit_msg" | grep -qE '^feat\([A-Z][0-9]+'; then
SCORE=$((SCORE + 50))
fi
if echo "$commit_msg" | grep -qiE 'milestone/|complete-milestone|GSD|slice'; then
SCORE=$((SCORE + 20))
fi
if [[ "$commit_ts" -gt "$WEEK_AGO" ]]; then
SCORE=$((SCORE + 10))
fi
FSCK_CANDIDATES+=("$sha|$SCORE")
FSCK_CANDIDATE_MSGS+=("$commit_msg")
FSCK_CANDIDATE_DATES+=("$commit_date_hr")
FSCK_CANDIDATE_FILES+=("?")
done <<< "$BATCH_LOG"
if [[ ${#FSCK_CANDIDATES[@]} -eq 0 ]]; then
error "No recent unreachable commits found within the last 30 days."
echo ""
echo "Objects may have been pruned by git gc, or the issue occurred more than 30 days ago."
echo "Try: git fsck --unreachable --no-reflogs 2>/dev/null | grep commit"
exit 1
fi
# Sort by score descending, keep top 10
IFS=$'\n' SORTED_CANDIDATES=($(
for i in "${!FSCK_CANDIDATES[@]}"; do
echo "${FSCK_CANDIDATES[$i]}|$i"
done | sort -t'|' -k2 -rn | head -10
))
unset IFS
info "Top candidates (scored by recency and GSD message patterns):"
echo ""
NUM=1
SORTED_IDXS=()
for entry in "${SORTED_CANDIDATES[@]}"; do
SHA="${entry%%|*}"
IDX="${entry##*|}"
SORTED_IDXS+=("$IDX")
MSG="${FSCK_CANDIDATE_MSGS[$IDX]}"
DATE="${FSCK_CANDIDATE_DATES[$IDX]}"
FILES="${FSCK_CANDIDATE_FILES[$IDX]}"
echo -e " ${BOLD}${NUM})${RESET} ${sha:0:12} ${GREEN}${MSG}${RESET}"
echo -e " ${DIM}${DATE}${FILES}${RESET}"
NUM=$((NUM + 1))
done
echo ""
fi
# ─── Step 4: Select the recovery commit ───────────────────────────────────────
section "── Step 4: Select recovery commit ──────────────────────────────────────"
RECOVERY_SHA=""
RECOVERY_SOURCE=""
if [[ -n "$REFLOG_FOUND_SHA" ]]; then
RECOVERY_SHA="$REFLOG_FOUND_SHA"
RECOVERY_SOURCE="reflog (${REFLOG_FOUND_BRANCH})"
info "Using reflog candidate: ${RECOVERY_SHA:0:12}"
MSG="$(git show -s --format="%s %ci" "$RECOVERY_SHA" 2>/dev/null || echo "unknown")"
dim " $MSG"
elif [[ ${#SORTED_IDXS[@]} -eq 1 ]] || $AUTO; then
# Auto-select first (highest scored) candidate
FIRST_ENTRY="${SORTED_CANDIDATES[0]}"
FIRST_SHA="${FIRST_ENTRY%%|*}"
FIRST_IDX="${FIRST_ENTRY##*|}"
RECOVERY_SHA="$FIRST_SHA"
RECOVERY_SOURCE="fsck (auto-selected)"
info "Auto-selecting best candidate: ${RECOVERY_SHA:0:12}"
else
# Prompt user to select
echo -n "Select a candidate to recover [1-${#SORTED_CANDIDATES[@]}, or q to quit]: "
read -r SELECTION
if [[ "$SELECTION" == "q" ]]; then
info "Aborted."
exit 0
fi
if ! [[ "$SELECTION" =~ ^[0-9]+$ ]] || \
[[ "$SELECTION" -lt 1 ]] || \
[[ "$SELECTION" -gt ${#SORTED_CANDIDATES[@]} ]]; then
die "Invalid selection: $SELECTION"
fi
SEL_IDX=$((SELECTION - 1))
SEL_ENTRY="${SORTED_CANDIDATES[$SEL_IDX]}"
RECOVERY_SHA="${SEL_ENTRY%%|*}"
RECOVERY_SOURCE="fsck (user-selected #${SELECTION})"
fi
if [[ -z "$RECOVERY_SHA" ]]; then
die "Could not determine a recovery commit. See output above."
fi
ok "Recovery commit: ${RECOVERY_SHA:0:16} (source: ${RECOVERY_SOURCE})"
# Show what's in this commit
echo ""
info "Commit details:"
git show -s --format=" Message: %s%n Author: %an <%ae>%n Date: %ci%n Full SHA: %H" "$RECOVERY_SHA"
echo ""
info "Files at this commit (first 30):"
git show --stat --format="" "$RECOVERY_SHA" 2>/dev/null | head -30
echo ""
# ─── Step 5: Create recovery branch ───────────────────────────────────────────
section "── Step 5: Create recovery branch ──────────────────────────────────────"
# Determine recovery branch name
if [[ -n "$MILESTONE_ID" ]]; then
RECOVERY_BRANCH="recovery/1668/${MILESTONE_ID}"
elif [[ -n "$REFLOG_FOUND_BRANCH" ]]; then
CLEAN_NAME="${REFLOG_FOUND_BRANCH//\//-}"
RECOVERY_BRANCH="recovery/1668/${CLEAN_NAME}"
else
SHORT_SHA="${RECOVERY_SHA:0:8}"
RECOVERY_BRANCH="recovery/1668/commit-${SHORT_SHA}"
fi
# Check if it already exists
if git show-ref --verify --quiet "refs/heads/${RECOVERY_BRANCH}" 2>/dev/null; then
warn "Branch ${RECOVERY_BRANCH} already exists."
if ! $AUTO; then
echo -n "Overwrite it? [y/N]: "
read -r ANSWER
if [[ "$ANSWER" != "y" && "$ANSWER" != "Y" ]]; then
info "Aborted. Existing branch preserved."
exit 0
fi
fi
run "git branch -D \"${RECOVERY_BRANCH}\""
fi
run "git branch \"${RECOVERY_BRANCH}\" \"${RECOVERY_SHA}\""
if ! $DRY_RUN; then
ok "Recovery branch created: ${RECOVERY_BRANCH}"
else
ok "(dry-run) Would create branch: ${RECOVERY_BRANCH}${RECOVERY_SHA:0:12}"
fi
# ─── Step 6: Verify the recovery branch ───────────────────────────────────────
if ! $DRY_RUN; then
section "── Step 6: Verify recovery branch ──────────────────────────────────────"
FILE_LIST="$(git ls-tree -r --name-only "${RECOVERY_BRANCH}" 2>/dev/null | grep -v '^\.gsd/' || true)"
FILE_COUNT="$(echo "$FILE_LIST" | grep -c . || true)"
info "Files recoverable (excluding .gsd/ state files): ${FILE_COUNT}"
echo "$FILE_LIST" | head -30 | while IFS= read -r f; do echo " $f"; done
if [[ "$FILE_COUNT" -gt 30 ]]; then
dim " ... and $((FILE_COUNT - 30)) more"
fi
fi
# ─── Summary ──────────────────────────────────────────────────────────────────
section "── Recovery Summary ─────────────────────────────────────────────────────"
if $DRY_RUN; then
echo -e "${YELLOW}Dry-run complete. Re-run without --dry-run to apply.${RESET}"
exit 0
fi
DEFAULT_BRANCH="$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' \
|| git for-each-ref --format='%(refname:short)' 'refs/heads/main' 'refs/heads/master' 2>/dev/null | head -1 \
|| git branch --show-current)"
echo -e "${GREEN}Recovery branch ready: ${BOLD}${RECOVERY_BRANCH}${RESET}"
echo ""
echo "Next steps:"
echo ""
echo -e " ${BOLD}1. Inspect the recovered files:${RESET}"
echo " git checkout ${RECOVERY_BRANCH}"
echo " ls -la"
echo ""
echo -e " ${BOLD}2. Verify your code is intact:${RESET}"
echo " git log --oneline ${RECOVERY_BRANCH} | head -20"
echo " git show --stat ${RECOVERY_BRANCH}"
echo ""
echo -e " ${BOLD}3. Merge to your default branch (${DEFAULT_BRANCH}):${RESET}"
echo " git checkout ${DEFAULT_BRANCH}"
echo " git merge --squash ${RECOVERY_BRANCH}"
echo " git commit -m \"feat: recover milestone from #1668\""
echo ""
echo -e " ${BOLD}4. Clean up after verifying:${RESET}"
echo " git branch -D ${RECOVERY_BRANCH}"
echo ""
echo -e "${DIM}Note: update GSD to v2.40.1+ to prevent this from recurring.${RESET}"
echo " PR: https://github.com/gsd-build/gsd-2/pull/1669"
echo ""

View file

@ -42,6 +42,7 @@ import { parseRoadmap } from "./files.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import {
nativeGetCurrentBranch,
nativeDetectMainBranch,
nativeWorkingTreeStatus,
nativeAddAllWithExclusions,
nativeCommit,
@ -852,13 +853,17 @@ export function mergeMilestoneToMain(
const previousCwd = process.cwd();
process.chdir(originalBasePath_);
// 4. Resolve integration branch — prefer milestone metadata, fall back to preferences / "main"
// 4. Resolve integration branch — prefer milestone metadata, then preferences,
// then auto-detect (origin/HEAD → main → master → current). Never hardcode
// "main": repos using "master" or a custom default branch would fail at
// checkout and leave the user with a broken merge state (#1668).
const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
const integrationBranch = readIntegrationBranch(
originalBasePath_,
milestoneId,
);
const mainBranch = integrationBranch ?? prefs.main_branch ?? "main";
const mainBranch =
integrationBranch ?? prefs.main_branch ?? nativeDetectMainBranch(originalBasePath_);
// Remove transient project-root state files before any branch or merge
// operation. Untracked milestone metadata can otherwise block squash merges.

View file

@ -325,6 +325,70 @@ async function main(): Promise<void> {
assertTrue(existsSync(join(repo, "skip-checkout.ts")), "skip-checkout.ts merged to main");
}
// ─── Test 7: Repo using `master` as default branch (#1668) ────────
//
// Reproduces the exact failure mode from the bug report: a repo initialised
// with `master`, no GSD preferences file setting `main_branch`, and no
// META.json (so readIntegrationBranch returns null). Before the fix,
// mergeMilestoneToMain would fall back to the hardcoded string "main",
// attempt `git checkout main`, fail, and leave the user with a broken state
// and a confusing error. After the fix, nativeDetectMainBranch detects
// `master` and the squash-merge succeeds normally.
console.log("\n=== master-branch repo — no META.json, no prefs (#1668) ===");
{
// Build a repo with `master` as the default branch (not `main`).
// Use -b master to override the system default (which may be `main`).
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-ms-master-test-")));
tempDirs.push(dir);
run("git init -b master", dir);
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# master-branch repo\n");
mkdirSync(join(dir, ".gsd"), { recursive: true });
writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
run("git add .", dir);
run("git commit -m init", dir);
// Leave the default branch as `master` — do NOT run `git branch -M main`
const defaultBranch = run("git rev-parse --abbrev-ref HEAD", dir);
assertEq(defaultBranch, "master", "repo is on master branch");
// Create a worktree for the milestone (creates milestone/M070 branch)
const wtPath = createAutoWorktree(dir, "M070");
addSliceToMilestone(dir, wtPath, "M070", "S01", "Master branch test", [
{ file: "master-feature.ts", content: "export const masterFeature = true;\n", message: "add master feature" },
]);
// No META.json written (simulates the captureIntegrationBranch failure
// described in the issue) — readIntegrationBranch will return null.
const metaFile = join(dir, ".gsd", "milestones", "M070", "M070-META.json");
assertTrue(!existsSync(metaFile), "no META.json — integration branch not captured");
const roadmap = makeRoadmap("M070", "Master branch milestone", [
{ id: "S01", title: "Master branch test" },
]);
// Should succeed: nativeDetectMainBranch detects `master` automatically.
let threw = false;
let errMsg = "";
try {
const result = mergeMilestoneToMain(dir, "M070", roadmap);
assertTrue(result.commitMessage.includes("feat(M070)"), "merge commit created on master");
} catch (err) {
threw = true;
errMsg = err instanceof Error ? err.message : String(err);
console.error("Unexpected error:", err);
}
assertTrue(!threw, `should not throw on master-branch repo (got: ${errMsg})`);
// Verify the code landed on master and the milestone branch is gone
const finalBranch = run("git rev-parse --abbrev-ref HEAD", dir);
assertEq(finalBranch, "master", "repo is still on master after merge");
assertTrue(existsSync(join(dir, "master-feature.ts")), "feature merged to master");
const branches = run("git branch", dir);
assertTrue(!branches.includes("milestone/M070"), "milestone branch deleted after merge");
}
} finally {
process.chdir(savedCwd);
for (const d of tempDirs) {

View file

@ -481,6 +481,43 @@ test("mergeAndExit in worktree mode restores to project root on merge failure",
assert.equal(findCalls(deps.calls, "GitServiceImpl").length, 1); // rebuilt after recovery
});
test("mergeAndExit failure message tells user worktree and branch are preserved (#1668)", () => {
// Regression test: before the fix, the failure message was a bare
// "Milestone merge failed: <reason>" with no recovery guidance. Users were
// left confused about whether their code had been deleted. The new message
// explicitly states that the worktree and branch are preserved and what to do.
const s = makeSession({
basePath: "/project/.gsd/worktrees/M001",
originalBasePath: "/project",
});
const deps = makeDeps({
isInAutoWorktree: () => true,
getIsolationMode: () => "worktree",
mergeMilestoneToMain: () => {
throw new Error("pathspec 'main' did not match any file(s) known to git");
},
});
const ctx = makeNotifyCtx();
const resolver = new WorktreeResolver(s, deps);
resolver.mergeAndExit("M001", ctx);
const warning = ctx.messages.find((m) => m.level === "warning");
assert.ok(warning, "a warning message is emitted");
// Must contain the original error
assert.ok(warning!.msg.includes("pathspec 'main' did not match"), "warning includes the original error");
// Must tell the user their work is safe
assert.ok(
warning!.msg.includes("preserved"),
"warning tells user the worktree and branch are preserved",
);
// Must suggest a recovery action
assert.ok(
warning!.msg.includes("retry") || warning!.msg.includes("manually"),
"warning suggests a recovery action",
);
});
// ─── mergeAndExit Tests (branch mode) ────────────────────────────────────────
test("mergeAndExit in branch mode merges when on milestone branch", () => {

View file

@ -372,7 +372,14 @@ export class WorktreeResolver {
error: msg,
fallback: "chdir-to-project-root",
});
ctx.notify(`Milestone merge failed: ${msg}`, "warning");
// Surface a clear, actionable error. The worktree and milestone branch are
// intentionally preserved — nothing has been deleted. The user can retry
// /complete-milestone or merge manually once the underlying issue is fixed
// (e.g. checkout to wrong branch, unresolved conflicts). (#1668)
ctx.notify(
`Milestone merge failed: ${msg}. Your worktree and milestone branch are preserved — retry /complete-milestone or merge manually.`,
"warning",
);
// Clean up stale merge state left by failed squash-merge (#1389)
try {