diff --git a/src/cli.ts b/src/cli.ts index 1216af4e5..15ccfe042 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ interface CliFlags { print?: boolean continue?: boolean noSession?: boolean + worktree?: boolean | string model?: string listModels?: string | true extensions: string[] @@ -81,6 +82,13 @@ function parseCliArgs(argv: string[]): CliFlags { } else if (arg === '--version' || arg === '-v') { process.stdout.write((process.env.GSD_VERSION || '0.0.0') + '\n') process.exit(0) + } else if (arg === '--worktree' || arg === '-w') { + // -w with no value → auto-generate name; -w → use that name + if (i + 1 < args.length && !args[i + 1].startsWith('-')) { + flags.worktree = args[++i] + } else { + flags.worktree = true + } } else if (arg === '--help' || arg === '-h') { printHelp(process.env.GSD_VERSION || '0.0.0') process.exit(0) @@ -408,6 +416,47 @@ if (isPrintMode) { process.exit(0) } +// --------------------------------------------------------------------------- +// Worktree subcommand — `gsd worktree ` +// --------------------------------------------------------------------------- +if (cliFlags.messages[0] === 'worktree' || cliFlags.messages[0] === 'wt') { + const { handleList, handleMerge, handleClean, handleRemove } = await import('./worktree-cli.js') + const sub = cliFlags.messages[1] + const subArgs = cliFlags.messages.slice(2) + + if (!sub || sub === 'list') { + handleList(process.cwd()) + } else if (sub === 'merge') { + await handleMerge(process.cwd(), subArgs) + } else if (sub === 'clean') { + handleClean(process.cwd()) + } else if (sub === 'remove' || sub === 'rm') { + handleRemove(process.cwd(), subArgs) + } else { + process.stderr.write(`Unknown worktree command: ${sub}\n`) + process.stderr.write('Commands: list, merge [name], clean, remove \n') + } + process.exit(0) +} + +// --------------------------------------------------------------------------- +// Worktree flag (-w) — create/resume a worktree for the interactive session +// --------------------------------------------------------------------------- +if (cliFlags.worktree) { + const { handleWorktreeFlag } = await import('./worktree-cli.js') + handleWorktreeFlag(cliFlags.worktree) +} + +// --------------------------------------------------------------------------- +// Active worktree banner — remind user of unmerged worktrees on normal launch +// --------------------------------------------------------------------------- +if (!cliFlags.worktree && !isPrintMode) { + try { + const { handleStatusBanner } = await import('./worktree-cli.js') + handleStatusBanner(process.cwd()) + } catch { /* non-fatal */ } +} + // --------------------------------------------------------------------------- // Interactive mode — normal TTY session // --------------------------------------------------------------------------- diff --git a/src/help-text.ts b/src/help-text.ts index 31c50b51a..03f873bda 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -32,6 +32,38 @@ const SUBCOMMAND_HELP: Record = { 'Compare with --continue (-c) which always resumes the most recent session.', ].join('\n'), + worktree: [ + 'Usage: gsd worktree [args]', + '', + 'Manage isolated git worktrees for parallel work streams.', + '', + 'Commands:', + ' list List worktrees with status (files changed, commits, dirty)', + ' merge [name] Squash-merge a worktree into main and clean up', + ' clean Remove all worktrees that have been merged or are empty', + ' remove Remove a worktree (--force to remove with unmerged changes)', + '', + 'The -w flag creates/resumes worktrees for interactive sessions:', + ' gsd -w Auto-name a new worktree, or resume the only active one', + ' gsd -w my-feature Create or resume a named worktree', + '', + 'Lifecycle:', + ' 1. gsd -w Create worktree, start session inside it', + ' 2. (work normally) All changes happen on the worktree branch', + ' 3. Ctrl+C Exit — dirty work is auto-committed', + ' 4. gsd -w Resume where you left off', + ' 5. gsd worktree merge Squash-merge into main when done', + '', + 'Examples:', + ' gsd -w Start in a new auto-named worktree', + ' gsd -w auth-refactor Create/resume "auth-refactor" worktree', + ' gsd worktree list See all worktrees and their status', + ' gsd worktree merge auth-refactor Merge and clean up', + ' gsd worktree clean Remove all merged/empty worktrees', + ' gsd worktree remove old-branch Remove a specific worktree', + ' gsd worktree remove old-branch --force Remove even with unmerged changes', + ].join('\n'), + headless: [ 'Usage: gsd headless [flags] [command] [args...]', '', @@ -76,6 +108,9 @@ const SUBCOMMAND_HELP: Record = { ].join('\n'), } +// Alias: `gsd wt --help` → same as `gsd worktree --help` +SUBCOMMAND_HELP['wt'] = SUBCOMMAND_HELP['worktree'] + export function printHelp(version: string): void { process.stdout.write(`GSD v${version} — Get Shit Done\n\n`) process.stdout.write('Usage: gsd [options] [message...]\n\n') @@ -83,6 +118,7 @@ export function printHelp(version: string): void { process.stdout.write(' --mode Output mode (default: interactive)\n') process.stdout.write(' --print, -p Single-shot print mode\n') process.stdout.write(' --continue, -c Resume the most recent session\n') + process.stdout.write(' --worktree, -w [name] Start in an isolated worktree (auto-named if omitted)\n') process.stdout.write(' --model Override model (e.g. claude-opus-4-6)\n') process.stdout.write(' --no-session Disable session persistence\n') process.stdout.write(' --extension Load additional extension\n') @@ -94,6 +130,7 @@ export function printHelp(version: string): void { process.stdout.write(' config Re-run the setup wizard\n') process.stdout.write(' update Update GSD to the latest version\n') process.stdout.write(' sessions List and resume a past session\n') + process.stdout.write(' worktree Manage worktrees (list, merge, clean, remove)\n') process.stdout.write(' headless [cmd] [args] Run /gsd commands without TUI (default: auto)\n') process.stdout.write('\nRun gsd --help for subcommand-specific help.\n') } diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index a4903f639..58fd21a1b 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -1048,6 +1048,19 @@ export default function (pi: ExtensionAPI) { } catch { /* best-effort */ } } + // Auto-commit dirty work in CLI-spawned worktrees so nothing is lost. + // The CLI sets GSD_CLI_WORKTREE when launched with -w. + const cliWorktree = process.env.GSD_CLI_WORKTREE; + if (cliWorktree) { + try { + const { autoCommitCurrentBranch } = await import("./worktree.js"); + const msg = autoCommitCurrentBranch(process.cwd(), "session-end", cliWorktree); + if (msg) { + ctx.ui.notify(`Auto-committed worktree ${cliWorktree} before exit.`, "info"); + } + } catch { /* best-effort */ } + } + if (!isAutoActive() && !isAutoPaused()) return; // Save the current session — the lock file stays on disk diff --git a/src/worktree-cli.ts b/src/worktree-cli.ts new file mode 100644 index 000000000..2af3b2cad --- /dev/null +++ b/src/worktree-cli.ts @@ -0,0 +1,361 @@ +/** + * GSD Worktree CLI — standalone subcommand and -w flag handling. + * + * Manages the full worktree lifecycle from the command line: + * gsd -w Create auto-named worktree, start interactive session + * gsd -w my-feature Create/resume named worktree + * gsd worktree list List worktrees with status + * gsd worktree merge [name] Squash-merge a worktree into main + * gsd worktree clean Remove all merged/empty worktrees + * gsd worktree remove Remove a specific worktree + * + * On session exit (via session_shutdown event), auto-commits dirty work + * so nothing is lost. The GSD extension reads GSD_CLI_WORKTREE to know + * when a session was launched via -w. + */ + +import chalk from 'chalk' +import { + createWorktree, + listWorktrees, + removeWorktree, + mergeWorktreeToMain, + diffWorktreeAll, + diffWorktreeNumstat, + worktreeBranchName, + worktreePath, +} from './resources/extensions/gsd/worktree-manager.js' +import { runWorktreePostCreateHook } from './resources/extensions/gsd/auto-worktree.js' +import { generateWorktreeName } from './worktree-name-gen.js' +import { + nativeHasChanges, + nativeWorkingTreeStatus, + nativeDetectMainBranch, + nativeCommitCountBetween, +} from './resources/extensions/gsd/native-git-bridge.js' +import { inferCommitType } from './resources/extensions/gsd/git-service.js' +import { existsSync } from 'node:fs' + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface WorktreeStatus { + name: string + path: string + branch: string + exists: boolean + filesChanged: number + linesAdded: number + linesRemoved: number + uncommitted: boolean + commits: number +} + +// ─── Status Helpers ───────────────────────────────────────────────────────── + +function getWorktreeStatus(basePath: string, name: string, wtPath: string): WorktreeStatus { + const diff = diffWorktreeAll(basePath, name) + const numstat = diffWorktreeNumstat(basePath, name) + 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 + try { uncommitted = existsSync(wtPath) && nativeHasChanges(wtPath) } catch { /* */ } + + let commits = 0 + try { + const mainBranch = nativeDetectMainBranch(basePath) + commits = nativeCommitCountBetween(basePath, mainBranch, worktreeBranchName(name)) + } catch { /* */ } + + return { + name, + path: wtPath, + branch: worktreeBranchName(name), + 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 ─────────────────────────────────────────────────────── + +function handleList(basePath: string): void { + const worktrees = listWorktrees(basePath) + + if (worktrees.length === 0) { + process.stderr.write(chalk.dim('No worktrees. Create one with: gsd -w \n')) + return + } + + process.stderr.write(chalk.bold('\nWorktrees\n\n')) + for (const wt of worktrees) { + const status = getWorktreeStatus(basePath, wt.name, wt.path) + process.stderr.write(formatStatus(status) + '\n\n') + } +} + +// ─── Subcommand: merge ────────────────────────────────────────────────────── + +async function handleMerge(basePath: string, args: string[]): Promise { + const name = args[0] + if (!name) { + // If only one worktree exists, merge it + const worktrees = listWorktrees(basePath) + if (worktrees.length === 1) { + await doMerge(basePath, worktrees[0].name) + return + } + process.stderr.write(chalk.red('Usage: gsd worktree merge \n')) + process.stderr.write(chalk.dim('Run gsd worktree list to see worktrees.\n')) + process.exit(1) + } + await doMerge(basePath, name) +} + +async function doMerge(basePath: string, name: string): Promise { + const worktrees = listWorktrees(basePath) + const wt = worktrees.find(w => w.name === name) + if (!wt) { + process.stderr.write(chalk.red(`Worktree "${name}" not found.\n`)) + process.exit(1) + } + + const status = getWorktreeStatus(basePath, name, wt.path) + if (status.filesChanged === 0 && !status.uncommitted) { + process.stderr.write(chalk.dim(`Worktree "${name}" has no changes to merge.\n`)) + // Clean up empty worktree + removeWorktree(basePath, name, { deleteBranch: true }) + process.stderr.write(chalk.green(`Removed empty worktree ${chalk.bold(name)}.\n`)) + return + } + + // Auto-commit dirty work before merge + if (status.uncommitted) { + try { + const { autoCommitCurrentBranch } = await import('./resources/extensions/gsd/worktree.js') + autoCommitCurrentBranch(wt.path, 'worktree-merge', name) + process.stderr.write(chalk.dim(' Auto-committed dirty work before merge.\n')) + } catch { /* best-effort */ } + } + + const commitType = inferCommitType(name) + const commitMessage = `${commitType}(${name}): merge worktree ${name}` + + process.stderr.write(`\nMerging ${chalk.bold.cyan(name)} → ${chalk.magenta(nativeDetectMainBranch(basePath))}\n`) + process.stderr.write(chalk.dim(` ${status.filesChanged} files, ${chalk.green(`+${status.linesAdded}`)} ${chalk.red(`-${status.linesRemoved}`)}\n\n`)) + + try { + mergeWorktreeToMain(basePath, name, commitMessage) + removeWorktree(basePath, name, { deleteBranch: true }) + 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`)) + process.stderr.write(chalk.dim(' Resolve conflicts manually, then run gsd worktree merge again.\n')) + process.exit(1) + } +} + +// ─── Subcommand: clean ────────────────────────────────────────────────────── + +function handleClean(basePath: string): void { + const worktrees = listWorktrees(basePath) + if (worktrees.length === 0) { + process.stderr.write(chalk.dim('No worktrees to clean.\n')) + return + } + + let cleaned = 0 + for (const wt of worktrees) { + const status = getWorktreeStatus(basePath, wt.name, wt.path) + if (status.filesChanged === 0 && !status.uncommitted) { + try { + removeWorktree(basePath, wt.name, { deleteBranch: true }) + 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 ───────────────────────────────────────────────────── + +function handleRemove(basePath: string, args: string[]): void { + const name = args[0] + if (!name) { + process.stderr.write(chalk.red('Usage: gsd worktree remove \n')) + process.exit(1) + } + + const worktrees = listWorktrees(basePath) + const wt = worktrees.find(w => w.name === name) + if (!wt) { + process.stderr.write(chalk.red(`Worktree "${name}" not found.\n`)) + process.exit(1) + } + + const status = getWorktreeStatus(basePath, name, wt.path) + if (status.filesChanged > 0 || status.uncommitted) { + process.stderr.write(chalk.yellow(`⚠ Worktree "${name}" has unmerged changes (${status.filesChanged} files).\n`)) + process.stderr.write(chalk.yellow(' Use --force to remove anyway, or merge first: gsd worktree merge ' + name + '\n')) + if (!process.argv.includes('--force')) { + process.exit(1) + } + } + + removeWorktree(basePath, name, { deleteBranch: true }) + process.stderr.write(chalk.green(`✓ Removed worktree ${chalk.bold(name)}\n`)) +} + +// ─── Subcommand: status (default when no args) ───────────────────────────── + +function handleStatusBanner(basePath: string): void { + const worktrees = listWorktrees(basePath) + if (worktrees.length === 0) return + + const withChanges = worktrees.filter(wt => { + try { + const diff = diffWorktreeAll(basePath, wt.name) + 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( + chalk.dim('[gsd] ') + + chalk.yellow(`${withChanges.length} worktree${withChanges.length === 1 ? '' : 's'} with unmerged changes: `) + + names + '\n' + + chalk.dim('[gsd] ') + + chalk.dim('Resume: gsd -w | Merge: gsd worktree merge | List: gsd worktree list\n\n'), + ) +} + +// ─── -w flag: create/resume worktree for interactive session ──────────────── + +function handleWorktreeFlag(worktreeFlag: boolean | string): void { + const basePath = process.cwd() + + // gsd -w (no name) — resume most recent worktree with changes, or create new + if (worktreeFlag === true) { + const existing = listWorktrees(basePath) + const withChanges = existing.filter(wt => { + try { + const diff = diffWorktreeAll(basePath, wt.name) + 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) + process.env.GSD_CLI_WORKTREE = wt.name + process.env.GSD_CLI_WORKTREE_BASE = basePath + 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) { + const status = getWorktreeStatus(basePath, wt.name, wt.path) + process.stderr.write(formatStatus(status) + '\n\n') + } + process.stderr.write(chalk.dim('Specify which one: gsd -w \n')) + process.exit(0) + } + + // No active worktrees — create a new one + const name = generateWorktreeName() + createAndEnter(basePath, name) + return + } + + // gsd -w — create or resume named worktree + const name = worktreeFlag as string + const existing = listWorktrees(basePath) + const found = existing.find(wt => wt.name === name) + + if (found) { + process.chdir(found.path) + process.env.GSD_CLI_WORKTREE = name + process.env.GSD_CLI_WORKTREE_BASE = basePath + 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 { + createAndEnter(basePath, name) + } +} + +function createAndEnter(basePath: string, name: string): void { + try { + const info = createWorktree(basePath, name) + + const hookError = runWorktreePostCreateHook(basePath, info.path) + if (hookError) { + process.stderr.write(chalk.yellow(`[gsd] ${hookError}\n`)) + } + + process.chdir(info.path) + process.env.GSD_CLI_WORKTREE = name + process.env.GSD_CLI_WORKTREE_BASE = basePath + 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) + process.stderr.write(chalk.red(`[gsd] Failed to create worktree: ${msg}\n`)) + process.exit(1) + } +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +export { + handleList, + handleMerge, + handleClean, + handleRemove, + handleStatusBanner, + handleWorktreeFlag, + getWorktreeStatus, +} diff --git a/src/worktree-name-gen.ts b/src/worktree-name-gen.ts new file mode 100644 index 000000000..6c228e9a5 --- /dev/null +++ b/src/worktree-name-gen.ts @@ -0,0 +1,49 @@ +/** + * Random worktree name generator. + * + * Produces names in the pattern: adjective-verbing-noun + * e.g. "noble-roaming-karp", "swift-whistling-matsumoto" + */ + +const ADJECTIVES = [ + 'agile', 'bold', 'brave', 'bright', 'calm', 'clear', 'cool', 'crisp', + 'dapper', 'eager', 'fair', 'fast', 'fierce', 'fine', 'fleet', 'fond', + 'gentle', 'glad', 'grand', 'happy', 'keen', 'kind', 'lively', 'lucid', + 'mellow', 'merry', 'mighty', 'neat', 'nimble', 'noble', 'plucky', 'polite', + 'proud', 'quiet', 'rapid', 'ready', 'serene', 'sharp', 'sleek', 'sleepy', + 'smooth', 'snappy', 'steady', 'sturdy', 'sunny', 'sure', 'swift', 'tidy', + 'tough', 'tranquil', 'vivid', 'warm', 'wise', 'witty', 'zesty', +] + +const VERBS = [ + 'baking', 'bouncing', 'building', 'carving', 'chasing', 'climbing', + 'coding', 'crafting', 'dancing', 'dashing', 'diving', 'drawing', + 'dreaming', 'drifting', 'drumming', 'exploring', 'fishing', 'floating', + 'flying', 'forging', 'gliding', 'growing', 'hiking', 'humming', + 'jumping', 'juggling', 'knitting', 'laughing', 'leaping', 'mapping', + 'mixing', 'painting', 'planting', 'playing', 'racing', 'reading', + 'riding', 'roaming', 'rowing', 'running', 'sailing', 'singing', + 'skating', 'sketching', 'spinning', 'squishing', 'surfing', 'swimming', + 'thinking', 'threading', 'tracing', 'walking', 'weaving', 'whistling', + 'writing', +] + +const NOUNS = [ + 'atlas', 'aurora', 'balloon', 'beacon', 'bolt', 'brook', 'canyon', + 'cedar', 'comet', 'cook', 'coral', 'cosmos', 'crest', 'dawn', 'delta', + 'echo', 'ember', 'falcon', 'fern', 'flare', 'frost', 'gale', 'glacier', + 'grove', 'harbor', 'hawk', 'horizon', 'iris', 'jade', 'karp', 'lantern', + 'lark', 'luna', 'maple', 'marsh', 'matsumoto', 'mesa', 'nebula', 'oasis', + 'orbit', 'otter', 'pebble', 'phoenix', 'pine', 'prism', 'puppy', 'quartz', + 'raven', 'reef', 'ridge', 'river', 'sage', 'shore', 'sierra', 'spark', + 'sprout', 'stone', 'summit', 'thorn', 'tide', 'topaz', 'trail', 'vale', + 'violet', 'wave', 'willow', 'zenith', +] + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]! +} + +export function generateWorktreeName(): string { + return `${pick(ADJECTIVES)}-${pick(VERBS)}-${pick(NOUNS)}` +}