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.
- package/.vwt.json +12 -0
- package/LICENSE +21 -0
- package/README.md +444 -0
- package/dist/commands/doctor.d.ts +7 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +198 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +90 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +11 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +106 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/open.d.ts +11 -0
- package/dist/commands/open.d.ts.map +1 -0
- package/dist/commands/open.js +52 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/prune.d.ts +7 -0
- package/dist/commands/prune.d.ts.map +1 -0
- package/dist/commands/prune.js +33 -0
- package/dist/commands/prune.js.map +1 -0
- package/dist/commands/rm.d.ts +13 -0
- package/dist/commands/rm.d.ts.map +1 -0
- package/dist/commands/rm.js +99 -0
- package/dist/commands/rm.js.map +1 -0
- package/dist/commands/spawn.d.ts +17 -0
- package/dist/commands/spawn.d.ts.map +1 -0
- package/dist/commands/spawn.js +127 -0
- package/dist/commands/spawn.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +44 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +109 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/git.d.ts +73 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +225 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/output.d.ts +33 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +83 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/paths.d.ts +36 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +84 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/secrets.d.ts +50 -0
- package/dist/lib/secrets.d.ts.map +1 -0
- package/dist/lib/secrets.js +146 -0
- package/dist/lib/secrets.js.map +1 -0
- package/dist/lib/types.d.ts +63 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +5 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +41 -0
- package/src/commands/doctor.ts +222 -0
- package/src/commands/init.ts +120 -0
- package/src/commands/list.ts +133 -0
- package/src/commands/open.ts +70 -0
- package/src/commands/prune.ts +36 -0
- package/src/commands/rm.ts +125 -0
- package/src/commands/spawn.ts +169 -0
- package/src/index.ts +112 -0
- package/src/lib/config.ts +120 -0
- package/src/lib/git.ts +243 -0
- package/src/lib/output.ts +102 -0
- package/src/lib/paths.ts +108 -0
- package/src/lib/secrets.ts +193 -0
- package/src/lib/types.ts +69 -0
- package/tests/config.test.ts +102 -0
- package/tests/paths.test.ts +155 -0
- package/tests/secrets.test.ts +150 -0
- package/tsconfig.json +20 -0
- 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
|
+
}
|