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:
parent
3e8cf4ba8f
commit
74b97bdcdb
6 changed files with 901 additions and 3 deletions
339
scripts/recover-gsd-1668.ps1
Normal file
339
scripts/recover-gsd-1668.ps1
Normal 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
446
scripts/recover-gsd-1668.sh
Executable 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 ""
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue