@jasonfutch/worktree-manager 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/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/constants.d.ts +152 -0
- package/dist/constants.js +148 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +117 -0
- package/dist/git/worktree.d.ts +57 -0
- package/dist/git/worktree.js +272 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +336 -0
- package/dist/tui/app.d.ts +39 -0
- package/dist/tui/app.js +842 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.js +4 -0
- package/dist/utils/checks.d.ts +12 -0
- package/dist/utils/checks.js +109 -0
- package/dist/utils/helpers.d.ts +11 -0
- package/dist/utils/helpers.js +20 -0
- package/dist/utils/launch.d.ts +17 -0
- package/dist/utils/launch.js +223 -0
- package/dist/utils/shell.d.ts +28 -0
- package/dist/utils/shell.js +94 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +11 -0
- package/package.json +68 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Worktree, CreateWorktreeOptions } from '../types.js';
|
|
2
|
+
export declare class GitWorktree {
|
|
3
|
+
private repoPath;
|
|
4
|
+
constructor(repoPath: string);
|
|
5
|
+
/**
|
|
6
|
+
* Check if path is a valid git repository
|
|
7
|
+
*/
|
|
8
|
+
static isGitRepo(dirPath: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Get the root of the git repository
|
|
11
|
+
*/
|
|
12
|
+
static getRepoRoot(dirPath: string): string | null;
|
|
13
|
+
/**
|
|
14
|
+
* List all worktrees for this repository
|
|
15
|
+
*/
|
|
16
|
+
list(): Promise<Worktree[]>;
|
|
17
|
+
/**
|
|
18
|
+
* Create a new worktree
|
|
19
|
+
*/
|
|
20
|
+
create(options: CreateWorktreeOptions): Promise<Worktree>;
|
|
21
|
+
/**
|
|
22
|
+
* Remove a worktree
|
|
23
|
+
*/
|
|
24
|
+
remove(worktreePath: string, force?: boolean): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Prune worktrees (clean up stale entries)
|
|
27
|
+
*/
|
|
28
|
+
prune(): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Delete a branch
|
|
31
|
+
* @param branch - Branch name to delete
|
|
32
|
+
* @param force - If true, use -D (force delete) instead of -d
|
|
33
|
+
*/
|
|
34
|
+
deleteBranch(branch: string, force?: boolean): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Check if a branch exists
|
|
37
|
+
* @returns true if branch exists, false otherwise
|
|
38
|
+
* @note This method returns false on any error (including invalid branch names)
|
|
39
|
+
*/
|
|
40
|
+
private branchExists;
|
|
41
|
+
/**
|
|
42
|
+
* Get list of all local branches
|
|
43
|
+
* @returns Array of branch names, empty array on error
|
|
44
|
+
* @note This method returns empty array on any error
|
|
45
|
+
*/
|
|
46
|
+
getBranches(): Promise<string[]>;
|
|
47
|
+
/**
|
|
48
|
+
* Get current branch name
|
|
49
|
+
* @returns Current branch name, or 'unknown' on error
|
|
50
|
+
* @note This method returns 'unknown' on any error
|
|
51
|
+
*/
|
|
52
|
+
getCurrentBranch(): Promise<string>;
|
|
53
|
+
/**
|
|
54
|
+
* Get repo name
|
|
55
|
+
*/
|
|
56
|
+
getRepoName(): string;
|
|
57
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { execSync, exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { WorktreeNotFoundError, InvalidBranchError, InvalidPathError, parseGitError, } from '../errors.js';
|
|
6
|
+
import { isPathSafe, isValidBranchName, escapeShellArg } from '../utils/shell.js';
|
|
7
|
+
import { GIT } from '../constants.js';
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
export class GitWorktree {
|
|
10
|
+
repoPath;
|
|
11
|
+
constructor(repoPath) {
|
|
12
|
+
this.repoPath = repoPath;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Check if path is a valid git repository
|
|
16
|
+
*/
|
|
17
|
+
static isGitRepo(dirPath) {
|
|
18
|
+
try {
|
|
19
|
+
execSync('git rev-parse --git-dir', { cwd: dirPath, stdio: 'pipe' });
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get the root of the git repository
|
|
28
|
+
*/
|
|
29
|
+
static getRepoRoot(dirPath) {
|
|
30
|
+
try {
|
|
31
|
+
const result = execSync('git rev-parse --show-toplevel', {
|
|
32
|
+
cwd: dirPath,
|
|
33
|
+
encoding: 'utf-8',
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
35
|
+
});
|
|
36
|
+
return result.trim();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* List all worktrees for this repository
|
|
44
|
+
*/
|
|
45
|
+
async list() {
|
|
46
|
+
const command = 'git worktree list --porcelain';
|
|
47
|
+
try {
|
|
48
|
+
const { stdout } = await execAsync(command, {
|
|
49
|
+
cwd: this.repoPath
|
|
50
|
+
});
|
|
51
|
+
const worktrees = [];
|
|
52
|
+
const entries = stdout.trim().split('\n\n');
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry.trim())
|
|
55
|
+
continue;
|
|
56
|
+
const lines = entry.split('\n');
|
|
57
|
+
const worktree = {
|
|
58
|
+
isMain: false,
|
|
59
|
+
isBare: false,
|
|
60
|
+
isLocked: false,
|
|
61
|
+
prunable: false,
|
|
62
|
+
};
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
if (line.startsWith('worktree ')) {
|
|
65
|
+
worktree.path = line.substring(9);
|
|
66
|
+
worktree.name = path.basename(worktree.path);
|
|
67
|
+
}
|
|
68
|
+
else if (line.startsWith('HEAD ')) {
|
|
69
|
+
// Extract SHA safely - handle variable length commits
|
|
70
|
+
const sha = line.substring(5);
|
|
71
|
+
worktree.commit = sha.substring(0, GIT.SHORT_SHA_LENGTH);
|
|
72
|
+
}
|
|
73
|
+
else if (line.startsWith('branch ')) {
|
|
74
|
+
// Parse branch reference more robustly
|
|
75
|
+
const ref = line.substring(7);
|
|
76
|
+
worktree.branch = ref.replace(/^refs\/heads\//, '');
|
|
77
|
+
}
|
|
78
|
+
else if (line === 'bare') {
|
|
79
|
+
worktree.isBare = true;
|
|
80
|
+
}
|
|
81
|
+
else if (line === 'locked') {
|
|
82
|
+
worktree.isLocked = true;
|
|
83
|
+
}
|
|
84
|
+
else if (line.startsWith('locked ')) {
|
|
85
|
+
worktree.isLocked = true;
|
|
86
|
+
worktree.lockedReason = line.substring(7);
|
|
87
|
+
}
|
|
88
|
+
else if (line === 'prunable') {
|
|
89
|
+
worktree.prunable = true;
|
|
90
|
+
}
|
|
91
|
+
else if (line === 'detached') {
|
|
92
|
+
worktree.branch = 'HEAD (detached)';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Check if this is the main worktree
|
|
96
|
+
if (worktree.path === this.repoPath) {
|
|
97
|
+
worktree.isMain = true;
|
|
98
|
+
}
|
|
99
|
+
if (worktree.path && worktree.commit) {
|
|
100
|
+
worktrees.push(worktree);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return worktrees;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw parseGitError(error, command);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Create a new worktree
|
|
111
|
+
*/
|
|
112
|
+
async create(options) {
|
|
113
|
+
const { branch, baseBranch = GIT.DEFAULT_BASE_BRANCH, path: customPath } = options;
|
|
114
|
+
// Validate branch name
|
|
115
|
+
if (!isValidBranchName(branch)) {
|
|
116
|
+
throw new InvalidBranchError(branch, 'Branch names cannot contain spaces, .., or special characters');
|
|
117
|
+
}
|
|
118
|
+
// Generate worktree path if not provided
|
|
119
|
+
const worktreePath = customPath || path.join(path.dirname(this.repoPath), GIT.WORKTREES_DIR, branch.replace(/\//g, '-'));
|
|
120
|
+
// Validate path doesn't contain traversal attempts
|
|
121
|
+
const repoParent = path.dirname(this.repoPath);
|
|
122
|
+
if (!isPathSafe(worktreePath, repoParent)) {
|
|
123
|
+
throw new InvalidPathError(worktreePath, 'Path contains invalid characters or traversal attempts');
|
|
124
|
+
}
|
|
125
|
+
// Ensure parent directory exists
|
|
126
|
+
const parentDir = path.dirname(worktreePath);
|
|
127
|
+
if (!fs.existsSync(parentDir)) {
|
|
128
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
// Check if branch exists
|
|
132
|
+
const branchExists = await this.branchExists(branch);
|
|
133
|
+
// Use escaped arguments for safety
|
|
134
|
+
const escapedPath = escapeShellArg(worktreePath);
|
|
135
|
+
const escapedBranch = escapeShellArg(branch);
|
|
136
|
+
const escapedBase = escapeShellArg(baseBranch);
|
|
137
|
+
let command;
|
|
138
|
+
if (branchExists) {
|
|
139
|
+
// Checkout existing branch
|
|
140
|
+
command = `git worktree add ${escapedPath} ${escapedBranch}`;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Create new branch from base
|
|
144
|
+
command = `git worktree add -b ${escapedBranch} ${escapedPath} ${escapedBase}`;
|
|
145
|
+
}
|
|
146
|
+
await execAsync(command, { cwd: this.repoPath });
|
|
147
|
+
// Get the created worktree info
|
|
148
|
+
const worktrees = await this.list();
|
|
149
|
+
const created = worktrees.find(wt => wt.path === worktreePath);
|
|
150
|
+
if (!created) {
|
|
151
|
+
throw new WorktreeNotFoundError(worktreePath);
|
|
152
|
+
}
|
|
153
|
+
return created;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof WorktreeNotFoundError) {
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
throw parseGitError(error, `git worktree add ${branch}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Remove a worktree
|
|
164
|
+
*/
|
|
165
|
+
async remove(worktreePath, force = false) {
|
|
166
|
+
// Validate path
|
|
167
|
+
if (!isPathSafe(worktreePath)) {
|
|
168
|
+
throw new InvalidPathError(worktreePath, 'Path contains invalid characters');
|
|
169
|
+
}
|
|
170
|
+
const escapedPath = escapeShellArg(worktreePath);
|
|
171
|
+
const forceFlag = force ? '--force' : '';
|
|
172
|
+
const command = `git worktree remove ${forceFlag} ${escapedPath}`;
|
|
173
|
+
try {
|
|
174
|
+
await execAsync(command, { cwd: this.repoPath });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
const gitError = parseGitError(error, command);
|
|
178
|
+
// Check if it's a not-found error
|
|
179
|
+
if (gitError.stderr.includes('is not a working tree')) {
|
|
180
|
+
throw new WorktreeNotFoundError(worktreePath);
|
|
181
|
+
}
|
|
182
|
+
throw gitError;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Prune worktrees (clean up stale entries)
|
|
187
|
+
*/
|
|
188
|
+
async prune() {
|
|
189
|
+
const command = 'git worktree prune';
|
|
190
|
+
try {
|
|
191
|
+
await execAsync(command, { cwd: this.repoPath });
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw parseGitError(error, command);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Delete a branch
|
|
199
|
+
* @param branch - Branch name to delete
|
|
200
|
+
* @param force - If true, use -D (force delete) instead of -d
|
|
201
|
+
*/
|
|
202
|
+
async deleteBranch(branch, force = false) {
|
|
203
|
+
if (!isValidBranchName(branch)) {
|
|
204
|
+
throw new InvalidBranchError(branch, 'Invalid branch name');
|
|
205
|
+
}
|
|
206
|
+
const escapedBranch = escapeShellArg(branch);
|
|
207
|
+
const flag = force ? '-D' : '-d';
|
|
208
|
+
const command = `git branch ${flag} ${escapedBranch}`;
|
|
209
|
+
try {
|
|
210
|
+
await execAsync(command, { cwd: this.repoPath });
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
throw parseGitError(error, command);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Check if a branch exists
|
|
218
|
+
* @returns true if branch exists, false otherwise
|
|
219
|
+
* @note This method returns false on any error (including invalid branch names)
|
|
220
|
+
*/
|
|
221
|
+
async branchExists(branch) {
|
|
222
|
+
try {
|
|
223
|
+
const escapedBranch = escapeShellArg(branch);
|
|
224
|
+
await execAsync(`git rev-parse --verify ${escapedBranch}`, {
|
|
225
|
+
cwd: this.repoPath
|
|
226
|
+
});
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Get list of all local branches
|
|
235
|
+
* @returns Array of branch names, empty array on error
|
|
236
|
+
* @note This method returns empty array on any error
|
|
237
|
+
*/
|
|
238
|
+
async getBranches() {
|
|
239
|
+
try {
|
|
240
|
+
const { stdout } = await execAsync('git branch -a --format="%(refname:short)"', {
|
|
241
|
+
cwd: this.repoPath
|
|
242
|
+
});
|
|
243
|
+
return stdout.trim().split('\n').filter(b => b && !b.startsWith('origin/'));
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Return empty array on error - caller should handle empty results
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Get current branch name
|
|
252
|
+
* @returns Current branch name, or 'unknown' on error
|
|
253
|
+
* @note This method returns 'unknown' on any error
|
|
254
|
+
*/
|
|
255
|
+
async getCurrentBranch() {
|
|
256
|
+
try {
|
|
257
|
+
const { stdout } = await execAsync('git branch --show-current', {
|
|
258
|
+
cwd: this.repoPath
|
|
259
|
+
});
|
|
260
|
+
return stdout.trim();
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return 'unknown';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Get repo name
|
|
268
|
+
*/
|
|
269
|
+
getRepoName() {
|
|
270
|
+
return path.basename(this.repoPath);
|
|
271
|
+
}
|
|
272
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { GitWorktree } from './git/worktree.js';
|
|
4
|
+
import { WorktreeManagerTUI } from './tui/app.js';
|
|
5
|
+
import { runStartupChecks } from './utils/checks.js';
|
|
6
|
+
import { openEditor, openTerminal, launchAI, validEditors, validAITools } from './utils/launch.js';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import readline from 'readline';
|
|
11
|
+
import { GIT } from './constants.js';
|
|
12
|
+
import { VERSION, PACKAGE_NAME, pkg } from './version.js';
|
|
13
|
+
import updateNotifier from 'update-notifier';
|
|
14
|
+
import { execSync } from 'child_process';
|
|
15
|
+
// Check dependencies before anything else
|
|
16
|
+
runStartupChecks();
|
|
17
|
+
// Check for updates (runs in background, notifies if update available)
|
|
18
|
+
const notifier = updateNotifier({ pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }); // Check daily
|
|
19
|
+
notifier.notify({ isGlobal: true });
|
|
20
|
+
// Helper to prompt user for input
|
|
21
|
+
function promptUser(question) {
|
|
22
|
+
const rl = readline.createInterface({
|
|
23
|
+
input: process.stdin,
|
|
24
|
+
output: process.stdout
|
|
25
|
+
});
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
rl.question(question, (answer) => {
|
|
28
|
+
rl.close();
|
|
29
|
+
resolve(answer.trim());
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// Helper to check if a branch is protected
|
|
34
|
+
function isProtectedBranch(branch) {
|
|
35
|
+
const normalizedBranch = branch.toLowerCase();
|
|
36
|
+
return GIT.PROTECTED_BRANCHES.some(protectedBranch => normalizedBranch === protectedBranch.toLowerCase());
|
|
37
|
+
}
|
|
38
|
+
const program = new Command();
|
|
39
|
+
program
|
|
40
|
+
.name('worktree-manager')
|
|
41
|
+
.description('Terminal app for managing git worktrees with AI assistance')
|
|
42
|
+
.version(VERSION)
|
|
43
|
+
.argument('[path]', 'Path to git repository', '.')
|
|
44
|
+
.action(async (repoPath) => {
|
|
45
|
+
const resolvedPath = path.resolve(repoPath);
|
|
46
|
+
// Validate path exists
|
|
47
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
48
|
+
console.error(chalk.red(`Error: Path does not exist: ${resolvedPath}`));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
// Validate it's a git repo
|
|
52
|
+
if (!GitWorktree.isGitRepo(resolvedPath)) {
|
|
53
|
+
console.error(chalk.red(`Error: Not a git repository: ${resolvedPath}`));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
// Get the repo root (in case user passed a subdirectory)
|
|
57
|
+
const repoRoot = GitWorktree.getRepoRoot(resolvedPath);
|
|
58
|
+
if (!repoRoot) {
|
|
59
|
+
console.error(chalk.red(`Error: Could not determine git repository root`));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
// Launch TUI
|
|
63
|
+
const tui = new WorktreeManagerTUI(repoRoot);
|
|
64
|
+
await tui.start();
|
|
65
|
+
});
|
|
66
|
+
// Subcommand: list worktrees (non-interactive)
|
|
67
|
+
program
|
|
68
|
+
.command('list')
|
|
69
|
+
.description('List all worktrees (non-interactive)')
|
|
70
|
+
.argument('[path]', 'Path to git repository', '.')
|
|
71
|
+
.action(async (repoPath) => {
|
|
72
|
+
const resolvedPath = path.resolve(repoPath);
|
|
73
|
+
const repoRoot = GitWorktree.getRepoRoot(resolvedPath);
|
|
74
|
+
if (!repoRoot) {
|
|
75
|
+
console.error(chalk.red('Not a git repository'));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const git = new GitWorktree(repoRoot);
|
|
79
|
+
const worktrees = await git.list();
|
|
80
|
+
console.log(chalk.cyan.bold('\n Git Worktrees\n'));
|
|
81
|
+
for (const wt of worktrees) {
|
|
82
|
+
const mainBadge = wt.isMain ? chalk.green(' [main]') : '';
|
|
83
|
+
const lockBadge = wt.isLocked ? chalk.red(' [locked]') : '';
|
|
84
|
+
console.log(` ${chalk.yellow('●')} ${chalk.bold(wt.branch)}${mainBadge}${lockBadge}`);
|
|
85
|
+
console.log(` ${chalk.gray('Path:')} ${wt.path}`);
|
|
86
|
+
console.log(` ${chalk.gray('Commit:')} ${wt.commit}\n`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
// Subcommand: create worktree
|
|
90
|
+
program
|
|
91
|
+
.command('create <branch>')
|
|
92
|
+
.description('Create a new worktree')
|
|
93
|
+
.argument('[path]', 'Path to git repository', '.')
|
|
94
|
+
.option('-b, --base <branch>', 'Base branch to create from', 'main')
|
|
95
|
+
.option('-p, --path <path>', 'Custom path for the worktree')
|
|
96
|
+
.action(async (branch, repoPath, options) => {
|
|
97
|
+
const resolvedPath = path.resolve(repoPath || '.');
|
|
98
|
+
const repoRoot = GitWorktree.getRepoRoot(resolvedPath);
|
|
99
|
+
if (!repoRoot) {
|
|
100
|
+
console.error(chalk.red('Not a git repository'));
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
const git = new GitWorktree(repoRoot);
|
|
104
|
+
console.log(chalk.cyan(`Creating worktree for branch: ${branch}...`));
|
|
105
|
+
try {
|
|
106
|
+
const wt = await git.create({
|
|
107
|
+
branch,
|
|
108
|
+
baseBranch: options.base,
|
|
109
|
+
path: options.path
|
|
110
|
+
});
|
|
111
|
+
console.log(chalk.green(`✓ Created worktree: ${wt.path}`));
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
console.error(chalk.red(`Error: ${error}`));
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
// Subcommand: remove worktree
|
|
119
|
+
program
|
|
120
|
+
.command('remove <branch-or-path>')
|
|
121
|
+
.description('Remove a worktree')
|
|
122
|
+
.argument('[repo-path]', 'Path to git repository', '.')
|
|
123
|
+
.option('-f, --force', 'Force removal even with local changes')
|
|
124
|
+
.action(async (branchOrPath, repoPath, options) => {
|
|
125
|
+
const resolvedPath = path.resolve(repoPath || '.');
|
|
126
|
+
const repoRoot = GitWorktree.getRepoRoot(resolvedPath);
|
|
127
|
+
if (!repoRoot) {
|
|
128
|
+
console.error(chalk.red('Not a git repository'));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
const git = new GitWorktree(repoRoot);
|
|
132
|
+
const worktrees = await git.list();
|
|
133
|
+
// Find worktree by branch name or path
|
|
134
|
+
const wt = worktrees.find(w => w.branch === branchOrPath ||
|
|
135
|
+
w.path === branchOrPath ||
|
|
136
|
+
w.path.endsWith(branchOrPath));
|
|
137
|
+
if (!wt) {
|
|
138
|
+
console.error(chalk.red(`Worktree not found: ${branchOrPath}`));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
if (wt.isMain) {
|
|
142
|
+
console.error(chalk.red('Cannot remove main worktree'));
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
const branchName = wt.branch;
|
|
146
|
+
console.log(chalk.yellow(`Removing worktree: ${branchName}...`));
|
|
147
|
+
try {
|
|
148
|
+
await git.remove(wt.path, options.force);
|
|
149
|
+
console.log(chalk.green(`✓ Removed worktree: ${branchName}`));
|
|
150
|
+
// Ask about branch deletion (only for non-protected branches)
|
|
151
|
+
if (!isProtectedBranch(branchName)) {
|
|
152
|
+
const answer = await promptUser(chalk.yellow(`\nAlso delete branch "${branchName}"? Type "yes" to confirm (or press Enter to skip): `));
|
|
153
|
+
if (answer === 'yes') {
|
|
154
|
+
console.log(chalk.yellow(`Deleting branch: ${branchName}...`));
|
|
155
|
+
try {
|
|
156
|
+
await git.deleteBranch(branchName, true);
|
|
157
|
+
console.log(chalk.green(`✓ Deleted branch: ${branchName}`));
|
|
158
|
+
}
|
|
159
|
+
catch (branchError) {
|
|
160
|
+
console.error(chalk.yellow(`⚠ Branch deletion failed: ${branchError}`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
console.log(chalk.gray(`Branch "${branchName}" kept.`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
console.error(chalk.red(`Error: ${error}`));
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
// Helper to find worktree by branch or path
|
|
174
|
+
async function findWorktree(branchOrPath, repoPath) {
|
|
175
|
+
const resolvedPath = path.resolve(repoPath || '.');
|
|
176
|
+
const repoRoot = GitWorktree.getRepoRoot(resolvedPath);
|
|
177
|
+
if (!repoRoot) {
|
|
178
|
+
console.error(chalk.red('Not a git repository'));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
const git = new GitWorktree(repoRoot);
|
|
182
|
+
const worktrees = await git.list();
|
|
183
|
+
const wt = worktrees.find(w => w.branch === branchOrPath ||
|
|
184
|
+
w.path === branchOrPath ||
|
|
185
|
+
w.path.endsWith(branchOrPath) ||
|
|
186
|
+
w.name === branchOrPath);
|
|
187
|
+
if (!wt) {
|
|
188
|
+
console.error(chalk.red(`Worktree not found: ${branchOrPath}`));
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
return wt;
|
|
192
|
+
}
|
|
193
|
+
// Subcommand: open worktree in editor
|
|
194
|
+
program
|
|
195
|
+
.command('open <branch-or-path>')
|
|
196
|
+
.description('Open a worktree in an editor')
|
|
197
|
+
.argument('[repo-path]', 'Path to git repository', '.')
|
|
198
|
+
.option('-e, --editor <editor>', `Editor to use (${validEditors.join(', ')})`, 'code')
|
|
199
|
+
.action(async (branchOrPath, repoPath, options) => {
|
|
200
|
+
const editor = options.editor;
|
|
201
|
+
if (!validEditors.includes(editor)) {
|
|
202
|
+
console.error(chalk.red(`Invalid editor: ${editor}`));
|
|
203
|
+
console.error(chalk.gray(`Valid options: ${validEditors.join(', ')}`));
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
const wt = await findWorktree(branchOrPath, repoPath);
|
|
207
|
+
console.log(chalk.cyan(`Opening ${wt.branch} in ${editor}...`));
|
|
208
|
+
openEditor(wt.path, editor);
|
|
209
|
+
});
|
|
210
|
+
// Subcommand: open terminal in worktree
|
|
211
|
+
program
|
|
212
|
+
.command('terminal <branch-or-path>')
|
|
213
|
+
.alias('term')
|
|
214
|
+
.description('Open a terminal in a worktree')
|
|
215
|
+
.argument('[repo-path]', 'Path to git repository', '.')
|
|
216
|
+
.action(async (branchOrPath, repoPath) => {
|
|
217
|
+
const wt = await findWorktree(branchOrPath, repoPath);
|
|
218
|
+
console.log(chalk.cyan(`Opening terminal in ${wt.branch}...`));
|
|
219
|
+
openTerminal(wt.path);
|
|
220
|
+
});
|
|
221
|
+
// Subcommand: launch AI tool in worktree
|
|
222
|
+
program
|
|
223
|
+
.command('ai <branch-or-path>')
|
|
224
|
+
.description('Launch an AI tool in a worktree')
|
|
225
|
+
.argument('[repo-path]', 'Path to git repository', '.')
|
|
226
|
+
.option('-t, --tool <tool>', `AI tool to use (${validAITools.join(', ')})`, 'claude')
|
|
227
|
+
.action(async (branchOrPath, repoPath, options) => {
|
|
228
|
+
const tool = options.tool;
|
|
229
|
+
if (!validAITools.includes(tool)) {
|
|
230
|
+
console.error(chalk.red(`Invalid AI tool: ${tool}`));
|
|
231
|
+
console.error(chalk.gray(`Valid options: ${validAITools.join(', ')}`));
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
const wt = await findWorktree(branchOrPath, repoPath);
|
|
235
|
+
console.log(chalk.cyan(`Launching ${tool} in ${wt.branch}...`));
|
|
236
|
+
launchAI(wt.path, tool);
|
|
237
|
+
});
|
|
238
|
+
// Subcommand: help with examples
|
|
239
|
+
program
|
|
240
|
+
.command('help')
|
|
241
|
+
.description('Show detailed help and examples')
|
|
242
|
+
.action(() => {
|
|
243
|
+
console.log(`
|
|
244
|
+
${chalk.cyan.bold('Worktree Manager')} - Terminal app for managing git worktrees
|
|
245
|
+
|
|
246
|
+
${chalk.yellow.bold('QUICK START')}
|
|
247
|
+
${chalk.gray('Launch TUI in current repo:')}
|
|
248
|
+
$ ${chalk.green('wtm')}
|
|
249
|
+
|
|
250
|
+
${chalk.gray('Launch TUI for specific repo:')}
|
|
251
|
+
$ ${chalk.green('wtm /path/to/repo')}
|
|
252
|
+
|
|
253
|
+
${chalk.yellow.bold('CLI COMMANDS')}
|
|
254
|
+
|
|
255
|
+
${chalk.cyan('wtm list')} ${chalk.gray('[path]')}
|
|
256
|
+
List all worktrees
|
|
257
|
+
$ wtm list
|
|
258
|
+
$ wtm list /path/to/repo
|
|
259
|
+
|
|
260
|
+
${chalk.cyan('wtm create')} ${chalk.white('<branch>')} ${chalk.gray('[path] [-b base] [-p path]')}
|
|
261
|
+
Create a new worktree
|
|
262
|
+
$ wtm create feature/login
|
|
263
|
+
$ wtm create feature/api -b develop
|
|
264
|
+
$ wtm create bugfix/issue-42 -p /custom/path
|
|
265
|
+
|
|
266
|
+
${chalk.cyan('wtm remove')} ${chalk.white('<branch>')} ${chalk.gray('[path] [-f]')}
|
|
267
|
+
Remove a worktree
|
|
268
|
+
$ wtm remove feature/login
|
|
269
|
+
$ wtm remove feature/old --force
|
|
270
|
+
|
|
271
|
+
${chalk.cyan('wtm open')} ${chalk.white('<branch>')} ${chalk.gray('[path] [-e editor]')}
|
|
272
|
+
Open worktree in editor
|
|
273
|
+
$ wtm open feature/login
|
|
274
|
+
$ wtm open main -e cursor
|
|
275
|
+
${chalk.gray('Editors: code, cursor, zed, webstorm, subl, nvim')}
|
|
276
|
+
|
|
277
|
+
${chalk.cyan('wtm terminal')} ${chalk.white('<branch>')} ${chalk.gray('[path]')}
|
|
278
|
+
Open terminal in worktree
|
|
279
|
+
$ wtm terminal feature/login
|
|
280
|
+
$ wtm term main
|
|
281
|
+
|
|
282
|
+
${chalk.cyan('wtm ai')} ${chalk.white('<branch>')} ${chalk.gray('[path] [-t tool]')}
|
|
283
|
+
Launch AI tool in worktree
|
|
284
|
+
$ wtm ai feature/login
|
|
285
|
+
$ wtm ai main -t gemini
|
|
286
|
+
${chalk.gray('Tools: claude, gemini, codex')}
|
|
287
|
+
|
|
288
|
+
${chalk.cyan('wtm update')}
|
|
289
|
+
Update to the latest version
|
|
290
|
+
$ wtm update
|
|
291
|
+
|
|
292
|
+
${chalk.yellow.bold('TUI KEYBINDINGS')}
|
|
293
|
+
${chalk.cyan('↑/k, ↓/j')} Navigate worktrees
|
|
294
|
+
${chalk.cyan('Enter')} Show details
|
|
295
|
+
${chalk.cyan('n')} Create new worktree
|
|
296
|
+
${chalk.cyan('d')} Delete worktree
|
|
297
|
+
${chalk.cyan('e')} Open in editor
|
|
298
|
+
${chalk.cyan('t')} Open terminal
|
|
299
|
+
${chalk.cyan('a')} Launch AI tool
|
|
300
|
+
${chalk.cyan('r')} Refresh list
|
|
301
|
+
${chalk.cyan('?')} Show help
|
|
302
|
+
${chalk.cyan('q')} Quit
|
|
303
|
+
`);
|
|
304
|
+
});
|
|
305
|
+
// Subcommand: update to latest version
|
|
306
|
+
program
|
|
307
|
+
.command('update')
|
|
308
|
+
.description('Update to the latest version')
|
|
309
|
+
.action(() => {
|
|
310
|
+
console.log(chalk.cyan(`\nCurrent version: ${VERSION}`));
|
|
311
|
+
console.log(chalk.gray('Checking for updates...\n'));
|
|
312
|
+
try {
|
|
313
|
+
// Check npm for latest version
|
|
314
|
+
const latestVersion = execSync(`npm view ${PACKAGE_NAME} version`, { encoding: 'utf-8' }).trim();
|
|
315
|
+
if (latestVersion === VERSION) {
|
|
316
|
+
console.log(chalk.green(`✓ Already on the latest version (${VERSION})`));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
console.log(chalk.yellow(`New version available: ${latestVersion}`));
|
|
320
|
+
console.log(chalk.gray(`Updating ${PACKAGE_NAME}...\n`));
|
|
321
|
+
// Run the update
|
|
322
|
+
execSync(`npm install -g ${PACKAGE_NAME}@latest`, { stdio: 'inherit' });
|
|
323
|
+
console.log(chalk.green(`\n✓ Successfully updated to ${latestVersion}`));
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (error instanceof Error && error.message.includes('npm view')) {
|
|
327
|
+
console.error(chalk.red('Error: Could not check for updates. Package may not be published yet.'));
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
console.error(chalk.red(`Error updating: ${error instanceof Error ? error.message : error}`));
|
|
331
|
+
console.log(chalk.gray(`\nTry manually: npm install -g ${PACKAGE_NAME}@latest`));
|
|
332
|
+
}
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
program.parse();
|