* 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:
parent
ea4d7d639e
commit
c0342c0883
2 changed files with 801 additions and 0 deletions
415
scripts/recover-gsd-1364.ps1
Normal file
415
scripts/recover-gsd-1364.ps1
Normal 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
386
scripts/recover-gsd-1364.sh
Executable 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.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
|
||||
Loading…
Add table
Reference in a new issue