2026-03-18 14:57:25 -06:00
|
|
|
/**
|
2026-04-15 14:54:20 +02:00
|
|
|
* SF Worktree CLI — standalone subcommand and -w flag handling.
|
2026-03-18 14:57:25 -06:00
|
|
|
*
|
|
|
|
|
* Manages the full worktree lifecycle from the command line:
|
2026-04-15 15:37:12 +02:00
|
|
|
* sf -w Create auto-named worktree, start interactive session
|
|
|
|
|
* sf -w my-feature Create/resume named worktree
|
|
|
|
|
* sf worktree list List worktrees with status
|
|
|
|
|
* sf worktree merge [name] Squash-merge a worktree into main
|
|
|
|
|
* sf worktree clean Remove all merged/empty worktrees
|
|
|
|
|
* sf worktree remove <n> Remove a specific worktree
|
2026-03-18 14:57:25 -06:00
|
|
|
*
|
|
|
|
|
* On session exit (via session_shutdown event), auto-commits dirty work
|
2026-04-15 14:54:20 +02:00
|
|
|
* so nothing is lost. The SF extension reads SF_CLI_WORKTREE to know
|
2026-03-18 14:57:25 -06:00
|
|
|
* when a session was launched via -w.
|
2026-03-18 20:51:27 -03:00
|
|
|
*
|
|
|
|
|
* Note: Extension modules are .ts files loaded via jiti (not compiled to .js).
|
|
|
|
|
* We use createJiti() here because this module is compiled by tsc but imports
|
2026-04-15 14:34:53 +02:00
|
|
|
* from resources/extensions/sf/ which are shipped as raw .ts (#1283).
|
2026-03-18 14:57:25 -06:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import chalk from 'chalk'
|
2026-03-18 20:51:27 -03:00
|
|
|
import { createJiti } from '@mariozechner/jiti'
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-03-18 14:57:25 -06:00
|
|
|
import { generateWorktreeName } from './worktree-name-gen.js'
|
|
|
|
|
import { existsSync } from 'node:fs'
|
M001: The Minimal Machine — linear auto-loop, sole-authority state, sidecar queue, WorktreeResolver (#1419)
* refactor: replace recursive auto-dispatch with linear autoLoop, delete ~3k lines of dead code
Replace the complex recursive dispatch system (dispatchNextUnit, reentrancy
guards, stall detection, idempotency tracking, skip-depth machinery) with a
simple linear while(s.active) loop in auto-loop.ts.
Key changes:
- New auto-loop.ts with autoLoop(), runUnit(), resolveAgentEnd()
- Deleted auto-idempotency.ts, auto-stuck-detection.ts, session-lock.ts,
mechanical-completion.ts, progress-score.ts, auto-constants.ts, unit-id.ts
- Extracted WorktreeResolver class for worktree path resolution
- Added auto-worktree-sync.ts for worktree synchronization
- Simplified auto.ts from ~1400 lines to ~400 lines
- Fixed 9 TypeScript errors (NotifyCtx type widening, capture typing)
- Comprehensive test coverage: 32 auto-loop tests + worktree resolver/DB tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address 6 audit findings in auto-loop refactor
1. CRITICAL: Move pendingResolve to AutoSession + queue orphaned agent_end
events instead of silently dropping them. Prevents permanent stalls when
error-recovery sendMessage retries fire between loop iterations.
2. HIGH: Scope pendingResolve per-session via _activeSession ref, preventing
concurrent /gsd auto sessions from corrupting each other's promises.
3. HIGH: Replace console.log in dispatchHookUnit with debugLog to prevent
hook prompt content (potentially containing secrets) from leaking to stdout.
4. HIGH: Restore parked milestone handling in state.ts — Phase 1 skips
parked milestones so they don't satisfy depends_on, Phase 2 registers
them as 'parked' status. Add 'parked' to MilestoneRegistryEntry type.
5. MEDIUM: Restore queuePhaseActive parameter in shouldBlockContextWrite
and re-export setQueuePhaseActive for guided-flow-queue.ts consumers.
6. MEDIUM: Add MAX_LOOP_ITERATIONS (500) lifetime cap to autoLoop to prevent
runaway loops when units alternate between IDs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve build breakers, add correctness fixes, and graduated recovery
Build breakers (CRITICAL):
- Restore unit-id.ts (deleted but still imported by complexity-classifier.ts, metrics.ts)
- Restore progress-score.ts (deleted but still imported by commands.ts, dashboard-overlay.ts, doctor.ts)
- Rewrite worktree-sync-milestones.test.ts to use new syncProjectRootToWorktree API
Correctness fixes (MEDIUM):
- Cap pendingAgentEndQueue to 3 entries to prevent unbounded growth from stale events
- Add milestoneId path traversal validation in WorktreeResolver
- Clear depthVerificationDone on session_start to prevent cross-session leaks in RPC mode
- Add verification gate for non-hook sidecar units (triage, quick-tasks)
- Remove dead handleAgentEnd import from index.ts
Graduated recovery (Jeremy's feedback):
- Blanket try/catch around loop body — one bad iteration no longer kills the session
- Graduated stuck recovery: at count 3 try artifact verification + cache invalidation,
at count 5 hard stop (was: binary stop at 5 with no recovery attempt)
- Graduated error recovery: 1st error retries, 2nd invalidates caches, 3rd stops
Test results: 32/32 auto-loop, 28/28 worktree-resolver, 11/11 sidecar-queue, tsc clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: restore copyWorktreeDb/reconcileWorktreeDb exports and fix loadToolApiKeys import
Two missing exports caused ~90% of the 120 pre-existing test failures:
1. copyWorktreeDb + reconcileWorktreeDb — imported by auto-worktree.ts but
never added to gsd-db.ts. Restored with the original implementations.
2. loadToolApiKeys — moved to commands-config.ts but index.ts still imported
from commands.ts. Fixed the import path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move loadToolApiKeys import to commands-config.js
loadToolApiKeys was moved to commands-config.ts but index.ts still
imported it from commands.ts, causing runtime failures in all tests
that transitively load the extension entry point.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: fix provider error assertion on windows
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:56:00 -06:00
|
|
|
import { resolveBundledSourceResource } from './bundled-resource-path.js'
|
2026-03-18 14:57:25 -06:00
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
const jiti = createJiti(fileURLToPath(import.meta.url), { interopDefault: true, debug: false })
|
2026-04-15 14:54:20 +02:00
|
|
|
const sfExtensionPath = (...segments: string[]) =>
|
|
|
|
|
resolveBundledSourceResource(import.meta.url, 'extensions', 'sf', ...segments)
|
2026-03-18 20:51:27 -03:00
|
|
|
|
|
|
|
|
// Lazily-loaded extension modules (loaded once on first use via jiti)
|
|
|
|
|
let _ext: ExtensionModules | null = null
|
|
|
|
|
|
|
|
|
|
interface ExtensionModules {
|
|
|
|
|
createWorktree: (basePath: string, name: string) => { path: string; branch: string }
|
|
|
|
|
listWorktrees: (basePath: string) => Array<{ name: string; path: string; branch: string }>
|
|
|
|
|
removeWorktree: (basePath: string, name: string, opts?: { deleteBranch?: boolean }) => void
|
|
|
|
|
mergeWorktreeToMain: (basePath: string, name: string, commitMessage: string) => void
|
|
|
|
|
diffWorktreeAll: (basePath: string, name: string) => { added: any[]; modified: any[]; removed: any[] }
|
|
|
|
|
diffWorktreeNumstat: (basePath: string, name: string) => Array<{ added: number; removed: number }>
|
|
|
|
|
worktreeBranchName: (name: string) => string
|
|
|
|
|
worktreePath: (basePath: string, name: string) => string
|
|
|
|
|
runWorktreePostCreateHook: (basePath: string, wtPath: string) => string | null
|
|
|
|
|
nativeHasChanges: (path: string) => boolean
|
|
|
|
|
nativeDetectMainBranch: (basePath: string) => string
|
|
|
|
|
nativeCommitCountBetween: (basePath: string, from: string, to: string) => number
|
|
|
|
|
inferCommitType: (name: string) => string
|
|
|
|
|
autoCommitCurrentBranch: (wtPath: string, reason: string, name: string) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadExtensionModules(): Promise<ExtensionModules> {
|
|
|
|
|
if (_ext) return _ext
|
|
|
|
|
const [wtMgr, autoWt, gitBridge, gitSvc, wt] = await Promise.all([
|
2026-04-15 14:54:20 +02:00
|
|
|
jiti.import(sfExtensionPath('worktree-manager.ts'), {}) as Promise<any>,
|
|
|
|
|
jiti.import(sfExtensionPath('auto-worktree.ts'), {}) as Promise<any>,
|
|
|
|
|
jiti.import(sfExtensionPath('native-git-bridge.ts'), {}) as Promise<any>,
|
|
|
|
|
jiti.import(sfExtensionPath('git-service.ts'), {}) as Promise<any>,
|
|
|
|
|
jiti.import(sfExtensionPath('worktree.ts'), {}) as Promise<any>,
|
2026-03-18 20:51:27 -03:00
|
|
|
])
|
|
|
|
|
_ext = {
|
|
|
|
|
createWorktree: wtMgr.createWorktree,
|
|
|
|
|
listWorktrees: wtMgr.listWorktrees,
|
|
|
|
|
removeWorktree: wtMgr.removeWorktree,
|
|
|
|
|
mergeWorktreeToMain: wtMgr.mergeWorktreeToMain,
|
|
|
|
|
diffWorktreeAll: wtMgr.diffWorktreeAll,
|
|
|
|
|
diffWorktreeNumstat: wtMgr.diffWorktreeNumstat,
|
|
|
|
|
worktreeBranchName: wtMgr.worktreeBranchName,
|
|
|
|
|
worktreePath: wtMgr.worktreePath,
|
|
|
|
|
runWorktreePostCreateHook: autoWt.runWorktreePostCreateHook,
|
|
|
|
|
nativeHasChanges: gitBridge.nativeHasChanges,
|
|
|
|
|
nativeDetectMainBranch: gitBridge.nativeDetectMainBranch,
|
|
|
|
|
nativeCommitCountBetween: gitBridge.nativeCommitCountBetween,
|
|
|
|
|
inferCommitType: gitSvc.inferCommitType,
|
|
|
|
|
autoCommitCurrentBranch: wt.autoCommitCurrentBranch,
|
|
|
|
|
}
|
|
|
|
|
return _ext
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:57:25 -06:00
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface WorktreeStatus {
|
|
|
|
|
name: string
|
|
|
|
|
path: string
|
|
|
|
|
branch: string
|
|
|
|
|
exists: boolean
|
|
|
|
|
filesChanged: number
|
|
|
|
|
linesAdded: number
|
|
|
|
|
linesRemoved: number
|
|
|
|
|
uncommitted: boolean
|
|
|
|
|
commits: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Status Helpers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
function getWorktreeStatus(ext: ExtensionModules, basePath: string, name: string, wtPath: string): WorktreeStatus {
|
|
|
|
|
const diff = ext.diffWorktreeAll(basePath, name)
|
|
|
|
|
const numstat = ext.diffWorktreeNumstat(basePath, name)
|
2026-03-18 14:57:25 -06:00
|
|
|
const filesChanged = diff.added.length + diff.modified.length + diff.removed.length
|
|
|
|
|
let linesAdded = 0
|
|
|
|
|
let linesRemoved = 0
|
|
|
|
|
for (const s of numstat) { linesAdded += s.added; linesRemoved += s.removed }
|
|
|
|
|
|
|
|
|
|
let uncommitted = false
|
2026-03-18 20:51:27 -03:00
|
|
|
try { uncommitted = existsSync(wtPath) && ext.nativeHasChanges(wtPath) } catch { /* */ }
|
2026-03-18 14:57:25 -06:00
|
|
|
|
|
|
|
|
let commits = 0
|
|
|
|
|
try {
|
2026-03-18 20:51:27 -03:00
|
|
|
const mainBranch = ext.nativeDetectMainBranch(basePath)
|
|
|
|
|
commits = ext.nativeCommitCountBetween(basePath, mainBranch, ext.worktreeBranchName(name))
|
2026-03-18 14:57:25 -06:00
|
|
|
} catch { /* */ }
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
path: wtPath,
|
2026-03-18 20:51:27 -03:00
|
|
|
branch: ext.worktreeBranchName(name),
|
2026-03-18 14:57:25 -06:00
|
|
|
exists: existsSync(wtPath),
|
|
|
|
|
filesChanged,
|
|
|
|
|
linesAdded,
|
|
|
|
|
linesRemoved,
|
|
|
|
|
uncommitted,
|
|
|
|
|
commits,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Formatters ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function formatStatus(s: WorktreeStatus): string {
|
|
|
|
|
const lines: string[] = []
|
|
|
|
|
const badge = s.uncommitted
|
|
|
|
|
? chalk.yellow(' (uncommitted)')
|
|
|
|
|
: s.filesChanged > 0
|
|
|
|
|
? chalk.cyan(' (unmerged)')
|
|
|
|
|
: chalk.green(' (clean)')
|
|
|
|
|
|
|
|
|
|
lines.push(` ${chalk.bold.cyan(s.name)}${badge}`)
|
|
|
|
|
lines.push(` ${chalk.dim('branch')} ${chalk.magenta(s.branch)}`)
|
|
|
|
|
lines.push(` ${chalk.dim('path')} ${chalk.dim(s.path)}`)
|
|
|
|
|
|
|
|
|
|
if (s.filesChanged > 0) {
|
|
|
|
|
lines.push(` ${chalk.dim('diff')} ${s.filesChanged} files, ${chalk.green(`+${s.linesAdded}`)} ${chalk.red(`-${s.linesRemoved}`)}, ${s.commits} commit${s.commits === 1 ? '' : 's'}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lines.join('\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Subcommand: list ───────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
async function handleList(basePath: string): Promise<void> {
|
|
|
|
|
const ext = await loadExtensionModules()
|
|
|
|
|
const worktrees = ext.listWorktrees(basePath)
|
2026-03-18 14:57:25 -06:00
|
|
|
|
|
|
|
|
if (worktrees.length === 0) {
|
2026-04-15 15:37:12 +02:00
|
|
|
process.stderr.write(chalk.dim('No worktrees. Create one with: sf -w <name>\n'))
|
2026-03-18 14:57:25 -06:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.stderr.write(chalk.bold('\nWorktrees\n\n'))
|
|
|
|
|
for (const wt of worktrees) {
|
2026-03-18 20:51:27 -03:00
|
|
|
const status = getWorktreeStatus(ext, basePath, wt.name, wt.path)
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(formatStatus(status) + '\n\n')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Subcommand: merge ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function handleMerge(basePath: string, args: string[]): Promise<void> {
|
2026-03-18 20:51:27 -03:00
|
|
|
const ext = await loadExtensionModules()
|
2026-03-18 14:57:25 -06:00
|
|
|
const name = args[0]
|
|
|
|
|
if (!name) {
|
|
|
|
|
// If only one worktree exists, merge it
|
2026-03-18 20:51:27 -03:00
|
|
|
const worktrees = ext.listWorktrees(basePath)
|
2026-03-18 14:57:25 -06:00
|
|
|
if (worktrees.length === 1) {
|
2026-03-18 20:51:27 -03:00
|
|
|
await doMerge(ext, basePath, worktrees[0].name)
|
2026-03-18 14:57:25 -06:00
|
|
|
return
|
|
|
|
|
}
|
2026-04-15 15:37:12 +02:00
|
|
|
process.stderr.write(chalk.red('Usage: sf worktree merge <name>\n'))
|
|
|
|
|
process.stderr.write(chalk.dim('Run sf worktree list to see worktrees.\n'))
|
2026-03-18 14:57:25 -06:00
|
|
|
process.exit(1)
|
|
|
|
|
}
|
2026-03-18 20:51:27 -03:00
|
|
|
await doMerge(ext, basePath, name)
|
2026-03-18 14:57:25 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
async function doMerge(ext: ExtensionModules, basePath: string, name: string): Promise<void> {
|
|
|
|
|
const worktrees = ext.listWorktrees(basePath)
|
2026-03-18 14:57:25 -06:00
|
|
|
const wt = worktrees.find(w => w.name === name)
|
|
|
|
|
if (!wt) {
|
|
|
|
|
process.stderr.write(chalk.red(`Worktree "${name}" not found.\n`))
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
const status = getWorktreeStatus(ext, basePath, name, wt.path)
|
2026-03-18 14:57:25 -06:00
|
|
|
if (status.filesChanged === 0 && !status.uncommitted) {
|
|
|
|
|
process.stderr.write(chalk.dim(`Worktree "${name}" has no changes to merge.\n`))
|
|
|
|
|
// Clean up empty worktree
|
2026-03-18 20:51:27 -03:00
|
|
|
ext.removeWorktree(basePath, name, { deleteBranch: true })
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.green(`Removed empty worktree ${chalk.bold(name)}.\n`))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-commit dirty work before merge
|
|
|
|
|
if (status.uncommitted) {
|
|
|
|
|
try {
|
2026-03-18 20:51:27 -03:00
|
|
|
ext.autoCommitCurrentBranch(wt.path, 'worktree-merge', name)
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.dim(' Auto-committed dirty work before merge.\n'))
|
|
|
|
|
} catch { /* best-effort */ }
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
const commitType = ext.inferCommitType(name)
|
2026-04-15 15:51:38 +02:00
|
|
|
const commitMessage = `${commitType}: merge worktree ${name}\n\nSF-Worktree: ${name}`
|
2026-03-18 14:57:25 -06:00
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
process.stderr.write(`\nMerging ${chalk.bold.cyan(name)} → ${chalk.magenta(ext.nativeDetectMainBranch(basePath))}\n`)
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.dim(` ${status.filesChanged} files, ${chalk.green(`+${status.linesAdded}`)} ${chalk.red(`-${status.linesRemoved}`)}\n\n`))
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-18 20:51:27 -03:00
|
|
|
ext.mergeWorktreeToMain(basePath, name, commitMessage)
|
|
|
|
|
ext.removeWorktree(basePath, name, { deleteBranch: true })
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.green(`✓ Merged and cleaned up ${chalk.bold(name)}\n`))
|
|
|
|
|
process.stderr.write(chalk.dim(` commit: ${commitMessage}\n`))
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
process.stderr.write(chalk.red(`✗ Merge failed: ${msg}\n`))
|
2026-04-15 15:37:12 +02:00
|
|
|
process.stderr.write(chalk.dim(' Resolve conflicts manually, then run sf worktree merge again.\n'))
|
2026-03-18 14:57:25 -06:00
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Subcommand: clean ──────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
async function handleClean(basePath: string): Promise<void> {
|
|
|
|
|
const ext = await loadExtensionModules()
|
|
|
|
|
const worktrees = ext.listWorktrees(basePath)
|
2026-03-18 14:57:25 -06:00
|
|
|
if (worktrees.length === 0) {
|
|
|
|
|
process.stderr.write(chalk.dim('No worktrees to clean.\n'))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cleaned = 0
|
|
|
|
|
for (const wt of worktrees) {
|
2026-03-18 20:51:27 -03:00
|
|
|
const status = getWorktreeStatus(ext, basePath, wt.name, wt.path)
|
2026-03-18 14:57:25 -06:00
|
|
|
if (status.filesChanged === 0 && !status.uncommitted) {
|
|
|
|
|
try {
|
2026-03-18 20:51:27 -03:00
|
|
|
ext.removeWorktree(basePath, wt.name, { deleteBranch: true })
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.green(` ✓ Removed ${chalk.bold(wt.name)} (clean)\n`))
|
|
|
|
|
cleaned++
|
|
|
|
|
} catch {
|
|
|
|
|
process.stderr.write(chalk.yellow(` ✗ Failed to remove ${wt.name}\n`))
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
process.stderr.write(chalk.dim(` ─ Kept ${chalk.bold(wt.name)} (${status.filesChanged} changed files)\n`))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.stderr.write(chalk.dim(`\nCleaned ${cleaned} worktree${cleaned === 1 ? '' : 's'}.\n`))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Subcommand: remove ─────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
async function handleRemove(basePath: string, args: string[]): Promise<void> {
|
|
|
|
|
const ext = await loadExtensionModules()
|
2026-03-18 14:57:25 -06:00
|
|
|
const name = args[0]
|
|
|
|
|
if (!name) {
|
2026-04-15 15:37:12 +02:00
|
|
|
process.stderr.write(chalk.red('Usage: sf worktree remove <name>\n'))
|
2026-03-18 14:57:25 -06:00
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
const worktrees = ext.listWorktrees(basePath)
|
2026-03-18 14:57:25 -06:00
|
|
|
const wt = worktrees.find(w => w.name === name)
|
|
|
|
|
if (!wt) {
|
|
|
|
|
process.stderr.write(chalk.red(`Worktree "${name}" not found.\n`))
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
const status = getWorktreeStatus(ext, basePath, name, wt.path)
|
2026-03-18 14:57:25 -06:00
|
|
|
if (status.filesChanged > 0 || status.uncommitted) {
|
|
|
|
|
process.stderr.write(chalk.yellow(`⚠ Worktree "${name}" has unmerged changes (${status.filesChanged} files).\n`))
|
2026-04-15 15:37:12 +02:00
|
|
|
process.stderr.write(chalk.yellow(' Use --force to remove anyway, or merge first: sf worktree merge ' + name + '\n'))
|
2026-03-18 14:57:25 -06:00
|
|
|
if (!process.argv.includes('--force')) {
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
ext.removeWorktree(basePath, name, { deleteBranch: true })
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.green(`✓ Removed worktree ${chalk.bold(name)}\n`))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Subcommand: status (default when no args) ─────────────────────────────
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
async function handleStatusBanner(basePath: string): Promise<void> {
|
|
|
|
|
const ext = await loadExtensionModules()
|
|
|
|
|
const worktrees = ext.listWorktrees(basePath)
|
2026-03-18 14:57:25 -06:00
|
|
|
if (worktrees.length === 0) return
|
|
|
|
|
|
|
|
|
|
const withChanges = worktrees.filter(wt => {
|
|
|
|
|
try {
|
2026-03-18 20:51:27 -03:00
|
|
|
const diff = ext.diffWorktreeAll(basePath, wt.name)
|
2026-03-18 14:57:25 -06:00
|
|
|
return diff.added.length + diff.modified.length + diff.removed.length > 0
|
|
|
|
|
} catch { return false }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (withChanges.length === 0) return
|
|
|
|
|
|
|
|
|
|
const names = withChanges.map(w => chalk.cyan(w.name)).join(', ')
|
|
|
|
|
process.stderr.write(
|
2026-04-15 14:11:45 +02:00
|
|
|
chalk.dim('[forge] ') +
|
2026-03-18 14:57:25 -06:00
|
|
|
chalk.yellow(`${withChanges.length} worktree${withChanges.length === 1 ? '' : 's'} with unmerged changes: `) +
|
|
|
|
|
names + '\n' +
|
2026-04-15 14:11:45 +02:00
|
|
|
chalk.dim('[forge] ') +
|
2026-04-15 15:37:12 +02:00
|
|
|
chalk.dim('Resume: sf -w <name> | Merge: sf worktree merge <name> | List: sf worktree list\n\n'),
|
2026-03-18 14:57:25 -06:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── -w flag: create/resume worktree for interactive session ────────────────
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
async function handleWorktreeFlag(worktreeFlag: boolean | string): Promise<void> {
|
|
|
|
|
const ext = await loadExtensionModules()
|
2026-03-18 14:57:25 -06:00
|
|
|
const basePath = process.cwd()
|
|
|
|
|
|
2026-04-15 15:37:12 +02:00
|
|
|
// sf -w (no name) — resume most recent worktree with changes, or create new
|
2026-03-18 14:57:25 -06:00
|
|
|
if (worktreeFlag === true) {
|
2026-03-18 20:51:27 -03:00
|
|
|
const existing = ext.listWorktrees(basePath)
|
2026-03-18 14:57:25 -06:00
|
|
|
const withChanges = existing.filter(wt => {
|
|
|
|
|
try {
|
2026-03-18 20:51:27 -03:00
|
|
|
const diff = ext.diffWorktreeAll(basePath, wt.name)
|
2026-03-18 14:57:25 -06:00
|
|
|
return diff.added.length + diff.modified.length + diff.removed.length > 0
|
|
|
|
|
} catch { return false }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (withChanges.length === 1) {
|
|
|
|
|
// Single active worktree — resume it
|
|
|
|
|
const wt = withChanges[0]
|
|
|
|
|
process.chdir(wt.path)
|
2026-04-15 14:54:20 +02:00
|
|
|
process.env.SF_CLI_WORKTREE = wt.name
|
|
|
|
|
process.env.SF_CLI_WORKTREE_BASE = basePath
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.green(`✓ Resumed worktree ${chalk.bold(wt.name)}\n`))
|
|
|
|
|
process.stderr.write(chalk.dim(` path ${wt.path}\n`))
|
|
|
|
|
process.stderr.write(chalk.dim(` branch ${wt.branch}\n\n`))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (withChanges.length > 1) {
|
|
|
|
|
// Multiple active worktrees — show them and ask user to pick
|
|
|
|
|
process.stderr.write(chalk.yellow(`${withChanges.length} worktrees have unmerged changes:\n\n`))
|
|
|
|
|
for (const wt of withChanges) {
|
2026-03-18 20:51:27 -03:00
|
|
|
const status = getWorktreeStatus(ext, basePath, wt.name, wt.path)
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(formatStatus(status) + '\n\n')
|
|
|
|
|
}
|
2026-04-15 15:37:12 +02:00
|
|
|
process.stderr.write(chalk.dim('Specify which one: sf -w <name>\n'))
|
2026-03-18 14:57:25 -06:00
|
|
|
process.exit(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No active worktrees — create a new one
|
|
|
|
|
const name = generateWorktreeName()
|
2026-03-18 20:51:27 -03:00
|
|
|
await createAndEnter(ext, basePath, name)
|
2026-03-18 14:57:25 -06:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 15:37:12 +02:00
|
|
|
// sf -w <name> — create or resume named worktree
|
2026-03-18 14:57:25 -06:00
|
|
|
const name = worktreeFlag as string
|
2026-03-18 20:51:27 -03:00
|
|
|
const existing = ext.listWorktrees(basePath)
|
2026-03-18 14:57:25 -06:00
|
|
|
const found = existing.find(wt => wt.name === name)
|
|
|
|
|
|
|
|
|
|
if (found) {
|
|
|
|
|
process.chdir(found.path)
|
2026-04-15 14:54:20 +02:00
|
|
|
process.env.SF_CLI_WORKTREE = name
|
|
|
|
|
process.env.SF_CLI_WORKTREE_BASE = basePath
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.green(`✓ Resumed worktree ${chalk.bold(name)}\n`))
|
|
|
|
|
process.stderr.write(chalk.dim(` path ${found.path}\n`))
|
|
|
|
|
process.stderr.write(chalk.dim(` branch ${found.branch}\n\n`))
|
|
|
|
|
} else {
|
2026-03-18 20:51:27 -03:00
|
|
|
await createAndEnter(ext, basePath, name)
|
2026-03-18 14:57:25 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
async function createAndEnter(ext: ExtensionModules, basePath: string, name: string): Promise<void> {
|
2026-03-18 14:57:25 -06:00
|
|
|
try {
|
2026-03-18 20:51:27 -03:00
|
|
|
const info = ext.createWorktree(basePath, name)
|
2026-03-18 14:57:25 -06:00
|
|
|
|
2026-03-18 20:51:27 -03:00
|
|
|
const hookError = ext.runWorktreePostCreateHook(basePath, info.path)
|
2026-03-18 14:57:25 -06:00
|
|
|
if (hookError) {
|
2026-04-15 14:11:45 +02:00
|
|
|
process.stderr.write(chalk.yellow(`[forge] ${hookError}\n`))
|
2026-03-18 14:57:25 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.chdir(info.path)
|
2026-04-15 14:54:20 +02:00
|
|
|
process.env.SF_CLI_WORKTREE = name
|
|
|
|
|
process.env.SF_CLI_WORKTREE_BASE = basePath
|
2026-03-18 14:57:25 -06:00
|
|
|
process.stderr.write(chalk.green(`✓ Created worktree ${chalk.bold(name)}\n`))
|
|
|
|
|
process.stderr.write(chalk.dim(` path ${info.path}\n`))
|
|
|
|
|
process.stderr.write(chalk.dim(` branch ${info.branch}\n\n`))
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err instanceof Error ? err.message : String(err)
|
2026-04-15 14:11:45 +02:00
|
|
|
process.stderr.write(chalk.red(`[forge] Failed to create worktree: ${msg}\n`))
|
2026-03-18 14:57:25 -06:00
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Exports ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export {
|
|
|
|
|
handleList,
|
|
|
|
|
handleMerge,
|
|
|
|
|
handleClean,
|
|
|
|
|
handleRemove,
|
|
|
|
|
handleStatusBanner,
|
|
|
|
|
handleWorktreeFlag,
|
|
|
|
|
getWorktreeStatus,
|
|
|
|
|
}
|