@jasonfutch/worktree-manager 1.0.0 → 1.1.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/README.md CHANGED
@@ -6,6 +6,7 @@ A terminal app for managing git worktrees with AI assistance.
6
6
 
7
7
  - **TUI Interface** - Full terminal UI built with blessed
8
8
  - **Git Worktree Management** - Create, list, and remove worktrees
9
+ - **Branch Flexibility** - Create worktrees from new branches, existing local branches, or remote branches
9
10
  - **IDE Integration** - Open worktrees in VS Code, Cursor, Zed, and more
10
11
  - **AI Integration** - Launch Claude, Gemini, or Codex in any worktree
11
12
  - **Parallel Development** - Work on multiple features simultaneously
@@ -42,7 +43,7 @@ wtm /path/to/repo
42
43
  | `↑/k` | Move up |
43
44
  | `↓/j` | Move down |
44
45
  | `Enter` | Select/Details |
45
- | `n` | Create new worktree |
46
+ | `n` | Create worktree (new or existing branch) |
46
47
  | `d` | Delete worktree |
47
48
  | `e` | Open in editor (selector) |
48
49
  | `t` | Open terminal |
@@ -51,6 +52,19 @@ wtm /path/to/repo
51
52
  | `?` | Help |
52
53
  | `q` | Quit |
53
54
 
55
+ ### Creating Worktrees
56
+
57
+ Press `n` to create a new worktree. You'll be presented with two options:
58
+
59
+ 1. **Create new branch** - Enter a new branch name and select a base branch to create it from
60
+ 2. **Use existing branch** - Select from available local or remote branches
61
+
62
+ When using existing branches:
63
+ - Local branches are listed first
64
+ - Remote branches are marked with ⬇ and listed after local branches
65
+ - Selecting a remote branch (e.g., `origin/feature`) automatically creates a local tracking branch
66
+ - Branches already checked out in other worktrees are filtered out
67
+
54
68
  ### CLI Commands
55
69
 
56
70
  ```bash
@@ -58,11 +72,15 @@ wtm /path/to/repo
58
72
  wtm list
59
73
  wtm list /path/to/repo
60
74
 
61
- # Create a new worktree
75
+ # Create a new worktree (creates new branch from base)
62
76
  wtm create feature/my-feature
63
77
  wtm create feature/my-feature -b main
64
78
  wtm create feature/my-feature -p /custom/path
65
79
 
80
+ # Create worktree from existing branch
81
+ wtm create existing-branch # Uses existing local branch
82
+ wtm create origin/feature -e # Creates local tracking branch from remote
83
+
66
84
  # Remove a worktree
67
85
  wtm remove feature/my-feature
68
86
  wtm remove feature/my-feature --force
@@ -1,4 +1,4 @@
1
- import type { Worktree, CreateWorktreeOptions } from '../types.js';
1
+ import type { Worktree, CreateWorktreeOptions, BranchInfo } from '../types.js';
2
2
  export declare class GitWorktree {
3
3
  private repoPath;
4
4
  constructor(repoPath: string);
@@ -44,6 +44,11 @@ export declare class GitWorktree {
44
44
  * @note This method returns empty array on any error
45
45
  */
46
46
  getBranches(): Promise<string[]>;
47
+ /**
48
+ * Get all branches (local and remote) with structured info
49
+ * @returns Array of BranchInfo objects, empty array on error
50
+ */
51
+ getAllBranches(): Promise<BranchInfo[]>;
47
52
  /**
48
53
  * Get current branch name
49
54
  * @returns Current branch name, or 'unknown' on error
@@ -110,13 +110,16 @@ export class GitWorktree {
110
110
  * Create a new worktree
111
111
  */
112
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');
113
+ const { branch, baseBranch = GIT.DEFAULT_BASE_BRANCH, path: customPath, useExisting = false } = options;
114
+ // For existing branches, the branch might be a remote ref like 'origin/feature'
115
+ const isRemoteBranch = branch.includes('/') && !branch.startsWith('refs/');
116
+ const localBranchName = isRemoteBranch ? branch.split('/').slice(1).join('/') : branch;
117
+ // Validate branch name (use local name for validation)
118
+ if (!isValidBranchName(localBranchName)) {
119
+ throw new InvalidBranchError(localBranchName, 'Branch names cannot contain spaces, .., or special characters');
117
120
  }
118
121
  // Generate worktree path if not provided
119
- const worktreePath = customPath || path.join(path.dirname(this.repoPath), GIT.WORKTREES_DIR, branch.replace(/\//g, '-'));
122
+ const worktreePath = customPath || path.join(path.dirname(this.repoPath), GIT.WORKTREES_DIR, localBranchName.replace(/\//g, '-'));
120
123
  // Validate path doesn't contain traversal attempts
121
124
  const repoParent = path.dirname(this.repoPath);
122
125
  if (!isPathSafe(worktreePath, repoParent)) {
@@ -128,20 +131,42 @@ export class GitWorktree {
128
131
  fs.mkdirSync(parentDir, { recursive: true });
129
132
  }
130
133
  try {
131
- // Check if branch exists
132
- const branchExists = await this.branchExists(branch);
133
134
  // Use escaped arguments for safety
134
135
  const escapedPath = escapeShellArg(worktreePath);
135
- const escapedBranch = escapeShellArg(branch);
136
+ const escapedLocalBranch = escapeShellArg(localBranchName);
136
137
  const escapedBase = escapeShellArg(baseBranch);
137
138
  let command;
138
- if (branchExists) {
139
- // Checkout existing branch
140
- command = `git worktree add ${escapedPath} ${escapedBranch}`;
139
+ if (useExisting) {
140
+ if (isRemoteBranch) {
141
+ // For remote branches, create a local tracking branch
142
+ const escapedRemoteBranch = escapeShellArg(branch);
143
+ // Check if a local branch with this name already exists
144
+ const localExists = await this.branchExists(localBranchName);
145
+ if (localExists) {
146
+ // Use the existing local branch
147
+ command = `git worktree add ${escapedPath} ${escapedLocalBranch}`;
148
+ }
149
+ else {
150
+ // Create a new local branch tracking the remote
151
+ command = `git worktree add -b ${escapedLocalBranch} ${escapedPath} ${escapedRemoteBranch}`;
152
+ }
153
+ }
154
+ else {
155
+ // For local branches, just check them out
156
+ command = `git worktree add ${escapedPath} ${escapedLocalBranch}`;
157
+ }
141
158
  }
142
159
  else {
143
- // Create new branch from base
144
- command = `git worktree add -b ${escapedBranch} ${escapedPath} ${escapedBase}`;
160
+ // Creating a new branch
161
+ const branchExists = await this.branchExists(branch);
162
+ if (branchExists) {
163
+ // Branch already exists, just check it out
164
+ command = `git worktree add ${escapedPath} ${escapedLocalBranch}`;
165
+ }
166
+ else {
167
+ // Create new branch from base
168
+ command = `git worktree add -b ${escapedLocalBranch} ${escapedPath} ${escapedBase}`;
169
+ }
145
170
  }
146
171
  await execAsync(command, { cwd: this.repoPath });
147
172
  // Get the created worktree info
@@ -247,6 +272,66 @@ export class GitWorktree {
247
272
  return [];
248
273
  }
249
274
  }
275
+ /**
276
+ * Get all branches (local and remote) with structured info
277
+ * @returns Array of BranchInfo objects, empty array on error
278
+ */
279
+ async getAllBranches() {
280
+ try {
281
+ // Fetch remote refs first to ensure we have the latest
282
+ await execAsync('git fetch --all --prune', { cwd: this.repoPath }).catch(() => {
283
+ // Ignore fetch errors (e.g., no network)
284
+ });
285
+ const { stdout } = await execAsync('git branch -a --format="%(refname:short)"', {
286
+ cwd: this.repoPath
287
+ });
288
+ const branches = [];
289
+ const lines = stdout.trim().split('\n').filter(b => b);
290
+ // Get list of branches already checked out in worktrees
291
+ const worktrees = await this.list();
292
+ const checkedOutBranches = new Set(worktrees.map(wt => wt.branch).filter(b => b !== 'HEAD (detached)'));
293
+ for (const line of lines) {
294
+ // Skip HEAD pointer
295
+ if (line === 'HEAD' || line.includes('->'))
296
+ continue;
297
+ if (line.startsWith('origin/')) {
298
+ // Remote branch
299
+ const remoteName = 'origin';
300
+ const branchName = line.substring(7); // Remove 'origin/' prefix
301
+ // Skip if this remote branch is already checked out locally
302
+ if (checkedOutBranches.has(branchName))
303
+ continue;
304
+ branches.push({
305
+ name: branchName,
306
+ fullName: line,
307
+ isRemote: true,
308
+ remote: remoteName
309
+ });
310
+ }
311
+ else {
312
+ // Local branch - skip if already checked out in a worktree
313
+ if (checkedOutBranches.has(line))
314
+ continue;
315
+ branches.push({
316
+ name: line,
317
+ fullName: line,
318
+ isRemote: false
319
+ });
320
+ }
321
+ }
322
+ // Sort: local branches first, then remote, alphabetically within each group
323
+ branches.sort((a, b) => {
324
+ if (a.isRemote !== b.isRemote) {
325
+ return a.isRemote ? 1 : -1;
326
+ }
327
+ return a.name.localeCompare(b.name);
328
+ });
329
+ return branches;
330
+ }
331
+ catch {
332
+ return [];
333
+ }
334
+ }
250
335
  /**
251
336
  * Get current branch name
252
337
  * @returns Current branch name, or 'unknown' on error
package/dist/index.js CHANGED
@@ -93,6 +93,7 @@ program
93
93
  .argument('[path]', 'Path to git repository', '.')
94
94
  .option('-b, --base <branch>', 'Base branch to create from', 'main')
95
95
  .option('-p, --path <path>', 'Custom path for the worktree')
96
+ .option('-e, --existing', 'Use existing branch (local or remote)')
96
97
  .action(async (branch, repoPath, options) => {
97
98
  const resolvedPath = path.resolve(repoPath || '.');
98
99
  const repoRoot = GitWorktree.getRepoRoot(resolvedPath);
@@ -101,12 +102,18 @@ program
101
102
  process.exit(1);
102
103
  }
103
104
  const git = new GitWorktree(repoRoot);
104
- console.log(chalk.cyan(`Creating worktree for branch: ${branch}...`));
105
+ const isRemote = branch.includes('/') && !branch.startsWith('refs/');
106
+ const displayBranch = isRemote ? branch : branch;
107
+ const message = options.existing && isRemote
108
+ ? `Creating worktree from ${displayBranch} (will create local tracking branch)...`
109
+ : `Creating worktree for branch: ${displayBranch}...`;
110
+ console.log(chalk.cyan(message));
105
111
  try {
106
112
  const wt = await git.create({
107
113
  branch,
108
114
  baseBranch: options.base,
109
- path: options.path
115
+ path: options.path,
116
+ useExisting: options.existing
110
117
  });
111
118
  console.log(chalk.green(`✓ Created worktree: ${wt.path}`));
112
119
  }
@@ -257,11 +264,12 @@ ${chalk.yellow.bold('CLI COMMANDS')}
257
264
  $ wtm list
258
265
  $ wtm list /path/to/repo
259
266
 
260
- ${chalk.cyan('wtm create')} ${chalk.white('<branch>')} ${chalk.gray('[path] [-b base] [-p path]')}
267
+ ${chalk.cyan('wtm create')} ${chalk.white('<branch>')} ${chalk.gray('[path] [-b base] [-p path] [-e]')}
261
268
  Create a new worktree
262
269
  $ wtm create feature/login
263
270
  $ wtm create feature/api -b develop
264
271
  $ wtm create bugfix/issue-42 -p /custom/path
272
+ $ wtm create origin/feature -e ${chalk.gray('# Use existing remote branch')}
265
273
 
266
274
  ${chalk.cyan('wtm remove')} ${chalk.white('<branch>')} ${chalk.gray('[path] [-f]')}
267
275
  Remove a worktree
package/dist/tui/app.d.ts CHANGED
@@ -26,6 +26,8 @@ export declare class WorktreeManagerTUI {
26
26
  private isProtectedBranch;
27
27
  private showHelp;
28
28
  private promptCreateWorktree;
29
+ private promptCreateNewBranch;
30
+ private promptUseExistingBranch;
29
31
  private promptDeleteWorktree;
30
32
  private openInEditor;
31
33
  private openEditorTool;
package/dist/tui/app.js CHANGED
@@ -368,8 +368,54 @@ export class WorktreeManagerTUI {
368
368
  if (this.isModalOpen)
369
369
  return;
370
370
  this.isModalOpen = true;
371
+ // First, show mode selector
372
+ const modeList = blessed.list({
373
+ parent: this.screen,
374
+ left: 'center',
375
+ top: 'center',
376
+ width: 50,
377
+ height: 8,
378
+ border: { type: 'line' },
379
+ style: {
380
+ border: { fg: 'green' },
381
+ selected: { bg: 'blue', fg: 'white' },
382
+ bg: 'default'
383
+ },
384
+ label: ' Create Worktree ',
385
+ keys: true,
386
+ vi: true,
387
+ items: [
388
+ ' Create new branch',
389
+ ' Use existing branch'
390
+ ]
391
+ });
392
+ modeList.focus();
393
+ this.setStatus(' Select worktree creation mode', 'blue');
394
+ this.screen.render();
395
+ const cleanupModeSelector = () => {
396
+ modeList.destroy();
397
+ this.screen.render();
398
+ };
399
+ modeList.key(['escape'], () => {
400
+ cleanupModeSelector();
401
+ this.isModalOpen = false;
402
+ this.worktreeList.focus();
403
+ this.setStatus(' Cancelled', 'blue');
404
+ });
405
+ modeList.key(['enter'], async () => {
406
+ const selectedIdx = modeList.selected;
407
+ cleanupModeSelector();
408
+ if (selectedIdx === 0) {
409
+ await this.promptCreateNewBranch();
410
+ }
411
+ else {
412
+ await this.promptUseExistingBranch();
413
+ }
414
+ });
415
+ }
416
+ async promptCreateNewBranch() {
371
417
  this.setStatus(' Loading branches...', 'blue');
372
- // Fetch available branches
418
+ // Fetch available branches for base branch selection
373
419
  const branches = await this.git.getBranches();
374
420
  if (branches.length === 0) {
375
421
  this.setStatus(' Warning: Could not load branches. Using default "main"', 'yellow');
@@ -386,13 +432,13 @@ export class WorktreeManagerTUI {
386
432
  border: { fg: 'green' },
387
433
  bg: 'default'
388
434
  },
389
- label: ' Create New Worktree '
435
+ label: ' Create New Branch '
390
436
  });
391
437
  blessed.text({
392
438
  parent: form,
393
439
  top: 1,
394
440
  left: 2,
395
- content: 'Branch name:',
441
+ content: 'New branch name:',
396
442
  style: { fg: 'default' }
397
443
  });
398
444
  const branchInput = blessed.textbox({
@@ -444,7 +490,7 @@ export class WorktreeManagerTUI {
444
490
  style: { fg: 'gray' }
445
491
  });
446
492
  branchInput.focus();
447
- this.setStatus(' Creating new worktree...', 'blue');
493
+ this.setStatus(' Enter new branch name', 'blue');
448
494
  const submitForm = async () => {
449
495
  this.isModalOpen = false;
450
496
  const branch = branchInput.getValue().replace(/\t/g, '').trim();
@@ -456,7 +502,7 @@ export class WorktreeManagerTUI {
456
502
  if (branch) {
457
503
  this.setStatus(` Creating worktree: ${branch} from ${baseBranch}...`, 'blue');
458
504
  try {
459
- await this.git.create({ branch, baseBranch });
505
+ await this.git.create({ branch, baseBranch, useExisting: false });
460
506
  await this.refresh();
461
507
  this.setStatus(` Created worktree: ${branch}`, 'green');
462
508
  }
@@ -493,6 +539,119 @@ export class WorktreeManagerTUI {
493
539
  });
494
540
  this.screen.render();
495
541
  }
542
+ async promptUseExistingBranch() {
543
+ this.setStatus(' Fetching branches...', 'blue');
544
+ this.screen.render();
545
+ // Fetch all branches including remotes
546
+ const allBranches = await this.git.getAllBranches();
547
+ if (allBranches.length === 0) {
548
+ this.isModalOpen = false;
549
+ this.worktreeList.focus();
550
+ this.setStatus(' No available branches found (all may already have worktrees)', 'yellow');
551
+ this.screen.render();
552
+ return;
553
+ }
554
+ const form = blessed.box({
555
+ parent: this.screen,
556
+ left: 'center',
557
+ top: 'center',
558
+ width: 60,
559
+ height: 20,
560
+ border: { type: 'line' },
561
+ style: {
562
+ border: { fg: 'cyan' },
563
+ bg: 'default'
564
+ },
565
+ label: ' Select Existing Branch '
566
+ });
567
+ blessed.text({
568
+ parent: form,
569
+ top: 1,
570
+ left: 2,
571
+ content: 'Available branches:',
572
+ style: { fg: 'default' }
573
+ });
574
+ // Format branch items for display
575
+ const branchItems = allBranches.map(b => {
576
+ const prefix = b.isRemote ? '{yellow-fg}⬇{/} ' : ' ';
577
+ const label = b.isRemote ? `${b.fullName}` : b.name;
578
+ return `${prefix}${label}`;
579
+ });
580
+ const branchList = blessed.list({
581
+ parent: form,
582
+ top: 3,
583
+ left: 2,
584
+ width: 54,
585
+ height: 12,
586
+ border: { type: 'line' },
587
+ style: {
588
+ border: { fg: 'cyan' },
589
+ selected: { bg: 'blue', fg: 'white' },
590
+ focus: { border: { fg: 'green' } }
591
+ },
592
+ keys: true,
593
+ vi: true,
594
+ mouse: true,
595
+ tags: true,
596
+ items: branchItems,
597
+ scrollbar: {
598
+ ch: '│',
599
+ style: { fg: 'cyan' }
600
+ }
601
+ });
602
+ blessed.text({
603
+ parent: form,
604
+ top: 16,
605
+ left: 2,
606
+ content: '↑↓/jk Navigate | Enter Select | Esc Cancel | {yellow-fg}⬇{/} = remote',
607
+ tags: true,
608
+ style: { fg: 'gray' }
609
+ });
610
+ branchList.focus();
611
+ this.setStatus(' Select a branch to create worktree from', 'blue');
612
+ this.screen.render();
613
+ const cancelForm = () => {
614
+ this.isModalOpen = false;
615
+ form.destroy();
616
+ this.worktreeList.focus();
617
+ this.setStatus(' Cancelled', 'blue');
618
+ this.screen.render();
619
+ };
620
+ branchList.key(['escape'], cancelForm);
621
+ branchList.key(['enter'], async () => {
622
+ const selectedIdx = branchList.selected;
623
+ const selectedBranch = allBranches[selectedIdx];
624
+ this.isModalOpen = false;
625
+ form.destroy();
626
+ this.worktreeList.focus();
627
+ if (selectedBranch) {
628
+ const displayName = selectedBranch.isRemote ? selectedBranch.fullName : selectedBranch.name;
629
+ const creatingMsg = selectedBranch.isRemote
630
+ ? ` Creating worktree from ${displayName} (will create local tracking branch)...`
631
+ : ` Creating worktree for ${displayName}...`;
632
+ this.setStatus(creatingMsg, 'blue');
633
+ this.screen.render();
634
+ try {
635
+ await this.git.create({
636
+ branch: selectedBranch.fullName,
637
+ useExisting: true
638
+ });
639
+ await this.refresh();
640
+ const successMsg = selectedBranch.isRemote
641
+ ? ` Created worktree: ${selectedBranch.name} (tracking ${selectedBranch.fullName})`
642
+ : ` Created worktree: ${selectedBranch.name}`;
643
+ this.setStatus(successMsg, 'green');
644
+ }
645
+ catch (error) {
646
+ const message = error instanceof GitCommandError
647
+ ? error.getUserMessage()
648
+ : (error instanceof Error ? error.message : String(error));
649
+ this.setStatus(` Error: ${message}`, 'red');
650
+ }
651
+ }
652
+ this.screen.render();
653
+ });
654
+ }
496
655
  async promptDeleteWorktree() {
497
656
  if (this.isModalOpen)
498
657
  return;
package/dist/types.d.ts CHANGED
@@ -34,4 +34,19 @@ export interface CreateWorktreeOptions {
34
34
  baseBranch?: string;
35
35
  /** Custom path for the worktree (default: auto-generated) */
36
36
  path?: string;
37
+ /** Whether to use an existing branch instead of creating a new one */
38
+ useExisting?: boolean;
39
+ }
40
+ /**
41
+ * Represents branch information
42
+ */
43
+ export interface BranchInfo {
44
+ /** Branch name (without remote prefix for remote branches) */
45
+ name: string;
46
+ /** Full ref name (e.g., 'origin/main' for remote branches) */
47
+ fullName: string;
48
+ /** Whether this is a remote branch */
49
+ isRemote: boolean;
50
+ /** Remote name if this is a remote branch */
51
+ remote?: string;
37
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonfutch/worktree-manager",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Terminal app for managing git worktrees with AI assistance",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",