cluttry 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.vwt.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +444 -0
  4. package/dist/commands/doctor.d.ts +7 -0
  5. package/dist/commands/doctor.d.ts.map +1 -0
  6. package/dist/commands/doctor.js +198 -0
  7. package/dist/commands/doctor.js.map +1 -0
  8. package/dist/commands/init.d.ts +11 -0
  9. package/dist/commands/init.d.ts.map +1 -0
  10. package/dist/commands/init.js +90 -0
  11. package/dist/commands/init.js.map +1 -0
  12. package/dist/commands/list.d.ts +11 -0
  13. package/dist/commands/list.d.ts.map +1 -0
  14. package/dist/commands/list.js +106 -0
  15. package/dist/commands/list.js.map +1 -0
  16. package/dist/commands/open.d.ts +11 -0
  17. package/dist/commands/open.d.ts.map +1 -0
  18. package/dist/commands/open.js +52 -0
  19. package/dist/commands/open.js.map +1 -0
  20. package/dist/commands/prune.d.ts +7 -0
  21. package/dist/commands/prune.d.ts.map +1 -0
  22. package/dist/commands/prune.js +33 -0
  23. package/dist/commands/prune.js.map +1 -0
  24. package/dist/commands/rm.d.ts +13 -0
  25. package/dist/commands/rm.d.ts.map +1 -0
  26. package/dist/commands/rm.js +99 -0
  27. package/dist/commands/rm.js.map +1 -0
  28. package/dist/commands/spawn.d.ts +17 -0
  29. package/dist/commands/spawn.d.ts.map +1 -0
  30. package/dist/commands/spawn.js +127 -0
  31. package/dist/commands/spawn.js.map +1 -0
  32. package/dist/index.d.ts +8 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +101 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/lib/config.d.ts +44 -0
  37. package/dist/lib/config.d.ts.map +1 -0
  38. package/dist/lib/config.js +109 -0
  39. package/dist/lib/config.js.map +1 -0
  40. package/dist/lib/git.d.ts +73 -0
  41. package/dist/lib/git.d.ts.map +1 -0
  42. package/dist/lib/git.js +225 -0
  43. package/dist/lib/git.js.map +1 -0
  44. package/dist/lib/output.d.ts +33 -0
  45. package/dist/lib/output.d.ts.map +1 -0
  46. package/dist/lib/output.js +83 -0
  47. package/dist/lib/output.js.map +1 -0
  48. package/dist/lib/paths.d.ts +36 -0
  49. package/dist/lib/paths.d.ts.map +1 -0
  50. package/dist/lib/paths.js +84 -0
  51. package/dist/lib/paths.js.map +1 -0
  52. package/dist/lib/secrets.d.ts +50 -0
  53. package/dist/lib/secrets.d.ts.map +1 -0
  54. package/dist/lib/secrets.js +146 -0
  55. package/dist/lib/secrets.js.map +1 -0
  56. package/dist/lib/types.d.ts +63 -0
  57. package/dist/lib/types.d.ts.map +1 -0
  58. package/dist/lib/types.js +5 -0
  59. package/dist/lib/types.js.map +1 -0
  60. package/package.json +41 -0
  61. package/src/commands/doctor.ts +222 -0
  62. package/src/commands/init.ts +120 -0
  63. package/src/commands/list.ts +133 -0
  64. package/src/commands/open.ts +70 -0
  65. package/src/commands/prune.ts +36 -0
  66. package/src/commands/rm.ts +125 -0
  67. package/src/commands/spawn.ts +169 -0
  68. package/src/index.ts +112 -0
  69. package/src/lib/config.ts +120 -0
  70. package/src/lib/git.ts +243 -0
  71. package/src/lib/output.ts +102 -0
  72. package/src/lib/paths.ts +108 -0
  73. package/src/lib/secrets.ts +193 -0
  74. package/src/lib/types.ts +69 -0
  75. package/tests/config.test.ts +102 -0
  76. package/tests/paths.test.ts +155 -0
  77. package/tests/secrets.test.ts +150 -0
  78. package/tsconfig.json +20 -0
  79. package/vitest.config.ts +15 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * cry spawn command
3
+ *
4
+ * Create a worktree for a branch with optional secrets handling and hooks.
5
+ */
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import {
10
+ isGitRepo,
11
+ getRepoRoot,
12
+ getRepoName,
13
+ branchExists,
14
+ addWorktree,
15
+ listWorktrees,
16
+ runCommand,
17
+ commandExists,
18
+ } from '../lib/git.js';
19
+ import { getMergedConfig, configExists } from '../lib/config.js';
20
+ import { getDefaultWorktreePath } from '../lib/paths.js';
21
+ import { processSecrets } from '../lib/secrets.js';
22
+ import * as out from '../lib/output.js';
23
+ import type { SecretMode } from '../lib/types.js';
24
+
25
+ interface SpawnOptions {
26
+ new?: boolean;
27
+ path?: string;
28
+ base?: string;
29
+ mode?: SecretMode;
30
+ run?: string;
31
+ agent?: string;
32
+ }
33
+
34
+ export async function spawn(branch: string, options: SpawnOptions): Promise<void> {
35
+ // Check if we're in a git repo
36
+ if (!isGitRepo()) {
37
+ out.error('Not a git repository. Run this command from within a git repo.');
38
+ process.exit(1);
39
+ }
40
+
41
+ const repoRoot = getRepoRoot();
42
+ const repoName = getRepoName();
43
+
44
+ // Load config
45
+ const config = configExists(repoRoot) ? getMergedConfig(repoRoot) : {
46
+ worktreeBaseDir: undefined,
47
+ defaultMode: 'none' as SecretMode,
48
+ include: [],
49
+ hooks: { postCreate: [] },
50
+ agentCommand: 'claude',
51
+ };
52
+
53
+ // Determine mode
54
+ const mode: SecretMode = options.mode ?? config.defaultMode;
55
+
56
+ // Calculate worktree path
57
+ const worktreePath = getDefaultWorktreePath(repoRoot, branch, {
58
+ explicitPath: options.path,
59
+ baseDir: options.base ?? config.worktreeBaseDir,
60
+ repoName,
61
+ });
62
+
63
+ // Check if destination already exists
64
+ if (existsSync(worktreePath)) {
65
+ out.error(`Destination already exists: ${worktreePath}`);
66
+ out.info('Remove it first or choose a different path with --path');
67
+ process.exit(1);
68
+ }
69
+
70
+ // Check if worktree already exists for this branch
71
+ const existingWorktrees = listWorktrees(repoRoot);
72
+ const existingForBranch = existingWorktrees.find((w) => w.branch === branch);
73
+ if (existingForBranch) {
74
+ out.error(`A worktree already exists for branch '${branch}'`);
75
+ out.info(`Path: ${existingForBranch.worktree}`);
76
+ out.info('Remove it first with: cry rm ' + branch);
77
+ process.exit(1);
78
+ }
79
+
80
+ // Determine if we need to create the branch
81
+ const needsNewBranch = options.new || !branchExists(branch, repoRoot);
82
+
83
+ out.header('Creating worktree');
84
+ out.log(` Branch: ${out.fmt.branch(branch)}${needsNewBranch ? out.fmt.gray(' (new)') : ''}`);
85
+ out.log(` Path: ${out.fmt.path(worktreePath)}`);
86
+ out.log(` Mode: ${out.fmt.cyan(mode)}`);
87
+ out.newline();
88
+
89
+ // Create the worktree
90
+ try {
91
+ addWorktree(worktreePath, branch, needsNewBranch, repoRoot);
92
+ out.success('Worktree created');
93
+ } catch (error) {
94
+ out.error(`Failed to create worktree: ${(error as Error).message}`);
95
+ process.exit(1);
96
+ }
97
+
98
+ // Handle secrets
99
+ if (mode !== 'none' && config.include.length > 0) {
100
+ out.newline();
101
+ out.log(`Processing secrets (${mode} mode)...`);
102
+
103
+ const { processed, skipped } = await processSecrets(
104
+ mode,
105
+ config.include,
106
+ repoRoot,
107
+ worktreePath
108
+ );
109
+
110
+ if (processed.length > 0) {
111
+ out.success(`${mode === 'copy' ? 'Copied' : 'Symlinked'} ${processed.length} file(s):`);
112
+ for (const file of processed) {
113
+ out.log(` ${out.fmt.dim('•')} ${file}`);
114
+ }
115
+ }
116
+
117
+ if (skipped.length > 0) {
118
+ out.warn(`Skipped ${skipped.length} file(s) for safety:`);
119
+ for (const file of skipped) {
120
+ out.log(` ${out.fmt.dim('•')} ${file.path}: ${file.reason}`);
121
+ }
122
+ }
123
+ }
124
+
125
+ // Run post-create hooks from config
126
+ const hooks = config.hooks.postCreate;
127
+ if (hooks.length > 0) {
128
+ out.newline();
129
+ out.log('Running post-create hooks...');
130
+ for (const hook of hooks) {
131
+ out.log(` ${out.fmt.dim('$')} ${hook}`);
132
+ const code = await runCommand(hook, worktreePath);
133
+ if (code !== 0) {
134
+ out.warn(`Hook exited with code ${code}`);
135
+ }
136
+ }
137
+ }
138
+
139
+ // Run --run command if provided
140
+ if (options.run) {
141
+ out.newline();
142
+ out.log('Running custom command...');
143
+ out.log(` ${out.fmt.dim('$')} ${options.run}`);
144
+ const code = await runCommand(options.run, worktreePath);
145
+ if (code !== 0) {
146
+ out.warn(`Command exited with code ${code}`);
147
+ }
148
+ }
149
+
150
+ // Handle agent launch
151
+ const agentChoice = options.agent ?? 'none';
152
+ if (agentChoice === 'claude') {
153
+ const agentCmd = config.agentCommand;
154
+ out.newline();
155
+
156
+ if (commandExists(agentCmd)) {
157
+ out.log(`Launching ${agentCmd}...`);
158
+ await runCommand(agentCmd, worktreePath);
159
+ } else {
160
+ out.warn(`Agent command '${agentCmd}' not found.`);
161
+ out.info('Install Claude Code: https://docs.anthropic.com/claude-code');
162
+ }
163
+ }
164
+
165
+ // Final summary
166
+ out.newline();
167
+ out.header('Worktree ready');
168
+ out.log(` ${out.fmt.dim('cd')} ${worktreePath}`);
169
+ }
package/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cry - Git worktrees made painless for vibecoders
4
+ *
5
+ * A CLI tool for managing git worktrees with parallel AI-agent sessions in mind.
6
+ */
7
+
8
+ import { Command } from 'commander';
9
+ import { init } from './commands/init.js';
10
+ import { spawn } from './commands/spawn.js';
11
+ import { list } from './commands/list.js';
12
+ import { open } from './commands/open.js';
13
+ import { rm } from './commands/rm.js';
14
+ import { prune } from './commands/prune.js';
15
+ import { doctor } from './commands/doctor.js';
16
+ import type { SecretMode } from './lib/types.js';
17
+
18
+ const program = new Command();
19
+
20
+ program
21
+ .name('cry')
22
+ .description('Git worktrees made painless for vibecoders running parallel AI-agent sessions')
23
+ .version('1.0.0');
24
+
25
+ // cry init
26
+ program
27
+ .command('init')
28
+ .description('Initialize cry configuration in the current repository')
29
+ .option('-f, --force', 'Overwrite existing configuration')
30
+ .action(async (options) => {
31
+ await init({ force: options.force });
32
+ });
33
+
34
+ // cry spawn <branch>
35
+ program
36
+ .command('spawn <branch>')
37
+ .description('Create a worktree for a branch')
38
+ .option('-n, --new', 'Create a new branch (equivalent to git worktree add -b)')
39
+ .option('-p, --path <dir>', 'Explicit path for the worktree')
40
+ .option('-b, --base <dir>', 'Base directory for worktrees')
41
+ .option('-m, --mode <mode>', 'Secret handling mode: copy, symlink, or none', 'copy')
42
+ .option('-r, --run <cmd>', 'Command to run after creating worktree')
43
+ .option('-a, --agent <agent>', 'Launch agent after setup: claude or none', 'none')
44
+ .action(async (branch: string, options) => {
45
+ const mode = options.mode as SecretMode;
46
+ if (!['copy', 'symlink', 'none'].includes(mode)) {
47
+ console.error(`Invalid mode: ${mode}. Must be 'copy', 'symlink', or 'none'.`);
48
+ process.exit(1);
49
+ }
50
+ await spawn(branch, {
51
+ new: options.new,
52
+ path: options.path,
53
+ base: options.base,
54
+ mode,
55
+ run: options.run,
56
+ agent: options.agent,
57
+ });
58
+ });
59
+
60
+ // cry list
61
+ program
62
+ .command('list')
63
+ .alias('ls')
64
+ .description('List all worktrees with their status')
65
+ .option('-j, --json', 'Output as JSON')
66
+ .action(async (options) => {
67
+ await list({ json: options.json });
68
+ });
69
+
70
+ // cry open <branch-or-path>
71
+ program
72
+ .command('open <branch-or-path>')
73
+ .description('Open or navigate to a worktree by branch name or path')
74
+ .option('-c, --cmd <cmd>', 'Command to execute in the worktree directory')
75
+ .action(async (branchOrPath: string, options) => {
76
+ await open(branchOrPath, { cmd: options.cmd });
77
+ });
78
+
79
+ // cry rm <branch-or-path>
80
+ program
81
+ .command('rm <branch-or-path>')
82
+ .alias('remove')
83
+ .description('Remove a worktree safely')
84
+ .option('-b, --with-branch', 'Also delete the branch')
85
+ .option('-f, --force', 'Force removal even if dirty')
86
+ .option('-y, --yes', 'Skip confirmation prompts')
87
+ .action(async (branchOrPath: string, options) => {
88
+ await rm(branchOrPath, {
89
+ withBranch: options.withBranch,
90
+ force: options.force,
91
+ yes: options.yes,
92
+ });
93
+ });
94
+
95
+ // cry prune
96
+ program
97
+ .command('prune')
98
+ .description('Clean up stale worktree references')
99
+ .action(async () => {
100
+ await prune();
101
+ });
102
+
103
+ // cry doctor
104
+ program
105
+ .command('doctor')
106
+ .description('Check and diagnose cry configuration and setup')
107
+ .action(async () => {
108
+ await doctor();
109
+ });
110
+
111
+ // Parse and execute
112
+ program.parse();
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Configuration management for VWT
3
+ */
4
+
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import type { MergedConfig, VwtConfig, VwtLocalConfig } from './types.js';
8
+
9
+ export const CONFIG_FILE = '.vwt.json';
10
+ export const LOCAL_CONFIG_FILE = '.vwt.local.json';
11
+ export const WORKTREE_INCLUDE_FILE = '.worktreeinclude';
12
+
13
+ const DEFAULT_CONFIG: VwtConfig = {
14
+ defaultMode: 'copy',
15
+ include: ['.env', '.env.*', '.env.local'],
16
+ hooks: {
17
+ postCreate: [],
18
+ },
19
+ agentCommand: 'claude',
20
+ };
21
+
22
+ /**
23
+ * Load the main config file
24
+ */
25
+ export function loadConfig(repoRoot: string): VwtConfig | null {
26
+ const configPath = path.join(repoRoot, CONFIG_FILE);
27
+ if (!existsSync(configPath)) {
28
+ return null;
29
+ }
30
+ try {
31
+ const content = readFileSync(configPath, 'utf-8');
32
+ return JSON.parse(content) as VwtConfig;
33
+ } catch (error) {
34
+ throw new Error(`Failed to parse ${CONFIG_FILE}: ${(error as Error).message}`);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Load the local config file
40
+ */
41
+ export function loadLocalConfig(repoRoot: string): VwtLocalConfig | null {
42
+ const configPath = path.join(repoRoot, LOCAL_CONFIG_FILE);
43
+ if (!existsSync(configPath)) {
44
+ return null;
45
+ }
46
+ try {
47
+ const content = readFileSync(configPath, 'utf-8');
48
+ return JSON.parse(content) as VwtLocalConfig;
49
+ } catch (error) {
50
+ throw new Error(`Failed to parse ${LOCAL_CONFIG_FILE}: ${(error as Error).message}`);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Merge main config with local overrides
56
+ */
57
+ export function mergeConfig(config: VwtConfig | null, localConfig: VwtLocalConfig | null): MergedConfig {
58
+ const base = config ?? DEFAULT_CONFIG;
59
+
60
+ return {
61
+ worktreeBaseDir: localConfig?.worktreeBaseDir ?? base.worktreeBaseDir,
62
+ defaultMode: base.defaultMode,
63
+ include: [...(base.include ?? []), ...(localConfig?.include ?? [])],
64
+ hooks: {
65
+ postCreate: [
66
+ ...(base.hooks?.postCreate ?? []),
67
+ ...(localConfig?.hooks?.postCreate ?? []),
68
+ ],
69
+ },
70
+ agentCommand: localConfig?.agentCommand ?? base.agentCommand ?? 'claude',
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Get merged configuration
76
+ */
77
+ export function getMergedConfig(repoRoot: string): MergedConfig {
78
+ const config = loadConfig(repoRoot);
79
+ const localConfig = loadLocalConfig(repoRoot);
80
+ return mergeConfig(config, localConfig);
81
+ }
82
+
83
+ /**
84
+ * Save the main config file
85
+ */
86
+ export function saveConfig(repoRoot: string, config: VwtConfig): void {
87
+ const configPath = path.join(repoRoot, CONFIG_FILE);
88
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
89
+ }
90
+
91
+ /**
92
+ * Save the local config file
93
+ */
94
+ export function saveLocalConfig(repoRoot: string, config: VwtLocalConfig): void {
95
+ const configPath = path.join(repoRoot, LOCAL_CONFIG_FILE);
96
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
97
+ }
98
+
99
+ /**
100
+ * Check if config exists
101
+ */
102
+ export function configExists(repoRoot: string): boolean {
103
+ return existsSync(path.join(repoRoot, CONFIG_FILE));
104
+ }
105
+
106
+ /**
107
+ * Get default config
108
+ */
109
+ export function getDefaultConfig(): VwtConfig {
110
+ return { ...DEFAULT_CONFIG };
111
+ }
112
+
113
+ /**
114
+ * Create default local config
115
+ */
116
+ export function getDefaultLocalConfig(): VwtLocalConfig {
117
+ return {
118
+ include: [],
119
+ };
120
+ }
package/src/lib/git.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Git operations for VWT
3
+ */
4
+
5
+ import { execSync, spawn } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import type { WorktreeInfo } from './types.js';
9
+
10
+ /**
11
+ * Execute a git command and return stdout
12
+ */
13
+ export function git(args: string[], cwd?: string): string {
14
+ const options = cwd ? { cwd, encoding: 'utf-8' as const } : { encoding: 'utf-8' as const };
15
+ try {
16
+ return execSync(`git ${args.join(' ')}`, { ...options, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
17
+ } catch (error: unknown) {
18
+ const execError = error as { stderr?: Buffer; message?: string };
19
+ const stderr = execError.stderr?.toString?.() || execError.message || 'Unknown git error';
20
+ throw new Error(stderr.trim());
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Check if we're in a git repository
26
+ */
27
+ export function isGitRepo(cwd?: string): boolean {
28
+ try {
29
+ git(['rev-parse', '--git-dir'], cwd);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Get the root directory of the git repository
38
+ */
39
+ export function getRepoRoot(cwd?: string): string {
40
+ return git(['rev-parse', '--show-toplevel'], cwd);
41
+ }
42
+
43
+ /**
44
+ * Get the repository name from the root path
45
+ */
46
+ export function getRepoName(cwd?: string): string {
47
+ const root = getRepoRoot(cwd);
48
+ return path.basename(root);
49
+ }
50
+
51
+ /**
52
+ * Check if a branch exists
53
+ */
54
+ export function branchExists(branch: string, cwd?: string): boolean {
55
+ try {
56
+ git(['rev-parse', '--verify', `refs/heads/${branch}`], cwd);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Get the current branch name
65
+ */
66
+ export function getCurrentBranch(cwd?: string): string | null {
67
+ try {
68
+ return git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Check if a file is tracked by git
76
+ */
77
+ export function isTracked(filePath: string, cwd?: string): boolean {
78
+ try {
79
+ git(['ls-files', '--error-unmatch', filePath], cwd);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check if a file is ignored by git
88
+ */
89
+ export function isIgnored(filePath: string, cwd?: string): boolean {
90
+ try {
91
+ git(['check-ignore', '-q', filePath], cwd);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * List all worktrees using porcelain format
100
+ */
101
+ export function listWorktrees(cwd?: string): WorktreeInfo[] {
102
+ const output = git(['worktree', 'list', '--porcelain'], cwd);
103
+ const worktrees: WorktreeInfo[] = [];
104
+ let current: Partial<WorktreeInfo> = {};
105
+
106
+ for (const line of output.split('\n')) {
107
+ if (line === '') {
108
+ if (current.worktree) {
109
+ worktrees.push(current as WorktreeInfo);
110
+ }
111
+ current = {};
112
+ continue;
113
+ }
114
+
115
+ if (line.startsWith('worktree ')) {
116
+ current.worktree = line.substring(9);
117
+ } else if (line.startsWith('HEAD ')) {
118
+ current.head = line.substring(5);
119
+ } else if (line.startsWith('branch ')) {
120
+ current.branch = line.substring(7).replace('refs/heads/', '');
121
+ } else if (line === 'bare') {
122
+ current.bare = true;
123
+ } else if (line === 'detached') {
124
+ current.detached = true;
125
+ }
126
+ }
127
+
128
+ // Don't forget the last entry
129
+ if (current.worktree) {
130
+ worktrees.push(current as WorktreeInfo);
131
+ }
132
+
133
+ return worktrees;
134
+ }
135
+
136
+ /**
137
+ * Add a worktree
138
+ */
139
+ export function addWorktree(
140
+ targetPath: string,
141
+ branch: string,
142
+ createBranch: boolean,
143
+ cwd?: string
144
+ ): void {
145
+ const args = ['worktree', 'add'];
146
+ if (createBranch) {
147
+ args.push('-b', branch);
148
+ }
149
+ args.push(targetPath);
150
+ if (!createBranch) {
151
+ args.push(branch);
152
+ }
153
+ git(args, cwd);
154
+ }
155
+
156
+ /**
157
+ * Remove a worktree
158
+ */
159
+ export function removeWorktree(worktreePath: string, force: boolean, cwd?: string): void {
160
+ const args = ['worktree', 'remove'];
161
+ if (force) {
162
+ args.push('--force');
163
+ }
164
+ args.push(worktreePath);
165
+ git(args, cwd);
166
+ }
167
+
168
+ /**
169
+ * Prune worktrees
170
+ */
171
+ export function pruneWorktrees(cwd?: string): string {
172
+ return git(['worktree', 'prune', '--verbose'], cwd);
173
+ }
174
+
175
+ /**
176
+ * Delete a branch
177
+ */
178
+ export function deleteBranch(branch: string, force: boolean, cwd?: string): void {
179
+ const flag = force ? '-D' : '-d';
180
+ git(['branch', flag, branch], cwd);
181
+ }
182
+
183
+ /**
184
+ * Check if a worktree is dirty (has uncommitted changes)
185
+ */
186
+ export function isWorktreeDirty(worktreePath: string): boolean {
187
+ try {
188
+ const status = git(['status', '--porcelain'], worktreePath);
189
+ return status.length > 0;
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Get short HEAD SHA for a worktree
197
+ */
198
+ export function getShortHead(worktreePath: string): string {
199
+ try {
200
+ return git(['rev-parse', '--short', 'HEAD'], worktreePath);
201
+ } catch {
202
+ return 'unknown';
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Run a command in a directory
208
+ */
209
+ export function runCommand(command: string, cwd: string): Promise<number> {
210
+ return new Promise((resolve) => {
211
+ const isWindows = process.platform === 'win32';
212
+ const shell = isWindows ? 'cmd.exe' : '/bin/sh';
213
+ const shellArgs = isWindows ? ['/c', command] : ['-c', command];
214
+
215
+ const child = spawn(shell, shellArgs, {
216
+ cwd,
217
+ stdio: 'inherit',
218
+ env: process.env,
219
+ });
220
+
221
+ child.on('close', (code) => {
222
+ resolve(code ?? 1);
223
+ });
224
+
225
+ child.on('error', () => {
226
+ resolve(1);
227
+ });
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Check if a command exists
233
+ */
234
+ export function commandExists(cmd: string): boolean {
235
+ try {
236
+ const isWindows = process.platform === 'win32';
237
+ const checkCmd = isWindows ? `where ${cmd}` : `which ${cmd}`;
238
+ execSync(checkCmd, { stdio: 'pipe' });
239
+ return true;
240
+ } catch {
241
+ return false;
242
+ }
243
+ }