fix: recover + prevent #1364 .gsd/ data-loss (v2.30.0–v2.38.0) (#1635)

* 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).
This commit is contained in:
Jeremy McSpadden 2026-03-20 14:26:09 -05:00 committed by GitHub
parent ea4d7d639e
commit c0342c0883
2 changed files with 801 additions and 0 deletions

View file

@ -0,0 +1,415 @@
# recover-gsd-1364.ps1 - Recovery script for issue #1364 (Windows)
#
# 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).
#
# 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:
# powershell -ExecutionPolicy Bypass -File scripts\recover-gsd-1364.ps1 [-DryRun]
#
# Options:
# -DryRun Show what would be done without making any changes
#
# Requirements: git >= 2.x, PowerShell >= 5.1, Git for Windows
[CmdletBinding()]
param(
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# ── Helpers ───────────────────────────────────────────────────────────────────
function Write-Info { param($msg) Write-Host "[info] $msg" -ForegroundColor Cyan }
function Write-Ok { param($msg) Write-Host "[ok] $msg" -ForegroundColor Green }
function Write-Warn { param($msg) Write-Host "[warn] $msg" -ForegroundColor Yellow }
function Write-Err { param($msg) Write-Host "[error] $msg" -ForegroundColor Red }
function Write-Section { param($msg) Write-Host "`n$msg" -ForegroundColor White }
function Exit-Fatal {
param($msg)
Write-Err $msg
exit 1
}
function Invoke-Git {
param([string[]]$Args, [switch]$AllowFailure)
try {
$result = & git @Args 2>&1
if ($LASTEXITCODE -ne 0) {
if ($AllowFailure) { return "" }
throw "git $($Args -join ' ') exited $LASTEXITCODE"
}
return ($result -join "`n").Trim()
} catch {
if ($AllowFailure) { return "" }
throw
}
}
# Run or dry-run a git command
function Invoke-GitOrDryRun {
param([string[]]$GitArgs, [string]$Display)
if ($DryRun) {
Write-Host " (dry-run) git $Display" -ForegroundColor Yellow
} else {
Invoke-Git $GitArgs | Out-Null
}
}
# Check whether a path is a symlink OR a junction (Windows uses junctions for
# the .gsd external-state migration via symlinkSync(..., "junction"))
function Test-ReparsePoint {
param([string]$Path)
if (-not (Test-Path $Path)) { return $false }
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return $false }
# LinkType covers: SymbolicLink, Junction, HardLink
return ($item.LinkType -eq 'SymbolicLink' -or $item.LinkType -eq 'Junction')
}
# ── Preflight ─────────────────────────────────────────────────────────────────
Write-Section "── Preflight ───────────────────────────────────────────────────────"
# Verify git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Exit-Fatal "git not found on PATH. Install Git for Windows from https://git-scm.com"
}
# Must be run from inside a git repo
$gitDirCheck = & git rev-parse --git-dir 2>&1
if ($LASTEXITCODE -ne 0) {
Exit-Fatal "Not inside a git repository. Run this from your project root."
}
$repoRoot = Invoke-Git @('rev-parse', '--show-toplevel')
Set-Location $repoRoot
Write-Info "Repo root: $repoRoot"
if ($DryRun) {
Write-Warn "DRY-RUN mode — no changes will be made."
}
# ── Step 1: Detect .gsd/ ─────────────────────────────────────────────────────
Write-Section "── Step 1: Detect .gsd/ directory ─────────────────────────────────"
$gsdDir = Join-Path $repoRoot '.gsd'
$GsdIsSymlink = $false
if (-not (Test-Path $gsdDir)) {
Write-Ok ".gsd/ does not exist in this repo — not affected."
exit 0
}
if (Test-ReparsePoint $gsdDir) {
# Scenario C: migration succeeded (symlink/junction in place) but git index was never
# cleaned — tracked .gsd/* files still appear as deleted through the reparse point.
$GsdIsSymlink = $true
Write-Warn ".gsd/ is a symlink/junction — checking for stale git index entries (Scenario C)..."
} else {
Write-Info ".gsd/ is a real directory (Scenario A/B)."
}
# ── Step 2: Check .gitignore for .gsd entry ──────────────────────────────────
Write-Section "── Step 2: Check .gitignore for .gsd entry ─────────────────────────"
$gitignorePath = Join-Path $repoRoot '.gitignore'
if (-not (Test-Path $gitignorePath) -and -not $GsdIsSymlink) {
Write-Ok ".gitignore does not exist — not affected."
exit 0
}
$gitignoreLines = @()
$gsdIgnoreLine = $null
if (Test-Path $gitignorePath) {
$gitignoreLines = Get-Content $gitignorePath -Encoding UTF8
$gsdIgnoreLine = $gitignoreLines | Where-Object {
$trimmed = $_.Trim()
$trimmed -eq '.gsd' -and -not $trimmed.StartsWith('#')
} | Select-Object -First 1
}
if ($GsdIsSymlink) {
# Symlink layout: .gsd SHOULD be ignored (it's external state).
if (-not $gsdIgnoreLine) {
Write-Warn '".gsd" missing from .gitignore — will add (migration complete, .gsd/ is external).'
} else {
Write-Ok '".gsd" already in .gitignore — correct for external-state layout.'
}
} else {
# Real-directory layout: .gsd should NOT be ignored.
if (-not $gsdIgnoreLine) {
Write-Ok '".gsd" not found in .gitignore — .gitignore not affected.'
} else {
Write-Warn '".gsd" found in .gitignore — this is the bad pattern from #1364.'
}
}
# ── Step 3: Find deleted .gsd/ files ─────────────────────────────────────────
Write-Section "── Step 3: Find deleted .gsd/ files ───────────────────────────────"
# Files deleted in working tree (tracked but missing)
$deletedRaw = Invoke-Git @('ls-files', '--deleted', '--', '.gsd/*') -AllowFailure
$deletedFiles = if ($deletedRaw) { $deletedRaw -split "`n" | Where-Object { $_ } } else { @() }
# Files tracked in HEAD right now
$trackedInHeadRaw = Invoke-Git @('ls-tree', '-r', '--name-only', 'HEAD', '--', '.gsd/') -AllowFailure
$trackedInHead = if ($trackedInHeadRaw) { $trackedInHeadRaw -split "`n" | Where-Object { $_ } } else { @() }
$deletedFromHistory = @()
if ($GsdIsSymlink) {
# Scenario C: migration succeeded. Files are safe via reparse point.
# Only index entries can be stale — no need to scan commit history.
if ($trackedInHead.Count -eq 0 -and $deletedFiles.Count -eq 0) {
Write-Ok "No stale index entries found — symlink/junction layout is healthy."
if (-not $gsdIgnoreLine) {
Write-Info "Add .gsd to .gitignore manually to complete the migration."
}
exit 0
}
$indexCount = if ($trackedInHead.Count -gt 0) { $trackedInHead.Count } else { $deletedFiles.Count }
Write-Warn "Scenario C: $indexCount .gsd/ file(s) tracked in git index but inaccessible through reparse point."
Write-Info "Files are safe in external storage — only the git index needs cleaning."
} else {
# Files deleted in committed history (post-commit damage scenario — Scenario B)
$deletedHistoryRaw = Invoke-Git @('log', '--all', '--diff-filter=D', '--name-only', '--format=', '--', '.gsd/*') -AllowFailure
$deletedFromHistory = if ($deletedHistoryRaw) {
$deletedHistoryRaw -split "`n" | Where-Object { $_ -match '^\.gsd' } | Sort-Object -Unique
} else { @() }
# Nothing was ever tracked in any scenario
if ($trackedInHead.Count -eq 0 -and $deletedFiles.Count -eq 0 -and $deletedFromHistory.Count -eq 0) {
Write-Ok "No .gsd/ files tracked in this repo — not affected by #1364."
if ($gsdIgnoreLine) {
Write-Warn '".gsd" is still in .gitignore but there is nothing to restore.'
}
exit 0
}
# Determine scenario
if ($trackedInHead.Count -gt 0) {
Write-Info "Scenario A: $($trackedInHead.Count) .gsd/ files still tracked in HEAD."
} elseif ($deletedFromHistory.Count -gt 0) {
Write-Warn "Scenario B: $($deletedFromHistory.Count) .gsd/ file(s) were tracked but deleted in a committed change:"
$deletedFromHistory | Select-Object -First 20 | ForEach-Object { Write-Host " - $_" }
if ($deletedFromHistory.Count -gt 20) {
Write-Host " ... and $($deletedFromHistory.Count - 20) more"
}
}
if ($deletedFiles.Count -gt 0) {
Write-Warn "$($deletedFiles.Count) .gsd/ file(s) are missing from working tree (tracked but deleted/gitignored):"
$deletedFiles | Select-Object -First 20 | ForEach-Object { Write-Host " - $_" }
if ($deletedFiles.Count -gt 20) {
Write-Host " ... and $($deletedFiles.Count - 20) more"
}
}
# HEAD has files and working tree is clean — only .gitignore needs fixing
if ($trackedInHead.Count -gt 0 -and $deletedFiles.Count -eq 0) {
if (-not $gsdIgnoreLine) {
Write-Ok "No action needed — .gsd/ is tracked in HEAD and .gitignore is clean."
exit 0
}
Write-Info ".gsd/ is tracked in HEAD and working tree is clean — only .gitignore needs fixing."
}
}
# ── Step 4: Find last clean commit (Scenario A/B only) ───────────────────────
Write-Section "── Step 4: Find last clean commit ──────────────────────────────────"
$damageCommit = $null
$cleanCommit = $null
$restorableFiles = @()
if ($GsdIsSymlink) {
Write-Info "Scenario C: symlink/junction layout — skipping commit history scan (no file restore needed)."
} else {
Write-Info "Scanning git log to find when .gsd was added to .gitignore..."
# Strategy 1: find first commit that added ".gsd" to .gitignore
$gitignoreCommits = Invoke-Git @('log', '--format=%H', '--', '.gitignore') -AllowFailure
if ($gitignoreCommits) {
foreach ($sha in ($gitignoreCommits -split "`n" | Where-Object { $_ })) {
$content = Invoke-Git @('show', "${sha}:.gitignore") -AllowFailure
if ($content -and ($content -split "`n" | Where-Object { $_.Trim() -eq '.gsd' })) {
$damageCommit = $sha
break
}
}
}
# Strategy 2: find commit that deleted .gsd/ files
if (-not $damageCommit -and $deletedFromHistory.Count -gt 0) {
Write-Info "Searching for the commit that deleted .gsd/ files from the index..."
$deleteCommits = Invoke-Git @('log', '--all', '--diff-filter=D', '--format=%H', '--', '.gsd/*') -AllowFailure
if ($deleteCommits) {
$damageCommit = ($deleteCommits -split "`n" | Where-Object { $_ } | Select-Object -First 1)
}
}
if (-not $damageCommit) {
Write-Warn "Could not pinpoint the damage commit — falling back to HEAD."
$cleanCommit = 'HEAD'
} else {
$damageMsg = Invoke-Git @('log', '--format=%s', '-1', $damageCommit) -AllowFailure
Write-Info "Damage commit: $damageCommit ($damageMsg)"
$cleanCommit = "${damageCommit}^"
$cleanMsg = Invoke-Git @('log', '--format=%s', '-1', $cleanCommit) -AllowFailure
if (-not $cleanMsg) { $cleanMsg = 'unknown' }
Write-Info "Restoring from: $cleanCommit$cleanMsg"
}
# Verify restore point has .gsd/ files
$restorable = Invoke-Git @('ls-tree', '-r', '--name-only', $cleanCommit, '--', '.gsd/') -AllowFailure
$restorableFiles = if ($restorable) { $restorable -split "`n" | Where-Object { $_ } } else { @() }
if ($restorableFiles.Count -eq 0) {
Exit-Fatal "No .gsd/ files found in restore point $cleanCommit — cannot recover. Check git log manually."
}
Write-Ok "Restore point has $($restorableFiles.Count) .gsd/ files available."
}
# ── Step 5: Clean index (Scenario C) or restore deleted files (Scenario A/B) ─
if ($GsdIsSymlink) {
Write-Section "── Step 5: Clean stale git index entries ───────────────────────────"
Write-Info "Running: git rm -r --cached --ignore-unmatch .gsd/ ..."
Invoke-GitOrDryRun -GitArgs @('rm', '-r', '--cached', '--ignore-unmatch', '.gsd') -Display "rm -r --cached --ignore-unmatch .gsd"
if (-not $DryRun) {
$stillStaleRaw = Invoke-Git @('ls-files', '--deleted', '--', '.gsd/*') -AllowFailure
$stillStale = if ($stillStaleRaw) { $stillStaleRaw -split "`n" | Where-Object { $_ } } else { @() }
if ($stillStale.Count -eq 0) {
Write-Ok "Git index cleaned — no stale .gsd/ entries remain."
} else {
Write-Warn "$($stillStale.Count) stale entr(ies) still present — may need manual cleanup."
}
}
} else {
Write-Section "── Step 5: Restore deleted .gsd/ files ────────────────────────────"
$needsRestore = ($deletedFiles.Count -gt 0) -or ($deletedFromHistory.Count -gt 0 -and $trackedInHead.Count -eq 0)
if (-not $needsRestore) {
Write-Ok "No deleted files to restore — skipping."
} else {
Write-Info "Restoring .gsd/ files from $cleanCommit..."
Invoke-GitOrDryRun -GitArgs @('checkout', $cleanCommit, '--', '.gsd/') -Display "checkout $cleanCommit -- .gsd/"
if (-not $DryRun) {
$stillMissingRaw = Invoke-Git @('ls-files', '--deleted', '--', '.gsd/*') -AllowFailure
$stillMissing = if ($stillMissingRaw) { $stillMissingRaw -split "`n" | Where-Object { $_ } } else { @() }
if ($stillMissing.Count -eq 0) {
Write-Ok "All .gsd/ files restored successfully."
} else {
Write-Warn "$($stillMissing.Count) file(s) still missing after restore — may need manual recovery:"
$stillMissing | Select-Object -First 10 | ForEach-Object { Write-Host " - $_" }
}
}
}
}
# ── Step 6: Fix .gitignore ────────────────────────────────────────────────────
Write-Section "── Step 6: Fix .gitignore ──────────────────────────────────────────"
if ($GsdIsSymlink) {
# Scenario C: .gsd IS external — it should be in .gitignore. Add if missing.
if (-not $gsdIgnoreLine) {
Write-Info 'Adding ".gsd" to .gitignore (migration complete — .gsd/ is external state)...'
if ($DryRun) {
Write-Host " (dry-run) Would append: .gsd" -ForegroundColor Yellow
} else {
$appendLines = @('', '# GSD external state (symlink/junction — added by recover-gsd-1364)', '.gsd')
Add-Content -LiteralPath $gitignorePath -Value $appendLines -Encoding UTF8
Write-Ok '".gsd" added to .gitignore.'
}
} else {
Write-Ok '".gsd" already in .gitignore — correct for external-state layout.'
}
} else {
# Scenario A/B: .gsd is a real tracked directory — remove the bad ignore line.
if (-not $gsdIgnoreLine) {
Write-Ok '".gsd" not in .gitignore — nothing to fix.'
} else {
Write-Info 'Removing bare ".gsd" line from .gitignore...'
if ($DryRun) {
Write-Host " (dry-run) Would remove line: .gsd" -ForegroundColor Yellow
} else {
# Filter out the exact bare ".gsd" line — preserve all other content including
# sub-path patterns like ".gsd/", ".gsd/activity/" and comments
$cleaned = $gitignoreLines | Where-Object { $_.Trim() -ne '.gsd' }
# Write with UTF-8 no BOM to match git's expectations
[System.IO.File]::WriteAllLines($gitignorePath, $cleaned, [System.Text.UTF8Encoding]::new($false))
Write-Ok '".gsd" line removed from .gitignore.'
}
}
}
# ── Step 7: Stage changes ─────────────────────────────────────────────────────
Write-Section "── Step 7: Stage recovery changes ──────────────────────────────────"
if (-not $DryRun) {
$changed = Invoke-Git @('status', '--short', '--', '.gsd/', '.gitignore') -AllowFailure
if (-not $changed) {
Write-Ok "No staged changes — working tree was already clean."
} else {
if ($GsdIsSymlink) {
# Scenario C: git rm --cached already staged the index cleanup.
# Only stage .gitignore — adding .gsd/ would fail (now gitignored).
Invoke-Git @('add', '.gitignore') -AllowFailure | Out-Null
} else {
Invoke-Git @('add', '.gsd/', '.gitignore') -AllowFailure | Out-Null
}
$stagedRaw = Invoke-Git @('diff', '--cached', '--name-only', '--', '.gsd/', '.gitignore') -AllowFailure
$stagedFiles = if ($stagedRaw) { $stagedRaw -split "`n" | Where-Object { $_ } } else { @() }
Write-Ok "$($stagedFiles.Count) file(s) staged and ready to commit."
}
}
# ── Summary ───────────────────────────────────────────────────────────────────
Write-Section "── Summary ──────────────────────────────────────────────────────────"
if ($DryRun) {
Write-Host "Dry-run complete. Re-run without -DryRun to apply changes." -ForegroundColor Yellow
} else {
$finalStagedRaw = Invoke-Git @('diff', '--cached', '--name-only', '--', '.gsd/', '.gitignore') -AllowFailure
$finalStaged = if ($finalStagedRaw) { $finalStagedRaw -split "`n" | Where-Object { $_ } } else { @() }
if ($finalStaged.Count -gt 0) {
Write-Host "Recovery complete. Commit with:" -ForegroundColor Green
Write-Host ""
if ($GsdIsSymlink) {
Write-Host ' git commit -m "fix: clean stale .gsd/ index entries after external-state migration"'
} else {
Write-Host ' git commit -m "fix: restore .gsd/ files deleted by #1364 regression"'
}
Write-Host ""
Write-Host "Staged files:"
$finalStaged | Select-Object -First 20 | ForEach-Object { Write-Host " + $_" }
if ($finalStaged.Count -gt 20) {
Write-Host " ... and $($finalStaged.Count - 20) more"
}
} else {
Write-Ok "Repo is healthy — no recovery needed."
}
}

386
scripts/recover-gsd-1364.sh Executable file
View file

@ -0,0 +1,386 @@
#!/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.02.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.0v2.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