worktree-flow 0.0.12 → 0.0.14

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
@@ -126,6 +126,8 @@ Configure different commands for specific repos by editing `~/.config/flow/confi
126
126
 
127
127
  Repos with per-repo commands use those; others fall back to the global `post-checkout` command.
128
128
 
129
+ 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.
130
+
129
131
  ## AGENTS.md
130
132
 
131
133
  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.
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { createServices } from '../lib/services.js';
3
3
  import { createUseCases } from '../usecases/usecases.js';
4
- import { getStatusIndicator } from './helpers.js';
4
+ import { StatusService } from '../lib/status.js';
5
5
  export async function runList(useCases, services) {
6
6
  const { destPath, sourcePath } = services.config.getRequired();
7
7
  const config = services.config.load();
@@ -45,10 +45,25 @@ export async function runList(useCases, services) {
45
45
  // Re-print with full status information
46
46
  services.console.log(chalk.bold('\nWorkspaces:'));
47
47
  for (const workspace of result.workspaces) {
48
- const statusIndicator = getStatusIndicator(workspace);
49
48
  const activeIndicator = workspace.isActive ? chalk.green('* ') : ' ';
50
49
  const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
51
- services.console.log(`${activeIndicator}${chalk.cyan(workspace.name)} ${repoCount} ${statusIndicator}`);
50
+ services.console.log(`${activeIndicator}${chalk.cyan(workspace.name)} ${repoCount}`);
51
+ // Load workspace config to get per-repo base branches
52
+ const workspaceConfig = services.workspaceConfig.load(workspace.path);
53
+ const getBaseBranch = (repoName) => workspaceConfig.baseBranches[repoName] || 'master';
54
+ // Display each repo with its status and tracking branch
55
+ for (const { repoName, status } of workspace.statuses) {
56
+ const baseBranch = getBaseBranch(repoName);
57
+ const statusMessage = StatusService.getStatusMessage(status, baseBranch);
58
+ const hasIssues = StatusService.hasIssues(status);
59
+ const indicator = hasIssues ? chalk.red('✗') : chalk.green('✓');
60
+ const message = hasIssues ? chalk.red(statusMessage) : chalk.green(statusMessage);
61
+ const trackingInfo = status.upstreamBranch
62
+ ? chalk.dim(` → ${status.upstreamBranch}`)
63
+ : chalk.dim(' (no upstream)');
64
+ services.console.log(` ${indicator} ${chalk.yellow(repoName)}: ${message}${trackingInfo}`);
65
+ }
66
+ services.console.log(''); // Blank line between workspaces
52
67
  }
53
68
  }
54
69
  export function registerListCommand(program) {
package/dist/lib/git.js CHANGED
@@ -45,7 +45,7 @@ export class GitService {
45
45
  return null;
46
46
  }
47
47
  async addWorktreeNewBranch(repoPath, worktreePath, branch, sourceBranch) {
48
- const args = ['worktree', 'add', '-b', branch, worktreePath];
48
+ const args = ['worktree', 'add', '--no-track', '-b', branch, worktreePath];
49
49
  if (sourceBranch) {
50
50
  args.push(sourceBranch);
51
51
  }
@@ -67,6 +67,15 @@ export class GitService {
67
67
  const output = await this.exec(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']);
68
68
  return output.trim();
69
69
  }
70
+ async getUpstreamBranch(worktreePath) {
71
+ try {
72
+ const output = await this.exec(worktreePath, ['rev-parse', '--abbrev-ref', '@{u}']);
73
+ return output.trim();
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
70
79
  async hasUncommittedChanges(repoPath) {
71
80
  const output = await this.exec(repoPath, ['status', '--porcelain']);
72
81
  return output.length > 0;
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  /**
3
2
  * PostCheckoutService handles execution of post-checkout commands.
4
3
  */
@@ -7,25 +6,7 @@ export class PostCheckoutService {
7
6
  constructor(shell) {
8
7
  this.shell = shell;
9
8
  }
10
- async runCommand(worktreeDirs, globalCommand, perRepoCommands) {
11
- // Determine command for each worktree (per-repo overrides global)
12
- const commandsToRun = worktreeDirs
13
- .map((dir) => {
14
- const repoName = path.basename(dir);
15
- const command = perRepoCommands[repoName] ?? globalCommand;
16
- return command ? { dir, command } : null;
17
- })
18
- .filter((x) => x !== null);
19
- let successCount = 0;
20
- await Promise.allSettled(commandsToRun.map(async ({ dir, command }) => {
21
- try {
22
- await this.shell.execFile('sh', ['-c', command], { cwd: dir });
23
- successCount++;
24
- }
25
- catch {
26
- // Error handled by caller
27
- }
28
- }));
29
- return { successCount, totalCount: commandsToRun.length };
9
+ async runCommandInDirectory(directory, command) {
10
+ await this.shell.execFile('sh', ['-c', command], { cwd: directory });
30
11
  }
31
12
  }
@@ -8,17 +8,20 @@ export class StatusService {
8
8
  }
9
9
  async getWorktreeStatus(worktreePath, baseBranch) {
10
10
  try {
11
+ // Get branch information
12
+ const currentBranch = await this.git.getCurrentBranch(worktreePath);
13
+ const upstreamBranch = await this.git.getUpstreamBranch(worktreePath);
11
14
  // Check for uncommitted changes first
12
15
  const hasUncommitted = await this.git.hasUncommittedChanges(worktreePath);
13
16
  if (hasUncommitted) {
14
- return { type: 'uncommitted' };
17
+ return { type: 'uncommitted', currentBranch, upstreamBranch };
15
18
  }
16
19
  // Compare against base branch using git cherry (handles squash merges)
17
20
  const isAhead = await this.git.isAheadOfMain(worktreePath, baseBranch);
18
21
  if (isAhead) {
19
- return { type: 'ahead', comparedTo: 'main' };
22
+ return { type: 'ahead', comparedTo: 'main', currentBranch, upstreamBranch };
20
23
  }
21
- return { type: 'clean', comparedTo: 'main' };
24
+ return { type: 'clean', comparedTo: 'main', currentBranch, upstreamBranch };
22
25
  }
23
26
  catch (err) {
24
27
  return {
package/dist/lib/tmux.js CHANGED
@@ -44,6 +44,15 @@ export class TmuxService {
44
44
  }
45
45
  }
46
46
  }
47
+ async sendKeysToPane(sessionName, paneIndex, command) {
48
+ await this.shell.execFile('tmux', [
49
+ 'send-keys',
50
+ '-t',
51
+ `${sessionName}:0.${paneIndex}`,
52
+ command,
53
+ 'Enter',
54
+ ]);
55
+ }
47
56
  async killSession(sessionName) {
48
57
  try {
49
58
  await this.shell.execFile('tmux', ['kill-session', '-t', sessionName]);
@@ -13,8 +13,8 @@ export class CheckoutWorkspaceUseCase {
13
13
  git;
14
14
  parallel;
15
15
  tmux;
16
- postCheckout;
17
- constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, postCheckout) {
16
+ runPostCheckout;
17
+ constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, runPostCheckout) {
18
18
  this.workspaceDir = workspaceDir;
19
19
  this.workspaceConfig = workspaceConfig;
20
20
  this.worktree = worktree;
@@ -22,7 +22,7 @@ export class CheckoutWorkspaceUseCase {
22
22
  this.git = git;
23
23
  this.parallel = parallel;
24
24
  this.tmux = tmux;
25
- this.postCheckout = postCheckout;
25
+ this.runPostCheckout = runPostCheckout;
26
26
  }
27
27
  async execute(params) {
28
28
  // 1. Discover all repos
@@ -69,11 +69,13 @@ export class CheckoutWorkspaceUseCase {
69
69
  }
70
70
  }
71
71
  // 9. Run post-checkout if configured
72
- let postCheckoutResult;
73
- if (params.postCheckout || params.perRepoPostCheckout) {
74
- const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
75
- postCheckoutResult = await this.postCheckout.runCommand(worktreeDirs, params.postCheckout, params.perRepoPostCheckout ?? {});
76
- }
72
+ const postCheckoutResult = await this.runPostCheckout.execute({
73
+ workspacePath,
74
+ sessionName: tmuxCreated ? params.branchName : undefined,
75
+ tmuxEnabled: tmuxCreated,
76
+ postCheckout: params.postCheckout,
77
+ perRepoPostCheckout: params.perRepoPostCheckout,
78
+ });
77
79
  return {
78
80
  workspacePath,
79
81
  matchingRepos: matching.length,
@@ -12,8 +12,8 @@ export class CreateBranchWorkspaceUseCase {
12
12
  git;
13
13
  parallel;
14
14
  tmux;
15
- postCheckout;
16
- constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, postCheckout) {
15
+ runPostCheckout;
16
+ constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, runPostCheckout) {
17
17
  this.workspaceDir = workspaceDir;
18
18
  this.workspaceConfig = workspaceConfig;
19
19
  this.worktree = worktree;
@@ -21,7 +21,7 @@ export class CreateBranchWorkspaceUseCase {
21
21
  this.git = git;
22
22
  this.parallel = parallel;
23
23
  this.tmux = tmux;
24
- this.postCheckout = postCheckout;
24
+ this.runPostCheckout = runPostCheckout;
25
25
  }
26
26
  async execute(params) {
27
27
  // 1. Create workspace directory
@@ -66,11 +66,13 @@ export class CreateBranchWorkspaceUseCase {
66
66
  }
67
67
  }
68
68
  // 6. Run post-checkout command if configured
69
- let postCheckoutResult;
70
- if (params.postCheckout || params.perRepoPostCheckout) {
71
- const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
72
- postCheckoutResult = await this.postCheckout.runCommand(worktreeDirs, params.postCheckout, params.perRepoPostCheckout ?? {});
73
- }
69
+ const postCheckoutResult = await this.runPostCheckout.execute({
70
+ workspacePath,
71
+ sessionName: tmuxCreated ? params.branchName : undefined,
72
+ tmuxEnabled: tmuxCreated,
73
+ postCheckout: params.postCheckout,
74
+ perRepoPostCheckout: params.perRepoPostCheckout,
75
+ });
74
76
  return {
75
77
  workspacePath,
76
78
  successCount,
@@ -1,19 +1,14 @@
1
- import path from 'node:path';
2
1
  /**
3
2
  * Use case for resuming tmux sessions across all workspaces.
4
3
  * Creates sessions for workspaces that don't already have one.
5
- * Only creates splits for directories that match repos from source-path.
4
+ * Creates splits for all git directories (directories with .git).
6
5
  */
7
6
  export class ResumeTmuxSessionsUseCase {
8
7
  workspaceDir;
9
8
  tmux;
10
- repos;
11
- config;
12
- constructor(workspaceDir, tmux, repos, config) {
9
+ constructor(workspaceDir, tmux) {
13
10
  this.workspaceDir = workspaceDir;
14
11
  this.tmux = tmux;
15
- this.repos = repos;
16
- this.config = config;
17
12
  }
18
13
  async execute(params) {
19
14
  // 1. List all workspaces
@@ -27,24 +22,16 @@ export class ResumeTmuxSessionsUseCase {
27
22
  errors: [],
28
23
  };
29
24
  }
30
- // 2. Get valid repo names from source-path
31
- const { sourcePath } = this.config.getRequired();
32
- const repoPaths = this.repos.discoverRepos(sourcePath);
33
- const validRepoNames = new Set(repoPaths.map(repoPath => path.basename(repoPath)));
34
25
  let sessionsCreated = 0;
35
26
  let sessionsSkipped = 0;
36
27
  const errors = [];
37
- // 3. Try to create tmux session for each workspace
28
+ // 2. Try to create tmux session for each workspace
38
29
  for (const workspace of workspaces) {
39
30
  try {
40
- const allWorktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
41
- // Filter to only include directories that match repo names from source-path
42
- const filteredWorktreeDirs = allWorktreeDirs.filter(worktreeDir => {
43
- const dirName = path.basename(worktreeDir);
44
- return validRepoNames.has(dirName);
45
- });
31
+ // Get all git directories (getWorktreeDirs now filters to only include dirs with .git)
32
+ const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
46
33
  // Try to create session - will throw on duplicate session
47
- await this.tmux.createSession(workspace.path, workspace.name, filteredWorktreeDirs);
34
+ await this.tmux.createSession(workspace.path, workspace.name, worktreeDirs);
48
35
  sessionsCreated++;
49
36
  }
50
37
  catch (error) {
@@ -0,0 +1,49 @@
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
+ }
@@ -10,23 +10,27 @@ import { FetchAllReposUseCase } from './fetchAllRepos.js';
10
10
  import { FetchWorkspaceReposUseCase } from './fetchWorkspaceRepos.js';
11
11
  import { FetchUsedReposUseCase } from './fetchUsedRepos.js';
12
12
  import { ResumeTmuxSessionsUseCase } from './resumeTmuxSessions.js';
13
+ import { RunPostCheckoutUseCase } from './runPostCheckout.js';
13
14
  /**
14
15
  * Factory function for creating all use cases with their service dependencies.
15
16
  * Use cases orchestrate workflows by coordinating multiple services.
16
17
  */
17
18
  export function createUseCases(services) {
19
+ // Create use cases that are dependencies first
20
+ const runPostCheckout = new RunPostCheckoutUseCase(services.workspaceDir, services.postCheckout, services.tmux);
18
21
  return {
19
22
  fetchAllRepos: new FetchAllReposUseCase(services.fetch, services.repos),
20
23
  fetchWorkspaceRepos: new FetchWorkspaceReposUseCase(services.workspaceDir, services.fetch),
21
24
  fetchUsedRepos: new FetchUsedReposUseCase(services.workspaceDir, services.fetch),
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),
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),
24
27
  removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.status, services.tmux),
25
28
  pushWorkspace: new PushWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
26
29
  pullWorkspace: new PullWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
27
30
  checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
28
31
  discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.workspaceConfig, services.status, services.git),
29
32
  listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
30
- resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux, services.repos, services.config),
33
+ resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux),
34
+ runPostCheckout,
31
35
  };
32
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-flow",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Manage git worktrees across a poly-repo environment",
5
5
  "type": "module",
6
6
  "bin": {