* fix: add recovery script for #1364 .gsd/ data-loss regression Adds scripts/recover-gsd-1364.sh to help users whose .gsd/ files were deleted by the ensureGitignore bug in v2.33.x–v2.35.x. The script handles both damage scenarios: - Scenario A: .gsd files deleted in working tree but not yet committed - Scenario B: git rm --cached .gsd/ was committed (files gone from HEAD) Steps performed: 1. Detects whether the repo is affected (symlink check, .gitignore scan, git history scan) 2. Finds the last clean commit before ".gsd" was added to .gitignore 3. Restores all deleted .gsd/ files via git checkout <clean-commit> -- .gsd/ 4. Removes the bare ".gsd" line from .gitignore 5. Stages both changes and prints the ready-to-commit command Supports --dry-run to preview without making changes. Safe to run on unaffected repos — exits early with no modifications. Closes #1364 * fix: add Windows PowerShell recovery script for #1364 Adds scripts/recover-gsd-1364.ps1, a PowerShell equivalent of the bash recovery script for users on Windows. Windows-specific differences handled: - Junction detection: GSD's migrateToExternalState() uses symlinkSync() with type "junction" on Windows instead of a POSIX symlink. The script checks Get-Item.LinkType for both "SymbolicLink" and "Junction" so migrated repos exit cleanly on step 1. - .gitignore rewrite uses [System.IO.File]::WriteAllLines() with UTF-8 no-BOM encoding to match git's expectations on Windows, rather than shell redirection which can introduce BOM or CRLF issues. - All git invocations use execFileSync-style array args via Invoke-Git helper — no shell string eval, no quoting edge cases. - Colour output uses Write-Host -ForegroundColor instead of ANSI escapes. - -DryRun is a proper PowerShell switch parameter. Also updates recover-gsd-1364.sh header to: - Clarify it is Linux/macOS only - Point Windows users to the .ps1 - Correct the affected version range to v2.30.0-v2.35.x (was 2.33.x) - Reference the three residual vectors on v2.36.0-v2.38.0 (PR #1635) Usage on Windows: powershell -ExecutionPolicy Bypass -File scripts\recover-gsd-1364.ps1 powershell -ExecutionPolicy Bypass -File scripts\recover-gsd-1364.ps1 -DryRun * fix(gsd): close residual #1364 data-loss vectors on v2.36.0+ Two targeted fixes that close the three remaining paths where .gsd/ tracked files can still be silently deleted after the v2.36.0 fix. --- Path 1: hasGitTrackedGsdFiles fails open on git error (gitignore.ts) nativeLsFiles() swallows git failures via allowFailure=true and returns [], making hasGitTrackedGsdFiles() indistinguishable between "nothing tracked" and "git failed". On any transient git failure (locked index, binary not on PATH, corrupted .git/index), the function returned false and .gsd was added to .gitignore, deleting all tracked state. Fix: after nativeLsFiles returns [], verify git is reachable with a cheap rev-parse call. If git is unavailable, return true (fail safe — assume tracked). The outer catch also returns true instead of false. --- Path 2: migration never cleans git index (migrate-external.ts) migrateToExternalState() correctly creates the .gsd symlink/junction but never ran `git rm -r --cached .gsd/`. All previously tracked .gsd/* files remained in the git index pointing through the new symlink, which git cannot follow — causing PROJECT.md, milestones/, REQUIREMENTS.md etc. to appear as deleted in git status immediately after every migration. Fix: after the symlink is verified, run: git rm -r --cached --ignore-unmatch .gsd --ignore-unmatch makes this a no-op on fresh/untracked projects. --- Path 3: race between migration and ensureGitignore Resolved by Path 2. If migration always cleans the index, the race window (another process converting .gsd/ to a symlink between the migrateToExternalState() and ensureGitignore() calls) is harmless — the index is already clean and there is nothing to lose. --- Tests added (gitignore-tracked-gsd.test.ts) - hasGitTrackedGsdFiles returns true (fail-safe) when git is unavailable (simulated via .git/index.lock to force git ls-files failure) - migrateToExternalState cleans git index so tracked files don't show as deleted after successful migration Fixes residual vectors from #1364 (original fix: #1367, v2.36.0) * fix(recovery): add Scenario C support to recover-gsd-1364 scripts Scenario C: .gsd/ is already a symlink/junction (migration succeeded on the filesystem) but `git rm -r --cached .gsd/` was never run, leaving tracked .gsd/* files appearing as deleted in git status. Both bash and PowerShell scripts previously exited early at Step 1 when they detected a symlink. Now they continue with a dedicated Scenario C path through all steps: - Step 1: sets GSD_IS_SYMLINK flag, continues instead of exiting - Step 2: inverted .gitignore check — warns if .gsd is MISSING (should be present for external-state layout) rather than if it's present - Step 3: skips commit-history scan (index issue only, no file restore needed); exits clean if no stale entries found - Step 4: skips damage-commit search (nothing to restore from history) - Step 5: runs `git rm -r --cached --ignore-unmatch .gsd` to clean the stale index entries instead of restoring files from a prior commit - Step 6: appends .gsd to .gitignore instead of removing it - Step 7: stages only .gitignore (not .gsd/) to avoid the "gitignored path" error; the index cleanup from Step 5 is already staged - Summary: uses a distinct commit message for Scenario C Smoke-tested against a synthetic repo that replicates the exact Scenario C failure mode (symlink in place, git rm --cached never run).
386 lines
16 KiB
Bash
Executable file
386 lines
16 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# recover-gsd-1364.sh — Recovery script for issue #1364 (Linux / macOS)
|
||
#
|
||
# For Windows use the PowerShell equivalent:
|
||
# powershell -ExecutionPolicy Bypass -File scripts\recover-gsd-1364.ps1 [-DryRun]
|
||
#
|
||
# CRITICAL DATA-LOSS BUG: GSD versions 2.30.0–2.35.x unconditionally added
|
||
# ".gsd" to .gitignore via ensureGitignore(), causing git to report all
|
||
# tracked .gsd/ 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 .gsd/ files from that commit
|
||
# 4. Removes the bad ".gsd" line from .gitignore (if .gsd/ is tracked)
|
||
# 5. Prints a ready-to-commit summary
|
||
#
|
||
# Usage:
|
||
# bash scripts/recover-gsd-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 .gsd/ exists ────────────────────────────────────────────
|
||
|
||
section "── Step 1: Detect .gsd/ directory ────────────────────────────────────"
|
||
|
||
GSD_DIR="$REPO_ROOT/.gsd"
|
||
GSD_IS_SYMLINK=false
|
||
|
||
if [[ ! -e "$GSD_DIR" ]]; then
|
||
ok ".gsd/ does not exist in this repo — not affected."
|
||
exit 0
|
||
fi
|
||
|
||
if [[ -L "$GSD_DIR" ]]; then
|
||
# Scenario C: migration succeeded (symlink in place) but git index was never
|
||
# cleaned — tracked .gsd/* files still appear as deleted through the symlink.
|
||
GSD_IS_SYMLINK=true
|
||
warn ".gsd/ is a symlink — checking for stale git index entries (Scenario C)..."
|
||
else
|
||
info ".gsd/ is a real directory (Scenario A/B)."
|
||
fi
|
||
|
||
# ─── Step 2: Check if .gsd is in .gitignore ───────────────────────────────────
|
||
|
||
section "── Step 2: Check .gitignore for .gsd entry ────────────────────────────"
|
||
|
||
GITIGNORE="$REPO_ROOT/.gitignore"
|
||
|
||
if [[ ! -f "$GITIGNORE" ]] && ! $GSD_IS_SYMLINK; then
|
||
ok ".gitignore does not exist — not affected."
|
||
exit 0
|
||
fi
|
||
|
||
# Look for a bare ".gsd" line (not a comment, not a sub-path like .gsd/)
|
||
GSD_IGNORE_LINE=""
|
||
if [[ -f "$GITIGNORE" ]]; then
|
||
while IFS= read -r line; do
|
||
trimmed="${line#"${line%%[![:space:]]*}"}"
|
||
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
|
||
if [[ "$trimmed" == ".gsd" ]] && [[ "${trimmed:0:1}" != "#" ]]; then
|
||
GSD_IGNORE_LINE="$trimmed"
|
||
break
|
||
fi
|
||
done < "$GITIGNORE"
|
||
fi
|
||
|
||
if $GSD_IS_SYMLINK; then
|
||
# Symlink layout: .gsd SHOULD be ignored (it's external state).
|
||
# Missing = needs adding. Present = correct.
|
||
if [[ -z "$GSD_IGNORE_LINE" ]]; then
|
||
warn '".gsd" missing from .gitignore — will add (migration complete, .gsd/ is external).'
|
||
else
|
||
ok '".gsd" already in .gitignore — correct for external-state layout.'
|
||
fi
|
||
else
|
||
# Real-directory layout: .gsd should NOT be ignored.
|
||
if [[ -z "$GSD_IGNORE_LINE" ]]; then
|
||
ok '".gsd" not found in .gitignore — .gitignore not affected.'
|
||
else
|
||
warn '".gsd" found in .gitignore — this is the bad pattern from #1364.'
|
||
fi
|
||
fi
|
||
|
||
# ─── Step 3: Find deleted .gsd/ tracked files ─────────────────────────────────
|
||
|
||
section "── Step 3: Find deleted .gsd/ files ───────────────────────────────────"
|
||
|
||
# Files showing as deleted in the working tree (tracked in index but missing)
|
||
DELETED_FILES="$(git ls-files --deleted -- '.gsd/*' 2>/dev/null || true)"
|
||
|
||
# Files tracked in HEAD right now
|
||
TRACKED_IN_HEAD="$(git ls-tree -r --name-only HEAD -- '.gsd/' 2>/dev/null || true)"
|
||
|
||
if $GSD_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 "$GSD_IGNORE_LINE" ]]; then
|
||
info "Add .gsd 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} .gsd/ 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="" -- '.gsd/*' 2>/dev/null \
|
||
| grep '^\.gsd' | sort -u || true)"
|
||
|
||
if [[ -z "$TRACKED_IN_HEAD" ]] && [[ -z "$DELETED_FILES" ]] && [[ -z "$DELETED_FROM_HISTORY" ]]; then
|
||
ok "No .gsd/ files tracked in this repo — not affected by #1364."
|
||
if [[ -n "$GSD_IGNORE_LINE" ]]; then
|
||
warn '".gsd" 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} .gsd/ 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} .gsd/ 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} .gsd/ 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 "$GSD_IGNORE_LINE" ]]; then
|
||
ok "No action needed — .gsd/ is tracked in HEAD and .gitignore is clean."
|
||
exit 0
|
||
fi
|
||
info ".gsd/ 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 $GSD_IS_SYMLINK; then
|
||
info "Scenario C: symlink layout — skipping commit history scan (no file restore needed)."
|
||
else
|
||
# Find the commit where ".gsd" was first added to .gitignore
|
||
# by walking the log and finding the first commit where .gitignore contained ".gsd"
|
||
info "Scanning git log to find when .gsd was added to .gitignore..."
|
||
|
||
# Strategy 1: find the first commit that added ".gsd" to .gitignore
|
||
while IFS= read -r sha; do
|
||
content="$(git show "${sha}:.gitignore" 2>/dev/null || true)"
|
||
if echo "$content" | grep -qx '\.gsd' 2>/dev/null; then
|
||
DAMAGE_COMMIT="$sha"
|
||
break
|
||
fi
|
||
done < <(git log --format="%H" -- .gitignore)
|
||
|
||
# Strategy 2: if .gsd files were committed as deleted, find that commit
|
||
if [[ -z "$DAMAGE_COMMIT" ]] && [[ -n "${DELETED_FROM_HISTORY:-}" ]]; then
|
||
info "Searching for the commit that deleted .gsd/ files from the index..."
|
||
DAMAGE_COMMIT="$(git log --all --diff-filter=D --format="%H" -- '.gsd/*' 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 .gsd/ files
|
||
RESTORABLE="$(git ls-tree -r --name-only "$CLEAN_COMMIT" -- '.gsd/' 2>/dev/null || true)"
|
||
if [[ -z "$RESTORABLE" ]]; then
|
||
die "No .gsd/ 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} .gsd/ files available."
|
||
fi
|
||
|
||
# ─── Step 5: Clean index (Scenario C) or restore deleted files (Scenario A/B) ─
|
||
|
||
if $GSD_IS_SYMLINK; then
|
||
section "── Step 5: Clean stale git index entries ───────────────────────────────"
|
||
|
||
info "Running: git rm -r --cached --ignore-unmatch .gsd/ ..."
|
||
run "git rm -r --cached --ignore-unmatch .gsd"
|
||
if ! $DRY_RUN; then
|
||
STILL_STALE="$(git ls-files --deleted -- '.gsd/*' 2>/dev/null || true)"
|
||
if [[ -z "$STILL_STALE" ]]; then
|
||
ok "Git index cleaned — no stale .gsd/ 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 .gsd/ 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 .gsd/ files from $CLEAN_COMMIT..."
|
||
run "git checkout \"$CLEAN_COMMIT\" -- .gsd/"
|
||
if ! $DRY_RUN; then
|
||
STILL_MISSING="$(git ls-files --deleted -- '.gsd/*' 2>/dev/null || true)"
|
||
if [[ -z "$STILL_MISSING" ]]; then
|
||
ok "All .gsd/ 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 $GSD_IS_SYMLINK; then
|
||
# Scenario C: .gsd IS external — it should be in .gitignore. Add if missing.
|
||
if [[ -z "$GSD_IGNORE_LINE" ]]; then
|
||
info 'Adding ".gsd" to .gitignore (migration complete — .gsd/ is external state)...'
|
||
if $DRY_RUN; then
|
||
echo -e " ${YELLOW}(dry-run)${RESET} Would append: .gsd"
|
||
else
|
||
printf '\n# GSD external state (symlink — added by recover-gsd-1364)\n.gsd\n' >> "$GITIGNORE"
|
||
ok '".gsd" added to .gitignore.'
|
||
fi
|
||
else
|
||
ok '".gsd" already in .gitignore — correct for external-state layout.'
|
||
fi
|
||
else
|
||
# Scenario A/B: .gsd is a real tracked directory — remove the bad ignore line.
|
||
if [[ -z "$GSD_IGNORE_LINE" ]]; then
|
||
ok '".gsd" not in .gitignore — nothing to fix.'
|
||
else
|
||
info 'Removing bare ".gsd" line from .gitignore...'
|
||
if $DRY_RUN; then
|
||
echo -e " ${YELLOW}(dry-run)${RESET} Would remove line: .gsd"
|
||
else
|
||
# Remove the exact line ".gsd" (not comments, not .gsd/ subdirs)
|
||
# Use a temp file for portability (no sed -i on all platforms)
|
||
TMP="$(mktemp)"
|
||
grep -v '^\.gsd$' "$GITIGNORE" > "$TMP" || true
|
||
mv "$TMP" "$GITIGNORE"
|
||
ok '".gsd" 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 -- '.gsd/' .gitignore 2>/dev/null || true)"
|
||
if [[ -z "$CHANGED" ]]; then
|
||
ok "No staged changes — working tree was already clean."
|
||
else
|
||
if $GSD_IS_SYMLINK; then
|
||
# Scenario C: the git rm --cached already staged the index cleanup.
|
||
# Only stage .gitignore — adding .gsd/ would fail (now gitignored).
|
||
git add .gitignore 2>/dev/null || true
|
||
else
|
||
git add .gsd/ .gitignore 2>/dev/null || true
|
||
fi
|
||
STAGED_COUNT="$(git diff --cached --name-only -- '.gsd/' .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 -- '.gsd/' .gitignore 2>/dev/null | wc -l | tr -d ' ')"
|
||
if (( FINAL_STAGED > 0 )); then
|
||
echo -e "${GREEN}Recovery complete. Commit with:${RESET}"
|
||
echo ""
|
||
if $GSD_IS_SYMLINK; then
|
||
echo " git commit -m \"fix: clean stale .gsd/ index entries after external-state migration\""
|
||
else
|
||
echo " git commit -m \"fix: restore .gsd/ files deleted by #1364 regression\""
|
||
fi
|
||
echo ""
|
||
echo "Staged files:"
|
||
git diff --cached --name-only -- '.gsd/' .gitignore | head -20 | while IFS= read -r f; do
|
||
echo " + $f"
|
||
done
|
||
TOTAL_STAGED="$(git diff --cached --name-only -- '.gsd/' .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
|