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,222 @@
1
+ /**
2
+ * cry doctor command
3
+ *
4
+ * Check and diagnose cry configuration and setup.
5
+ */
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import {
10
+ isGitRepo,
11
+ getRepoRoot,
12
+ isTracked,
13
+ isIgnored,
14
+ commandExists,
15
+ } from '../lib/git.js';
16
+ import {
17
+ CONFIG_FILE,
18
+ LOCAL_CONFIG_FILE,
19
+ configExists,
20
+ loadConfig,
21
+ loadLocalConfig,
22
+ getMergedConfig,
23
+ } from '../lib/config.js';
24
+ import { expandIncludePatterns, checkFileSafety } from '../lib/secrets.js';
25
+ import * as out from '../lib/output.js';
26
+
27
+ interface CheckResult {
28
+ name: string;
29
+ status: 'pass' | 'warn' | 'fail';
30
+ message: string;
31
+ }
32
+
33
+ export async function doctor(): Promise<void> {
34
+ // Check if we're in a git repo
35
+ if (!isGitRepo()) {
36
+ out.error('Not a git repository. Run this command from within a git repo.');
37
+ process.exit(1);
38
+ }
39
+
40
+ const repoRoot = getRepoRoot();
41
+ const checks: CheckResult[] = [];
42
+
43
+ out.header('cry Doctor');
44
+ out.log('Checking your cry configuration...');
45
+ out.newline();
46
+
47
+ // Check 1: Config file exists
48
+ if (configExists(repoRoot)) {
49
+ checks.push({
50
+ name: 'Config file',
51
+ status: 'pass',
52
+ message: `${CONFIG_FILE} exists`,
53
+ });
54
+ } else {
55
+ checks.push({
56
+ name: 'Config file',
57
+ status: 'warn',
58
+ message: `${CONFIG_FILE} not found. Run 'cry init' to create one.`,
59
+ });
60
+ }
61
+
62
+ // Check 2: Local config is gitignored
63
+ const localConfigPath = path.join(repoRoot, LOCAL_CONFIG_FILE);
64
+ if (existsSync(localConfigPath)) {
65
+ if (isIgnored(LOCAL_CONFIG_FILE, repoRoot)) {
66
+ checks.push({
67
+ name: 'Local config ignored',
68
+ status: 'pass',
69
+ message: `${LOCAL_CONFIG_FILE} is properly gitignored`,
70
+ });
71
+ } else if (isTracked(LOCAL_CONFIG_FILE, repoRoot)) {
72
+ checks.push({
73
+ name: 'Local config ignored',
74
+ status: 'fail',
75
+ message: `${LOCAL_CONFIG_FILE} is TRACKED by git! Remove it from tracking.`,
76
+ });
77
+ } else {
78
+ checks.push({
79
+ name: 'Local config ignored',
80
+ status: 'warn',
81
+ message: `${LOCAL_CONFIG_FILE} exists but is not in .gitignore`,
82
+ });
83
+ }
84
+ } else {
85
+ checks.push({
86
+ name: 'Local config',
87
+ status: 'pass',
88
+ message: `${LOCAL_CONFIG_FILE} not present (optional)`,
89
+ });
90
+ }
91
+
92
+ // Check 3: .worktrees directory is gitignored
93
+ const worktreesDir = '.worktrees';
94
+ const worktreesDirPath = path.join(repoRoot, worktreesDir);
95
+ if (existsSync(worktreesDirPath)) {
96
+ if (isIgnored(worktreesDir, repoRoot) || isIgnored(worktreesDir + '/', repoRoot)) {
97
+ checks.push({
98
+ name: 'Worktrees dir ignored',
99
+ status: 'pass',
100
+ message: `${worktreesDir}/ is properly gitignored`,
101
+ });
102
+ } else {
103
+ checks.push({
104
+ name: 'Worktrees dir ignored',
105
+ status: 'fail',
106
+ message: `${worktreesDir}/ is NOT gitignored! Add it to .gitignore.`,
107
+ });
108
+ }
109
+ } else {
110
+ if (isIgnored(worktreesDir, repoRoot) || isIgnored(worktreesDir + '/', repoRoot)) {
111
+ checks.push({
112
+ name: 'Worktrees dir ignored',
113
+ status: 'pass',
114
+ message: `${worktreesDir}/ will be gitignored when created`,
115
+ });
116
+ } else {
117
+ checks.push({
118
+ name: 'Worktrees dir ignored',
119
+ status: 'warn',
120
+ message: `${worktreesDir}/ not in .gitignore (add it before spawning)`,
121
+ });
122
+ }
123
+ }
124
+
125
+ // Check 4: Include files are safe
126
+ if (configExists(repoRoot)) {
127
+ const config = getMergedConfig(repoRoot);
128
+ const files = await expandIncludePatterns(config.include, repoRoot);
129
+
130
+ let allSafe = true;
131
+ const problems: string[] = [];
132
+
133
+ for (const file of files) {
134
+ const result = checkFileSafety(file, repoRoot);
135
+ if (!result.safe && result.exists) {
136
+ allSafe = false;
137
+ problems.push(`${file}: ${result.reason}`);
138
+ }
139
+ }
140
+
141
+ if (files.length === 0) {
142
+ checks.push({
143
+ name: 'Include patterns',
144
+ status: 'pass',
145
+ message: 'No files matched include patterns (this is fine)',
146
+ });
147
+ } else if (allSafe) {
148
+ checks.push({
149
+ name: 'Include files safety',
150
+ status: 'pass',
151
+ message: `All ${files.length} matched file(s) are safely gitignored`,
152
+ });
153
+ } else {
154
+ checks.push({
155
+ name: 'Include files safety',
156
+ status: 'fail',
157
+ message: `Some include files are NOT safe:\n ${problems.join('\n ')}`,
158
+ });
159
+ }
160
+ }
161
+
162
+ // Check 5: Agent command exists
163
+ if (configExists(repoRoot)) {
164
+ const config = getMergedConfig(repoRoot);
165
+ const agentCmd = config.agentCommand;
166
+
167
+ if (commandExists(agentCmd)) {
168
+ checks.push({
169
+ name: 'Agent command',
170
+ status: 'pass',
171
+ message: `'${agentCmd}' is available`,
172
+ });
173
+ } else {
174
+ checks.push({
175
+ name: 'Agent command',
176
+ status: 'warn',
177
+ message: `'${agentCmd}' not found (optional, but --agent won't work)`,
178
+ });
179
+ }
180
+ }
181
+
182
+ // Print results
183
+ let hasFailures = false;
184
+ let hasWarnings = false;
185
+
186
+ for (const check of checks) {
187
+ let icon: string;
188
+ let colorFn: (s: string) => string;
189
+
190
+ switch (check.status) {
191
+ case 'pass':
192
+ icon = '✓';
193
+ colorFn = out.fmt.green;
194
+ break;
195
+ case 'warn':
196
+ icon = '⚠';
197
+ colorFn = out.fmt.yellow;
198
+ hasWarnings = true;
199
+ break;
200
+ case 'fail':
201
+ icon = '✗';
202
+ colorFn = out.fmt.red;
203
+ hasFailures = true;
204
+ break;
205
+ }
206
+
207
+ out.log(`${colorFn(icon)} ${out.fmt.bold(check.name)}`);
208
+ out.log(` ${check.message}`);
209
+ out.newline();
210
+ }
211
+
212
+ // Summary
213
+ out.log('─'.repeat(50));
214
+ if (hasFailures) {
215
+ out.error('Some checks failed. Please fix the issues above.');
216
+ process.exit(1);
217
+ } else if (hasWarnings) {
218
+ out.warn('Some warnings detected. Consider addressing them.');
219
+ } else {
220
+ out.success('All checks passed!');
221
+ }
222
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * cry init command
3
+ *
4
+ * Create or update repo-level config files for cry.
5
+ */
6
+
7
+ import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { isGitRepo, getRepoRoot } from '../lib/git.js';
10
+ import {
11
+ CONFIG_FILE,
12
+ LOCAL_CONFIG_FILE,
13
+ configExists,
14
+ getDefaultConfig,
15
+ getDefaultLocalConfig,
16
+ saveConfig,
17
+ saveLocalConfig,
18
+ loadConfig,
19
+ } from '../lib/config.js';
20
+ import * as out from '../lib/output.js';
21
+
22
+ interface InitOptions {
23
+ force?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Ensure entries exist in .gitignore
28
+ */
29
+ function ensureGitignoreEntries(repoRoot: string, entries: string[]): string[] {
30
+ const gitignorePath = path.join(repoRoot, '.gitignore');
31
+ let content = '';
32
+
33
+ if (existsSync(gitignorePath)) {
34
+ content = readFileSync(gitignorePath, 'utf-8');
35
+ }
36
+
37
+ const lines = content.split('\n').map((l) => l.trim());
38
+ const added: string[] = [];
39
+
40
+ for (const entry of entries) {
41
+ if (!lines.includes(entry)) {
42
+ added.push(entry);
43
+ }
44
+ }
45
+
46
+ if (added.length > 0) {
47
+ const suffix = content.endsWith('\n') || content === '' ? '' : '\n';
48
+ const header = content === '' ? '' : '\n# cry\n';
49
+ appendFileSync(gitignorePath, suffix + header + added.join('\n') + '\n');
50
+ }
51
+
52
+ return added;
53
+ }
54
+
55
+ export async function init(options: InitOptions): Promise<void> {
56
+ // Check if we're in a git repo
57
+ if (!isGitRepo()) {
58
+ out.error('Not a git repository. Run this command from within a git repo.');
59
+ process.exit(1);
60
+ }
61
+
62
+ const repoRoot = getRepoRoot();
63
+ const changes: string[] = [];
64
+
65
+ // Check if config already exists
66
+ if (configExists(repoRoot) && !options.force) {
67
+ out.info(`${CONFIG_FILE} already exists. Use --force to overwrite.`);
68
+ const existing = loadConfig(repoRoot);
69
+ if (existing) {
70
+ out.log('\nCurrent configuration:');
71
+ out.log(JSON.stringify(existing, null, 2));
72
+ }
73
+ return;
74
+ }
75
+
76
+ // Create main config
77
+ const config = getDefaultConfig();
78
+ saveConfig(repoRoot, config);
79
+ changes.push(`Created ${out.fmt.path(CONFIG_FILE)}`);
80
+
81
+ // Create local config if it doesn't exist
82
+ const localConfigPath = path.join(repoRoot, LOCAL_CONFIG_FILE);
83
+ if (!existsSync(localConfigPath)) {
84
+ const localConfig = getDefaultLocalConfig();
85
+ saveLocalConfig(repoRoot, localConfig);
86
+ changes.push(`Created ${out.fmt.path(LOCAL_CONFIG_FILE)} (gitignored)`);
87
+ }
88
+
89
+ // Ensure .gitignore entries
90
+ const gitignoreEntries = [
91
+ LOCAL_CONFIG_FILE,
92
+ '.worktrees/',
93
+ '.worktreeinclude',
94
+ ];
95
+
96
+ const addedEntries = ensureGitignoreEntries(repoRoot, gitignoreEntries);
97
+ if (addedEntries.length > 0) {
98
+ changes.push(`Added to .gitignore: ${addedEntries.join(', ')}`);
99
+ }
100
+
101
+ // Output summary
102
+ out.header('cry Initialized');
103
+ out.newline();
104
+
105
+ for (const change of changes) {
106
+ out.success(change);
107
+ }
108
+
109
+ out.newline();
110
+ out.log('Configuration created with defaults:');
111
+ out.log(` • Default mode: ${out.fmt.cyan(config.defaultMode)}`);
112
+ out.log(` • Include patterns: ${config.include.map((p) => out.fmt.gray(p)).join(', ')}`);
113
+ out.log(` • Agent command: ${out.fmt.cyan(config.agentCommand ?? 'claude')}`);
114
+
115
+ out.newline();
116
+ out.log('Next steps:');
117
+ out.log(` 1. Edit ${out.fmt.path(CONFIG_FILE)} to customize patterns`);
118
+ out.log(` 2. Run ${out.fmt.cyan('cry spawn <branch>')} to create a worktree`);
119
+ out.log(` 3. Run ${out.fmt.cyan('cry doctor')} to verify your setup`);
120
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * cry list command
3
+ *
4
+ * List all worktrees with their status.
5
+ */
6
+
7
+ import { statSync } from 'node:fs';
8
+ import {
9
+ isGitRepo,
10
+ getRepoRoot,
11
+ listWorktrees,
12
+ isWorktreeDirty,
13
+ getShortHead,
14
+ } from '../lib/git.js';
15
+ import * as out from '../lib/output.js';
16
+ import type { WorktreeListItem } from '../lib/types.js';
17
+
18
+ interface ListOptions {
19
+ json?: boolean;
20
+ }
21
+
22
+ function getLastModified(worktreePath: string): Date | null {
23
+ try {
24
+ const stats = statSync(worktreePath);
25
+ return stats.mtime;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function formatRelativeTime(date: Date | null): string {
32
+ if (!date) return 'unknown';
33
+
34
+ const now = new Date();
35
+ const diffMs = now.getTime() - date.getTime();
36
+ const diffMins = Math.floor(diffMs / 60000);
37
+ const diffHours = Math.floor(diffMs / 3600000);
38
+ const diffDays = Math.floor(diffMs / 86400000);
39
+
40
+ if (diffMins < 1) return 'just now';
41
+ if (diffMins < 60) return `${diffMins}m ago`;
42
+ if (diffHours < 24) return `${diffHours}h ago`;
43
+ if (diffDays < 7) return `${diffDays}d ago`;
44
+ return date.toLocaleDateString();
45
+ }
46
+
47
+ export async function list(options: ListOptions): Promise<void> {
48
+ // Check if we're in a git repo
49
+ if (!isGitRepo()) {
50
+ out.error('Not a git repository. Run this command from within a git repo.');
51
+ process.exit(1);
52
+ }
53
+
54
+ const repoRoot = getRepoRoot();
55
+ const worktrees = listWorktrees(repoRoot);
56
+
57
+ // Build list items with extra info
58
+ const items: WorktreeListItem[] = worktrees.map((wt) => ({
59
+ branch: wt.branch ?? (wt.detached ? '(detached)' : null),
60
+ path: wt.worktree,
61
+ headShort: getShortHead(wt.worktree),
62
+ dirty: isWorktreeDirty(wt.worktree),
63
+ lastModified: getLastModified(wt.worktree),
64
+ }));
65
+
66
+ // JSON output
67
+ if (options.json) {
68
+ const jsonItems = items.map((item) => ({
69
+ ...item,
70
+ lastModified: item.lastModified?.toISOString() ?? null,
71
+ }));
72
+ out.json(jsonItems);
73
+ return;
74
+ }
75
+
76
+ // No worktrees
77
+ if (items.length === 0) {
78
+ out.info('No worktrees found.');
79
+ return;
80
+ }
81
+
82
+ // Table output
83
+ out.header('Worktrees');
84
+ out.newline();
85
+
86
+ // Calculate column widths
87
+ const branchWidth = Math.max(
88
+ 6,
89
+ ...items.map((i) => (i.branch ?? '').length)
90
+ );
91
+ const pathWidth = Math.max(4, ...items.map((i) => i.path.length));
92
+
93
+ // Header row
94
+ out.log(
95
+ ' ' +
96
+ out.fmt.bold('Branch'.padEnd(branchWidth)) +
97
+ ' ' +
98
+ out.fmt.bold('SHA'.padEnd(7)) +
99
+ ' ' +
100
+ out.fmt.bold('Status'.padEnd(8)) +
101
+ ' ' +
102
+ out.fmt.bold('Modified'.padEnd(12)) +
103
+ ' ' +
104
+ out.fmt.bold('Path')
105
+ );
106
+ out.log(' ' + '─'.repeat(branchWidth + 7 + 8 + 12 + pathWidth + 12));
107
+
108
+ // Data rows
109
+ for (const item of items) {
110
+ const branch = (item.branch ?? '(none)').padEnd(branchWidth);
111
+ const sha = item.headShort.padEnd(7);
112
+ const status = item.dirty
113
+ ? out.fmt.yellow('dirty'.padEnd(8))
114
+ : out.fmt.green('clean'.padEnd(8));
115
+ const modified = formatRelativeTime(item.lastModified).padEnd(12);
116
+
117
+ out.log(
118
+ ' ' +
119
+ out.fmt.branch(branch) +
120
+ ' ' +
121
+ out.fmt.dim(sha) +
122
+ ' ' +
123
+ status +
124
+ ' ' +
125
+ out.fmt.dim(modified) +
126
+ ' ' +
127
+ out.fmt.path(item.path)
128
+ );
129
+ }
130
+
131
+ out.newline();
132
+ out.log(` ${out.fmt.dim(`${items.length} worktree(s)`)}`);
133
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * cry open command
3
+ *
4
+ * Open or navigate to a worktree by branch name or path.
5
+ */
6
+
7
+ import {
8
+ isGitRepo,
9
+ getRepoRoot,
10
+ listWorktrees,
11
+ runCommand,
12
+ } from '../lib/git.js';
13
+ import { resolveBranchOrPath } from '../lib/paths.js';
14
+ import * as out from '../lib/output.js';
15
+
16
+ interface OpenOptions {
17
+ cmd?: string;
18
+ }
19
+
20
+ export async function open(branchOrPath: string, options: OpenOptions): Promise<void> {
21
+ // Check if we're in a git repo
22
+ if (!isGitRepo()) {
23
+ out.error('Not a git repository. Run this command from within a git repo.');
24
+ process.exit(1);
25
+ }
26
+
27
+ const repoRoot = getRepoRoot();
28
+ const worktrees = listWorktrees(repoRoot);
29
+
30
+ // Build lookup list
31
+ const wtList = worktrees.map((wt) => ({
32
+ branch: wt.branch ?? null,
33
+ path: wt.worktree,
34
+ }));
35
+
36
+ // Resolve the worktree
37
+ const resolved = resolveBranchOrPath(branchOrPath, wtList, repoRoot);
38
+
39
+ if (!resolved) {
40
+ out.error(`Worktree not found: ${branchOrPath}`);
41
+ out.info('Available worktrees:');
42
+ for (const wt of wtList) {
43
+ out.log(` • ${wt.branch ?? '(detached)'} → ${wt.path}`);
44
+ }
45
+ process.exit(1);
46
+ }
47
+
48
+ const { path: wtPath, branch } = resolved;
49
+
50
+ // If --cmd is provided, run it
51
+ if (options.cmd) {
52
+ out.log(`Running in ${out.fmt.path(wtPath)}:`);
53
+ out.log(` ${out.fmt.dim('$')} ${options.cmd}`);
54
+ const code = await runCommand(options.cmd, wtPath);
55
+ process.exit(code);
56
+ }
57
+
58
+ // Otherwise, print the path and helper
59
+ out.success(`Found worktree: ${branch ? out.fmt.branch(branch) : '(detached)'}`);
60
+ out.newline();
61
+ out.log(`Path: ${out.fmt.path(wtPath)}`);
62
+ out.newline();
63
+ out.log('To navigate there:');
64
+ out.log(` ${out.fmt.cyan(`cd "${wtPath}"`)}`);
65
+
66
+ // For shell integration hint
67
+ out.newline();
68
+ out.log(out.fmt.dim('Tip: Use command substitution in your shell:'));
69
+ out.log(out.fmt.dim(` cd "$(vwt open ${branchOrPath} 2>/dev/null | grep "^Path:" | cut -d' ' -f2-)"`));
70
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * cry prune command
3
+ *
4
+ * Clean up stale worktree references.
5
+ */
6
+
7
+ import { isGitRepo, getRepoRoot, pruneWorktrees } from '../lib/git.js';
8
+ import * as out from '../lib/output.js';
9
+
10
+ export async function prune(): Promise<void> {
11
+ // Check if we're in a git repo
12
+ if (!isGitRepo()) {
13
+ out.error('Not a git repository. Run this command from within a git repo.');
14
+ process.exit(1);
15
+ }
16
+
17
+ const repoRoot = getRepoRoot();
18
+
19
+ out.log('Pruning stale worktree references...');
20
+ out.newline();
21
+
22
+ try {
23
+ const output = pruneWorktrees(repoRoot);
24
+
25
+ if (output.trim()) {
26
+ out.log(output);
27
+ out.newline();
28
+ out.success('Pruned stale worktree references');
29
+ } else {
30
+ out.success('No stale worktree references found');
31
+ }
32
+ } catch (error) {
33
+ out.error(`Failed to prune: ${(error as Error).message}`);
34
+ process.exit(1);
35
+ }
36
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * cry rm command
3
+ *
4
+ * Remove a worktree safely with optional branch deletion.
5
+ */
6
+
7
+ import { createInterface } from 'node:readline';
8
+ import {
9
+ isGitRepo,
10
+ getRepoRoot,
11
+ listWorktrees,
12
+ removeWorktree,
13
+ deleteBranch,
14
+ isWorktreeDirty,
15
+ getCurrentBranch,
16
+ } from '../lib/git.js';
17
+ import { resolveBranchOrPath } from '../lib/paths.js';
18
+ import * as out from '../lib/output.js';
19
+
20
+ interface RmOptions {
21
+ withBranch?: boolean;
22
+ force?: boolean;
23
+ yes?: boolean;
24
+ }
25
+
26
+ async function confirm(message: string): Promise<boolean> {
27
+ const rl = createInterface({
28
+ input: process.stdin,
29
+ output: process.stdout,
30
+ });
31
+
32
+ return new Promise((resolve) => {
33
+ rl.question(`${message} [y/N] `, (answer) => {
34
+ rl.close();
35
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
36
+ });
37
+ });
38
+ }
39
+
40
+ export async function rm(branchOrPath: string, options: RmOptions): Promise<void> {
41
+ // Check if we're in a git repo
42
+ if (!isGitRepo()) {
43
+ out.error('Not a git repository. Run this command from within a git repo.');
44
+ process.exit(1);
45
+ }
46
+
47
+ const repoRoot = getRepoRoot();
48
+ const worktrees = listWorktrees(repoRoot);
49
+
50
+ // Build lookup list
51
+ const wtList = worktrees.map((wt) => ({
52
+ branch: wt.branch ?? null,
53
+ path: wt.worktree,
54
+ }));
55
+
56
+ // Resolve the worktree
57
+ const resolved = resolveBranchOrPath(branchOrPath, wtList, repoRoot);
58
+
59
+ if (!resolved) {
60
+ out.error(`Worktree not found: ${branchOrPath}`);
61
+ out.info('Available worktrees:');
62
+ for (const wt of wtList) {
63
+ out.log(` • ${wt.branch ?? '(detached)'} → ${wt.path}`);
64
+ }
65
+ process.exit(1);
66
+ }
67
+
68
+ const { path: wtPath, branch } = resolved;
69
+
70
+ // Check if it's the main worktree (the original checkout)
71
+ if (wtPath === repoRoot) {
72
+ out.error('Cannot remove the main worktree.');
73
+ out.info('This is your primary repository checkout.');
74
+ process.exit(1);
75
+ }
76
+
77
+ // Check if dirty
78
+ const dirty = isWorktreeDirty(wtPath);
79
+ if (dirty && !options.force) {
80
+ out.error('Worktree has uncommitted changes.');
81
+ out.info('Use --force to remove anyway (changes will be lost).');
82
+ process.exit(1);
83
+ }
84
+
85
+ // Warn and confirm if dirty and force
86
+ if (dirty && options.force && !options.yes) {
87
+ out.warn('Worktree has uncommitted changes that will be lost!');
88
+ const confirmed = await confirm('Are you sure you want to remove it?');
89
+ if (!confirmed) {
90
+ out.log('Aborted.');
91
+ process.exit(0);
92
+ }
93
+ }
94
+
95
+ // Remove the worktree
96
+ out.log(`Removing worktree: ${out.fmt.path(wtPath)}`);
97
+ try {
98
+ removeWorktree(wtPath, options.force ?? false, repoRoot);
99
+ out.success('Worktree removed');
100
+ } catch (error) {
101
+ out.error(`Failed to remove worktree: ${(error as Error).message}`);
102
+ process.exit(1);
103
+ }
104
+
105
+ // Optionally delete the branch
106
+ if (options.withBranch && branch) {
107
+ const currentBranch = getCurrentBranch(repoRoot);
108
+
109
+ if (branch === currentBranch) {
110
+ out.warn(`Cannot delete branch '${branch}' - it's currently checked out in main worktree.`);
111
+ } else {
112
+ out.log(`Deleting branch: ${out.fmt.branch(branch)}`);
113
+ try {
114
+ deleteBranch(branch, options.force ?? false, repoRoot);
115
+ out.success('Branch deleted');
116
+ } catch (error) {
117
+ out.warn(`Failed to delete branch: ${(error as Error).message}`);
118
+ out.info('You may need to use --force or delete it manually.');
119
+ }
120
+ }
121
+ }
122
+
123
+ out.newline();
124
+ out.success('Done');
125
+ }