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:
parent
8f160677b7
commit
30730dd25b
13 changed files with 86 additions and 1632 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
rust-analyzer
|
||||
rustc
|
||||
rustfmt
|
||||
uv
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
|
|
|
|||
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
"allowImportingTsExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"target": "ES2024",
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue