diff --git a/Dockerfile b/Dockerfile index 82168e867..743615415 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # ────────────────────────────────────────────── # Runtime -# Image: ghcr.io/sf-build/sf-run +# Image: ghcr.io/singularity-ng/singularity-foundry # Used by: end users via docker run # ────────────────────────────────────────────── FROM node:24-slim AS runtime diff --git a/flake.nix b/flake.nix index 805f532ff..f9022962d 100644 --- a/flake.nix +++ b/flake.nix @@ -25,6 +25,7 @@ rust-analyzer rustc rustfmt + uv ]; shellHook = '' diff --git a/package-lock.json b/package-lock.json index 1f7c6e6ab..c8227dd11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sf-run", - "version": "2.75.0", + "version": "2.74.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sf-run", - "version": "2.75.0", + "version": "2.74.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -3177,6 +3177,21 @@ "resolved": "packages/daemon", "link": true }, + "node_modules/@singularity-forge/engine-darwin-arm64": { + "optional": true + }, + "node_modules/@singularity-forge/engine-darwin-x64": { + "optional": true + }, + "node_modules/@singularity-forge/engine-linux-arm64-gnu": { + "optional": true + }, + "node_modules/@singularity-forge/engine-linux-x64-gnu": { + "optional": true + }, + "node_modules/@singularity-forge/engine-win32-x64-msvc": { + "optional": true + }, "node_modules/@singularity-forge/mcp-server": { "resolved": "packages/mcp-server", "link": true @@ -9537,11 +9552,11 @@ }, "packages/daemon": { "name": "@singularity-forge/daemon", - "version": "2.75.0", + "version": "2.74.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.52.0", - "@singularity-forge/rpc-client": "^2.75.0", + "@singularity-forge/rpc-client": "^2.74.0", "discord.js": "^14.25.1", "yaml": "^2.8.0", "zod": "^3.24.0" @@ -9577,11 +9592,11 @@ }, "packages/mcp-server": { "name": "@singularity-forge/mcp-server", - "version": "2.75.0", + "version": "2.74.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", - "@singularity-forge/rpc-client": "^2.75.0", + "@singularity-forge/rpc-client": "^2.74.0", "zod": "^4.0.0" }, "bin": { @@ -9597,16 +9612,16 @@ }, "packages/native": { "name": "@singularity-forge/native", - "version": "2.75.0", + "version": "2.74.0", "license": "MIT" }, "packages/pi-agent-core": { "name": "@singularity-forge/pi-agent-core", - "version": "2.75.0" + "version": "2.74.0" }, "packages/pi-ai": { "name": "@singularity-forge/pi-ai", - "version": "2.75.0", + "version": "2.74.0", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@anthropic-ai/vertex-sdk": "^0.14.4", @@ -9645,7 +9660,7 @@ }, "packages/pi-coding-agent": { "name": "@singularity-forge/pi-coding-agent", - "version": "2.75.0", + "version": "2.74.0", "dependencies": { "@mariozechner/jiti": "^2.6.2", "@silvia-odwyer/photon-node": "^0.3.4", @@ -9966,7 +9981,7 @@ }, "packages/pi-tui": { "name": "@singularity-forge/pi-tui", - "version": "2.75.0", + "version": "2.74.0", "dependencies": { "chalk": "^5.6.2", "get-east-asian-width": "^1.3.0", @@ -9982,7 +9997,7 @@ }, "packages/rpc-client": { "name": "@singularity-forge/rpc-client", - "version": "2.75.0", + "version": "2.74.0", "license": "MIT", "engines": { "node": ">=22.0.0" diff --git a/package.json b/package.json index 30411c1b3..870835f62 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,11 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/singularity-forge/sf-run.git" + "url": "https://github.com/singularity-ng/singularity-foundry.git" }, - "homepage": "https://github.com/singularity-forge/sf-run#readme", + "homepage": "https://github.com/singularity-ng/singularity-foundry#readme", "bugs": { - "url": "https://github.com/singularity-forge/sf-run/issues" + "url": "https://github.com/singularity-ng/singularity-foundry/issues" }, "type": "module", "workspaces": [ diff --git a/packages/pi-coding-agent/src/utils/proxy-server.ts b/packages/pi-coding-agent/src/utils/proxy-server.ts index cf6cfc521..a2c5f6bff 100644 --- a/packages/pi-coding-agent/src/utils/proxy-server.ts +++ b/packages/pi-coding-agent/src/utils/proxy-server.ts @@ -20,6 +20,37 @@ export type ProxyServerOptions = { onLog?: (msg: string) => void; }; +// Per-family provider priority for bare model ID resolution. When the same model ID +// exists across multiple providers, the first matching family rule wins; within that +// rule providers are tried in order, preferring those with auth configured. Providers +// not listed in any rule fall back to insertion order. +const PROXY_FAMILY_PRIORITY: Array<{ match: RegExp; providers: string[] }> = [ + // MiniMax: international direct > CN endpoint + { match: /^MiniMax-/i, providers: ["minimax", "minimax-cn"] }, + // GLM: zai is the canonical direct provider > opencode aggregators + { match: /^glm-/i, providers: ["zai", "opencode", "opencode-go"] }, + // Kimi: kimi-coding direct > opencode aggregators + { match: /^kimi-/i, providers: ["kimi-coding", "opencode", "opencode-go"] }, + // Gemini/Gemma: google direct > vertex (enterprise) > CLI (OAuth) > copilot + { match: /^gemini-|^gemma-/i, providers: ["google", "google-vertex", "google-gemini-cli", "github-copilot"] }, + // Claude: anthropic direct > opencode > google-antigravity > copilot + { match: /^claude-/i, providers: ["anthropic", "opencode", "google-antigravity", "github-copilot"] }, + // GPT/OpenAI: openai direct > azure > copilot + { match: /^gpt-|^o[0-9]|^codex-/i, providers: ["openai", "azure-openai-responses", "github-copilot"] }, +]; + +function sortByFamilyPriority(models: T[]): T[] { + if (models.length <= 1) return models; + const [first] = models; + const rule = PROXY_FAMILY_PRIORITY.find((r) => r.match.test(first.id)); + const order = rule?.providers ?? []; + return [...models].sort((a, b) => { + const pa = order.indexOf(a.provider); + const pb = order.indexOf(b.provider); + return (pa === -1 ? Infinity : pa) - (pb === -1 ? Infinity : pb); + }); +} + export class ProxyServer { private server: Server | null = null; diff --git a/scripts/link-workspace-packages.cjs b/scripts/link-workspace-packages.cjs index f48ee293f..e84c7eacc 100644 --- a/scripts/link-workspace-packages.cjs +++ b/scripts/link-workspace-packages.cjs @@ -2,8 +2,8 @@ /** * link-workspace-packages.cjs * - * Creates node_modules/@singularity-forge/* and node_modules/@singularity-forge/* symlinks pointing - * to shipped packages/* directories. + * Creates node_modules/@singularity-forge/* symlinks pointing to shipped + * packages/* directories. * * During development, npm workspaces creates these automatically. But in the * published tarball, workspace packages are shipped under packages/ (via the @@ -21,34 +21,29 @@ const { resolve, join } = require('path') const root = resolve(__dirname, '..') const packagesDir = join(root, 'packages') -const scopeDirs = { - '@singularity-forge': join(root, 'node_modules', '@singularity-forge'), - '@singularity-forge': join(root, 'node_modules', '@singularity-forge'), -} +const scope = '@singularity-forge' +const scopeDir = join(root, 'node_modules', scope) -// Map directory names to scoped package names -const packageMap = { - 'native': { scope: '@singularity-forge', name: 'native' }, - 'pi-agent-core': { scope: '@singularity-forge', name: 'pi-agent-core' }, - 'pi-ai': { scope: '@singularity-forge', name: 'pi-ai' }, - 'pi-coding-agent': { scope: '@singularity-forge', name: 'pi-coding-agent' }, - 'pi-tui': { scope: '@singularity-forge', name: 'pi-tui' }, - 'rpc-client': { scope: '@singularity-forge', name: 'rpc-client' }, - 'mcp-server': { scope: '@singularity-forge', name: 'mcp-server' }, -} +// Directory names under packages/ that should be linked as @singularity-forge/ +const packageDirs = [ + 'native', + 'pi-agent-core', + 'pi-ai', + 'pi-coding-agent', + 'pi-tui', + 'rpc-client', + 'mcp-server', +] -for (const scopeDir of Object.values(scopeDirs)) { - if (!existsSync(scopeDir)) { - mkdirSync(scopeDir, { recursive: true }) - } +if (!existsSync(scopeDir)) { + mkdirSync(scopeDir, { recursive: true }) } let linked = 0 let copied = 0 -for (const [dir, pkg] of Object.entries(packageMap)) { +for (const dir of packageDirs) { const source = join(packagesDir, dir) - const scopeDir = scopeDirs[pkg.scope] - const target = join(scopeDir, pkg.name) + const target = join(scopeDir, dir) if (!existsSync(source)) continue diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 4efe3d463..611957fdc 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -16,10 +16,6 @@ const PLAYWRIGHT_SKIP = process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1' || process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === 'true' const RTK_SKIP = - process.env.SF_SKIP_RTK_INSTALL === '1' || - process.env.SF_SKIP_RTK_INSTALL === 'true' || - process.env.SF_RTK_DISABLED === '1' || - process.env.SF_RTK_DISABLED === 'true' || process.env.SF_SKIP_RTK_INSTALL === '1' || process.env.SF_SKIP_RTK_INSTALL === 'true' || process.env.SF_RTK_DISABLED === '1' || @@ -28,7 +24,7 @@ const RTK_SKIP = const RTK_VERSION = '0.33.1' const RTK_REPO = 'rtk-ai/rtk' const RTK_ENV = { ...process.env, RTK_TELEMETRY_DISABLED: '1' } -const managedBinDir = join(process.env.SF_HOME || process.env.SF_HOME || join(homedir(), '.sf'), 'agent', 'bin') +const managedBinDir = join(process.env.SF_HOME || join(homedir(), '.sf'), 'agent', 'bin') const managedBinaryPath = join(managedBinDir, platform() === 'win32' ? 'rtk.exe' : 'rtk') function run(cmd) { diff --git a/scripts/recover-gsd-1364.ps1 b/scripts/recover-gsd-1364.ps1 deleted file mode 100644 index c6c159dae..000000000 --- a/scripts/recover-gsd-1364.ps1 +++ /dev/null @@ -1,415 +0,0 @@ -# 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." - } -} diff --git a/scripts/recover-gsd-1364.sh b/scripts/recover-gsd-1364.sh deleted file mode 100755 index 43bf5e23d..000000000 --- a/scripts/recover-gsd-1364.sh +++ /dev/null @@ -1,386 +0,0 @@ -#!/usr/bin/env bash -# recover-sf-1364.sh — Recovery script for issue #1364 (Linux / macOS) -# -# For Windows use the PowerShell equivalent: -# powershell -ExecutionPolicy Bypass -File scripts\recover-sf-1364.ps1 [-DryRun] -# -# CRITICAL DATA-LOSS BUG: SF versions 2.30.0–2.35.x unconditionally added -# ".sf" to .gitignore via ensureGitignore(), causing git to report all -# tracked .sf/ files as deleted. Fixed in v2.36.0 (PR #1367). -# Three residual vectors remain on v2.36.0–v2.38.0 — see PR #1635 for details. -# -# This script: -# 1. Detects whether the repo was affected -# 2. Finds the last clean commit before the damage -# 3. Restores all deleted .sf/ files from that commit -# 4. Removes the bad ".sf" line from .gitignore (if .sf/ is tracked) -# 5. Prints a ready-to-commit summary -# -# Usage: -# bash scripts/recover-sf-1364.sh [--dry-run] -# -# Options: -# --dry-run Show what would be done without making any changes -# -# Requirements: git >= 2.x, bash >= 4.x - -set -euo pipefail - -# ─── Colours ────────────────────────────────────────────────────────────────── - -RED='\033[0;31m' -YELLOW='\033[1;33m' -GREEN='\033[0;32m' -CYAN='\033[0;36m' -BOLD='\033[1m' -RESET='\033[0m' - -# ─── Args ───────────────────────────────────────────────────────────────────── - -DRY_RUN=false -for arg in "$@"; do - case "$arg" in - --dry-run) DRY_RUN=true ;; - *) echo "Unknown argument: $arg" >&2; exit 1 ;; - esac -done - -# ─── Helpers ────────────────────────────────────────────────────────────────── - -info() { echo -e "${CYAN}[info]${RESET} $*"; } -ok() { echo -e "${GREEN}[ok]${RESET} $*"; } -warn() { echo -e "${YELLOW}[warn]${RESET} $*"; } -error() { echo -e "${RED}[error]${RESET} $*" >&2; } -section() { echo -e "\n${BOLD}$*${RESET}"; } - -die() { - error "$*" - exit 1 -} - -# Run or print-only depending on --dry-run -run() { - if $DRY_RUN; then - echo -e " ${YELLOW}(dry-run)${RESET} $*" - else - eval "$*" - fi -} - -# ─── Preflight ──────────────────────────────────────────────────────────────── - -section "── Preflight ───────────────────────────────────────────────────────" - -# Must be run from a git repo root -if ! git rev-parse --git-dir > /dev/null 2>&1; then - die "Not inside a git repository. Run this from your project root." -fi - -REPO_ROOT="$(git rev-parse --show-toplevel)" -cd "$REPO_ROOT" -info "Repo root: $REPO_ROOT" - -if $DRY_RUN; then - warn "DRY-RUN mode — no changes will be made." -fi - -# ─── Step 1: Check if .sf/ exists ──────────────────────────────────────────── - -section "── Step 1: Detect .sf/ directory ────────────────────────────────────" - -SF_DIR="$REPO_ROOT/.sf" -SF_IS_SYMLINK=false - -if [[ ! -e "$SF_DIR" ]]; then - ok ".sf/ does not exist in this repo — not affected." - exit 0 -fi - -if [[ -L "$SF_DIR" ]]; then - # Scenario C: migration succeeded (symlink in place) but git index was never - # cleaned — tracked .sf/* files still appear as deleted through the symlink. - SF_IS_SYMLINK=true - warn ".sf/ is a symlink — checking for stale git index entries (Scenario C)..." -else - info ".sf/ is a real directory (Scenario A/B)." -fi - -# ─── Step 2: Check if .sf is in .gitignore ─────────────────────────────────── - -section "── Step 2: Check .gitignore for .sf entry ────────────────────────────" - -GITIGNORE="$REPO_ROOT/.gitignore" - -if [[ ! -f "$GITIGNORE" ]] && ! $SF_IS_SYMLINK; then - ok ".gitignore does not exist — not affected." - exit 0 -fi - -# Look for a bare ".sf" line (not a comment, not a sub-path like .sf/) -SF_IGNORE_LINE="" -if [[ -f "$GITIGNORE" ]]; then - while IFS= read -r line; do - trimmed="${line#"${line%%[![:space:]]*}"}" - trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" - if [[ "$trimmed" == ".sf" ]] && [[ "${trimmed:0:1}" != "#" ]]; then - SF_IGNORE_LINE="$trimmed" - break - fi - done < "$GITIGNORE" -fi - -if $SF_IS_SYMLINK; then - # Symlink layout: .sf SHOULD be ignored (it's external state). - # Missing = needs adding. Present = correct. - if [[ -z "$SF_IGNORE_LINE" ]]; then - warn '".sf" missing from .gitignore — will add (migration complete, .sf/ is external).' - else - ok '".sf" already in .gitignore — correct for external-state layout.' - fi -else - # Real-directory layout: .sf should NOT be ignored. - if [[ -z "$SF_IGNORE_LINE" ]]; then - ok '".sf" not found in .gitignore — .gitignore not affected.' - else - warn '".sf" found in .gitignore — this is the bad pattern from #1364.' - fi -fi - -# ─── Step 3: Find deleted .sf/ tracked files ───────────────────────────────── - -section "── Step 3: Find deleted .sf/ files ───────────────────────────────────" - -# Files showing as deleted in the working tree (tracked in index but missing) -DELETED_FILES="$(git ls-files --deleted -- '.sf/*' 2>/dev/null || true)" - -# Files tracked in HEAD right now -TRACKED_IN_HEAD="$(git ls-tree -r --name-only HEAD -- '.sf/' 2>/dev/null || true)" - -if $SF_IS_SYMLINK; then - # Scenario C: migration succeeded. Files are safe via symlink. - # Only index entries can be stale — no need to scan commit history. - if [[ -z "$TRACKED_IN_HEAD" ]] && [[ -z "$DELETED_FILES" ]]; then - ok "No stale index entries found — symlink layout is healthy." - if [[ -z "$SF_IGNORE_LINE" ]]; then - info "Add .sf to .gitignore manually to complete the migration." - fi - exit 0 - fi - INDEX_COUNT="$(echo "${TRACKED_IN_HEAD:-$DELETED_FILES}" | wc -l | tr -d ' ')" - warn "Scenario C: ${INDEX_COUNT} .sf/ file(s) tracked in git index but inaccessible through symlink." - info "Files are safe in external storage — only the git index needs cleaning." -else - # Files deleted via a committed git rm --cached (Scenario B) - DELETED_FROM_HISTORY="$(git log --all --diff-filter=D --name-only --format="" -- '.sf/*' 2>/dev/null \ - | grep '^\.sf' | sort -u || true)" - - if [[ -z "$TRACKED_IN_HEAD" ]] && [[ -z "$DELETED_FILES" ]] && [[ -z "$DELETED_FROM_HISTORY" ]]; then - ok "No .sf/ files tracked in this repo — not affected by #1364." - if [[ -n "$SF_IGNORE_LINE" ]]; then - warn '".sf" is still in .gitignore but there is nothing to restore.' - fi - exit 0 - fi - - if [[ -n "$TRACKED_IN_HEAD" ]]; then - TRACKED_COUNT="$(echo "$TRACKED_IN_HEAD" | wc -l | tr -d ' ')" - info "Scenario A: ${TRACKED_COUNT} .sf/ files still tracked in HEAD." - elif [[ -n "$DELETED_FROM_HISTORY" ]]; then - DELETED_HIST_COUNT="$(echo "$DELETED_FROM_HISTORY" | wc -l | tr -d ' ')" - warn "Scenario B: ${DELETED_HIST_COUNT} .sf/ file(s) deleted in a committed change:" - echo "$DELETED_FROM_HISTORY" | head -20 | while IFS= read -r f; do echo " - $f"; done - if (( DELETED_HIST_COUNT > 20 )); then echo " ... and $((DELETED_HIST_COUNT - 20)) more"; fi - fi - - if [[ -n "$DELETED_FILES" ]]; then - DELETED_COUNT="$(echo "$DELETED_FILES" | wc -l | tr -d ' ')" - warn "${DELETED_COUNT} .sf/ file(s) missing from working tree:" - echo "$DELETED_FILES" | head -20 | while IFS= read -r f; do echo " - $f"; done - if (( DELETED_COUNT > 20 )); then echo " ... and $((DELETED_COUNT - 20)) more"; fi - fi - - if [[ -n "$TRACKED_IN_HEAD" ]] && [[ -z "$DELETED_FILES" ]]; then - if [[ -z "$SF_IGNORE_LINE" ]]; then - ok "No action needed — .sf/ is tracked in HEAD and .gitignore is clean." - exit 0 - fi - info ".sf/ is tracked in HEAD and working tree is clean — only .gitignore needs fixing." - fi -fi - -# ─── Step 4: Find the last clean commit (Scenario A/B only) ─────────────────── - -section "── Step 4: Find last clean commit ──────────────────────────────────────" - -DAMAGE_COMMIT="" -CLEAN_COMMIT="" -RESTORABLE="" - -if $SF_IS_SYMLINK; then - info "Scenario C: symlink layout — skipping commit history scan (no file restore needed)." -else - # Find the commit where ".sf" was first added to .gitignore - # by walking the log and finding the first commit where .gitignore contained ".sf" - info "Scanning git log to find when .sf was added to .gitignore..." - - # Strategy 1: find the first commit that added ".sf" to .gitignore - while IFS= read -r sha; do - content="$(git show "${sha}:.gitignore" 2>/dev/null || true)" - if echo "$content" | grep -qx '\.sf' 2>/dev/null; then - DAMAGE_COMMIT="$sha" - break - fi - done < <(git log --format="%H" -- .gitignore) - - # Strategy 2: if .sf files were committed as deleted, find that commit - if [[ -z "$DAMAGE_COMMIT" ]] && [[ -n "${DELETED_FROM_HISTORY:-}" ]]; then - info "Searching for the commit that deleted .sf/ files from the index..." - DAMAGE_COMMIT="$(git log --all --diff-filter=D --format="%H" -- '.sf/*' 2>/dev/null | head -1 || true)" - fi - - if [[ -z "$DAMAGE_COMMIT" ]]; then - warn "Could not pinpoint the damage commit — falling back to HEAD." - CLEAN_COMMIT="HEAD" - else - info "Damage commit: $DAMAGE_COMMIT ($(git log --format='%s' -1 "$DAMAGE_COMMIT"))" - CLEAN_COMMIT="${DAMAGE_COMMIT}^" - CLEAN_MSG="$(git log --format='%s' -1 "$CLEAN_COMMIT" 2>/dev/null || echo "unknown")" - info "Restoring from: $CLEAN_COMMIT — $CLEAN_MSG" - fi - - # Verify the clean commit actually has .sf/ files - RESTORABLE="$(git ls-tree -r --name-only "$CLEAN_COMMIT" -- '.sf/' 2>/dev/null || true)" - if [[ -z "$RESTORABLE" ]]; then - die "No .sf/ files found in restore point $CLEAN_COMMIT — cannot recover. Check git log manually." - fi - - RESTORABLE_COUNT="$(echo "$RESTORABLE" | wc -l | tr -d ' ')" - ok "Restore point has ${RESTORABLE_COUNT} .sf/ files available." -fi - -# ─── Step 5: Clean index (Scenario C) or restore deleted files (Scenario A/B) ─ - -if $SF_IS_SYMLINK; then - section "── Step 5: Clean stale git index entries ───────────────────────────────" - - info "Running: git rm -r --cached --ignore-unmatch .sf/ ..." - run "git rm -r --cached --ignore-unmatch .sf" - if ! $DRY_RUN; then - STILL_STALE="$(git ls-files --deleted -- '.sf/*' 2>/dev/null || true)" - if [[ -z "$STILL_STALE" ]]; then - ok "Git index cleaned — no stale .sf/ entries remain." - else - warn "$(echo "$STILL_STALE" | wc -l | tr -d ' ') stale entr(ies) still present — may need manual cleanup." - fi - fi -else - section "── Step 5: Restore deleted .sf/ files ────────────────────────────────" - - NEEDS_RESTORE=false - [[ -n "$DELETED_FILES" ]] && NEEDS_RESTORE=true - [[ -n "${DELETED_FROM_HISTORY:-}" ]] && [[ -z "$TRACKED_IN_HEAD" ]] && NEEDS_RESTORE=true - - if ! $NEEDS_RESTORE; then - ok "No deleted files to restore — skipping." - else - info "Restoring .sf/ files from $CLEAN_COMMIT..." - run "git checkout \"$CLEAN_COMMIT\" -- .sf/" - if ! $DRY_RUN; then - STILL_MISSING="$(git ls-files --deleted -- '.sf/*' 2>/dev/null || true)" - if [[ -z "$STILL_MISSING" ]]; then - ok "All .sf/ files restored successfully." - else - MISS_COUNT="$(echo "$STILL_MISSING" | wc -l | tr -d ' ')" - warn "${MISS_COUNT} file(s) still missing after restore — may need manual recovery:" - echo "$STILL_MISSING" | head -10 | while IFS= read -r f; do echo " - $f"; done - fi - fi - fi -fi - -# ─── Step 6: Fix .gitignore ─────────────────────────────────────────────────── - -section "── Step 6: Fix .gitignore ───────────────────────────────────────────────" - -if $SF_IS_SYMLINK; then - # Scenario C: .sf IS external — it should be in .gitignore. Add if missing. - if [[ -z "$SF_IGNORE_LINE" ]]; then - info 'Adding ".sf" to .gitignore (migration complete — .sf/ is external state)...' - if $DRY_RUN; then - echo -e " ${YELLOW}(dry-run)${RESET} Would append: .sf" - else - printf '\n# SF external state (symlink — added by recover-sf-1364)\n.sf\n' >> "$GITIGNORE" - ok '".sf" added to .gitignore.' - fi - else - ok '".sf" already in .gitignore — correct for external-state layout.' - fi -else - # Scenario A/B: .sf is a real tracked directory — remove the bad ignore line. - if [[ -z "$SF_IGNORE_LINE" ]]; then - ok '".sf" not in .gitignore — nothing to fix.' - else - info 'Removing bare ".sf" line from .gitignore...' - if $DRY_RUN; then - echo -e " ${YELLOW}(dry-run)${RESET} Would remove line: .sf" - else - # Remove the exact line ".sf" (not comments, not .sf/ subdirs) - # Use a temp file for portability (no sed -i on all platforms) - TMP="$(mktemp)" - grep -v '^\.sf$' "$GITIGNORE" > "$TMP" || true - mv "$TMP" "$GITIGNORE" - ok '".sf" line removed from .gitignore.' - fi - fi -fi - -# ─── Step 7: Stage changes ──────────────────────────────────────────────────── - -section "── Step 7: Stage recovery changes ──────────────────────────────────────" - -if ! $DRY_RUN; then - CHANGED="$(git status --short -- '.sf/' .gitignore 2>/dev/null || true)" - if [[ -z "$CHANGED" ]]; then - ok "No staged changes — working tree was already clean." - else - if $SF_IS_SYMLINK; then - # Scenario C: the git rm --cached already staged the index cleanup. - # Only stage .gitignore — adding .sf/ would fail (now gitignored). - git add .gitignore 2>/dev/null || true - else - git add .sf/ .gitignore 2>/dev/null || true - fi - STAGED_COUNT="$(git diff --cached --name-only -- '.sf/' .gitignore | wc -l | tr -d ' ')" - ok "${STAGED_COUNT} file(s) staged and ready to commit." - fi -fi - -# ─── Summary ────────────────────────────────────────────────────────────────── - -section "── Summary ──────────────────────────────────────────────────────────────" - -if $DRY_RUN; then - echo -e "${YELLOW}Dry-run complete. Re-run without --dry-run to apply changes.${RESET}" -else - FINAL_STAGED="$(git diff --cached --name-only -- '.sf/' .gitignore 2>/dev/null | wc -l | tr -d ' ')" - if (( FINAL_STAGED > 0 )); then - echo -e "${GREEN}Recovery complete. Commit with:${RESET}" - echo "" - if $SF_IS_SYMLINK; then - echo " git commit -m \"fix: clean stale .sf/ index entries after external-state migration\"" - else - echo " git commit -m \"fix: restore .sf/ files deleted by #1364 regression\"" - fi - echo "" - echo "Staged files:" - git diff --cached --name-only -- '.sf/' .gitignore | head -20 | while IFS= read -r f; do - echo " + $f" - done - TOTAL_STAGED="$(git diff --cached --name-only -- '.sf/' .gitignore | wc -l | tr -d ' ')" - if (( TOTAL_STAGED > 20 )); then - echo " ... and $((TOTAL_STAGED - 20)) more" - fi - else - ok "Repo is healthy — no recovery needed." - fi -fi diff --git a/scripts/recover-gsd-1668.ps1 b/scripts/recover-gsd-1668.ps1 deleted file mode 100644 index 9d67447c9..000000000 --- a/scripts/recover-gsd-1668.ps1 +++ /dev/null @@ -1,339 +0,0 @@ -# recover-sf-1668.ps1 — Recovery script for issue #1668 (Windows) -# -# SF v2.39.x deleted the milestone branch and worktree directory when a -# merge failed due to the repo using `master` as its default branch (not -# `main`). The commits were never merged — they are orphaned in the git -# object store and can be recovered via git reflog or git fsck. -# -# This script: -# 1. Searches git reflog for the deleted milestone branch (fastest path) -# 2. Falls back to git fsck --unreachable to find orphaned commits -# 3. Ranks candidates by recency and SF commit message patterns -# 4. Creates a recovery branch at the identified commit -# 5. Reports what was found and how to complete the merge manually -# -# Usage: -# powershell -ExecutionPolicy Bypass -File scripts\recover-sf-1668.ps1 [-MilestoneId ] [-DryRun] [-Auto] -# -# Options: -# -MilestoneId SF milestone ID (e.g. M001-g2nalq). -# -DryRun Show what would be done without making any changes. -# -Auto Pick best candidate automatically (no prompts). -# -# Requirements: git >= 2.23, PowerShell >= 5.1, Git for Windows -# -# Affected versions: SF.39.x -# Fixed in: SF.40.1 (PR #1669) - -[CmdletBinding()] -param( - [string]$MilestoneId = "", - [switch]$DryRun, - [switch]$Auto -) - -$ErrorActionPreference = 'Stop' - -# ── Helpers ─────────────────────────────────────────────────────────────────── - -function Info { param($msg) Write-Host "[info] $msg" -ForegroundColor Cyan } -function Ok { param($msg) Write-Host "[ok] $msg" -ForegroundColor Green } -function Warn { param($msg) Write-Host "[warn] $msg" -ForegroundColor Yellow } -function Err { param($msg) Write-Host "[error] $msg" -ForegroundColor Red } -function Section { param($msg) Write-Host "`n$msg" -ForegroundColor White } -function Dim { param($msg) Write-Host " $msg" -ForegroundColor DarkGray } - -function Run { - param($cmd) - if ($DryRun) { - Write-Host " (dry-run) $cmd" -ForegroundColor Yellow - } else { - Invoke-Expression $cmd - } -} - -function Git { - param([string[]]$args) - $output = & git @args 2>&1 - if ($LASTEXITCODE -ne 0) { return "" } - return $output -join "`n" -} - -function Die { - param($msg) - Err $msg - exit 1 -} - -# ── Preflight ───────────────────────────────────────────────────────────────── - -Section "── Preflight ───────────────────────────────────────────────────────────" - -$gitDir = & git rev-parse --git-dir 2>&1 -if ($LASTEXITCODE -ne 0) { - Die "Not inside a git repository. Run this from your project root." -} - -$repoRoot = (& git rev-parse --show-toplevel).Trim() -Set-Location $repoRoot -Info "Repo root: $repoRoot" - -if ($DryRun) { Warn "DRY-RUN mode — no changes will be made." } - -# ── Step 1: Check live milestone branches ──────────────────────────────────── - -Section "── Step 1: Verify milestone branch is missing ───────────────────────────" - -$branchPattern = if ($MilestoneId) { "milestone/$MilestoneId" } else { "milestone/" } -$liveBranches = & git branch 2>/dev/null | Where-Object { $_ -match [regex]::Escape($branchPattern) } | ForEach-Object { $_.Trim().TrimStart('* ') } - -if ($liveBranches) { - Ok "Found live milestone branch(es):" - $liveBranches | ForEach-Object { Write-Host " $_" } - Warn "The branch still exists — are you sure it was lost?" - Write-Host " git checkout $($liveBranches[0])" - if (-not $MilestoneId) { exit 0 } -} - -if ($MilestoneId -and -not $liveBranches) { - Info "Confirmed: milestone/$MilestoneId branch is gone." -} elseif (-not $MilestoneId) { - Info "No live milestone/ branches found — scanning for orphaned commits." -} - -# ── Step 2: Search git reflog ───────────────────────────────────────────────── - -Section "── Step 2: Search git reflog for deleted branch ────────────────────────" - -$reflogFoundSha = "" -$reflogFoundBranch = "" - -if ($MilestoneId) { - $reflogPath = Join-Path $repoRoot ".git\logs\refs\heads\milestone\$MilestoneId" - if (Test-Path $reflogPath) { - $lines = Get-Content $reflogPath - if ($lines) { - $lastLine = $lines[-1] - $reflogFoundSha = ($lastLine -split '\s+')[1] - $reflogFoundBranch = "milestone/$MilestoneId" - Ok "Reflog entry found for milestone/$MilestoneId — commit: $($reflogFoundSha.Substring(0,12))" - } - } else { - Info "No reflog file at .git\logs\refs\heads\milestone\$MilestoneId" - } -} - -if (-not $reflogFoundSha) { - Info "Scanning git reflog for milestone/ commits..." - $reflogAll = & git reflog --all --format="%H %gs" 2>/dev/null | Where-Object { $_ -match "milestone/" } | Select-Object -First 20 - if ($reflogAll) { - Info "Found milestone-related reflog entries:" - $reflogAll | ForEach-Object { Dim $_ } - $match = if ($MilestoneId) { - $reflogAll | Where-Object { $_ -match "milestone/$([regex]::Escape($MilestoneId))" } | Select-Object -First 1 - } else { - $reflogAll | Select-Object -First 1 - } - if ($match) { - $reflogFoundSha = ($match -split '\s+')[0] - if ($match -match 'milestone/(\S+)') { $reflogFoundBranch = "milestone/$($Matches[1])" } - else { $reflogFoundBranch = "milestone/unknown" } - } - } else { - Info "No milestone/ entries in reflog." - } -} - -# ── Step 3: Fall back to git fsck ───────────────────────────────────────────── - -Section "── Step 3: Scan for orphaned (unreachable) commits ───────────────────" - -$sortedCandidates = @() - -if (-not $reflogFoundSha) { - Info "Running git fsck --unreachable (this may take a moment)..." - - $fsckOutput = & git fsck --unreachable --no-reflogs 2>/dev/null | Where-Object { $_ -match '^unreachable commit' } - if (-not $fsckOutput) { - $fsckOutput = & git fsck --unreachable 2>/dev/null | Where-Object { $_ -match '^unreachable commit' } - } - - $unreachableCommits = $fsckOutput | ForEach-Object { ($_ -split '\s+')[2] } | Where-Object { $_ } - - $total = @($unreachableCommits).Count - Info "Found $total unreachable commit object(s)." - - if ($total -eq 0) { - Err "No unreachable commits found." - Write-Host "" - Write-Host "This means one of:" - Write-Host " 1. git gc has already pruned the objects (default: 14 days)" - Write-Host " 2. The commits were never written to the object store" - Write-Host " 3. The wrong repository is being scanned" - exit 1 - } - - $cutoff = (Get-Date).AddDays(-30).ToUnixTimeSeconds() - - $candidates = @() - foreach ($sha in $unreachableCommits) { - if (-not $sha) { continue } - $commitDate = [long](& git show -s --format="%ct" $sha 2>/dev/null) - if (-not $commitDate -or $commitDate -lt $cutoff) { continue } - - $commitMsg = (& git show -s --format="%s" $sha 2>/dev/null) -join "" - $commitBody = (& git show -s --format="%b" $sha 2>/dev/null) -join " " - $commitDateHr = (& git show -s --format="%ci" $sha 2>/dev/null) -join "" - - $score = 0 - if ($MilestoneId -and ($commitMsg + $commitBody) -match [regex]::Escape($MilestoneId)) { $score += 100 } - if ($commitMsg -match '^feat\([A-Z][0-9]+') { $score += 50 } - if (($commitMsg + $commitBody) -match 'milestone/|complete-milestone|SF|slice') { $score += 20 } - - $weekAgo = (Get-Date).AddDays(-7).ToUnixTimeSeconds() - if ($commitDate -gt $weekAgo) { $score += 10 } - - $fileCount = (& git show --stat --format="" $sha 2>/dev/null | Select-Object -Last 1) -replace '.*?(\d+) file.*','$1' - - $candidates += [PSCustomObject]@{ - SHA = $sha - Score = $score - Message = $commitMsg - Date = $commitDateHr - FileCount = $fileCount - } - } - - if ($candidates.Count -eq 0) { - Err "No recent unreachable commits found within the last 30 days." - Write-Host "Objects may have been pruned by git gc." - exit 1 - } - - $sortedCandidates = $candidates | Sort-Object -Property Score -Descending | Select-Object -First 10 - - Info "Top candidates (scored by recency and SF message patterns):" - Write-Host "" - $num = 1 - foreach ($c in $sortedCandidates) { - Write-Host " $num) $($c.SHA.Substring(0,12)) $($c.Message)" -ForegroundColor Green - Dim "$($c.Date) — $($c.FileCount) file(s)" - $num++ - } - Write-Host "" -} - -# ── Step 4: Select recovery commit ─────────────────────────────────────────── - -Section "── Step 4: Select recovery commit ──────────────────────────────────────" - -$recoverySha = "" -$recoverySource = "" - -if ($reflogFoundSha) { - $recoverySha = $reflogFoundSha - $recoverySource = "reflog ($reflogFoundBranch)" - Info "Using reflog candidate: $($recoverySha.Substring(0,12))" - Dim (& git show -s --format="%s %ci" $recoverySha 2>/dev/null) - -} elseif ($sortedCandidates.Count -eq 1 -or $Auto) { - $recoverySha = $sortedCandidates[0].SHA - $recoverySource = "fsck (auto-selected)" - Info "Auto-selecting best candidate: $($recoverySha.Substring(0,12))" - -} else { - $selection = Read-Host "Select a candidate to recover [1-$($sortedCandidates.Count), or q to quit]" - if ($selection -eq 'q') { Info "Aborted."; exit 0 } - $selIdx = [int]$selection - 1 - if ($selIdx -lt 0 -or $selIdx -ge $sortedCandidates.Count) { Die "Invalid selection: $selection" } - $recoverySha = $sortedCandidates[$selIdx].SHA - $recoverySource = "fsck (user-selected #$selection)" -} - -if (-not $recoverySha) { Die "Could not determine a recovery commit." } - -Ok "Recovery commit: $($recoverySha.Substring(0,16)) (source: $recoverySource)" -Write-Host "" -Info "Commit details:" -& git show -s --format=" Message: %s`n Author: %an <%ae>`n Date: %ci`n Full SHA: %H" $recoverySha -Write-Host "" -Info "Files at this commit (first 30):" -& git show --stat --format="" $recoverySha 2>/dev/null | Select-Object -First 30 -Write-Host "" - -# ── Step 5: Create recovery branch ─────────────────────────────────────────── - -Section "── Step 5: Create recovery branch ──────────────────────────────────────" - -$recoveryBranch = if ($MilestoneId) { - "recovery/1668/$MilestoneId" -} elseif ($reflogFoundBranch) { - "recovery/1668/$($reflogFoundBranch -replace '/','-')" -} else { - "recovery/1668/commit-$($recoverySha.Substring(0,8))" -} - -$branchExists = & git show-ref --verify --quiet "refs/heads/$recoveryBranch" 2>/dev/null; $exists = $LASTEXITCODE -eq 0 -if ($exists) { - Warn "Branch $recoveryBranch already exists." - if (-not $Auto) { - $answer = Read-Host "Overwrite it? [y/N]" - if ($answer -notin @('y','Y')) { Info "Aborted."; exit 0 } - } - Run "git branch -D `"$recoveryBranch`"" -} - -Run "git branch `"$recoveryBranch`" `"$recoverySha`"" - -if (-not $DryRun) { - Ok "Recovery branch created: $recoveryBranch" -} else { - Ok "(dry-run) Would create branch: $recoveryBranch -> $($recoverySha.Substring(0,12))" -} - -# ── Step 6: Verify ──────────────────────────────────────────────────────────── - -if (-not $DryRun) { - Section "── Step 6: Verify recovery branch ──────────────────────────────────────" - $fileList = & git ls-tree -r --name-only $recoveryBranch 2>/dev/null | Where-Object { $_ -notmatch '^\.sf/' } - $fileCount = @($fileList).Count - Info "Files recoverable (excluding .sf/ state files): $fileCount" - $fileList | Select-Object -First 30 | ForEach-Object { Write-Host " $_" } - if ($fileCount -gt 30) { Dim " ... and $($fileCount - 30) more" } -} - -# ── Summary ─────────────────────────────────────────────────────────────────── - -Section "── Recovery Summary ─────────────────────────────────────────────────────" - -if ($DryRun) { - Write-Host "Dry-run complete. Re-run without -DryRun to apply." -ForegroundColor Yellow - exit 0 -} - -$defaultBranch = (& git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null) -replace 'refs/remotes/origin/','' -if (-not $defaultBranch) { $defaultBranch = (& git branch --show-current) } - -Write-Host "Recovery branch ready: " -NoNewline -Write-Host $recoveryBranch -ForegroundColor Green -Write-Host "" -Write-Host "Next steps:" -Write-Host "" -Write-Host " 1. Inspect the recovered files:" -Write-Host " git checkout $recoveryBranch" -Write-Host " dir" -Write-Host "" -Write-Host " 2. Verify your code is intact:" -Write-Host " git log --oneline $recoveryBranch | head -20" -Write-Host "" -Write-Host " 3. Merge to your default branch ($defaultBranch):" -Write-Host " git checkout $defaultBranch" -Write-Host " git merge --squash $recoveryBranch" -Write-Host " git commit -m `"feat: recover milestone from #1668`"" -Write-Host "" -Write-Host " 4. Clean up after verifying:" -Write-Host " git branch -D $recoveryBranch" -Write-Host "" -Write-Host "Note: update SF to v2.40.1+ to prevent this from recurring." -ForegroundColor DarkGray -Write-Host " PR: https://github.com/singularity-forge/sf-run/pull/1669" -ForegroundColor DarkGray -Write-Host "" diff --git a/scripts/recover-gsd-1668.sh b/scripts/recover-gsd-1668.sh deleted file mode 100755 index 59cdb3e32..000000000 --- a/scripts/recover-gsd-1668.sh +++ /dev/null @@ -1,446 +0,0 @@ -#!/usr/bin/env bash -# recover-sf-1668.sh — Recovery script for issue #1668 (Linux / macOS) -# -# SF v2.39.x deleted the milestone branch and worktree directory when a -# merge failed due to the repo using `master` as its default branch (not -# `main`). The commits were never merged — they are orphaned in the git -# object store and can be recovered via git reflog or git fsck. -# -# This script: -# 1. Searches git reflog for the deleted milestone branch (fastest path) -# 2. Falls back to git fsck --unreachable to find orphaned commits -# 3. Ranks candidates by recency and SF commit message patterns -# 4. Creates a recovery branch at the identified commit -# 5. Reports what was found and how to complete the merge manually -# -# Usage: -# bash scripts/recover-sf-1668.sh [--milestone ] [--dry-run] [--auto] -# -# Options: -# --milestone SF milestone ID (e.g. M001-g2nalq). -# When omitted the script scans all recent orphans. -# --dry-run Show what would be done without making any changes. -# --auto Pick the best candidate automatically (no prompts). -# -# Requirements: git >= 2.23, bash >= 4.x -# -# Affected versions: SF.39.x -# Fixed in: SF.40.1 (PR #1669) - -set -euo pipefail - -# ─── Colours ────────────────────────────────────────────────────────────────── - -RED='\033[0;31m' -YELLOW='\033[1;33m' -GREEN='\033[0;32m' -CYAN='\033[0;36m' -BOLD='\033[1m' -DIM='\033[2m' -RESET='\033[0m' - -# ─── Args ───────────────────────────────────────────────────────────────────── - -DRY_RUN=false -AUTO=false -MILESTONE_ID="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) DRY_RUN=true; shift ;; - --auto) AUTO=true; shift ;; - --milestone) - [[ $# -lt 2 ]] && { echo "Error: --milestone requires an argument" >&2; exit 1; } - MILESTONE_ID="$2"; shift 2 ;; - --milestone=*) - MILESTONE_ID="${1#--milestone=}"; shift ;; - -h|--help) - sed -n '2,/^set -/p' "$0" | grep '^#' | sed 's/^# \{0,1\}//' - exit 0 ;; - *) - echo "Unknown argument: $1" >&2 - echo "Usage: $0 [--milestone ] [--dry-run] [--auto]" >&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}"; } -dim() { echo -e "${DIM}$*${RESET}"; } - -die() { - error "$*" - exit 1 -} - -run() { - if $DRY_RUN; then - echo -e " ${YELLOW}(dry-run)${RESET} $*" - else - eval "$*" - fi -} - -# ─── Preflight ──────────────────────────────────────────────────────────────── - -section "── Preflight ───────────────────────────────────────────────────────────" - -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" - -$DRY_RUN && warn "DRY-RUN mode — no changes will be made." - -# ─── Step 1: Confirm the milestone branch is gone ───────────────────────────── - -section "── Step 1: Verify milestone branch is missing ───────────────────────────" - -BRANCH_PATTERN="milestone/" -if [[ -n "$MILESTONE_ID" ]]; then - BRANCH_PATTERN="milestone/${MILESTONE_ID}" -fi - -LIVE_BRANCHES="$(git branch | grep "$BRANCH_PATTERN" 2>/dev/null | tr -d '* ' || true)" - -if [[ -n "$LIVE_BRANCHES" ]]; then - ok "Found live milestone branch(es):" - echo "$LIVE_BRANCHES" | while IFS= read -r b; do echo " $b"; done - echo "" - warn "The branch still exists — are you sure it was lost?" - echo " If you want to check out existing work: git checkout ${LIVE_BRANCHES%%$'\n'*}" - echo " To merge it manually: git checkout master && git merge --squash ${LIVE_BRANCHES%%$'\n'*}" - echo "" - echo "Re-run with --milestone to force scanning for a specific orphaned commit." - if [[ -z "$MILESTONE_ID" ]]; then - exit 0 - fi -fi - -if [[ -n "$MILESTONE_ID" && -n "$LIVE_BRANCHES" ]]; then - warn "Milestone branch milestone/${MILESTONE_ID} is still live — continuing scan anyway." -elif [[ -n "$MILESTONE_ID" ]]; then - info "Confirmed: milestone/${MILESTONE_ID} branch is gone." -else - info "No live milestone/ branches found — scanning for orphaned commits." -fi - -# ─── Step 2: Search git reflog (fastest, most reliable) ─────────────────────── - -section "── Step 2: Search git reflog for deleted branch ────────────────────────" - -# git reflog stores branch moves and deletions in .git/logs/refs/heads/ -# It is retained for 90 days by default (gc.reflogExpire). -REFLOG_FOUND_SHA="" -REFLOG_FOUND_BRANCH="" - -if [[ -n "$MILESTONE_ID" ]]; then - REFLOG_PATH="${REPO_ROOT}/.git/logs/refs/heads/milestone/${MILESTONE_ID}" - if [[ -f "$REFLOG_PATH" ]]; then - # Last line of the reflog for this branch is the most recent tip - REFLOG_FOUND_SHA="$(tail -1 "$REFLOG_PATH" | awk '{print $2}')" - REFLOG_FOUND_BRANCH="milestone/${MILESTONE_ID}" - ok "Reflog entry found for milestone/${MILESTONE_ID} — commit: ${REFLOG_FOUND_SHA:0:12}" - else - info "No reflog file at .git/logs/refs/heads/milestone/${MILESTONE_ID}" - fi -fi - -# Also try git reflog (in-memory index, works without the raw file) -if [[ -z "$REFLOG_FOUND_SHA" ]]; then - info "Scanning git reflog for milestone/ commits..." - REFLOG_MILESTONES="$(git reflog --all --format="%H %gs" 2>/dev/null \ - | grep -E "(checkout|commit|merge).*milestone/" \ - | head -20 || true)" - - if [[ -n "$REFLOG_MILESTONES" ]]; then - info "Found milestone-related reflog entries:" - echo "$REFLOG_MILESTONES" | while IFS= read -r line; do - dim " $line" - done - # Extract the most recent SHA from the most relevant entry - if [[ -n "$MILESTONE_ID" ]]; then - MATCH="$(echo "$REFLOG_MILESTONES" | grep "milestone/${MILESTONE_ID}" | head -1 || true)" - else - MATCH="$(echo "$REFLOG_MILESTONES" | head -1 || true)" - fi - if [[ -n "$MATCH" ]]; then - REFLOG_FOUND_SHA="$(echo "$MATCH" | awk '{print $1}')" - REFLOG_FOUND_BRANCH="$(echo "$MATCH" | grep -oE 'milestone/[^ ]+' | head -1 || echo "milestone/unknown")" - fi - else - info "No milestone/ entries in reflog." - fi -fi - -# ─── Step 3: Fall back to git fsck if reflog didn't find it ─────────────────── - -section "── Step 3: Scan for orphaned (unreachable) commits ───────────────────" - -FSCK_CANDIDATES=() -FSCK_CANDIDATE_MSGS=() -FSCK_CANDIDATE_DATES=() -FSCK_CANDIDATE_FILES=() - -if [[ -z "$REFLOG_FOUND_SHA" ]]; then - info "Running git fsck --unreachable (this may take a moment)..." - - # Collect all unreachable commit hashes - UNREACHABLE_COMMITS="$(git fsck --unreachable --no-reflogs 2>/dev/null \ - | grep '^unreachable commit' \ - | awk '{print $3}' || true)" - - if [[ -z "$UNREACHABLE_COMMITS" ]]; then - # Try without --no-reflogs as a fallback (less conservative) - UNREACHABLE_COMMITS="$(git fsck --unreachable 2>/dev/null \ - | grep '^unreachable commit' \ - | awk '{print $3}' || true)" - fi - - TOTAL="$(echo "$UNREACHABLE_COMMITS" | grep -c . || true)" - info "Found ${TOTAL} unreachable commit object(s)." - - if [[ -z "$UNREACHABLE_COMMITS" || "$TOTAL" -eq 0 ]]; then - error "No unreachable commits found." - echo "" - echo "This means one of:" - echo " 1. git gc has already been run and the objects were pruned" - echo " (objects are pruned after 14 days by default)" - echo " 2. The commits were never written to the object store" - echo " 3. The wrong repository is being scanned" - echo "" - echo "If git gc ran, the objects may be unrecoverable without a backup." - echo "Try: git reflog --all | grep milestone" - exit 1 - fi - - # Score each unreachable commit — rank by recency and SF message patterns. - # SF milestone commits look like: "feat(M001-g2nalq): " - # Slice merges look like: "feat(M001-g2nalq/S01): <slice>" - # - # Performance: use a single `git log --no-walk=unsorted --stdin` call to - # read all commit metadata in one pass instead of one `git show` per commit. - CUTOFF="$(date -d '30 days ago' '+%s' 2>/dev/null || date -v-30d '+%s' 2>/dev/null || echo 0)" - WEEK_AGO="$(date -d '7 days ago' '+%s' 2>/dev/null || date -v-7d '+%s' 2>/dev/null || echo 0)" - - # Batch-read all commits: output format per commit is: - # HASH<TAB>UNIX_TIMESTAMP<TAB>ISO_DATE<TAB>SUBJECT - # separated by NUL so multi-line subjects don't break parsing. - BATCH_LOG="$(echo "$UNREACHABLE_COMMITS" \ - | git log --no-walk=unsorted --stdin --format=$'%H\t%ct\t%ci\t%s' 2>/dev/null || true)" - - while IFS=$'\t' read -r sha commit_ts commit_date_hr commit_msg; do - [[ -z "$sha" ]] && continue - [[ -z "$commit_ts" || "$commit_ts" -lt "$CUTOFF" ]] && continue - - # Score: milestone pattern in subject is highest signal - SCORE=0 - if [[ -n "$MILESTONE_ID" ]] && echo "$commit_msg" | grep -qiE "(milestone[/ ])?${MILESTONE_ID}"; then - SCORE=$((SCORE + 100)) - fi - if echo "$commit_msg" | grep -qE '^feat\([A-Z][0-9]+'; then - SCORE=$((SCORE + 50)) - fi - if echo "$commit_msg" | grep -qiE 'milestone/|complete-milestone|SF|slice'; then - SCORE=$((SCORE + 20)) - fi - if [[ "$commit_ts" -gt "$WEEK_AGO" ]]; then - SCORE=$((SCORE + 10)) - fi - - FSCK_CANDIDATES+=("$sha|$SCORE") - FSCK_CANDIDATE_MSGS+=("$commit_msg") - FSCK_CANDIDATE_DATES+=("$commit_date_hr") - FSCK_CANDIDATE_FILES+=("?") - done <<< "$BATCH_LOG" - - if [[ ${#FSCK_CANDIDATES[@]} -eq 0 ]]; then - error "No recent unreachable commits found within the last 30 days." - echo "" - echo "Objects may have been pruned by git gc, or the issue occurred more than 30 days ago." - echo "Try: git fsck --unreachable --no-reflogs 2>/dev/null | grep commit" - exit 1 - fi - - # Sort by score descending, keep top 10 - IFS=$'\n' SORTED_CANDIDATES=($( - for i in "${!FSCK_CANDIDATES[@]}"; do - echo "${FSCK_CANDIDATES[$i]}|$i" - done | sort -t'|' -k2 -rn | head -10 - )) - unset IFS - - info "Top candidates (scored by recency and SF message patterns):" - echo "" - NUM=1 - SORTED_IDXS=() - for entry in "${SORTED_CANDIDATES[@]}"; do - SHA="${entry%%|*}" - IDX="${entry##*|}" - SORTED_IDXS+=("$IDX") - MSG="${FSCK_CANDIDATE_MSGS[$IDX]}" - DATE="${FSCK_CANDIDATE_DATES[$IDX]}" - FILES="${FSCK_CANDIDATE_FILES[$IDX]}" - echo -e " ${BOLD}${NUM})${RESET} ${sha:0:12} ${GREEN}${MSG}${RESET}" - echo -e " ${DIM}${DATE} — ${FILES}${RESET}" - NUM=$((NUM + 1)) - done - echo "" -fi - -# ─── Step 4: Select the recovery commit ─────────────────────────────────────── - -section "── Step 4: Select recovery commit ──────────────────────────────────────" - -RECOVERY_SHA="" -RECOVERY_SOURCE="" - -if [[ -n "$REFLOG_FOUND_SHA" ]]; then - RECOVERY_SHA="$REFLOG_FOUND_SHA" - RECOVERY_SOURCE="reflog (${REFLOG_FOUND_BRANCH})" - info "Using reflog candidate: ${RECOVERY_SHA:0:12}" - MSG="$(git show -s --format="%s %ci" "$RECOVERY_SHA" 2>/dev/null || echo "unknown")" - dim " $MSG" - -elif [[ ${#SORTED_IDXS[@]} -eq 1 ]] || $AUTO; then - # Auto-select first (highest scored) candidate - FIRST_ENTRY="${SORTED_CANDIDATES[0]}" - FIRST_SHA="${FIRST_ENTRY%%|*}" - FIRST_IDX="${FIRST_ENTRY##*|}" - RECOVERY_SHA="$FIRST_SHA" - RECOVERY_SOURCE="fsck (auto-selected)" - info "Auto-selecting best candidate: ${RECOVERY_SHA:0:12}" - -else - # Prompt user to select - echo -n "Select a candidate to recover [1-${#SORTED_CANDIDATES[@]}, or q to quit]: " - read -r SELECTION - - if [[ "$SELECTION" == "q" ]]; then - info "Aborted." - exit 0 - fi - - if ! [[ "$SELECTION" =~ ^[0-9]+$ ]] || \ - [[ "$SELECTION" -lt 1 ]] || \ - [[ "$SELECTION" -gt ${#SORTED_CANDIDATES[@]} ]]; then - die "Invalid selection: $SELECTION" - fi - - SEL_IDX=$((SELECTION - 1)) - SEL_ENTRY="${SORTED_CANDIDATES[$SEL_IDX]}" - RECOVERY_SHA="${SEL_ENTRY%%|*}" - RECOVERY_SOURCE="fsck (user-selected #${SELECTION})" -fi - -if [[ -z "$RECOVERY_SHA" ]]; then - die "Could not determine a recovery commit. See output above." -fi - -ok "Recovery commit: ${RECOVERY_SHA:0:16} (source: ${RECOVERY_SOURCE})" - -# Show what's in this commit -echo "" -info "Commit details:" -git show -s --format=" Message: %s%n Author: %an <%ae>%n Date: %ci%n Full SHA: %H" "$RECOVERY_SHA" -echo "" -info "Files at this commit (first 30):" -git show --stat --format="" "$RECOVERY_SHA" 2>/dev/null | head -30 -echo "" - -# ─── Step 5: Create recovery branch ─────────────────────────────────────────── - -section "── Step 5: Create recovery branch ──────────────────────────────────────" - -# Determine recovery branch name -if [[ -n "$MILESTONE_ID" ]]; then - RECOVERY_BRANCH="recovery/1668/${MILESTONE_ID}" -elif [[ -n "$REFLOG_FOUND_BRANCH" ]]; then - CLEAN_NAME="${REFLOG_FOUND_BRANCH//\//-}" - RECOVERY_BRANCH="recovery/1668/${CLEAN_NAME}" -else - SHORT_SHA="${RECOVERY_SHA:0:8}" - RECOVERY_BRANCH="recovery/1668/commit-${SHORT_SHA}" -fi - -# Check if it already exists -if git show-ref --verify --quiet "refs/heads/${RECOVERY_BRANCH}" 2>/dev/null; then - warn "Branch ${RECOVERY_BRANCH} already exists." - if ! $AUTO; then - echo -n "Overwrite it? [y/N]: " - read -r ANSWER - if [[ "$ANSWER" != "y" && "$ANSWER" != "Y" ]]; then - info "Aborted. Existing branch preserved." - exit 0 - fi - fi - run "git branch -D \"${RECOVERY_BRANCH}\"" -fi - -run "git branch \"${RECOVERY_BRANCH}\" \"${RECOVERY_SHA}\"" - -if ! $DRY_RUN; then - ok "Recovery branch created: ${RECOVERY_BRANCH}" -else - ok "(dry-run) Would create branch: ${RECOVERY_BRANCH} → ${RECOVERY_SHA:0:12}" -fi - -# ─── Step 6: Verify the recovery branch ─────────────────────────────────────── - -if ! $DRY_RUN; then - section "── Step 6: Verify recovery branch ──────────────────────────────────────" - - FILE_LIST="$(git ls-tree -r --name-only "${RECOVERY_BRANCH}" 2>/dev/null | grep -v '^\.sf/' || true)" - FILE_COUNT="$(echo "$FILE_LIST" | grep -c . || true)" - - info "Files recoverable (excluding .sf/ state files): ${FILE_COUNT}" - echo "$FILE_LIST" | head -30 | while IFS= read -r f; do echo " $f"; done - if [[ "$FILE_COUNT" -gt 30 ]]; then - dim " ... and $((FILE_COUNT - 30)) more" - fi -fi - -# ─── Summary ────────────────────────────────────────────────────────────────── - -section "── Recovery Summary ─────────────────────────────────────────────────────" - -if $DRY_RUN; then - echo -e "${YELLOW}Dry-run complete. Re-run without --dry-run to apply.${RESET}" - exit 0 -fi - -DEFAULT_BRANCH="$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' \ - || git for-each-ref --format='%(refname:short)' 'refs/heads/main' 'refs/heads/master' 2>/dev/null | head -1 \ - || git branch --show-current)" - -echo -e "${GREEN}Recovery branch ready: ${BOLD}${RECOVERY_BRANCH}${RESET}" -echo "" -echo "Next steps:" -echo "" -echo -e " ${BOLD}1. Inspect the recovered files:${RESET}" -echo " git checkout ${RECOVERY_BRANCH}" -echo " ls -la" -echo "" -echo -e " ${BOLD}2. Verify your code is intact:${RESET}" -echo " git log --oneline ${RECOVERY_BRANCH} | head -20" -echo " git show --stat ${RECOVERY_BRANCH}" -echo "" -echo -e " ${BOLD}3. Merge to your default branch (${DEFAULT_BRANCH}):${RESET}" -echo " git checkout ${DEFAULT_BRANCH}" -echo " git merge --squash ${RECOVERY_BRANCH}" -echo " git commit -m \"feat: recover milestone from #1668\"" -echo "" -echo -e " ${BOLD}4. Clean up after verifying:${RESET}" -echo " git branch -D ${RECOVERY_BRANCH}" -echo "" -echo -e "${DIM}Note: update SF to v2.40.1+ to prevent this from recurring.${RESET}" -echo " PR: https://github.com/singularity-forge/sf-run/pull/1669" -echo "" diff --git a/src/cli.ts b/src/cli.ts index 184368ccb..36c019a34 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -177,10 +177,10 @@ if (cliFlags.messages[0] === 'update') { // --------------------------------------------------------------------------- if (cliFlags.messages[0] === 'graph') { const sub = cliFlags.messages[1] - const { buildGraph, writeGraph, graphStatus, graphQuery, graphDiff, resolveGsdRoot } = await import('@singularity-forge/mcp-server') + const { buildGraph, writeGraph, graphStatus, graphQuery, graphDiff, resolveSFRoot } = await import('@singularity-forge/mcp-server') const projectDir = process.cwd() - const sfRoot = resolveGsdRoot(projectDir) + const sfRoot = resolveSFRoot(projectDir) if (!sub || sub === 'build') { try { diff --git a/tsconfig.extensions.json b/tsconfig.extensions.json index 9c6ad1ede..5d8aad216 100644 --- a/tsconfig.extensions.json +++ b/tsconfig.extensions.json @@ -5,6 +5,8 @@ "allowImportingTsExtensions": true, "allowJs": true, "checkJs": false, + "target": "ES2024", + "lib": ["ES2024", "DOM", "DOM.Iterable"], "rootDir": ".", "baseUrl": ".", "paths": {