feat: add -w/--worktree CLI flag for isolated worktree sessions (#1247)
* feat: add -w/--worktree CLI flag to start in an isolated worktree Enables `gsd -w` to auto-create a randomly-named worktree (adjective-verbing-noun pattern) and `gsd -w my-feature` for named worktrees. Reuses existing worktree infrastructure under .gsd/worktrees/ with worktree/<name> branches. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: full worktree lifecycle — subcommands, auto-commit on exit, status banners Major improvements to the -w/--worktree system: - `gsd worktree list` — show worktrees with status (files changed, commits, dirty) - `gsd worktree merge [name]` — squash-merge into main and clean up - `gsd worktree clean` — remove all merged/empty worktrees - `gsd worktree remove <name>` — remove with --force safety gate - `gsd -w` (no name) resumes the only active worktree instead of creating a new one - `gsd -w` with multiple active worktrees shows a picker - Auto-commit dirty work on session exit (session_shutdown hook) - Status banner on normal `gsd` launch when unmerged worktrees exist - Full help text with lifecycle documentation (`gsd worktree --help`) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
558b2e1c10
commit
35cee7b05f
5 changed files with 509 additions and 0 deletions
49
src/cli.ts
49
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 <name> → 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 <list|merge|clean|remove>`
|
||||
// ---------------------------------------------------------------------------
|
||||
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 <name>\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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -32,6 +32,38 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
|||
'Compare with --continue (-c) which always resumes the most recent session.',
|
||||
].join('\n'),
|
||||
|
||||
worktree: [
|
||||
'Usage: gsd worktree <command> [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 <name> 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<string, string> = {
|
|||
].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 <text|json|rpc|mcp> 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 <id> Override model (e.g. claude-opus-4-6)\n')
|
||||
process.stdout.write(' --no-session Disable session persistence\n')
|
||||
process.stdout.write(' --extension <path> 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 <cmd> 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 <subcommand> --help for subcommand-specific help.\n')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
361
src/worktree-cli.ts
Normal file
361
src/worktree-cli.ts
Normal file
|
|
@ -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 <n> 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 <name>\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<void> {
|
||||
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 <name>\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<void> {
|
||||
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 <name>\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 <name> | Merge: gsd worktree merge <name> | 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 <name>\n'))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// No active worktrees — create a new one
|
||||
const name = generateWorktreeName()
|
||||
createAndEnter(basePath, name)
|
||||
return
|
||||
}
|
||||
|
||||
// gsd -w <name> — 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,
|
||||
}
|
||||
49
src/worktree-name-gen.ts
Normal file
49
src/worktree-name-gen.ts
Normal file
|
|
@ -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<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]!
|
||||
}
|
||||
|
||||
export function generateWorktreeName(): string {
|
||||
return `${pick(ADJECTIVES)}-${pick(VERBS)}-${pick(NOUNS)}`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue