From 74b97bdcdb2bf1ca0852d08365b7f5204f193b7c Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Sat, 21 Mar 2026 09:34:55 -0500 Subject: [PATCH] fix(worktree): detect default branch instead of hardcoding "main" on milestone merge (#1668) (#1669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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: " 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/ 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...) (+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>/ branch and prints the exact commands to inspect, merge, and clean up. Supports: --milestone , --dry-run, --auto Platforms: bash (Linux/macOS) and PowerShell (Windows) --- scripts/recover-gsd-1668.ps1 | 339 +++++++++++++ scripts/recover-gsd-1668.sh | 446 ++++++++++++++++++ src/resources/extensions/gsd/auto-worktree.ts | 9 +- .../auto-worktree-milestone-merge.test.ts | 64 +++ .../gsd/tests/worktree-resolver.test.ts | 37 ++ .../extensions/gsd/worktree-resolver.ts | 9 +- 6 files changed, 901 insertions(+), 3 deletions(-) create mode 100644 scripts/recover-gsd-1668.ps1 create mode 100755 scripts/recover-gsd-1668.sh diff --git a/scripts/recover-gsd-1668.ps1 b/scripts/recover-gsd-1668.ps1 new file mode 100644 index 000000000..d2f290fd1 --- /dev/null +++ b/scripts/recover-gsd-1668.ps1 @@ -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 ] [-DryRun] [-Auto] +# +# Options: +# -MilestoneId 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 "" diff --git a/scripts/recover-gsd-1668.sh b/scripts/recover-gsd-1668.sh new file mode 100755 index 000000000..47b7c321a --- /dev/null +++ b/scripts/recover-gsd-1668.sh @@ -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 ] [--dry-run] [--auto] +# +# Options: +# --milestone 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 ] [--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 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): " + # 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 "" diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index ce4455a8f..f6717c0c9 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -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. diff --git a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts index 0ea4d05ff..30fd9a7e4 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts @@ -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) { diff --git a/src/resources/extensions/gsd/tests/worktree-resolver.test.ts b/src/resources/extensions/gsd/tests/worktree-resolver.test.ts index 23abed9a3..df0170228 100644 --- a/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-resolver.test.ts @@ -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", () => { diff --git a/src/resources/extensions/gsd/worktree-resolver.ts b/src/resources/extensions/gsd/worktree-resolver.ts index b944f3d15..5d8cc52a8 100644 --- a/src/resources/extensions/gsd/worktree-resolver.ts +++ b/src/resources/extensions/gsd/worktree-resolver.ts @@ -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 {