Updates channel prefixes, log messages, comments, and configuration values across daemon, mcp-server, and related packages to complete the rebrand from gsd to sf-run naming. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
446 lines
17 KiB
Bash
Executable file
446 lines
17 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# recover-sf-1668.sh — Recovery script for issue #1668 (Linux / macOS)
|
|
#
|
|
# SF v2.39.x deleted the milestone branch and worktree directory when a
|
|
# merge failed due to the repo using `master` as its default branch (not
|
|
# `main`). The commits were never merged — they are orphaned in the git
|
|
# object store and can be recovered via git reflog or git fsck.
|
|
#
|
|
# This script:
|
|
# 1. Searches git reflog for the deleted milestone branch (fastest path)
|
|
# 2. Falls back to git fsck --unreachable to find orphaned commits
|
|
# 3. Ranks candidates by recency and SF commit message patterns
|
|
# 4. Creates a recovery branch at the identified commit
|
|
# 5. Reports what was found and how to complete the merge manually
|
|
#
|
|
# Usage:
|
|
# bash scripts/recover-sf-1668.sh [--milestone <ID>] [--dry-run] [--auto]
|
|
#
|
|
# Options:
|
|
# --milestone <ID> SF 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: SF.39.x
|
|
# Fixed in: SF.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 SF message patterns.
|
|
# SF 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|SF|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 SF 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 '^\.sf/' || true)"
|
|
FILE_COUNT="$(echo "$FILE_LIST" | grep -c . || true)"
|
|
|
|
info "Files recoverable (excluding .sf/ 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 SF to v2.40.1+ to prevent this from recurring.${RESET}"
|
|
echo " PR: https://github.com/singularity-forge/sf-run/pull/1669"
|
|
echo ""
|