worktree-flow 0.0.16 → 0.0.17

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
@@ -56,6 +56,10 @@ Create a new branch across selected repos. Interactively select which repos to i
56
56
 
57
57
  Checkout an existing branch. Fetches all repos, detects which have the branch, and creates worktrees.
58
58
 
59
+ ### `flow add [name]`
60
+
61
+ Add repos to an existing workspace. Discovers available repos not yet in the workspace, presents an interactive picker, creates worktrees with new branches, copies config files, and runs post-checkout commands. Auto-detects the workspace from the current directory, or specify a branch name explicitly.
62
+
59
63
  ### `flow pull`
60
64
 
61
65
  Pull latest changes for all repos in the current workspace. Run from anywhere inside a workspace.
@@ -126,6 +130,22 @@ Repos with per-repo commands use those; others fall back to the global `post-che
126
130
 
127
131
  When tmux is enabled, post-checkout commands run in the corresponding tmux panes instead of executing directly. This lets you see the output in real-time within your tmux session.
128
132
 
133
+ ### Repo-level configuration
134
+
135
+ Individual repos can define a `flow-config.json` at their root to override global settings:
136
+
137
+ ```json
138
+ {
139
+ "copy-files": ".env,.env.local",
140
+ "post-checkout": "yarn install"
141
+ }
142
+ ```
143
+
144
+ Both fields are optional. Precedence (highest to lowest):
145
+
146
+ - **post-checkout**: repo's `flow-config.json` > global `post-checkout`
147
+ - **copy-files**: repo's `flow-config.json` > global `copy-files`
148
+
129
149
  ## AGENTS.md
130
150
 
131
151
  If an `AGENTS.md` file exists at the root of your source-path, it will be copied into each workspace. This is useful for providing AI coding agents with context about your multi-repo setup.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import { registerConfigCommand } from './commands/config.js';
4
+ import { registerAddCommand } from './commands/add.js';
4
5
  import { registerBranchCommand } from './commands/branch.js';
5
6
  import { registerCheckoutCommand } from './commands/checkout.js';
6
7
  import { registerListCommand } from './commands/list.js';
@@ -18,6 +19,7 @@ program
18
19
  .description('Manage git worktrees across a poly-repo environment')
19
20
  .version('0.1.0');
20
21
  registerConfigCommand(program);
22
+ registerAddCommand(program);
21
23
  registerBranchCommand(program);
22
24
  registerCheckoutCommand(program);
23
25
  registerListCommand(program);
@@ -0,0 +1,120 @@
1
+ import path from 'node:path';
2
+ import checkbox, { Separator } from '@inquirer/checkbox';
3
+ import input from '@inquirer/input';
4
+ import confirm from '@inquirer/confirm';
5
+ import chalk from 'chalk';
6
+ import { createServices } from '../lib/services.js';
7
+ import { createUseCases } from '../usecases/usecases.js';
8
+ import { NoReposFoundError } from '../lib/errors.js';
9
+ import { resolveWorkspace } from '../lib/workspaceResolver.js';
10
+ import { buildRepoCheckboxChoices } from './helpers.js';
11
+ export async function runAdd(branchName, useCases, services, deps) {
12
+ // 1. Resolve workspace (from arg or cwd)
13
+ const { workspacePath, displayName } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
14
+ const { sourcePath } = services.config.getRequired();
15
+ const config = services.config.load();
16
+ // 2. Discover all repos
17
+ const repos = services.repos.discoverRepos(sourcePath);
18
+ if (repos.length === 0) {
19
+ throw new NoReposFoundError(sourcePath);
20
+ }
21
+ // 3. Filter out repos already in the workspace
22
+ const existingWorktrees = services.workspaceDir
23
+ .getWorktreeDirs(workspacePath)
24
+ .map((dir) => path.basename(dir));
25
+ const existingSet = new Set(existingWorktrees);
26
+ const availableRepos = repos.filter((repoPath) => !existingSet.has(path.basename(repoPath)));
27
+ if (availableRepos.length === 0) {
28
+ services.console.log('All repos are already in this workspace.');
29
+ return;
30
+ }
31
+ // 4. Repo picker (same pattern as branch command)
32
+ const checkboxChoices = buildRepoCheckboxChoices(availableRepos, services, config, (label) => new Separator(label));
33
+ const selected = await deps.checkbox({
34
+ message: `Select repos to add to "${displayName}":`,
35
+ choices: checkboxChoices,
36
+ pageSize: 20,
37
+ loop: false,
38
+ });
39
+ if (selected.length === 0) {
40
+ services.console.log('No repos selected.');
41
+ return;
42
+ }
43
+ // 5. Ask for source branch
44
+ const sourceBranch = await deps.input({
45
+ message: 'Branch from which branch?',
46
+ default: 'master',
47
+ });
48
+ // 6. Post-checkout confirmation
49
+ let shouldRunPostCheckout = false;
50
+ if (config.postCheckout) {
51
+ shouldRunPostCheckout = await deps.confirm({
52
+ message: `Run "${config.postCheckout}" in new workspaces?`,
53
+ default: true,
54
+ });
55
+ }
56
+ services.console.log('\nAdding repos to workspace...');
57
+ // 7. Fetch selected repos
58
+ await services.fetch.fetchRepos(selected, {
59
+ ttlSeconds: config.fetchCacheTtlSeconds,
60
+ });
61
+ // 8. For each selected repo in parallel: createBranch then addToWorkspace
62
+ // If tmux is enabled, use the workspace branch name as session name
63
+ const sessionName = config.tmux ? displayName : undefined;
64
+ const results = await Promise.allSettled(selected.map(async (repoPath) => {
65
+ const repoName = path.basename(repoPath);
66
+ // Resolve per-repo post-checkout command
67
+ const repoConf = services.repoConfig.load(repoPath);
68
+ const resolvedPostCheckout = shouldRunPostCheckout
69
+ ? services.repoConfig.resolvePostCheckout(repoName, config.perRepoPostCheckout, repoConf, config.postCheckout)
70
+ : undefined;
71
+ // a. Create branch in source repo
72
+ const branchResult = await useCases.createBranch.execute({
73
+ repoPath,
74
+ branchName: displayName,
75
+ sourceBranch,
76
+ });
77
+ // b. Add repo to workspace (creates worktree, copies files, tmux pane, post-checkout)
78
+ return useCases.addToWorkspace.execute({
79
+ repoPath,
80
+ workspacePath,
81
+ branchName: displayName,
82
+ baseBranch: branchResult.baseBranch,
83
+ sessionName,
84
+ copyFiles: config.copyFiles,
85
+ postCheckout: resolvedPostCheckout,
86
+ });
87
+ }));
88
+ // 9. Track usage
89
+ services.fetchCache.trackBranchUsage(selected.map((r) => path.basename(r)));
90
+ // Tally results
91
+ const successCount = results.filter((r) => r.status === 'fulfilled').length;
92
+ const totalCount = results.length;
93
+ const postCheckoutResults = results
94
+ .filter((r) => r.status === 'fulfilled')
95
+ .map((r) => r.value)
96
+ .filter((r) => r.postCheckoutRan);
97
+ const postCheckoutSuccess = postCheckoutResults.filter((r) => r.postCheckoutSuccess).length;
98
+ const postCheckoutTotal = postCheckoutResults.length;
99
+ // 10. Display results
100
+ services.console.log(`\nAdded ${successCount}/${totalCount} repos to ${chalk.cyan(workspacePath)}.`);
101
+ if (postCheckoutTotal > 0) {
102
+ services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
103
+ }
104
+ }
105
+ export function registerAddCommand(program) {
106
+ program
107
+ .command('add [branch-name]')
108
+ .description('Add repos to an existing workspace (auto-detects from current directory if branch not provided)')
109
+ .action(async (branchName) => {
110
+ const services = createServices();
111
+ const useCases = createUseCases(services);
112
+ try {
113
+ await runAdd(branchName, useCases, services, { checkbox, input, confirm });
114
+ }
115
+ catch (error) {
116
+ services.console.error(error.message);
117
+ services.process.exit(1);
118
+ }
119
+ });
120
+ }
@@ -6,6 +6,7 @@ import chalk from 'chalk';
6
6
  import { createServices } from '../lib/services.js';
7
7
  import { createUseCases } from '../usecases/usecases.js';
8
8
  import { NoReposFoundError } from '../lib/errors.js';
9
+ import { buildRepoCheckboxChoices } from './helpers.js';
9
10
  export async function runBranch(branchName, useCases, services, deps) {
10
11
  const { sourcePath, destPath } = services.config.getRequired();
11
12
  const config = services.config.load();
@@ -14,25 +15,7 @@ export async function runBranch(branchName, useCases, services, deps) {
14
15
  throw new NoReposFoundError(sourcePath);
15
16
  }
16
17
  // User prompts
17
- const choices = services.repos.formatRepoChoices(repos).map((choice) => ({
18
- ...choice,
19
- checked: config.branchAutoSelectRepos.includes(choice.name),
20
- }));
21
- const recentlyUsed = new Set(services.fetchCache.getRecentlyUsedRepos(8));
22
- const commonlyUsed = choices.filter((c) => recentlyUsed.has(c.name));
23
- let checkboxChoices;
24
- if (commonlyUsed.length > 0) {
25
- const commonlyUsedNames = new Set(commonlyUsed.map((c) => c.name));
26
- const remaining = choices.filter((c) => !commonlyUsedNames.has(c.name));
27
- checkboxChoices = [
28
- new Separator('Recently Used'),
29
- ...commonlyUsed,
30
- ...(remaining.length > 0 ? [new Separator(), ...remaining] : []),
31
- ];
32
- }
33
- else {
34
- checkboxChoices = choices;
35
- }
18
+ const checkboxChoices = buildRepoCheckboxChoices(repos, services, config, (label) => new Separator(label));
36
19
  const selected = await deps.checkbox({
37
20
  message: `Select repos for branch "${branchName}":`,
38
21
  choices: checkboxChoices,
@@ -59,27 +42,58 @@ export async function runBranch(branchName, useCases, services, deps) {
59
42
  await services.fetch.fetchRepos(selected, {
60
43
  ttlSeconds: config.fetchCacheTtlSeconds,
61
44
  });
62
- // Execute use case
63
- const result = await useCases.createBranchWorkspace.execute({
64
- repos: selected,
45
+ // 1. Create workspace directory, placeholder config, AGENTS.md, tmux session
46
+ const workspaceResult = await useCases.createWorkspace.execute({
65
47
  branchName,
66
- sourceBranch,
67
48
  sourcePath,
68
49
  destPath,
69
- copyFiles: config.copyFiles,
70
50
  tmux: config.tmux,
71
- postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
72
- perRepoPostCheckout: shouldRunPostCheckout ? config.perRepoPostCheckout : {},
73
51
  });
52
+ const { workspacePath, tmuxCreated } = workspaceResult;
53
+ const sessionName = tmuxCreated ? branchName : undefined;
54
+ // 2. For each selected repo in parallel: createBranch then addToWorkspace
55
+ const results = await Promise.allSettled(selected.map(async (repoPath) => {
56
+ const repoName = path.basename(repoPath);
57
+ // Resolve per-repo post-checkout command
58
+ const repoConf = services.repoConfig.load(repoPath);
59
+ const resolvedPostCheckout = shouldRunPostCheckout
60
+ ? services.repoConfig.resolvePostCheckout(repoName, config.perRepoPostCheckout, repoConf, config.postCheckout)
61
+ : undefined;
62
+ // a. Create branch in source repo
63
+ const branchResult = await useCases.createBranch.execute({
64
+ repoPath,
65
+ branchName,
66
+ sourceBranch,
67
+ });
68
+ // b. Add repo to workspace (creates worktree, copies files, tmux pane, post-checkout)
69
+ return useCases.addToWorkspace.execute({
70
+ repoPath,
71
+ workspacePath,
72
+ branchName,
73
+ baseBranch: branchResult.baseBranch,
74
+ sessionName,
75
+ copyFiles: config.copyFiles,
76
+ postCheckout: resolvedPostCheckout,
77
+ });
78
+ }));
74
79
  // Track which repos were branched from
75
80
  services.fetchCache.trackBranchUsage(selected.map((r) => path.basename(r)));
81
+ // Tally results
82
+ const successCount = results.filter((r) => r.status === 'fulfilled').length;
83
+ const totalCount = results.length;
84
+ const postCheckoutResults = results
85
+ .filter((r) => r.status === 'fulfilled')
86
+ .map((r) => r.value)
87
+ .filter((r) => r.postCheckoutRan);
88
+ const postCheckoutSuccess = postCheckoutResults.filter((r) => r.postCheckoutSuccess).length;
89
+ const postCheckoutTotal = postCheckoutResults.length;
76
90
  // Display results
77
- services.console.log(`\nCreated workspace at ${chalk.cyan(result.workspacePath)} with ${result.successCount}/${result.totalCount} repos.`);
78
- if (result.tmuxCreated) {
91
+ services.console.log(`\nCreated workspace at ${chalk.cyan(workspacePath)} with ${successCount}/${totalCount} repos.`);
92
+ if (tmuxCreated) {
79
93
  services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
80
94
  }
81
- if (result.postCheckoutSuccess !== undefined) {
82
- services.console.log(`\nCompleted post-checkout in ${result.postCheckoutSuccess}/${result.postCheckoutTotal} workspace(s).`);
95
+ if (postCheckoutTotal > 0) {
96
+ services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
83
97
  }
84
98
  else if (!config.postCheckout) {
85
99
  services.console.log('\nTip: Configure a post-checkout command to run automatically after branching/checkout.');
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  import confirm from '@inquirer/confirm';
2
3
  import chalk from 'chalk';
3
4
  import { createServices } from '../lib/services.js';
@@ -14,23 +15,18 @@ export async function runCheckout(branchName, useCases, services, deps) {
14
15
  }
15
16
  services.console.log('\nChecking for branch...');
16
17
  try {
17
- // Fetch all repos from source-path
18
+ // 1. Fetch all repos from source-path
18
19
  await useCases.fetchAllRepos.execute({
19
20
  sourcePath,
20
21
  fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
21
22
  });
22
- // Execute use case
23
- const result = await useCases.checkoutWorkspace.execute({
24
- branchName,
23
+ // 2. Discover repos and find which ones have the branch
24
+ const discoverResult = await useCases.discoverReposWithBranch.execute({
25
25
  sourcePath,
26
- destPath,
27
- copyFiles: config.copyFiles,
28
- tmux: config.tmux,
29
- postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
30
- perRepoPostCheckout: shouldRunPostCheckout ? config.perRepoPostCheckout : {},
26
+ branchName,
31
27
  });
32
- // Display branch check results
33
- for (const checkResult of result.branchCheckResults) {
28
+ // 3. Display per-repo branch check results
29
+ for (const checkResult of discoverResult.branchCheckResults) {
34
30
  if (checkResult.error) {
35
31
  services.console.log(`${checkResult.repoName}... ${chalk.red(`error: ${checkResult.error}`)}`);
36
32
  }
@@ -41,12 +37,56 @@ export async function runCheckout(branchName, useCases, services, deps) {
41
37
  services.console.log(`${checkResult.repoName}... ${chalk.dim('no branch')}`);
42
38
  }
43
39
  }
44
- services.console.log(`\nCreated workspace at ${chalk.cyan(result.workspacePath)} with ${result.successCount}/${result.totalCount} repos.`);
45
- if (result.tmuxCreated) {
40
+ // 4. Throw error if no repos match
41
+ if (discoverResult.matchingRepos.length === 0) {
42
+ throw new Error(`Branch "${branchName}" not found in any repo.`);
43
+ }
44
+ // 5. Create workspace directory, placeholder config, AGENTS.md, tmux session
45
+ const workspaceResult = await useCases.createWorkspace.execute({
46
+ branchName,
47
+ sourcePath,
48
+ destPath,
49
+ tmux: config.tmux,
50
+ });
51
+ const { workspacePath, tmuxCreated } = workspaceResult;
52
+ const sessionName = tmuxCreated ? branchName : undefined;
53
+ // 6. For each matching repo in parallel: detect base branch, then addToWorkspace
54
+ const results = await Promise.allSettled(discoverResult.matchingRepos.map(async (repoPath) => {
55
+ const repoName = path.basename(repoPath);
56
+ // Resolve per-repo post-checkout command
57
+ const repoConf = services.repoConfig.load(repoPath);
58
+ const resolvedPostCheckout = shouldRunPostCheckout
59
+ ? services.repoConfig.resolvePostCheckout(repoName, config.perRepoPostCheckout, repoConf, config.postCheckout)
60
+ : undefined;
61
+ // Detect base branch for this repo
62
+ const baseBranch = await services.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']) ?? 'master';
63
+ // Add repo to workspace (creates worktree, copies files, tmux pane, post-checkout)
64
+ return useCases.addToWorkspace.execute({
65
+ repoPath,
66
+ workspacePath,
67
+ branchName,
68
+ baseBranch,
69
+ sessionName,
70
+ copyFiles: config.copyFiles,
71
+ postCheckout: resolvedPostCheckout,
72
+ });
73
+ }));
74
+ // Tally results
75
+ const successCount = results.filter((r) => r.status === 'fulfilled').length;
76
+ const totalCount = results.length;
77
+ const postCheckoutResults = results
78
+ .filter((r) => r.status === 'fulfilled')
79
+ .map((r) => r.value)
80
+ .filter((r) => r.postCheckoutRan);
81
+ const postCheckoutSuccess = postCheckoutResults.filter((r) => r.postCheckoutSuccess).length;
82
+ const postCheckoutTotal = postCheckoutResults.length;
83
+ // 7. Display results
84
+ services.console.log(`\nCreated workspace at ${chalk.cyan(workspacePath)} with ${successCount}/${totalCount} repos.`);
85
+ if (tmuxCreated) {
46
86
  services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
47
87
  }
48
- if (result.postCheckoutSuccess !== undefined) {
49
- services.console.log(`\nCompleted post-checkout in ${result.postCheckoutSuccess}/${result.postCheckoutTotal} workspace(s).`);
88
+ if (postCheckoutTotal > 0) {
89
+ services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
50
90
  }
51
91
  else if (!config.postCheckout) {
52
92
  services.console.log('\nTip: Configure a post-checkout command to run automatically after branching/checkout.');
@@ -47,6 +47,30 @@ export function logStatus(header, workspaces, linesToClear, getBaseBranch, conso
47
47
  console.log('');
48
48
  }
49
49
  }
50
+ /**
51
+ * Build the ordered list of checkbox choices for a repo picker, grouping recently used
52
+ * repos under a "Recently Used" separator and placing the rest below.
53
+ *
54
+ * @param createSeparator - factory from the display layer (e.g. inquirer's Separator constructor)
55
+ */
56
+ export function buildRepoCheckboxChoices(repos, services, config, createSeparator) {
57
+ const choices = services.repos.formatRepoChoices(repos).map((choice) => ({
58
+ ...choice,
59
+ checked: config.branchAutoSelectRepos.includes(choice.name),
60
+ }));
61
+ const recentlyUsed = new Set(services.fetchCache.getRecentlyUsedRepos(8));
62
+ const commonlyUsed = choices.filter((c) => recentlyUsed.has(c.name));
63
+ if (commonlyUsed.length > 0) {
64
+ const commonlyUsedNames = new Set(commonlyUsed.map((c) => c.name));
65
+ const remaining = choices.filter((c) => !commonlyUsedNames.has(c.name));
66
+ return [
67
+ createSeparator('Recently Used'),
68
+ ...commonlyUsed,
69
+ ...(remaining.length > 0 ? [createSeparator(), ...remaining] : []),
70
+ ];
71
+ }
72
+ return choices;
73
+ }
50
74
  /**
51
75
  * Get a human-readable status indicator for a workspace based on its worktree statuses.
52
76
  */
package/dist/lib/git.js CHANGED
@@ -127,6 +127,9 @@ export class GitService {
127
127
  return true;
128
128
  }
129
129
  }
130
+ async createBranch(repoPath, branchName, startPoint) {
131
+ await this.exec(repoPath, ['branch', '--no-track', branchName, startPoint]);
132
+ }
130
133
  async removeWorktree(repoPath, worktreePath) {
131
134
  await this.exec(repoPath, ['worktree', 'remove', worktreePath]);
132
135
  }
@@ -0,0 +1,60 @@
1
+ import path from 'node:path';
2
+ import { z } from 'zod';
3
+ const RepoConfigSchema = z.object({
4
+ 'copy-files': z.string().optional(),
5
+ 'post-checkout': z.string().optional(),
6
+ });
7
+ /**
8
+ * RepoConfigService handles loading per-repo flow-config.json files
9
+ * from source repositories.
10
+ */
11
+ export class RepoConfigService {
12
+ fs;
13
+ constructor(fs) {
14
+ this.fs = fs;
15
+ }
16
+ /**
17
+ * Load flow-config.json from a repo's root directory.
18
+ * Returns undefined if the file doesn't exist, is invalid JSON, or has no relevant fields.
19
+ */
20
+ load(repoPath) {
21
+ const configPath = path.join(repoPath, 'flow-config.json');
22
+ if (!this.fs.existsSync(configPath)) {
23
+ return undefined;
24
+ }
25
+ try {
26
+ const raw = this.fs.readFileSync(configPath, 'utf-8');
27
+ const parsed = JSON.parse(raw);
28
+ const validated = RepoConfigSchema.parse(parsed);
29
+ const copyFiles = validated['copy-files'];
30
+ const postCheckout = validated['post-checkout'];
31
+ // Return undefined if no relevant fields are set
32
+ if (!copyFiles && !postCheckout) {
33
+ return undefined;
34
+ }
35
+ return { copyFiles, postCheckout };
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ /**
42
+ * Resolve the post-checkout command for a repo with 3-level precedence:
43
+ * 1. per-repo-post-checkout from central config (highest priority)
44
+ * 2. post-checkout from repo's flow-config.json
45
+ * 3. global post-checkout from central config (lowest priority)
46
+ */
47
+ resolvePostCheckout(repoName, perRepoPostCheckout, repoConfig, globalPostCheckout) {
48
+ return (perRepoPostCheckout?.[repoName] ??
49
+ repoConfig?.postCheckout ??
50
+ globalPostCheckout);
51
+ }
52
+ /**
53
+ * Resolve the copy-files for a repo with 2-level precedence:
54
+ * 1. copy-files from repo's flow-config.json (overrides global)
55
+ * 2. global copy-files from central config
56
+ */
57
+ resolveCopyFiles(repoConfig, globalCopyFiles) {
58
+ return repoConfig?.copyFiles ?? globalCopyFiles;
59
+ }
60
+ }
@@ -14,6 +14,7 @@ import { FetchCacheService } from './fetchCache.js';
14
14
  import { ParallelService } from './parallel.js';
15
15
  import { StatusService } from './status.js';
16
16
  import { TmuxService } from './tmux.js';
17
+ import { RepoConfigService } from './repoConfig.js';
17
18
  export function createServices() {
18
19
  // Create adapters
19
20
  const fs = new NodeFileSystem();
@@ -30,6 +31,7 @@ export function createServices() {
30
31
  const fetch = new FetchService(git, console, fetchCache);
31
32
  const repos = new RepoService(fs, git);
32
33
  const postCheckout = new PostCheckoutService(shell);
34
+ const repoConfig = new RepoConfigService(fs);
33
35
  // Focused workspace services
34
36
  const workspaceDir = new WorkspaceDirectoryService(fs);
35
37
  const workspaceConfig = new WorkspaceConfigService(fs);
@@ -42,6 +44,7 @@ export function createServices() {
42
44
  workspaceConfig,
43
45
  worktree,
44
46
  postCheckout,
47
+ repoConfig,
45
48
  fetch,
46
49
  fetchCache,
47
50
  parallel,
package/dist/lib/tmux.js CHANGED
@@ -53,6 +53,29 @@ export class TmuxService {
53
53
  'Enter',
54
54
  ]);
55
55
  }
56
+ async addPane(sessionName, worktreePath) {
57
+ await this.shell.execFile('tmux', [
58
+ 'split-window',
59
+ '-t',
60
+ sessionName,
61
+ '-c',
62
+ worktreePath,
63
+ ]);
64
+ const { stdout } = await this.shell.execFile('tmux', [
65
+ 'display-message',
66
+ '-p',
67
+ '-t',
68
+ sessionName,
69
+ '#{pane_index}',
70
+ ]);
71
+ await this.shell.execFile('tmux', [
72
+ 'select-layout',
73
+ '-t',
74
+ sessionName,
75
+ 'tiled',
76
+ ]);
77
+ return parseInt(stdout.trim(), 10);
78
+ }
56
79
  async killSession(sessionName) {
57
80
  try {
58
81
  await this.shell.execFile('tmux', ['kill-session', '-t', sessionName]);
@@ -0,0 +1,75 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Use case for adding a single repo to an existing workspace.
4
+ * Creates a worktree for an existing branch, copies config files,
5
+ * saves base branch to workspace config, adds a tmux pane (if enabled),
6
+ * and runs post-checkout (if configured).
7
+ */
8
+ export class AddToWorkspaceUseCase {
9
+ worktree;
10
+ workspaceConfig;
11
+ repoConfig;
12
+ postCheckout;
13
+ tmux;
14
+ constructor(worktree, workspaceConfig, repoConfig, postCheckout, tmux) {
15
+ this.worktree = worktree;
16
+ this.workspaceConfig = workspaceConfig;
17
+ this.repoConfig = repoConfig;
18
+ this.postCheckout = postCheckout;
19
+ this.tmux = tmux;
20
+ }
21
+ async execute(params) {
22
+ const repoName = path.basename(params.repoPath);
23
+ const worktreeDest = path.join(params.workspacePath, repoName);
24
+ // 1. Create worktree by checking out the existing branch
25
+ await this.worktree.createWorktreeCheckout(params.repoPath, worktreeDest, params.branchName);
26
+ // 2. Resolve and copy config files using repo-level overrides with global fallback
27
+ const repoConf = this.repoConfig.load(params.repoPath);
28
+ const resolvedCopyFiles = this.repoConfig.resolveCopyFiles(repoConf, params.copyFiles);
29
+ this.worktree.copyConfigFilesToWorktree(params.repoPath, worktreeDest, resolvedCopyFiles);
30
+ // 3. Save base branch to workspace config
31
+ this.workspaceConfig.save(params.workspacePath, {
32
+ baseBranches: { [repoName]: params.baseBranch },
33
+ });
34
+ // 4. Add tmux pane (if session name provided)
35
+ let paneIndex;
36
+ let tmuxPaneAdded = false;
37
+ if (params.sessionName) {
38
+ try {
39
+ paneIndex = await this.tmux.addPane(params.sessionName, worktreeDest);
40
+ tmuxPaneAdded = true;
41
+ }
42
+ catch {
43
+ // Don't fail if tmux pane addition fails
44
+ }
45
+ }
46
+ // 5. Run post-checkout command (if configured)
47
+ const postCheckoutCommand = params.postCheckout;
48
+ let postCheckoutRan = false;
49
+ let postCheckoutSuccess = false;
50
+ if (postCheckoutCommand) {
51
+ postCheckoutRan = true;
52
+ try {
53
+ if (params.sessionName !== undefined && paneIndex !== undefined) {
54
+ // tmux enabled: send keys to the pane
55
+ await this.tmux.sendKeysToPane(params.sessionName, paneIndex, postCheckoutCommand);
56
+ }
57
+ else {
58
+ // tmux disabled: run command directly in worktree directory
59
+ await this.postCheckout.runCommandInDirectory(worktreeDest, postCheckoutCommand);
60
+ }
61
+ postCheckoutSuccess = true;
62
+ }
63
+ catch {
64
+ // Post-checkout failure is non-fatal; reported via postCheckoutSuccess: false
65
+ }
66
+ }
67
+ return {
68
+ repoName,
69
+ worktreePath: worktreeDest,
70
+ postCheckoutRan,
71
+ postCheckoutSuccess,
72
+ tmuxPaneAdded,
73
+ };
74
+ }
75
+ }
@@ -0,0 +1,33 @@
1
+ import { RepoService } from '../lib/repos.js';
2
+ const DEFAULT_BRANCH_CANDIDATES = ['master', 'main', 'trunk', 'develop'];
3
+ /**
4
+ * Use case for creating a git branch in a single repo.
5
+ * Handles fallback to default branches when the source branch doesn't exist.
6
+ */
7
+ export class CreateBranchUseCase {
8
+ git;
9
+ constructor(git) {
10
+ this.git = git;
11
+ }
12
+ async execute(params) {
13
+ const repoName = RepoService.getRepoName(params.repoPath);
14
+ // 1. Check if the source branch exists as a remote-tracking branch
15
+ const sourceBranchExists = await this.git.localRemoteBranchExists(params.repoPath, params.sourceBranch);
16
+ let actualBaseBranch = params.sourceBranch;
17
+ // 2. Fall back to first existing default branch if source branch doesn't exist
18
+ if (!sourceBranchExists) {
19
+ const fallback = await this.git.findFirstExistingBranch(params.repoPath, DEFAULT_BRANCH_CANDIDATES);
20
+ if (fallback === null) {
21
+ throw new Error(`Cannot create branch in ${repoName}: source branch "${params.sourceBranch}" not found and no fallback branch exists (tried: ${DEFAULT_BRANCH_CANDIDATES.join(', ')})`);
22
+ }
23
+ actualBaseBranch = fallback;
24
+ }
25
+ // 3. Create the branch from origin/<actualBaseBranch>
26
+ await this.git.createBranch(params.repoPath, params.branchName, `origin/${actualBaseBranch}`);
27
+ // 4. Return the actual base branch used
28
+ return {
29
+ repoName,
30
+ baseBranch: actualBaseBranch,
31
+ };
32
+ }
33
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Use case for creating a new workspace directory with initial config, AGENTS.md copy,
3
+ * and an optional tmux session (root pane only, no worktrees yet).
4
+ */
5
+ export class CreateWorkspaceUseCase {
6
+ workspaceDir;
7
+ workspaceConfig;
8
+ tmux;
9
+ constructor(workspaceDir, workspaceConfig, tmux) {
10
+ this.workspaceDir = workspaceDir;
11
+ this.workspaceConfig = workspaceConfig;
12
+ this.tmux = tmux;
13
+ }
14
+ async execute(params) {
15
+ // 1. Create workspace directory
16
+ const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
17
+ // 2. Save placeholder config
18
+ this.workspaceConfig.savePlaceholder(workspacePath);
19
+ // 3. Copy AGENTS.md if it exists in source-path
20
+ this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
21
+ // 4. Create tmux session (root pane only) if enabled
22
+ let tmuxCreated = false;
23
+ if (params.tmux) {
24
+ try {
25
+ await this.tmux.createSession(workspacePath, params.branchName, []);
26
+ tmuxCreated = true;
27
+ }
28
+ catch {
29
+ // Don't fail the workspace creation, just report tmux wasn't created
30
+ }
31
+ }
32
+ return {
33
+ workspacePath,
34
+ tmuxCreated,
35
+ };
36
+ }
37
+ }
@@ -0,0 +1,25 @@
1
+ import { NoReposFoundError } from '../lib/errors.js';
2
+ /**
3
+ * Use case for discovering repos in source-path and checking which ones have a given branch.
4
+ * Extracts repo discovery + branch checking logic into a focused, reusable use case.
5
+ */
6
+ export class DiscoverReposWithBranchUseCase {
7
+ repos;
8
+ constructor(repos) {
9
+ this.repos = repos;
10
+ }
11
+ async execute(params) {
12
+ // 1. Discover all repos
13
+ const allRepos = this.repos.discoverRepos(params.sourcePath);
14
+ if (allRepos.length === 0) {
15
+ throw new NoReposFoundError(params.sourcePath);
16
+ }
17
+ // 2. Check which repos have the branch
18
+ const { matching, results } = await this.repos.findReposWithBranch(allRepos, params.branchName);
19
+ return {
20
+ allRepos,
21
+ matchingRepos: matching,
22
+ branchCheckResults: results,
23
+ };
24
+ }
25
+ }
@@ -1,5 +1,3 @@
1
- import { CreateBranchWorkspaceUseCase } from './createBranchWorkspace.js';
2
- import { CheckoutWorkspaceUseCase } from './checkoutWorkspace.js';
3
1
  import { RemoveWorkspaceUseCase } from './removeWorkspace.js';
4
2
  import { PushWorkspaceUseCase } from './pushWorkspace.js';
5
3
  import { PullWorkspaceUseCase } from './pullWorkspace.js';
@@ -10,20 +8,19 @@ import { FetchAllReposUseCase } from './fetchAllRepos.js';
10
8
  import { FetchWorkspaceReposUseCase } from './fetchWorkspaceRepos.js';
11
9
  import { FetchUsedReposUseCase } from './fetchUsedRepos.js';
12
10
  import { ResumeTmuxSessionsUseCase } from './resumeTmuxSessions.js';
13
- import { RunPostCheckoutUseCase } from './runPostCheckout.js';
11
+ import { CreateWorkspaceUseCase } from './createWorkspace.js';
12
+ import { CreateBranchUseCase } from './createBranch.js';
13
+ import { AddToWorkspaceUseCase } from './addToWorkspace.js';
14
+ import { DiscoverReposWithBranchUseCase } from './discoverReposWithBranch.js';
14
15
  /**
15
16
  * Factory function for creating all use cases with their service dependencies.
16
17
  * Use cases orchestrate workflows by coordinating multiple services.
17
18
  */
18
19
  export function createUseCases(services) {
19
- // Create use cases that are dependencies first
20
- const runPostCheckout = new RunPostCheckoutUseCase(services.workspaceDir, services.postCheckout, services.tmux);
21
20
  return {
22
21
  fetchAllRepos: new FetchAllReposUseCase(services.fetch, services.repos),
23
22
  fetchWorkspaceRepos: new FetchWorkspaceReposUseCase(services.workspaceDir, services.fetch),
24
23
  fetchUsedRepos: new FetchUsedReposUseCase(services.workspaceDir, services.fetch),
25
- createBranchWorkspace: new CreateBranchWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.git, services.parallel, services.tmux, runPostCheckout),
26
- checkoutWorkspace: new CheckoutWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.git, services.parallel, services.tmux, runPostCheckout),
27
24
  removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.status, services.tmux),
28
25
  pushWorkspace: new PushWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
29
26
  pullWorkspace: new PullWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
@@ -31,6 +28,9 @@ export function createUseCases(services) {
31
28
  discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.workspaceConfig, services.status, services.git),
32
29
  listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
33
30
  resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux),
34
- runPostCheckout,
31
+ createWorkspace: new CreateWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.tmux),
32
+ createBranch: new CreateBranchUseCase(services.git),
33
+ addToWorkspace: new AddToWorkspaceUseCase(services.worktree, services.workspaceConfig, services.repoConfig, services.postCheckout, services.tmux),
34
+ discoverReposWithBranch: new DiscoverReposWithBranchUseCase(services.repos),
35
35
  };
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-flow",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "Manage git worktrees across a poly-repo environment",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,91 +0,0 @@
1
- import path from 'node:path';
2
- import { RepoService } from '../lib/repos.js';
3
- import { NoReposFoundError } from '../lib/errors.js';
4
- /**
5
- * Use case for checking out an existing branch across multiple repos.
6
- * Orchestrates repo discovery, branch checking, and workspace creation.
7
- */
8
- export class CheckoutWorkspaceUseCase {
9
- workspaceDir;
10
- workspaceConfig;
11
- worktree;
12
- repos;
13
- git;
14
- parallel;
15
- tmux;
16
- runPostCheckout;
17
- constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, runPostCheckout) {
18
- this.workspaceDir = workspaceDir;
19
- this.workspaceConfig = workspaceConfig;
20
- this.worktree = worktree;
21
- this.repos = repos;
22
- this.git = git;
23
- this.parallel = parallel;
24
- this.tmux = tmux;
25
- this.runPostCheckout = runPostCheckout;
26
- }
27
- async execute(params) {
28
- // 1. Discover all repos
29
- const allRepos = this.repos.discoverRepos(params.sourcePath);
30
- if (allRepos.length === 0) {
31
- throw new NoReposFoundError(params.sourcePath);
32
- }
33
- // 2. Find repos that have this branch
34
- const { matching, results } = await this.repos.findReposWithBranch(allRepos, params.branchName);
35
- if (matching.length === 0) {
36
- throw new Error(`Branch "${params.branchName}" not found in any repo.`);
37
- }
38
- // 3. Create workspace directory
39
- const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
40
- this.workspaceConfig.savePlaceholder(workspacePath);
41
- // 4. Create worktrees in parallel
42
- const successCount = await this.parallel.processInParallel(matching, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
43
- const name = RepoService.getRepoName(repoPath);
44
- const worktreeDest = path.join(workspacePath, name);
45
- await this.worktree.createWorktreeCheckout(repoPath, worktreeDest, params.branchName);
46
- this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
47
- return 'created';
48
- });
49
- // 5. Copy AGENTS.md
50
- this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
51
- // 6. Detect base branches for each repo
52
- const baseBranches = {};
53
- for (const repoPath of matching) {
54
- const repoName = RepoService.getRepoName(repoPath);
55
- const baseBranch = await this.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']);
56
- baseBranches[repoName] = baseBranch || 'master';
57
- }
58
- // 7. Save workspace config with base branches
59
- this.workspaceConfig.save(workspacePath, { baseBranches });
60
- // 8. Create tmux session if enabled
61
- let tmuxCreated = false;
62
- if (params.tmux) {
63
- try {
64
- const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
65
- await this.tmux.createSession(workspacePath, params.branchName, worktreeDirs);
66
- tmuxCreated = true;
67
- }
68
- catch (error) {
69
- // Don't fail
70
- }
71
- }
72
- // 9. Run post-checkout if configured
73
- const postCheckoutResult = await this.runPostCheckout.execute({
74
- workspacePath,
75
- sessionName: tmuxCreated ? params.branchName : undefined,
76
- tmuxEnabled: tmuxCreated,
77
- postCheckout: params.postCheckout,
78
- perRepoPostCheckout: params.perRepoPostCheckout,
79
- });
80
- return {
81
- workspacePath,
82
- matchingRepos: matching.length,
83
- successCount,
84
- totalCount: matching.length,
85
- tmuxCreated,
86
- postCheckoutSuccess: postCheckoutResult?.successCount,
87
- postCheckoutTotal: postCheckoutResult?.totalCount,
88
- branchCheckResults: results,
89
- };
90
- }
91
- }
@@ -1,86 +0,0 @@
1
- import path from 'node:path';
2
- import { RepoService } from '../lib/repos.js';
3
- /**
4
- * Use case for creating a workspace with new branches across multiple repos.
5
- * Orchestrates the entire workflow from workspace creation to post-checkout commands.
6
- */
7
- export class CreateBranchWorkspaceUseCase {
8
- workspaceDir;
9
- workspaceConfig;
10
- worktree;
11
- repos;
12
- git;
13
- parallel;
14
- tmux;
15
- runPostCheckout;
16
- constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, runPostCheckout) {
17
- this.workspaceDir = workspaceDir;
18
- this.workspaceConfig = workspaceConfig;
19
- this.worktree = worktree;
20
- this.repos = repos;
21
- this.git = git;
22
- this.parallel = parallel;
23
- this.tmux = tmux;
24
- this.runPostCheckout = runPostCheckout;
25
- }
26
- async execute(params) {
27
- // 1. Create workspace directory
28
- const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
29
- this.workspaceConfig.savePlaceholder(workspacePath);
30
- // Track base branches for each repo
31
- const baseBranches = {};
32
- // 2. Create worktrees in parallel
33
- const successCount = await this.parallel.processInParallel(params.repos, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
34
- const name = RepoService.getRepoName(repoPath);
35
- const worktreeDest = path.join(workspacePath, name);
36
- // Determine which base branch to use
37
- let actualBaseBranch = params.sourceBranch;
38
- // Try the user-specified source branch first, fall back if it doesn't exist
39
- const branchExists = await this.git.localRemoteBranchExists(repoPath, params.sourceBranch);
40
- if (!branchExists) {
41
- const fallbackBranch = await this.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']);
42
- if (fallbackBranch) {
43
- actualBaseBranch = fallbackBranch;
44
- }
45
- // If no fallback found, still try with the original (will fail with clear error)
46
- }
47
- // Track the actual base branch used
48
- baseBranches[name] = actualBaseBranch;
49
- await this.worktree.createWorktreeWithBranch(repoPath, worktreeDest, params.branchName, `origin/${actualBaseBranch}`);
50
- this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
51
- return 'created';
52
- });
53
- // 3. Copy AGENTS.md if exists
54
- this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
55
- // 4. Save workspace config with base branches
56
- this.workspaceConfig.save(workspacePath, { baseBranches });
57
- // 5. Create tmux session if enabled
58
- let tmuxCreated = false;
59
- if (params.tmux) {
60
- try {
61
- const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
62
- await this.tmux.createSession(workspacePath, params.branchName, worktreeDirs);
63
- tmuxCreated = true;
64
- }
65
- catch (error) {
66
- // Don't fail, just return false
67
- }
68
- }
69
- // 6. Run post-checkout command if configured
70
- const postCheckoutResult = await this.runPostCheckout.execute({
71
- workspacePath,
72
- sessionName: tmuxCreated ? params.branchName : undefined,
73
- tmuxEnabled: tmuxCreated,
74
- postCheckout: params.postCheckout,
75
- perRepoPostCheckout: params.perRepoPostCheckout,
76
- });
77
- return {
78
- workspacePath,
79
- successCount,
80
- totalCount: params.repos.length,
81
- tmuxCreated,
82
- postCheckoutSuccess: postCheckoutResult?.successCount,
83
- postCheckoutTotal: postCheckoutResult?.totalCount,
84
- };
85
- }
86
- }
@@ -1,49 +0,0 @@
1
- import path from 'node:path';
2
- /**
3
- * Use case for running post-checkout commands.
4
- * Runs commands either directly in worktree directories or in tmux panes.
5
- */
6
- export class RunPostCheckoutUseCase {
7
- workspaceDir;
8
- postCheckout;
9
- tmux;
10
- constructor(workspaceDir, postCheckout, tmux) {
11
- this.workspaceDir = workspaceDir;
12
- this.postCheckout = postCheckout;
13
- this.tmux = tmux;
14
- }
15
- async execute(params) {
16
- // Skip if no commands configured
17
- if (!params.postCheckout && (!params.perRepoPostCheckout || Object.keys(params.perRepoPostCheckout).length === 0)) {
18
- return undefined;
19
- }
20
- // Get worktree directories
21
- const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
22
- // Map each worktree to its command (per-repo overrides global)
23
- const commandsToRun = worktreeDirs
24
- .map((dir, index) => {
25
- const repoName = path.basename(dir);
26
- const command = params.perRepoPostCheckout?.[repoName] ?? params.postCheckout;
27
- return command ? { dir, command, paneIndex: index + 1 } : null;
28
- })
29
- .filter((x) => x !== null);
30
- // Run commands in parallel
31
- let successCount = 0;
32
- await Promise.allSettled(commandsToRun.map(async ({ dir, command, paneIndex }) => {
33
- try {
34
- if (params.tmuxEnabled && params.sessionName) {
35
- // Panes are indexed from 0, first pane (root) is 0, worktrees start at 1
36
- await this.tmux.sendKeysToPane(params.sessionName, paneIndex, command);
37
- }
38
- else {
39
- await this.postCheckout.runCommandInDirectory(dir, command);
40
- }
41
- successCount++;
42
- }
43
- catch {
44
- // Error handled by counting successes
45
- }
46
- }));
47
- return { successCount, totalCount: commandsToRun.length };
48
- }
49
- }