worktree-flow 0.0.7 → 0.0.9

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
@@ -4,6 +4,7 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/worktree-flow)](https://www.npmjs.com/package/worktree-flow)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/worktree-flow)](https://www.npmjs.com/package/worktree-flow)
7
+ [![Build](https://github.com/simonpratt/worktree-flow/actions/workflows/build.yml/badge.svg)](https://github.com/simonpratt/worktree-flow/actions/workflows/build.yml)
7
8
  [![license](https://img.shields.io/npm/l/worktree-flow)](https://github.com/simonpratt/worktree-flow/blob/master/LICENSE)
8
9
 
9
10
  Stop juggling branches across repos. `flow` creates isolated workspaces with git worktrees from multiple repositories, all on the same branch, with a single command.
@@ -72,10 +73,10 @@ Check the status of all repos in a workspace. Shows uncommitted changes, commits
72
73
 
73
74
  List all workspaces with status indicators. Shows:
74
75
  - Active workspace (marked with `*`) based on current directory
75
- - Overall status: `clean`, `uncommitted`, `ahead`, `behind`, `diverged`, or `mixed`
76
+ - Overall status: `clean`, `uncommitted`, `ahead`, or `mixed`
76
77
  - Repo count for each workspace
77
78
 
78
- Fetches all repos before checking status to ensure accurate information.
79
+ Fetches all repos before checking status to ensure accurate information. The status check uses git's patch-id comparison, which correctly handles squash-merged branches.
79
80
 
80
81
  ### `flow remove <name>`
81
82
 
@@ -85,6 +86,12 @@ Remove a workspace and all its worktrees. Fetches latest, checks for uncommitted
85
86
 
86
87
  Remove stale workspaces in bulk. Finds workspaces where all worktrees are clean, fully merged, and haven't been committed to in over 7 days.
87
88
 
89
+ ### `flow tmux resume`
90
+
91
+ Create tmux sessions for all workspaces that don't already have one. Each session is created with split panes (one for the workspace root and one for each worktree) using a tiled layout. Skips workspaces that already have active sessions.
92
+
93
+ Requires `tmux` to be installed and the `tmux` config option to be enabled.
94
+
88
95
  ### `flow config set <key> <value>`
89
96
 
90
97
  Configure flow settings. See [Configuration](#configuration) below.
package/dist/cli.js CHANGED
@@ -10,6 +10,7 @@ import { registerRemoveCommand } from './commands/remove.js';
10
10
  import { registerStatusCommand } from './commands/status.js';
11
11
  import { registerPruneCommand } from './commands/prune.js';
12
12
  import { registerFetchCommand } from './commands/fetch.js';
13
+ import { registerTmuxCommand } from './commands/tmux.js';
13
14
  const program = new Command();
14
15
  program
15
16
  .name('flow')
@@ -25,4 +26,5 @@ registerRemoveCommand(program);
25
26
  registerStatusCommand(program);
26
27
  registerPruneCommand(program);
27
28
  registerFetchCommand(program);
29
+ registerTmuxCommand(program);
28
30
  program.parse();
@@ -45,6 +45,16 @@ export function registerConfigCommand(program) {
45
45
  const displayValue = isDefault ? chalk.gray(value) : chalk.green(value);
46
46
  services.console.log(` ${chalk.cyan(key)}: ${displayValue}`);
47
47
  }
48
+ // Display per-repo post-checkout commands
49
+ const perRepoCommands = displayConfig.perRepoPostCheckout;
50
+ if (Object.keys(perRepoCommands).length > 0) {
51
+ services.console.log('');
52
+ services.console.log(chalk.bold('Per-repo post-checkout commands:'));
53
+ services.console.log('');
54
+ for (const [repo, command] of Object.entries(perRepoCommands)) {
55
+ services.console.log(` ${chalk.cyan(repo)}: ${chalk.green(command)}`);
56
+ }
57
+ }
48
58
  services.console.log('');
49
59
  });
50
60
  }
@@ -57,7 +57,6 @@ export async function runList(useCases, services) {
57
57
  const result = await useCases.listWorkspacesWithStatus.execute({
58
58
  destPath,
59
59
  sourcePath,
60
- mainBranch: config.mainBranch,
61
60
  cwd,
62
61
  });
63
62
  // Phase 4: Clear previous output and re-print with status
@@ -16,14 +16,13 @@ export async function runPrune(useCases, services, deps) {
16
16
  const result = await useCases.discoverPrunableWorkspaces.execute({
17
17
  destPath,
18
18
  sourcePath,
19
- mainBranch: config.mainBranch,
20
19
  daysOld: 7,
21
20
  });
22
21
  if (result.prunable.length === 0) {
23
22
  services.console.log('No workspaces to prune.');
24
23
  services.console.log(`\nWorkspaces are prunable when:`);
25
24
  services.console.log(` - All worktrees have no uncommitted changes`);
26
- services.console.log(` - All worktrees are not ahead of ${config.mainBranch}`);
25
+ services.console.log(` - All worktrees are not ahead of their base branch`);
27
26
  services.console.log(` - Last commit is more than 7 days old`);
28
27
  services.process.exit(0);
29
28
  }
@@ -56,7 +55,6 @@ export async function runPrune(useCases, services, deps) {
56
55
  workspacePath: workspace.path,
57
56
  branchName: workspace.name,
58
57
  sourcePath,
59
- mainBranch: config.mainBranch,
60
58
  tmux: config.tmux,
61
59
  });
62
60
  services.console.log(` ${chalk.green('✓')} Removed ${workspace.name}`);
@@ -18,10 +18,14 @@ export async function runRemove(branchName, useCases, services, deps) {
18
18
  sourcePath,
19
19
  fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
20
20
  });
21
- services.console.log(`\nChecking for uncommitted changes and commits ahead of ${config.mainBranch}...`);
22
- const results = await services.status.checkAllWorktrees(worktreeDirs, config.mainBranch);
21
+ services.console.log(`\nChecking for uncommitted changes and commits ahead of base branch...`);
22
+ // Load workspace config to get per-repo base branches
23
+ const workspaceConfig = services.workspaceConfig.load(workspacePath);
24
+ const getBaseBranch = (repoName) => workspaceConfig.baseBranches[repoName] || 'master';
25
+ const results = await services.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
23
26
  for (const { repoName, status } of results) {
24
- const message = StatusService.getStatusMessage(status, config.mainBranch);
27
+ const baseBranch = getBaseBranch(repoName);
28
+ const message = StatusService.getStatusMessage(status, baseBranch);
25
29
  if (StatusService.hasIssues(status)) {
26
30
  services.console.log(`${repoName}... ${chalk.red(message)}`);
27
31
  }
@@ -56,7 +60,6 @@ export async function runRemove(branchName, useCases, services, deps) {
56
60
  workspacePath,
57
61
  branchName: branchNameForDisplay,
58
62
  sourcePath,
59
- mainBranch: config.mainBranch,
60
63
  tmux: config.tmux,
61
64
  });
62
65
  // Display worktree removal results
@@ -20,15 +20,18 @@ export async function runStatus(branchName, useCases, services) {
20
20
  fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
21
21
  });
22
22
  services.console.log('');
23
- services.console.log(`\nStatus (comparing against ${chalk.cyan(config.mainBranch)}):\n`);
23
+ services.console.log(`\nStatus:\n`);
24
24
  const result = await useCases.checkWorkspaceStatus.execute({
25
25
  workspacePath,
26
- mainBranch: config.mainBranch,
27
26
  });
27
+ // Load workspace config to get per-repo base branches
28
+ const workspaceConfig = services.workspaceConfig.load(workspacePath);
29
+ const getBaseBranch = (repoName) => workspaceConfig.baseBranches[repoName] || 'master';
28
30
  let cleanCount = 0;
29
31
  let issuesCount = 0;
30
32
  for (const { repoName, status } of result.statuses) {
31
- const message = StatusService.getStatusMessage(status, config.mainBranch);
33
+ const baseBranch = getBaseBranch(repoName);
34
+ const message = StatusService.getStatusMessage(status, baseBranch);
32
35
  if (StatusService.hasIssues(status)) {
33
36
  services.console.log(` ${chalk.red('✗')} ${repoName}: ${chalk.red(message)}`);
34
37
  issuesCount++;
@@ -0,0 +1,45 @@
1
+ import chalk from 'chalk';
2
+ import { createServices } from '../lib/services.js';
3
+ import { createUseCases } from '../usecases/usecases.js';
4
+ export async function runTmuxResume(useCases, services) {
5
+ const { destPath } = services.config.getRequired();
6
+ const result = await useCases.resumeTmuxSessions.execute({ destPath });
7
+ if (result.totalWorkspaces === 0) {
8
+ services.console.log('No workspaces found.');
9
+ return;
10
+ }
11
+ // Display results
12
+ if (result.sessionsCreated > 0) {
13
+ services.console.log(chalk.green(`✓ Created ${result.sessionsCreated} tmux session${result.sessionsCreated === 1 ? '' : 's'}`));
14
+ }
15
+ if (result.sessionsSkipped > 0) {
16
+ services.console.log(chalk.blue(`○ Skipped ${result.sessionsSkipped} existing session${result.sessionsSkipped === 1 ? '' : 's'}`));
17
+ }
18
+ if (result.errors.length > 0) {
19
+ services.console.log(chalk.red('\nErrors:'));
20
+ for (const error of result.errors) {
21
+ services.console.log(chalk.red(` ${error.workspace}: ${error.error}`));
22
+ }
23
+ }
24
+ // Summary
25
+ services.console.log(chalk.dim(`\nTotal: ${result.totalWorkspaces} workspace${result.totalWorkspaces === 1 ? '' : 's'}`));
26
+ }
27
+ export function registerTmuxCommand(program) {
28
+ const tmuxCommand = program
29
+ .command('tmux')
30
+ .description('Manage tmux sessions for workspaces');
31
+ tmuxCommand
32
+ .command('resume')
33
+ .description('Create tmux sessions for all workspaces that don\'t have one')
34
+ .action(async () => {
35
+ const services = createServices();
36
+ const useCases = createUseCases(services);
37
+ try {
38
+ await runTmuxResume(useCases, services);
39
+ }
40
+ catch (error) {
41
+ services.console.error(error.message);
42
+ services.process.exit(1);
43
+ }
44
+ });
45
+ }
@@ -8,7 +8,6 @@ const RawConfigSchema = z.object({
8
8
  'dest-path': z.string().optional(),
9
9
  'copy-files': z.string().optional(),
10
10
  'tmux': z.enum(['true', 'false']).optional(),
11
- 'main-branch': z.string().optional(),
12
11
  'post-checkout': z.string().optional(),
13
12
  'fetch-cache-ttl-seconds': z.string().optional(),
14
13
  'per-repo-post-checkout': z.record(z.string(), z.string()).optional(),
@@ -19,7 +18,6 @@ const ParsedConfigSchema = z.object({
19
18
  destPath: z.string().optional(),
20
19
  copyFiles: z.string().default('.env'),
21
20
  tmux: z.boolean().default(false),
22
- mainBranch: z.string().default('master'),
23
21
  postCheckout: z.string().optional(),
24
22
  fetchCacheTtlSeconds: z.number().default(300),
25
23
  perRepoPostCheckout: z.record(z.string(), z.string()).default({}),
@@ -35,14 +33,13 @@ const ConfigValueSchemas = {
35
33
  'dest-path': z.string().transform((val) => path.resolve(val)),
36
34
  'copy-files': z.string(),
37
35
  'tmux': z.enum(['true', 'false']),
38
- 'main-branch': z.string(),
39
36
  'post-checkout': z.string(),
40
37
  'fetch-cache-ttl-seconds': z.string().refine((val) => {
41
38
  const num = parseInt(val, 10);
42
39
  return !isNaN(num) && num >= 0;
43
40
  }, { message: 'Must be a non-negative integer' }),
44
41
  };
45
- export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'main-branch', 'post-checkout', 'fetch-cache-ttl-seconds'];
42
+ export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'post-checkout', 'fetch-cache-ttl-seconds'];
46
43
  /**
47
44
  * Pure utility functions (no I/O)
48
45
  */
@@ -86,7 +83,6 @@ export class ConfigService {
86
83
  destPath: raw['dest-path'],
87
84
  copyFiles: raw['copy-files'],
88
85
  tmux: raw.tmux === 'true',
89
- mainBranch: raw['main-branch'],
90
86
  postCheckout: raw['post-checkout'],
91
87
  fetchCacheTtlSeconds: raw['fetch-cache-ttl-seconds']
92
88
  ? parseInt(raw['fetch-cache-ttl-seconds'], 10)
@@ -130,10 +126,6 @@ export class ConfigService {
130
126
  value: raw.tmux ?? `${config.tmux} (default)`,
131
127
  isDefault: !raw.tmux,
132
128
  },
133
- 'main-branch': {
134
- value: raw['main-branch'] ?? `${config.mainBranch} (default)`,
135
- isDefault: !raw['main-branch'],
136
- },
137
129
  'post-checkout': {
138
130
  value: raw['post-checkout'] ?? '(not set)',
139
131
  isDefault: !raw['post-checkout'],
@@ -142,6 +134,7 @@ export class ConfigService {
142
134
  value: raw['fetch-cache-ttl-seconds'] ?? `${config.fetchCacheTtlSeconds} (default)`,
143
135
  isDefault: !raw['fetch-cache-ttl-seconds'],
144
136
  },
137
+ perRepoPostCheckout: config.perRepoPostCheckout,
145
138
  };
146
139
  }
147
140
  }
package/dist/lib/git.js CHANGED
@@ -28,6 +28,14 @@ export class GitService {
28
28
  return false;
29
29
  }
30
30
  }
31
+ async findFirstExistingBranch(repoPath, candidates) {
32
+ for (const branch of candidates) {
33
+ const exists = await this.localRemoteBranchExists(repoPath, branch);
34
+ if (exists)
35
+ return branch;
36
+ }
37
+ return null;
38
+ }
31
39
  async addWorktreeNewBranch(repoPath, worktreePath, branch, sourceBranch) {
32
40
  const args = ['worktree', 'add', '-b', branch, worktreePath];
33
41
  if (sourceBranch) {
@@ -87,9 +95,15 @@ export class GitService {
87
95
  }
88
96
  async isAheadOfMain(repoPath, mainBranch) {
89
97
  try {
90
- // Check if there's a diff between current branch and main
91
- const output = await this.exec(repoPath, ['diff', `origin/${mainBranch}...HEAD`]);
92
- return output.length > 0;
98
+ // Use git cherry to detect if commits exist in main via patch equivalence
99
+ // cherry outputs nothing if all commits are equivalent to main
100
+ // Format: "+ hash message" for unmerged, "- hash message" for already merged
101
+ const output = await this.exec(repoPath, ['cherry', `origin/${mainBranch}`, 'HEAD']);
102
+ // Filter to only unmerged commits (those starting with +)
103
+ const unmergedCommits = output
104
+ .split('\n')
105
+ .filter(line => line.trim().startsWith('+'));
106
+ return unmergedCommits.length > 0;
93
107
  }
94
108
  catch {
95
109
  // If we can't determine, assume there are changes to be safe
@@ -6,6 +6,7 @@ import { ConfigService } from './config.js';
6
6
  import { GitService } from './git.js';
7
7
  import { RepoService } from './repos.js';
8
8
  import { WorkspaceDirectoryService } from './workspaceDirectory.js';
9
+ import { WorkspaceConfigService } from './workspaceConfig.js';
9
10
  import { WorktreeService } from './worktree.js';
10
11
  import { PostCheckoutService } from './postCheckout.js';
11
12
  import { FetchService } from './fetch.js';
@@ -31,12 +32,14 @@ export function createServices() {
31
32
  const postCheckout = new PostCheckoutService(shell);
32
33
  // Focused workspace services
33
34
  const workspaceDir = new WorkspaceDirectoryService(fs);
35
+ const workspaceConfig = new WorkspaceConfigService(fs);
34
36
  const worktree = new WorktreeService(fs, git);
35
37
  return {
36
38
  config,
37
39
  git,
38
40
  repos,
39
41
  workspaceDir,
42
+ workspaceConfig,
40
43
  worktree,
41
44
  postCheckout,
42
45
  fetch,
@@ -6,32 +6,15 @@ export class StatusService {
6
6
  constructor(git) {
7
7
  this.git = git;
8
8
  }
9
- async getWorktreeStatus(worktreePath, mainBranch) {
9
+ async getWorktreeStatus(worktreePath, baseBranch) {
10
10
  try {
11
- // Always check for uncommitted changes first
11
+ // Check for uncommitted changes first
12
12
  const hasUncommitted = await this.git.hasUncommittedChanges(worktreePath);
13
13
  if (hasUncommitted) {
14
14
  return { type: 'uncommitted' };
15
15
  }
16
- // Check if origin branch exists
17
- const hasOriginBranch = await this.git.originBranchExists(worktreePath);
18
- if (hasOriginBranch) {
19
- // Compare against origin branch
20
- const isAhead = await this.git.isAheadOfOrigin(worktreePath);
21
- const isBehind = await this.git.isBehindOrigin(worktreePath);
22
- if (isAhead && isBehind) {
23
- return { type: 'diverged', comparedTo: 'origin' };
24
- }
25
- if (isAhead) {
26
- return { type: 'ahead', comparedTo: 'origin' };
27
- }
28
- if (isBehind) {
29
- return { type: 'behind', comparedTo: 'origin' };
30
- }
31
- return { type: 'clean', comparedTo: 'origin' };
32
- }
33
- // No origin branch, compare against main
34
- const isAhead = await this.git.isAheadOfMain(worktreePath, mainBranch);
16
+ // Compare against base branch using git cherry (handles squash merges)
17
+ const isAhead = await this.git.isAheadOfMain(worktreePath, baseBranch);
35
18
  if (isAhead) {
36
19
  return { type: 'ahead', comparedTo: 'main' };
37
20
  }
@@ -44,20 +27,14 @@ export class StatusService {
44
27
  };
45
28
  }
46
29
  }
47
- static getStatusMessage(status, mainBranch) {
30
+ static getStatusMessage(status, baseBranch) {
48
31
  switch (status.type) {
49
32
  case 'clean':
50
33
  return 'up to date';
51
34
  case 'uncommitted':
52
35
  return 'uncommitted changes';
53
36
  case 'ahead':
54
- return status.comparedTo === 'origin'
55
- ? 'ahead of origin'
56
- : `ahead of ${mainBranch}`;
57
- case 'behind':
58
- return 'behind origin';
59
- case 'diverged':
60
- return 'diverged from origin';
37
+ return `ahead of ${baseBranch}`;
61
38
  case 'error':
62
39
  return `error: ${status.error}`;
63
40
  }
@@ -65,16 +42,16 @@ export class StatusService {
65
42
  static hasIssues(status) {
66
43
  return (status.type === 'uncommitted' ||
67
44
  status.type === 'ahead' ||
68
- status.type === 'diverged' ||
69
45
  status.type === 'error');
70
46
  }
71
47
  /**
72
48
  * Check status of all worktrees in parallel
73
49
  */
74
- async checkAllWorktrees(worktreeDirs, mainBranch) {
50
+ async checkAllWorktrees(worktreeDirs, getBaseBranch) {
75
51
  const results = await Promise.all(worktreeDirs.map(async (worktreePath) => {
76
52
  const repoName = worktreePath.split('/').pop() || worktreePath;
77
- const status = await this.getWorktreeStatus(worktreePath, mainBranch);
53
+ const baseBranch = getBaseBranch(repoName);
54
+ const status = await this.getWorktreeStatus(worktreePath, baseBranch);
78
55
  return { repoName, status };
79
56
  }));
80
57
  return results;
@@ -82,8 +59,8 @@ export class StatusService {
82
59
  /**
83
60
  * Find repos with issues (for removal validation)
84
61
  */
85
- async findReposWithIssues(worktreeDirs, mainBranch) {
86
- const results = await this.checkAllWorktrees(worktreeDirs, mainBranch);
62
+ async findReposWithIssues(worktreeDirs, getBaseBranch) {
63
+ const results = await this.checkAllWorktrees(worktreeDirs, getBaseBranch);
87
64
  return results
88
65
  .filter(({ status }) => StatusService.hasIssues(status))
89
66
  .map(({ repoName }) => repoName);
@@ -0,0 +1,41 @@
1
+ import path from 'node:path';
2
+ import { z } from 'zod';
3
+ // Schema for workspace config
4
+ const WorkspaceConfigSchema = z.object({
5
+ baseBranches: z.record(z.string(), z.string()),
6
+ });
7
+ /**
8
+ * WorkspaceConfigService manages flow-config.json files in workspace directories.
9
+ * These files track the base branch used for each repository in a workspace.
10
+ */
11
+ export class WorkspaceConfigService {
12
+ fs;
13
+ constructor(fs) {
14
+ this.fs = fs;
15
+ }
16
+ getConfigPath(workspacePath) {
17
+ return path.join(workspacePath, 'flow-config.json');
18
+ }
19
+ exists(workspacePath) {
20
+ const configPath = this.getConfigPath(workspacePath);
21
+ return this.fs.existsSync(configPath);
22
+ }
23
+ load(workspacePath) {
24
+ const configPath = this.getConfigPath(workspacePath);
25
+ if (!this.fs.existsSync(configPath)) {
26
+ return { baseBranches: {} };
27
+ }
28
+ const raw = this.fs.readFileSync(configPath, 'utf-8');
29
+ const parsed = JSON.parse(raw);
30
+ return WorkspaceConfigSchema.parse(parsed);
31
+ }
32
+ save(workspacePath, config) {
33
+ const configPath = this.getConfigPath(workspacePath);
34
+ const validated = WorkspaceConfigSchema.parse(config);
35
+ this.fs.writeFileSync(configPath, JSON.stringify(validated, null, 2) + '\n');
36
+ }
37
+ getBaseBranch(workspacePath, repoName) {
38
+ const config = this.load(workspacePath);
39
+ return config.baseBranches[repoName] || 'master';
40
+ }
41
+ }
@@ -3,14 +3,19 @@
3
3
  */
4
4
  export class CheckWorkspaceStatusUseCase {
5
5
  workspaceDir;
6
+ workspaceConfig;
6
7
  status;
7
- constructor(workspaceDir, status) {
8
+ constructor(workspaceDir, workspaceConfig, status) {
8
9
  this.workspaceDir = workspaceDir;
10
+ this.workspaceConfig = workspaceConfig;
9
11
  this.status = status;
10
12
  }
11
13
  async execute(params) {
12
14
  const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
13
- const statuses = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
15
+ // Load workspace config to get per-repo base branches
16
+ const config = this.workspaceConfig.load(params.workspacePath);
17
+ const getBaseBranch = (repoName) => config.baseBranches[repoName] || 'master';
18
+ const statuses = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
14
19
  return { statuses };
15
20
  }
16
21
  }
@@ -7,15 +7,19 @@ import { NoReposFoundError } from '../lib/errors.js';
7
7
  */
8
8
  export class CheckoutWorkspaceUseCase {
9
9
  workspaceDir;
10
+ workspaceConfig;
10
11
  worktree;
11
12
  repos;
13
+ git;
12
14
  parallel;
13
15
  tmux;
14
16
  postCheckout;
15
- constructor(workspaceDir, worktree, repos, parallel, tmux, postCheckout) {
17
+ constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, postCheckout) {
16
18
  this.workspaceDir = workspaceDir;
19
+ this.workspaceConfig = workspaceConfig;
17
20
  this.worktree = worktree;
18
21
  this.repos = repos;
22
+ this.git = git;
19
23
  this.parallel = parallel;
20
24
  this.tmux = tmux;
21
25
  this.postCheckout = postCheckout;
@@ -43,7 +47,16 @@ export class CheckoutWorkspaceUseCase {
43
47
  });
44
48
  // 5. Copy AGENTS.md
45
49
  this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
46
- // 6. Create tmux session if enabled
50
+ // 6. Detect base branches for each repo
51
+ const baseBranches = {};
52
+ for (const repoPath of matching) {
53
+ const repoName = RepoService.getRepoName(repoPath);
54
+ const baseBranch = await this.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']);
55
+ baseBranches[repoName] = baseBranch || 'master';
56
+ }
57
+ // 7. Save workspace config with base branches
58
+ this.workspaceConfig.save(workspacePath, { baseBranches });
59
+ // 8. Create tmux session if enabled
47
60
  let tmuxCreated = false;
48
61
  if (params.tmux) {
49
62
  try {
@@ -55,7 +68,7 @@ export class CheckoutWorkspaceUseCase {
55
68
  // Don't fail
56
69
  }
57
70
  }
58
- // 7. Run post-checkout if configured
71
+ // 9. Run post-checkout if configured
59
72
  let postCheckoutResult;
60
73
  if (params.postCheckout || params.perRepoPostCheckout) {
61
74
  const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
@@ -6,15 +6,19 @@ import { RepoService } from '../lib/repos.js';
6
6
  */
7
7
  export class CreateBranchWorkspaceUseCase {
8
8
  workspaceDir;
9
+ workspaceConfig;
9
10
  worktree;
10
11
  repos;
12
+ git;
11
13
  parallel;
12
14
  tmux;
13
15
  postCheckout;
14
- constructor(workspaceDir, worktree, repos, parallel, tmux, postCheckout) {
16
+ constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, postCheckout) {
15
17
  this.workspaceDir = workspaceDir;
18
+ this.workspaceConfig = workspaceConfig;
16
19
  this.worktree = worktree;
17
20
  this.repos = repos;
21
+ this.git = git;
18
22
  this.parallel = parallel;
19
23
  this.tmux = tmux;
20
24
  this.postCheckout = postCheckout;
@@ -22,17 +26,34 @@ export class CreateBranchWorkspaceUseCase {
22
26
  async execute(params) {
23
27
  // 1. Create workspace directory
24
28
  const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
29
+ // Track base branches for each repo
30
+ const baseBranches = {};
25
31
  // 2. Create worktrees in parallel
26
32
  const successCount = await this.parallel.processInParallel(params.repos, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
27
33
  const name = RepoService.getRepoName(repoPath);
28
34
  const worktreeDest = path.join(workspacePath, name);
29
- await this.worktree.createWorktreeWithBranch(repoPath, worktreeDest, params.branchName, params.sourceBranch);
35
+ // Determine which base branch to use
36
+ let actualBaseBranch = params.sourceBranch;
37
+ // Try the user-specified source branch first, fall back if it doesn't exist
38
+ const branchExists = await this.git.localRemoteBranchExists(repoPath, params.sourceBranch);
39
+ if (!branchExists) {
40
+ const fallbackBranch = await this.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']);
41
+ if (fallbackBranch) {
42
+ actualBaseBranch = fallbackBranch;
43
+ }
44
+ // If no fallback found, still try with the original (will fail with clear error)
45
+ }
46
+ // Track the actual base branch used
47
+ baseBranches[name] = actualBaseBranch;
48
+ await this.worktree.createWorktreeWithBranch(repoPath, worktreeDest, params.branchName, actualBaseBranch);
30
49
  this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
31
50
  return 'created';
32
51
  });
33
52
  // 3. Copy AGENTS.md if exists
34
53
  this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
35
- // 4. Create tmux session if enabled
54
+ // 4. Save workspace config with base branches
55
+ this.workspaceConfig.save(workspacePath, { baseBranches });
56
+ // 5. Create tmux session if enabled
36
57
  let tmuxCreated = false;
37
58
  if (params.tmux) {
38
59
  try {
@@ -44,7 +65,7 @@ export class CreateBranchWorkspaceUseCase {
44
65
  // Don't fail, just return false
45
66
  }
46
67
  }
47
- // 5. Run post-checkout command if configured
68
+ // 6. Run post-checkout command if configured
48
69
  let postCheckoutResult;
49
70
  if (params.postCheckout || params.perRepoPostCheckout) {
50
71
  const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
@@ -5,10 +5,12 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
5
5
  */
6
6
  export class DiscoverPrunableWorkspacesUseCase {
7
7
  workspaceDir;
8
+ workspaceConfig;
8
9
  status;
9
10
  git;
10
- constructor(workspaceDir, status, git) {
11
+ constructor(workspaceDir, workspaceConfig, status, git) {
11
12
  this.workspaceDir = workspaceDir;
13
+ this.workspaceConfig = workspaceConfig;
12
14
  this.status = status;
13
15
  this.git = git;
14
16
  }
@@ -23,8 +25,11 @@ export class DiscoverPrunableWorkspacesUseCase {
23
25
  if (!worktreeDirs || worktreeDirs.length === 0) {
24
26
  continue;
25
27
  }
28
+ // Load workspace config to get per-repo base branches
29
+ const config = this.workspaceConfig.load(workspace.path);
30
+ const getBaseBranch = (repoName) => config.baseBranches[repoName] || 'master';
26
31
  // Check status for all worktrees
27
- const statuses = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
32
+ const statuses = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
28
33
  // Skip if any worktree has issues
29
34
  const hasAnyIssues = statuses.some(({ status }) => StatusServiceClass.hasIssues(status));
30
35
  if (hasAnyIssues) {
@@ -4,9 +4,11 @@
4
4
  */
5
5
  export class ListWorkspacesWithStatusUseCase {
6
6
  workspaceDir;
7
+ workspaceConfig;
7
8
  status;
8
- constructor(workspaceDir, status) {
9
+ constructor(workspaceDir, workspaceConfig, status) {
9
10
  this.workspaceDir = workspaceDir;
11
+ this.workspaceConfig = workspaceConfig;
10
12
  this.status = status;
11
13
  }
12
14
  async execute(params) {
@@ -24,8 +26,11 @@ export class ListWorkspacesWithStatusUseCase {
24
26
  }
25
27
  // Check if this workspace is active (contains current working directory)
26
28
  const isActive = this.workspaceDir.detectWorkspace(params.cwd, params.destPath) === workspace.path;
29
+ // Load workspace config to get per-repo base branches
30
+ const config = this.workspaceConfig.load(workspace.path);
31
+ const getBaseBranch = (repoName) => config.baseBranches[repoName] || 'master';
27
32
  // Check status for all worktrees
28
- const statuses = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
33
+ const statuses = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
29
34
  workspacesWithStatus.push({
30
35
  name: workspace.name,
31
36
  path: workspace.path,
@@ -7,12 +7,14 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
7
7
  */
8
8
  export class RemoveWorkspaceUseCase {
9
9
  workspaceDir;
10
+ workspaceConfig;
10
11
  worktree;
11
12
  repos;
12
13
  status;
13
14
  tmux;
14
- constructor(workspaceDir, worktree, repos, status, tmux) {
15
+ constructor(workspaceDir, workspaceConfig, worktree, repos, status, tmux) {
15
16
  this.workspaceDir = workspaceDir;
17
+ this.workspaceConfig = workspaceConfig;
16
18
  this.worktree = worktree;
17
19
  this.repos = repos;
18
20
  this.status = status;
@@ -23,12 +25,16 @@ export class RemoveWorkspaceUseCase {
23
25
  const issuesFound = [];
24
26
  // 1. Check status if worktrees exist
25
27
  if (worktreeDirs.length > 0) {
26
- const results = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
28
+ // Load workspace config to get per-repo base branches
29
+ const config = this.workspaceConfig.load(params.workspacePath);
30
+ const getBaseBranch = (repoName) => config.baseBranches[repoName] || 'master';
31
+ const results = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
27
32
  for (const { repoName, status } of results) {
28
33
  if (StatusServiceClass.hasIssues(status)) {
34
+ const baseBranch = getBaseBranch(repoName);
29
35
  issuesFound.push({
30
36
  repoName,
31
- issue: StatusServiceClass.getStatusMessage(status, params.mainBranch),
37
+ issue: StatusServiceClass.getStatusMessage(status, baseBranch),
32
38
  });
33
39
  }
34
40
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Use case for resuming tmux sessions across all workspaces.
3
+ * Creates sessions for workspaces that don't already have one.
4
+ */
5
+ export class ResumeTmuxSessionsUseCase {
6
+ workspaceDir;
7
+ tmux;
8
+ constructor(workspaceDir, tmux) {
9
+ this.workspaceDir = workspaceDir;
10
+ this.tmux = tmux;
11
+ }
12
+ async execute(params) {
13
+ // 1. List all workspaces
14
+ const workspaces = this.workspaceDir.listWorkspaces(params.destPath);
15
+ let sessionsCreated = 0;
16
+ let sessionsSkipped = 0;
17
+ const errors = [];
18
+ // 2. Try to create tmux session for each workspace
19
+ for (const workspace of workspaces) {
20
+ try {
21
+ const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
22
+ // Try to create session - will throw on duplicate session
23
+ await this.tmux.createSession(workspace.path, workspace.name, worktreeDirs);
24
+ sessionsCreated++;
25
+ }
26
+ catch (error) {
27
+ // Check if session already exists
28
+ if (error.message?.includes('duplicate session')) {
29
+ sessionsSkipped++;
30
+ }
31
+ else {
32
+ // Other error
33
+ errors.push({
34
+ workspace: workspace.name,
35
+ error: error.message || 'Unknown error',
36
+ });
37
+ }
38
+ }
39
+ }
40
+ return {
41
+ totalWorkspaces: workspaces.length,
42
+ sessionsCreated,
43
+ sessionsSkipped,
44
+ errors,
45
+ };
46
+ }
47
+ }
@@ -9,6 +9,7 @@ import { ListWorkspacesWithStatusUseCase } from './listWorkspacesWithStatus.js';
9
9
  import { FetchAllReposUseCase } from './fetchAllRepos.js';
10
10
  import { FetchWorkspaceReposUseCase } from './fetchWorkspaceRepos.js';
11
11
  import { FetchUsedReposUseCase } from './fetchUsedRepos.js';
12
+ import { ResumeTmuxSessionsUseCase } from './resumeTmuxSessions.js';
12
13
  /**
13
14
  * Factory function for creating all use cases with their service dependencies.
14
15
  * Use cases orchestrate workflows by coordinating multiple services.
@@ -18,13 +19,14 @@ export function createUseCases(services) {
18
19
  fetchAllRepos: new FetchAllReposUseCase(services.fetch, services.repos),
19
20
  fetchWorkspaceRepos: new FetchWorkspaceReposUseCase(services.workspaceDir, services.fetch),
20
21
  fetchUsedRepos: new FetchUsedReposUseCase(services.workspaceDir, services.fetch),
21
- createBranchWorkspace: new CreateBranchWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.parallel, services.tmux, services.postCheckout),
22
- checkoutWorkspace: new CheckoutWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.parallel, services.tmux, services.postCheckout),
23
- removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.status, services.tmux),
22
+ createBranchWorkspace: new CreateBranchWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.git, services.parallel, services.tmux, services.postCheckout),
23
+ checkoutWorkspace: new CheckoutWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.git, services.parallel, services.tmux, services.postCheckout),
24
+ removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.status, services.tmux),
24
25
  pushWorkspace: new PushWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
25
26
  pullWorkspace: new PullWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
26
- checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.status),
27
- discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.status, services.git),
28
- listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.status),
27
+ checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
28
+ discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.workspaceConfig, services.status, services.git),
29
+ listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
30
+ resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux),
29
31
  };
30
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-flow",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
4
4
  "description": "Manage git worktrees across a poly-repo environment",
5
5
  "type": "module",
6
6
  "bin": {