Fix rebrand artifacts, add family-priority model routing to proxy server

- Update Dockerfile image name and package.json URLs to singularity-ng/singularity-foundry
- Add uv to nix develop shell in flake.nix
- Rename resolveGsdRoot → resolveSFRoot in src/cli.ts
- Add PROXY_FAMILY_PRIORITY routing table + sortByFamilyPriority to proxy-server.ts
- Fix duplicate scope key and simplify link-workspace-packages.cjs
- Remove duplicate conditions in postinstall.js
- Add ES2024 target/lib to tsconfig.extensions.json
- Delete obsolete GSD recovery scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-18 12:27:06 +02:00
parent 8f160677b7
commit 30730dd25b
13 changed files with 86 additions and 1632 deletions

View file

@ -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

View file

@ -25,6 +25,7 @@
rust-analyzer
rustc
rustfmt
uv
];
shellHook = ''

39
package-lock.json generated
View file

@ -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"

View file

@ -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": [

View file

@ -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<T extends { id: string; provider: string }>(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;

View file

@ -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/<dir>
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

View file

@ -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) {

View file

@ -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."
}
}

View file

@ -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.02.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.0v2.38.0 — see PR #1635 for details.
#
# This script:
# 1. Detects whether the repo was affected
# 2. Finds the last clean commit before the damage
# 3. Restores all deleted .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

View file

@ -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 <ID>] [-DryRun] [-Auto]
#
# Options:
# -MilestoneId <ID> 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 ""

View file

@ -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 <ID>] [--dry-run] [--auto]
#
# Options:
# --milestone <ID> 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 <ID>] [--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 <ID> 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): <title>"
# 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 ""

View file

@ -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 {

View file

@ -5,6 +5,8 @@
"allowImportingTsExtensions": true,
"allowJs": true,
"checkJs": false,
"target": "ES2024",
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"rootDir": ".",
"baseUrl": ".",
"paths": {