383 lines
16 KiB
Bash
Executable file
383 lines
16 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# recover-sf-1364.sh — Recovery script for issue #1364 (Linux / macOS)
|
||
#
|
||
# CRITICAL DATA-LOSS BUG: SF versions 2.30.0–2.35.x unconditionally added
|
||
# ".sf" to .gitignore via ensureGitignore(), causing git to report all
|
||
# tracked .sf/ files as deleted. Fixed in v2.36.0 (PR #1367).
|
||
# Three residual vectors remain on v2.36.0–v2.38.0 — see PR #1635 for details.
|
||
#
|
||
# This script:
|
||
# 1. Detects whether the repo was affected
|
||
# 2. Finds the last clean commit before the damage
|
||
# 3. Restores all deleted .sf/ files from that commit
|
||
# 4. Removes the bad ".sf" line from .gitignore (if .sf/ is tracked)
|
||
# 5. Prints a ready-to-commit summary
|
||
#
|
||
# Usage:
|
||
# bash scripts/recover-sf-1364.sh [--dry-run]
|
||
#
|
||
# Options:
|
||
# --dry-run Show what would be done without making any changes
|
||
#
|
||
# Requirements: git >= 2.x, bash >= 4.x
|
||
|
||
set -euo pipefail
|
||
|
||
# ─── Colours ──────────────────────────────────────────────────────────────────
|
||
|
||
RED='\033[0;31m'
|
||
YELLOW='\033[1;33m'
|
||
GREEN='\033[0;32m'
|
||
CYAN='\033[0;36m'
|
||
BOLD='\033[1m'
|
||
RESET='\033[0m'
|
||
|
||
# ─── Args ─────────────────────────────────────────────────────────────────────
|
||
|
||
DRY_RUN=false
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
--dry-run) DRY_RUN=true ;;
|
||
*) echo "Unknown argument: $arg" >&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}"; }
|
||
|
||
die() {
|
||
error "$*"
|
||
exit 1
|
||
}
|
||
|
||
# Run or print-only depending on --dry-run
|
||
run() {
|
||
if $DRY_RUN; then
|
||
echo -e " ${YELLOW}(dry-run)${RESET} $*"
|
||
else
|
||
eval "$*"
|
||
fi
|
||
}
|
||
|
||
# ─── Preflight ────────────────────────────────────────────────────────────────
|
||
|
||
section "── Preflight ───────────────────────────────────────────────────────"
|
||
|
||
# Must be run from a git repo root
|
||
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"
|
||
|
||
if $DRY_RUN; then
|
||
warn "DRY-RUN mode — no changes will be made."
|
||
fi
|
||
|
||
# ─── Step 1: Check if .sf/ exists ────────────────────────────────────────────
|
||
|
||
section "── Step 1: Detect .sf/ directory ────────────────────────────────────"
|
||
|
||
SF_DIR="$REPO_ROOT/.sf"
|
||
SF_IS_SYMLINK=false
|
||
|
||
if [[ ! -e "$SF_DIR" ]]; then
|
||
ok ".sf/ does not exist in this repo — not affected."
|
||
exit 0
|
||
fi
|
||
|
||
if [[ -L "$SF_DIR" ]]; then
|
||
# Scenario C: migration succeeded (symlink in place) but git index was never
|
||
# cleaned — tracked .sf/* files still appear as deleted through the symlink.
|
||
SF_IS_SYMLINK=true
|
||
warn ".sf/ is a symlink — checking for stale git index entries (Scenario C)..."
|
||
else
|
||
info ".sf/ is a real directory (Scenario A/B)."
|
||
fi
|
||
|
||
# ─── Step 2: Check if .sf is in .gitignore ───────────────────────────────────
|
||
|
||
section "── Step 2: Check .gitignore for .sf entry ────────────────────────────"
|
||
|
||
GITIGNORE="$REPO_ROOT/.gitignore"
|
||
|
||
if [[ ! -f "$GITIGNORE" ]] && ! $SF_IS_SYMLINK; then
|
||
ok ".gitignore does not exist — not affected."
|
||
exit 0
|
||
fi
|
||
|
||
# Look for a bare ".sf" line (not a comment, not a sub-path like .sf/)
|
||
SF_IGNORE_LINE=""
|
||
if [[ -f "$GITIGNORE" ]]; then
|
||
while IFS= read -r line; do
|
||
trimmed="${line#"${line%%[![:space:]]*}"}"
|
||
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
|
||
if [[ "$trimmed" == ".sf" ]] && [[ "${trimmed:0:1}" != "#" ]]; then
|
||
SF_IGNORE_LINE="$trimmed"
|
||
break
|
||
fi
|
||
done < "$GITIGNORE"
|
||
fi
|
||
|
||
if $SF_IS_SYMLINK; then
|
||
# Symlink layout: .sf SHOULD be ignored (it's external state).
|
||
# Missing = needs adding. Present = correct.
|
||
if [[ -z "$SF_IGNORE_LINE" ]]; then
|
||
warn '".sf" missing from .gitignore — will add (migration complete, .sf/ is external).'
|
||
else
|
||
ok '".sf" already in .gitignore — correct for external-state layout.'
|
||
fi
|
||
else
|
||
# Real-directory layout: .sf should NOT be ignored.
|
||
if [[ -z "$SF_IGNORE_LINE" ]]; then
|
||
ok '".sf" not found in .gitignore — .gitignore not affected.'
|
||
else
|
||
warn '".sf" found in .gitignore — this is the bad pattern from #1364.'
|
||
fi
|
||
fi
|
||
|
||
# ─── Step 3: Find deleted .sf/ tracked files ─────────────────────────────────
|
||
|
||
section "── Step 3: Find deleted .sf/ files ───────────────────────────────────"
|
||
|
||
# Files showing as deleted in the working tree (tracked in index but missing)
|
||
DELETED_FILES="$(git ls-files --deleted -- '.sf/*' 2>/dev/null || true)"
|
||
|
||
# Files tracked in HEAD right now
|
||
TRACKED_IN_HEAD="$(git ls-tree -r --name-only HEAD -- '.sf/' 2>/dev/null || true)"
|
||
|
||
if $SF_IS_SYMLINK; then
|
||
# Scenario C: migration succeeded. Files are safe via symlink.
|
||
# Only index entries can be stale — no need to scan commit history.
|
||
if [[ -z "$TRACKED_IN_HEAD" ]] && [[ -z "$DELETED_FILES" ]]; then
|
||
ok "No stale index entries found — symlink layout is healthy."
|
||
if [[ -z "$SF_IGNORE_LINE" ]]; then
|
||
info "Add .sf to .gitignore manually to complete the migration."
|
||
fi
|
||
exit 0
|
||
fi
|
||
INDEX_COUNT="$(echo "${TRACKED_IN_HEAD:-$DELETED_FILES}" | wc -l | tr -d ' ')"
|
||
warn "Scenario C: ${INDEX_COUNT} .sf/ file(s) tracked in git index but inaccessible through symlink."
|
||
info "Files are safe in external storage — only the git index needs cleaning."
|
||
else
|
||
# Files deleted via a committed git rm --cached (Scenario B)
|
||
DELETED_FROM_HISTORY="$(git log --all --diff-filter=D --name-only --format="" -- '.sf/*' 2>/dev/null \
|
||
| grep '^\.sf' | sort -u || true)"
|
||
|
||
if [[ -z "$TRACKED_IN_HEAD" ]] && [[ -z "$DELETED_FILES" ]] && [[ -z "$DELETED_FROM_HISTORY" ]]; then
|
||
ok "No .sf/ files tracked in this repo — not affected by #1364."
|
||
if [[ -n "$SF_IGNORE_LINE" ]]; then
|
||
warn '".sf" is still in .gitignore but there is nothing to restore.'
|
||
fi
|
||
exit 0
|
||
fi
|
||
|
||
if [[ -n "$TRACKED_IN_HEAD" ]]; then
|
||
TRACKED_COUNT="$(echo "$TRACKED_IN_HEAD" | wc -l | tr -d ' ')"
|
||
info "Scenario A: ${TRACKED_COUNT} .sf/ files still tracked in HEAD."
|
||
elif [[ -n "$DELETED_FROM_HISTORY" ]]; then
|
||
DELETED_HIST_COUNT="$(echo "$DELETED_FROM_HISTORY" | wc -l | tr -d ' ')"
|
||
warn "Scenario B: ${DELETED_HIST_COUNT} .sf/ file(s) deleted in a committed change:"
|
||
echo "$DELETED_FROM_HISTORY" | head -20 | while IFS= read -r f; do echo " - $f"; done
|
||
if (( DELETED_HIST_COUNT > 20 )); then echo " ... and $((DELETED_HIST_COUNT - 20)) more"; fi
|
||
fi
|
||
|
||
if [[ -n "$DELETED_FILES" ]]; then
|
||
DELETED_COUNT="$(echo "$DELETED_FILES" | wc -l | tr -d ' ')"
|
||
warn "${DELETED_COUNT} .sf/ file(s) missing from working tree:"
|
||
echo "$DELETED_FILES" | head -20 | while IFS= read -r f; do echo " - $f"; done
|
||
if (( DELETED_COUNT > 20 )); then echo " ... and $((DELETED_COUNT - 20)) more"; fi
|
||
fi
|
||
|
||
if [[ -n "$TRACKED_IN_HEAD" ]] && [[ -z "$DELETED_FILES" ]]; then
|
||
if [[ -z "$SF_IGNORE_LINE" ]]; then
|
||
ok "No action needed — .sf/ is tracked in HEAD and .gitignore is clean."
|
||
exit 0
|
||
fi
|
||
info ".sf/ is tracked in HEAD and working tree is clean — only .gitignore needs fixing."
|
||
fi
|
||
fi
|
||
|
||
# ─── Step 4: Find the last clean commit (Scenario A/B only) ───────────────────
|
||
|
||
section "── Step 4: Find last clean commit ──────────────────────────────────────"
|
||
|
||
DAMAGE_COMMIT=""
|
||
CLEAN_COMMIT=""
|
||
RESTORABLE=""
|
||
|
||
if $SF_IS_SYMLINK; then
|
||
info "Scenario C: symlink layout — skipping commit history scan (no file restore needed)."
|
||
else
|
||
# Find the commit where ".sf" was first added to .gitignore
|
||
# by walking the log and finding the first commit where .gitignore contained ".sf"
|
||
info "Scanning git log to find when .sf was added to .gitignore..."
|
||
|
||
# Strategy 1: find the first commit that added ".sf" to .gitignore
|
||
while IFS= read -r sha; do
|
||
content="$(git show "${sha}:.gitignore" 2>/dev/null || true)"
|
||
if echo "$content" | grep -qx '\.sf' 2>/dev/null; then
|
||
DAMAGE_COMMIT="$sha"
|
||
break
|
||
fi
|
||
done < <(git log --format="%H" -- .gitignore)
|
||
|
||
# Strategy 2: if .sf files were committed as deleted, find that commit
|
||
if [[ -z "$DAMAGE_COMMIT" ]] && [[ -n "${DELETED_FROM_HISTORY:-}" ]]; then
|
||
info "Searching for the commit that deleted .sf/ files from the index..."
|
||
DAMAGE_COMMIT="$(git log --all --diff-filter=D --format="%H" -- '.sf/*' 2>/dev/null | head -1 || true)"
|
||
fi
|
||
|
||
if [[ -z "$DAMAGE_COMMIT" ]]; then
|
||
warn "Could not pinpoint the damage commit — falling back to HEAD."
|
||
CLEAN_COMMIT="HEAD"
|
||
else
|
||
info "Damage commit: $DAMAGE_COMMIT ($(git log --format='%s' -1 "$DAMAGE_COMMIT"))"
|
||
CLEAN_COMMIT="${DAMAGE_COMMIT}^"
|
||
CLEAN_MSG="$(git log --format='%s' -1 "$CLEAN_COMMIT" 2>/dev/null || echo "unknown")"
|
||
info "Restoring from: $CLEAN_COMMIT — $CLEAN_MSG"
|
||
fi
|
||
|
||
# Verify the clean commit actually has .sf/ files
|
||
RESTORABLE="$(git ls-tree -r --name-only "$CLEAN_COMMIT" -- '.sf/' 2>/dev/null || true)"
|
||
if [[ -z "$RESTORABLE" ]]; then
|
||
die "No .sf/ files found in restore point $CLEAN_COMMIT — cannot recover. Check git log manually."
|
||
fi
|
||
|
||
RESTORABLE_COUNT="$(echo "$RESTORABLE" | wc -l | tr -d ' ')"
|
||
ok "Restore point has ${RESTORABLE_COUNT} .sf/ files available."
|
||
fi
|
||
|
||
# ─── Step 5: Clean index (Scenario C) or restore deleted files (Scenario A/B) ─
|
||
|
||
if $SF_IS_SYMLINK; then
|
||
section "── Step 5: Clean stale git index entries ───────────────────────────────"
|
||
|
||
info "Running: git rm -r --cached --ignore-unmatch .sf/ ..."
|
||
run "git rm -r --cached --ignore-unmatch .sf"
|
||
if ! $DRY_RUN; then
|
||
STILL_STALE="$(git ls-files --deleted -- '.sf/*' 2>/dev/null || true)"
|
||
if [[ -z "$STILL_STALE" ]]; then
|
||
ok "Git index cleaned — no stale .sf/ entries remain."
|
||
else
|
||
warn "$(echo "$STILL_STALE" | wc -l | tr -d ' ') stale entr(ies) still present — may need manual cleanup."
|
||
fi
|
||
fi
|
||
else
|
||
section "── Step 5: Restore deleted .sf/ files ────────────────────────────────"
|
||
|
||
NEEDS_RESTORE=false
|
||
[[ -n "$DELETED_FILES" ]] && NEEDS_RESTORE=true
|
||
[[ -n "${DELETED_FROM_HISTORY:-}" ]] && [[ -z "$TRACKED_IN_HEAD" ]] && NEEDS_RESTORE=true
|
||
|
||
if ! $NEEDS_RESTORE; then
|
||
ok "No deleted files to restore — skipping."
|
||
else
|
||
info "Restoring .sf/ files from $CLEAN_COMMIT..."
|
||
run "git checkout \"$CLEAN_COMMIT\" -- .sf/"
|
||
if ! $DRY_RUN; then
|
||
STILL_MISSING="$(git ls-files --deleted -- '.sf/*' 2>/dev/null || true)"
|
||
if [[ -z "$STILL_MISSING" ]]; then
|
||
ok "All .sf/ files restored successfully."
|
||
else
|
||
MISS_COUNT="$(echo "$STILL_MISSING" | wc -l | tr -d ' ')"
|
||
warn "${MISS_COUNT} file(s) still missing after restore — may need manual recovery:"
|
||
echo "$STILL_MISSING" | head -10 | while IFS= read -r f; do echo " - $f"; done
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# ─── Step 6: Fix .gitignore ───────────────────────────────────────────────────
|
||
|
||
section "── Step 6: Fix .gitignore ───────────────────────────────────────────────"
|
||
|
||
if $SF_IS_SYMLINK; then
|
||
# Scenario C: .sf IS external — it should be in .gitignore. Add if missing.
|
||
if [[ -z "$SF_IGNORE_LINE" ]]; then
|
||
info 'Adding ".sf" to .gitignore (migration complete — .sf/ is external state)...'
|
||
if $DRY_RUN; then
|
||
echo -e " ${YELLOW}(dry-run)${RESET} Would append: .sf"
|
||
else
|
||
printf '\n# SF external state (symlink — added by recover-sf-1364)\n.sf\n' >> "$GITIGNORE"
|
||
ok '".sf" added to .gitignore.'
|
||
fi
|
||
else
|
||
ok '".sf" already in .gitignore — correct for external-state layout.'
|
||
fi
|
||
else
|
||
# Scenario A/B: .sf is a real tracked directory — remove the bad ignore line.
|
||
if [[ -z "$SF_IGNORE_LINE" ]]; then
|
||
ok '".sf" not in .gitignore — nothing to fix.'
|
||
else
|
||
info 'Removing bare ".sf" line from .gitignore...'
|
||
if $DRY_RUN; then
|
||
echo -e " ${YELLOW}(dry-run)${RESET} Would remove line: .sf"
|
||
else
|
||
# Remove the exact line ".sf" (not comments, not .sf/ subdirs)
|
||
# Use a temp file for portability (no sed -i on all platforms)
|
||
TMP="$(mktemp)"
|
||
grep -v '^\.sf$' "$GITIGNORE" > "$TMP" || true
|
||
mv "$TMP" "$GITIGNORE"
|
||
ok '".sf" line removed from .gitignore.'
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# ─── Step 7: Stage changes ────────────────────────────────────────────────────
|
||
|
||
section "── Step 7: Stage recovery changes ──────────────────────────────────────"
|
||
|
||
if ! $DRY_RUN; then
|
||
CHANGED="$(git status --short -- '.sf/' .gitignore 2>/dev/null || true)"
|
||
if [[ -z "$CHANGED" ]]; then
|
||
ok "No staged changes — working tree was already clean."
|
||
else
|
||
if $SF_IS_SYMLINK; then
|
||
# Scenario C: the git rm --cached already staged the index cleanup.
|
||
# Only stage .gitignore — adding .sf/ would fail (now gitignored).
|
||
git add .gitignore 2>/dev/null || true
|
||
else
|
||
git add .sf/ .gitignore 2>/dev/null || true
|
||
fi
|
||
STAGED_COUNT="$(git diff --cached --name-only -- '.sf/' .gitignore | wc -l | tr -d ' ')"
|
||
ok "${STAGED_COUNT} file(s) staged and ready to commit."
|
||
fi
|
||
fi
|
||
|
||
# ─── Summary ──────────────────────────────────────────────────────────────────
|
||
|
||
section "── Summary ──────────────────────────────────────────────────────────────"
|
||
|
||
if $DRY_RUN; then
|
||
echo -e "${YELLOW}Dry-run complete. Re-run without --dry-run to apply changes.${RESET}"
|
||
else
|
||
FINAL_STAGED="$(git diff --cached --name-only -- '.sf/' .gitignore 2>/dev/null | wc -l | tr -d ' ')"
|
||
if (( FINAL_STAGED > 0 )); then
|
||
echo -e "${GREEN}Recovery complete. Commit with:${RESET}"
|
||
echo ""
|
||
if $SF_IS_SYMLINK; then
|
||
echo " git commit -m \"fix: clean stale .sf/ index entries after external-state migration\""
|
||
else
|
||
echo " git commit -m \"fix: restore .sf/ files deleted by #1364 regression\""
|
||
fi
|
||
echo ""
|
||
echo "Staged files:"
|
||
git diff --cached --name-only -- '.sf/' .gitignore | head -20 | while IFS= read -r f; do
|
||
echo " + $f"
|
||
done
|
||
TOTAL_STAGED="$(git diff --cached --name-only -- '.sf/' .gitignore | wc -l | tr -d ' ')"
|
||
if (( TOTAL_STAGED > 20 )); then
|
||
echo " ... and $((TOTAL_STAGED - 20)) more"
|
||
fi
|
||
else
|
||
ok "Repo is healthy — no recovery needed."
|
||
fi
|
||
fi
|