Updates channel prefixes, log messages, comments, and configuration values across daemon, mcp-server, and related packages to complete the rebrand from gsd to sf-run naming. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
415 lines
19 KiB
PowerShell
415 lines
19 KiB
PowerShell
# recover-sf-1364.ps1 - Recovery script for issue #1364 (Windows)
|
|
#
|
|
# CRITICAL DATA-LOSS BUG: SF versions 2.30.0-2.35.x unconditionally added
|
|
# ".sf" to .gitignore via ensureGitignore(), causing git to report all
|
|
# tracked .sf/ files as deleted. Fixed in v2.36.0 (PR #1367).
|
|
#
|
|
# This script:
|
|
# 1. Detects whether the repo was affected
|
|
# 2. Finds the last clean commit before the damage
|
|
# 3. Restores all deleted .sf/ files from that commit
|
|
# 4. Removes the bad ".sf" line from .gitignore (if .sf/ is tracked)
|
|
# 5. Prints a ready-to-commit summary
|
|
#
|
|
# Usage:
|
|
# powershell -ExecutionPolicy Bypass -File scripts\recover-sf-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 .sf 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 .sf/ ─────────────────────────────────────────────────────
|
|
|
|
Write-Section "── Step 1: Detect .sf/ directory ─────────────────────────────────"
|
|
|
|
$sfDir = Join-Path $repoRoot '.sf'
|
|
$GsdIsSymlink = $false
|
|
|
|
if (-not (Test-Path $sfDir)) {
|
|
Write-Ok ".sf/ does not exist in this repo — not affected."
|
|
exit 0
|
|
}
|
|
|
|
if (Test-ReparsePoint $sfDir) {
|
|
# Scenario C: migration succeeded (symlink/junction in place) but git index was never
|
|
# cleaned — tracked .sf/* files still appear as deleted through the reparse point.
|
|
$GsdIsSymlink = $true
|
|
Write-Warn ".sf/ is a symlink/junction — checking for stale git index entries (Scenario C)..."
|
|
} else {
|
|
Write-Info ".sf/ is a real directory (Scenario A/B)."
|
|
}
|
|
|
|
# ── Step 2: Check .gitignore for .sf entry ──────────────────────────────────
|
|
|
|
Write-Section "── Step 2: Check .gitignore for .sf 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 '.sf' -and -not $trimmed.StartsWith('#')
|
|
} | Select-Object -First 1
|
|
}
|
|
|
|
if ($GsdIsSymlink) {
|
|
# Symlink layout: .sf SHOULD be ignored (it's external state).
|
|
if (-not $gsdIgnoreLine) {
|
|
Write-Warn '".sf" missing from .gitignore — will add (migration complete, .sf/ is external).'
|
|
} else {
|
|
Write-Ok '".sf" already in .gitignore — correct for external-state layout.'
|
|
}
|
|
} else {
|
|
# Real-directory layout: .sf should NOT be ignored.
|
|
if (-not $gsdIgnoreLine) {
|
|
Write-Ok '".sf" not found in .gitignore — .gitignore not affected.'
|
|
} else {
|
|
Write-Warn '".sf" found in .gitignore — this is the bad pattern from #1364.'
|
|
}
|
|
}
|
|
|
|
# ── Step 3: Find deleted .sf/ files ─────────────────────────────────────────
|
|
|
|
Write-Section "── Step 3: Find deleted .sf/ files ───────────────────────────────"
|
|
|
|
# Files deleted in working tree (tracked but missing)
|
|
$deletedRaw = Invoke-Git @('ls-files', '--deleted', '--', '.sf/*') -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', '--', '.sf/') -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 .sf 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 .sf/ 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=', '--', '.sf/*') -AllowFailure
|
|
$deletedFromHistory = if ($deletedHistoryRaw) {
|
|
$deletedHistoryRaw -split "`n" | Where-Object { $_ -match '^\.sf' } | 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 .sf/ files tracked in this repo — not affected by #1364."
|
|
if ($gsdIgnoreLine) {
|
|
Write-Warn '".sf" 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) .sf/ files still tracked in HEAD."
|
|
} elseif ($deletedFromHistory.Count -gt 0) {
|
|
Write-Warn "Scenario B: $($deletedFromHistory.Count) .sf/ 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) .sf/ 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 — .sf/ is tracked in HEAD and .gitignore is clean."
|
|
exit 0
|
|
}
|
|
Write-Info ".sf/ 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 .sf was added to .gitignore..."
|
|
|
|
# Strategy 1: find first commit that added ".sf" 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 '.sf' })) {
|
|
$damageCommit = $sha
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
# Strategy 2: find commit that deleted .sf/ files
|
|
if (-not $damageCommit -and $deletedFromHistory.Count -gt 0) {
|
|
Write-Info "Searching for the commit that deleted .sf/ files from the index..."
|
|
$deleteCommits = Invoke-Git @('log', '--all', '--diff-filter=D', '--format=%H', '--', '.sf/*') -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 .sf/ files
|
|
$restorable = Invoke-Git @('ls-tree', '-r', '--name-only', $cleanCommit, '--', '.sf/') -AllowFailure
|
|
$restorableFiles = if ($restorable) { $restorable -split "`n" | Where-Object { $_ } } else { @() }
|
|
|
|
if ($restorableFiles.Count -eq 0) {
|
|
Exit-Fatal "No .sf/ files found in restore point $cleanCommit — cannot recover. Check git log manually."
|
|
}
|
|
|
|
Write-Ok "Restore point has $($restorableFiles.Count) .sf/ 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 .sf/ ..."
|
|
Invoke-GitOrDryRun -GitArgs @('rm', '-r', '--cached', '--ignore-unmatch', '.sf') -Display "rm -r --cached --ignore-unmatch .sf"
|
|
|
|
if (-not $DryRun) {
|
|
$stillStaleRaw = Invoke-Git @('ls-files', '--deleted', '--', '.sf/*') -AllowFailure
|
|
$stillStale = if ($stillStaleRaw) { $stillStaleRaw -split "`n" | Where-Object { $_ } } else { @() }
|
|
if ($stillStale.Count -eq 0) {
|
|
Write-Ok "Git index cleaned — no stale .sf/ entries remain."
|
|
} else {
|
|
Write-Warn "$($stillStale.Count) stale entr(ies) still present — may need manual cleanup."
|
|
}
|
|
}
|
|
} else {
|
|
Write-Section "── Step 5: Restore deleted .sf/ 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 .sf/ files from $cleanCommit..."
|
|
Invoke-GitOrDryRun -GitArgs @('checkout', $cleanCommit, '--', '.sf/') -Display "checkout $cleanCommit -- .sf/"
|
|
|
|
if (-not $DryRun) {
|
|
$stillMissingRaw = Invoke-Git @('ls-files', '--deleted', '--', '.sf/*') -AllowFailure
|
|
$stillMissing = if ($stillMissingRaw) { $stillMissingRaw -split "`n" | Where-Object { $_ } } else { @() }
|
|
if ($stillMissing.Count -eq 0) {
|
|
Write-Ok "All .sf/ 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: .sf IS external — it should be in .gitignore. Add if missing.
|
|
if (-not $gsdIgnoreLine) {
|
|
Write-Info 'Adding ".sf" to .gitignore (migration complete — .sf/ is external state)...'
|
|
if ($DryRun) {
|
|
Write-Host " (dry-run) Would append: .sf" -ForegroundColor Yellow
|
|
} else {
|
|
$appendLines = @('', '# SF external state (symlink/junction — added by recover-sf-1364)', '.sf')
|
|
Add-Content -LiteralPath $gitignorePath -Value $appendLines -Encoding UTF8
|
|
Write-Ok '".sf" added to .gitignore.'
|
|
}
|
|
} else {
|
|
Write-Ok '".sf" already in .gitignore — correct for external-state layout.'
|
|
}
|
|
} else {
|
|
# Scenario A/B: .sf is a real tracked directory — remove the bad ignore line.
|
|
if (-not $gsdIgnoreLine) {
|
|
Write-Ok '".sf" not in .gitignore — nothing to fix.'
|
|
} else {
|
|
Write-Info 'Removing bare ".sf" line from .gitignore...'
|
|
if ($DryRun) {
|
|
Write-Host " (dry-run) Would remove line: .sf" -ForegroundColor Yellow
|
|
} else {
|
|
# Filter out the exact bare ".sf" line — preserve all other content including
|
|
# sub-path patterns like ".sf/", ".sf/activity/" and comments
|
|
$cleaned = $gitignoreLines | Where-Object { $_.Trim() -ne '.sf' }
|
|
# Write with UTF-8 no BOM to match git's expectations
|
|
[System.IO.File]::WriteAllLines($gitignorePath, $cleaned, [System.Text.UTF8Encoding]::new($false))
|
|
Write-Ok '".sf" line removed from .gitignore.'
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── Step 7: Stage changes ─────────────────────────────────────────────────────
|
|
|
|
Write-Section "── Step 7: Stage recovery changes ──────────────────────────────────"
|
|
|
|
if (-not $DryRun) {
|
|
$changed = Invoke-Git @('status', '--short', '--', '.sf/', '.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 .sf/ would fail (now gitignored).
|
|
Invoke-Git @('add', '.gitignore') -AllowFailure | Out-Null
|
|
} else {
|
|
Invoke-Git @('add', '.sf/', '.gitignore') -AllowFailure | Out-Null
|
|
}
|
|
$stagedRaw = Invoke-Git @('diff', '--cached', '--name-only', '--', '.sf/', '.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', '--', '.sf/', '.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 .sf/ index entries after external-state migration"'
|
|
} else {
|
|
Write-Host ' git commit -m "fix: restore .sf/ 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."
|
|
}
|
|
}
|