worktree-flow 0.0.19 → 0.0.21

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.
@@ -24,6 +24,9 @@ export class NodeFileSystem {
24
24
  copyFileSync(src, dest) {
25
25
  fs.copyFileSync(src, dest);
26
26
  }
27
+ cpSync(src, dest, options) {
28
+ fs.cpSync(src, dest, options);
29
+ }
27
30
  rmSync(path, options) {
28
31
  fs.rmSync(path, options);
29
32
  }
package/dist/cli.js CHANGED
@@ -19,18 +19,23 @@ program
19
19
  .name('flow')
20
20
  .description('Manage git worktrees across a poly-repo environment')
21
21
  .version('0.1.0');
22
+ // Getting Started
23
+ registerQuickstartCommand(program);
22
24
  registerConfigCommand(program);
25
+ // Workspaces
23
26
  registerCreateCommand(program);
24
- registerAttachCommand(program);
25
- registerBranchCommand(program);
26
27
  registerCheckoutCommand(program);
27
28
  registerListCommand(program);
28
- registerPullCommand(program);
29
- registerPushCommand(program);
29
+ registerAttachCommand(program);
30
30
  registerDropCommand(program);
31
- registerStatusCommand(program);
32
31
  registerPruneCommand(program);
32
+ // Git Operations
33
+ registerStatusCommand(program);
34
+ registerPullCommand(program);
35
+ registerPushCommand(program);
33
36
  registerFetchCommand(program);
37
+ // Extras
34
38
  registerTmuxCommand(program);
35
- registerQuickstartCommand(program);
39
+ // Deprecated
40
+ registerBranchCommand(program);
36
41
  program.parse();
@@ -105,6 +105,7 @@ export async function runAttach(branchName, useCases, services, deps) {
105
105
  export function registerAttachCommand(program) {
106
106
  program
107
107
  .command('attach [branch-name]')
108
+ .helpGroup('Workspaces')
108
109
  .description('Attach repos to an existing workspace (auto-detects from current directory if branch not provided)')
109
110
  .action(async (branchName) => {
110
111
  const services = createServices();
@@ -3,6 +3,7 @@ import { createServices } from '../lib/services.js';
3
3
  export function registerBranchCommand(program) {
4
4
  program
5
5
  .command('branch <branch-name>')
6
+ .helpGroup('Deprecated')
6
7
  .description(chalk.dim('Deprecated: use "flow create <branch-name>" instead'))
7
8
  .action(async () => {
8
9
  const services = createServices();
@@ -6,16 +6,9 @@ import { createUseCases } from '../usecases/usecases.js';
6
6
  export async function runCheckout(branchName, useCases, services, deps) {
7
7
  const { sourcePath, destPath } = services.config.getRequired();
8
8
  const config = services.config.load();
9
- let shouldRunPostCheckout = false;
10
- if (config.postCheckout) {
11
- shouldRunPostCheckout = await deps.confirm({
12
- message: `Run "${config.postCheckout}" in all workspaces?`,
13
- default: true,
14
- });
15
- }
16
- services.console.log('\nChecking for branch...');
17
9
  try {
18
10
  // 1. Fetch all repos from source-path
11
+ services.console.log('\nFetching repos...');
19
12
  await useCases.fetchAllRepos.execute({
20
13
  sourcePath,
21
14
  fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
@@ -25,7 +18,7 @@ export async function runCheckout(branchName, useCases, services, deps) {
25
18
  sourcePath,
26
19
  branchName,
27
20
  });
28
- // 3. Display per-repo branch check results
21
+ // 3. Display only repos that have changes (matching or errored)
29
22
  for (const checkResult of discoverResult.branchCheckResults) {
30
23
  if (checkResult.error) {
31
24
  services.console.log(`${checkResult.repoName}... ${chalk.red(`error: ${checkResult.error}`)}`);
@@ -33,15 +26,20 @@ export async function runCheckout(branchName, useCases, services, deps) {
33
26
  else if (checkResult.hasBranch) {
34
27
  services.console.log(`${checkResult.repoName}... ${chalk.green('found')}`);
35
28
  }
36
- else {
37
- services.console.log(`${checkResult.repoName}... ${chalk.dim('no branch')}`);
38
- }
39
29
  }
40
30
  // 4. Throw error if no repos match
41
31
  if (discoverResult.matchingRepos.length === 0) {
42
32
  throw new Error(`Branch "${branchName}" not found in any repo.`);
43
33
  }
44
- // 5. Create workspace directory, placeholder config, AGENTS.md, tmux session
34
+ // 5. Prompt for post-checkout
35
+ let shouldRunPostCheckout = false;
36
+ if (config.postCheckout) {
37
+ shouldRunPostCheckout = await deps.confirm({
38
+ message: `Run "${config.postCheckout}" in all workspaces?`,
39
+ default: true,
40
+ });
41
+ }
42
+ // 6. Create workspace directory, placeholder config, AGENTS.md, tmux session
45
43
  const workspaceResult = await useCases.createWorkspace.execute({
46
44
  branchName,
47
45
  sourcePath,
@@ -50,7 +48,7 @@ export async function runCheckout(branchName, useCases, services, deps) {
50
48
  });
51
49
  const { workspacePath, tmuxCreated } = workspaceResult;
52
50
  const sessionName = tmuxCreated ? branchName : undefined;
53
- // 6. For each matching repo in parallel: detect base branch, then addToWorkspace
51
+ // 7. For each matching repo in parallel: detect base branch, then addToWorkspace
54
52
  const results = await Promise.allSettled(discoverResult.matchingRepos.map(async (repoPath) => {
55
53
  const repoName = path.basename(repoPath);
56
54
  // Resolve per-repo post-checkout command
@@ -80,7 +78,7 @@ export async function runCheckout(branchName, useCases, services, deps) {
80
78
  .filter((r) => r.postCheckoutRan);
81
79
  const postCheckoutSuccess = postCheckoutResults.filter((r) => r.postCheckoutSuccess).length;
82
80
  const postCheckoutTotal = postCheckoutResults.length;
83
- // 7. Display results
81
+ // 8. Display results
84
82
  services.console.log(`\nCreated workspace at ${chalk.cyan(workspacePath)} with ${successCount}/${totalCount} repos.`);
85
83
  if (tmuxCreated) {
86
84
  services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
@@ -101,6 +99,7 @@ export async function runCheckout(branchName, useCases, services, deps) {
101
99
  export function registerCheckoutCommand(program) {
102
100
  program
103
101
  .command('checkout <branch-name>')
102
+ .helpGroup('Workspaces')
104
103
  .description('Checkout an existing branch across repos')
105
104
  .action(async (branchName) => {
106
105
  const services = createServices();
@@ -5,6 +5,7 @@ import { createServices } from '../lib/services.js';
5
5
  export function registerConfigCommand(program) {
6
6
  const configCmd = program
7
7
  .command('config')
8
+ .helpGroup('Getting Started')
8
9
  .description('Manage flow configuration');
9
10
  configCmd
10
11
  .command('set <key> <value>')
@@ -103,6 +103,7 @@ export async function runCreate(branchName, useCases, services, deps) {
103
103
  export function registerCreateCommand(program) {
104
104
  program
105
105
  .command('create <branch-name>')
106
+ .helpGroup('Workspaces')
106
107
  .description('Create branches and worktrees for selected repos')
107
108
  .action(async (branchName) => {
108
109
  const services = createServices();
@@ -29,10 +29,8 @@ export async function runDrop(branchName, useCases, services, deps) {
29
29
  const statusResult = await useCases.checkWorkspaceStatus.execute({
30
30
  workspacePath,
31
31
  });
32
- // Load workspace config to get per-repo base branches
33
- const workspaceConfig = services.workspaceConfig.load(workspacePath);
34
32
  // Phase 2: Clear Phase 1 lines and re-render with full status
35
- logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: statusResult.statuses }], loadingLines, (_, repoName) => workspaceConfig.baseBranches[repoName] || 'master', services.console);
33
+ logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: statusResult.statuses }], loadingLines, services.console);
36
34
  // Block if any repos have uncommitted changes — user must resolve them first
37
35
  const reposWithIssues = statusResult.statuses.filter(({ status }) => StatusService.hasIssues(status));
38
36
  if (reposWithIssues.length > 0) {
@@ -95,6 +93,7 @@ export async function runDrop(branchName, useCases, services, deps) {
95
93
  export function registerDropCommand(program) {
96
94
  program
97
95
  .command('drop [branch-name]')
96
+ .helpGroup('Workspaces')
98
97
  .description('Drop a workspace and all its worktrees (auto-detects from current directory if branch not provided)')
99
98
  .action(async (branchName) => {
100
99
  const services = createServices();
@@ -28,6 +28,7 @@ export async function runFetch(branchName, useCases, services) {
28
28
  export function registerFetchCommand(program) {
29
29
  program
30
30
  .command('fetch [branch-name]')
31
+ .helpGroup('Git Operations')
31
32
  .description('Fetch repos (workspace-scoped if branch provided, all workspaces otherwise)')
32
33
  .action(async (branchName) => {
33
34
  const services = createServices();
@@ -3,15 +3,11 @@ import { StatusService } from '../lib/status.js';
3
3
  /**
4
4
  * Format a single repo's status as a display line, consistent across list and status commands.
5
5
  */
6
- export function formatRepoStatusLine(repoName, status, baseBranch) {
7
- const statusMessage = StatusService.getStatusMessage(status, baseBranch);
6
+ export function formatRepoStatusLine(repoName, status) {
7
+ const statusMessage = StatusService.getStatusMessage(status);
8
8
  const hasIssues = StatusService.hasIssues(status);
9
- const indicator = hasIssues ? chalk.red('✗') : chalk.green('✓');
10
9
  const message = hasIssues ? chalk.red(statusMessage) : chalk.green(statusMessage);
11
- const trackingInfo = status.upstreamBranch
12
- ? chalk.dim(` → ${status.upstreamBranch}`)
13
- : chalk.dim(' (no upstream)');
14
- return ` ${indicator} ${chalk.yellow(repoName)}: ${message}${trackingInfo}`;
10
+ return ` ${chalk.yellow(repoName)}: ${message}`;
15
11
  }
16
12
  /**
17
13
  * Render Phase 1: print a header and workspace rows with a "fetching..." indicator.
@@ -30,7 +26,7 @@ export function logStatusFetching(header, workspaces, console) {
30
26
  /**
31
27
  * Render Phase 2: clear the Phase 1 lines then print the header and full workspace status.
32
28
  */
33
- export function logStatus(header, workspaces, linesToClear, getBaseBranch, console) {
29
+ export function logStatus(header, workspaces, linesToClear, console) {
34
30
  for (let i = 0; i < linesToClear; i++) {
35
31
  console.write('\x1b[1A'); // Move cursor up one line
36
32
  console.write('\x1b[2K'); // Clear entire line
@@ -41,8 +37,7 @@ export function logStatus(header, workspaces, linesToClear, getBaseBranch, conso
41
37
  const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
42
38
  console.log(`${activeIndicator}${chalk.cyan(workspace.name)} ${repoCount}`);
43
39
  for (const { repoName, status } of workspace.statuses) {
44
- const baseBranch = getBaseBranch(workspace.path, repoName);
45
- console.log(formatRepoStatusLine(repoName, status, baseBranch));
40
+ console.log(formatRepoStatusLine(repoName, status));
46
41
  }
47
42
  console.log('');
48
43
  }
@@ -27,12 +27,13 @@ export async function runList(useCases, services) {
27
27
  cwd,
28
28
  });
29
29
  // Phase 4: Clear previous output and re-print with full status
30
- logStatus('Workspaces:', result.workspaces, loadingLines, (wsPath, repoName) => services.workspaceConfig.load(wsPath).baseBranches[repoName] || 'master', services.console);
30
+ logStatus('Workspaces:', result.workspaces, loadingLines, services.console);
31
31
  }
32
32
  export function registerListCommand(program) {
33
33
  program
34
34
  .command('list')
35
35
  .alias('ls')
36
+ .helpGroup('Workspaces')
36
37
  .description('List all workspaces with status indicators')
37
38
  .action(async () => {
38
39
  const services = createServices();
@@ -31,15 +31,15 @@ export async function runPrune(useCases, services, deps) {
31
31
  cwd,
32
32
  });
33
33
  // Phase 2: Clear Phase 1 and re-print with full status (same as `list` command)
34
- logStatus('Workspaces:', result.workspaces, loadingLines, (wsPath, repoName) => services.workspaceConfig.load(wsPath).baseBranches[repoName] || 'master', services.console);
34
+ logStatus('Workspaces:', result.workspaces, loadingLines, services.console);
35
35
  // Partition workspaces into prunable (no issues) and skipped (has issues)
36
36
  const prunableWorkspaces = result.workspaces.filter(ws => !ws.statuses.some(({ status }) => StatusService.hasIssues(status)));
37
37
  const skippedWorkspaces = result.workspaces.filter(ws => ws.statuses.some(({ status }) => StatusService.hasIssues(status)));
38
38
  // Log skipped workspaces with reason
39
39
  for (const ws of skippedWorkspaces) {
40
- const hasUncommitted = ws.statuses.some(s => s.status.type === 'uncommitted');
40
+ const hasDirty = ws.statuses.some(s => s.status.type === 'dirty');
41
41
  const hasError = ws.statuses.some(s => s.status.type === 'error');
42
- const reason = hasUncommitted ? 'uncommitted changes' : hasError ? 'errors' : 'issues';
42
+ const reason = hasDirty ? 'uncommitted changes' : hasError ? 'errors' : 'issues';
43
43
  services.console.log(`${chalk.yellow('⚠')} Skipping ${chalk.cyan(ws.name)} (${reason})`);
44
44
  }
45
45
  // Newline if there were skipped workspaces
@@ -112,6 +112,7 @@ export async function runPrune(useCases, services, deps) {
112
112
  export function registerPruneCommand(program) {
113
113
  program
114
114
  .command('prune')
115
+ .helpGroup('Workspaces')
115
116
  .description('Select and remove workspaces')
116
117
  .action(async () => {
117
118
  const services = createServices();
@@ -16,6 +16,7 @@ export async function runPull(branchName, useCases, services) {
16
16
  export function registerPullCommand(program) {
17
17
  program
18
18
  .command('pull [branch-name]')
19
+ .helpGroup('Git Operations')
19
20
  .description('Pull all repos in a workspace (auto-detects from current directory if branch not provided)')
20
21
  .action(async (branchName) => {
21
22
  const services = createServices();
@@ -16,6 +16,7 @@ export async function runPush(branchName, useCases, services) {
16
16
  export function registerPushCommand(program) {
17
17
  program
18
18
  .command('push [branch-name]')
19
+ .helpGroup('Git Operations')
19
20
  .description('Push all repos in a workspace (auto-detects from current directory if branch not provided)')
20
21
  .action(async (branchName) => {
21
22
  const services = createServices();
@@ -143,6 +143,7 @@ export async function runQuickstart(services, deps) {
143
143
  export function registerQuickstartCommand(program) {
144
144
  program
145
145
  .command('quickstart')
146
+ .helpGroup('Getting Started')
146
147
  .description('Interactive setup wizard for first-time configuration')
147
148
  .action(async () => {
148
149
  const services = createServices();
@@ -26,14 +26,13 @@ export async function runStatus(branchName, useCases, services) {
26
26
  const result = await useCases.checkWorkspaceStatus.execute({
27
27
  workspacePath,
28
28
  });
29
- // Load workspace config to get per-repo base branches
30
- const workspaceConfig = services.workspaceConfig.load(workspacePath);
31
29
  // Phase 2: Clear Phase 1 lines and re-render with full status
32
- logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: result.statuses }], loadingLines, (_, repoName) => workspaceConfig.baseBranches[repoName] || 'master', services.console);
30
+ logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: result.statuses }], loadingLines, services.console);
33
31
  }
34
32
  export function registerStatusCommand(program) {
35
33
  program
36
34
  .command('status [branch-name]')
35
+ .helpGroup('Git Operations')
37
36
  .description('Show status of all worktrees in a workspace (auto-detects from current directory if branch not provided)')
38
37
  .action(async (branchName) => {
39
38
  const services = createServices();
@@ -27,6 +27,7 @@ export async function runTmuxSync(useCases, services) {
27
27
  export function registerTmuxCommand(program) {
28
28
  const tmuxCommand = program
29
29
  .command('tmux')
30
+ .helpGroup('Extras')
30
31
  .description('Manage tmux sessions for workspaces');
31
32
  tmuxCommand
32
33
  .command('sync')
package/dist/lib/git.js CHANGED
@@ -36,6 +36,15 @@ export class GitService {
36
36
  }
37
37
  }
38
38
  }
39
+ async remoteTrackingBranchExists(repoPath, branch) {
40
+ try {
41
+ await this.exec(repoPath, ['rev-parse', '--verify', `origin/${branch}`]);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
39
48
  async findFirstExistingBranch(repoPath, candidates) {
40
49
  for (const branch of candidates) {
41
50
  const exists = await this.localRemoteBranchExists(repoPath, branch);
@@ -76,55 +85,36 @@ export class GitService {
76
85
  return null;
77
86
  }
78
87
  }
79
- async hasUncommittedChanges(repoPath) {
88
+ async getStatusCounts(repoPath) {
80
89
  const output = await this.exec(repoPath, ['status', '--porcelain']);
81
- return output.length > 0;
82
- }
83
- async originBranchExists(repoPath) {
84
- try {
85
- const branch = await this.getCurrentBranch(repoPath);
86
- await this.exec(repoPath, ['rev-parse', '--verify', `origin/${branch}`]);
87
- return true;
88
- }
89
- catch {
90
- return false;
91
- }
92
- }
93
- async isAheadOfOrigin(repoPath) {
94
- try {
95
- const branch = await this.getCurrentBranch(repoPath);
96
- const output = await this.exec(repoPath, ['rev-list', '--count', `origin/${branch}..HEAD`]);
97
- return parseInt(output, 10) > 0;
98
- }
99
- catch {
100
- return false;
90
+ const lines = output.split('\n').filter(line => line.length > 0);
91
+ let untracked = 0;
92
+ let uncommitted = 0;
93
+ for (const line of lines) {
94
+ if (line.startsWith('??')) {
95
+ untracked++;
96
+ }
97
+ else {
98
+ uncommitted++;
99
+ }
101
100
  }
101
+ return { untracked, uncommitted };
102
102
  }
103
- async isBehindOrigin(repoPath) {
103
+ async getUnpushedCommitCount(repoPath) {
104
104
  try {
105
105
  const branch = await this.getCurrentBranch(repoPath);
106
- const output = await this.exec(repoPath, ['rev-list', '--count', `HEAD..origin/${branch}`]);
107
- return parseInt(output, 10) > 0;
108
- }
109
- catch {
110
- return false;
111
- }
112
- }
113
- async isAheadOfMain(repoPath, mainBranch) {
114
- try {
115
- // Use git cherry to detect if commits exist in main via patch equivalence
116
- // cherry outputs nothing if all commits are equivalent to main
117
- // Format: "+ hash message" for unmerged, "- hash message" for already merged
118
- const output = await this.exec(repoPath, ['cherry', `origin/${mainBranch}`, 'HEAD']);
119
- // Filter to only unmerged commits (those starting with +)
120
- const unmergedCommits = output
121
- .split('\n')
122
- .filter(line => line.trim().startsWith('+'));
123
- return unmergedCommits.length > 0;
106
+ try {
107
+ const output = await this.exec(repoPath, ['rev-list', '--count', `origin/${branch}..HEAD`]);
108
+ return parseInt(output.trim(), 10);
109
+ }
110
+ catch {
111
+ // origin/<branch> doesn't exist yet — count commits not reachable from any remote ref
112
+ const output = await this.exec(repoPath, ['rev-list', '--count', 'HEAD', '--not', '--remotes']);
113
+ return parseInt(output.trim(), 10);
114
+ }
124
115
  }
125
116
  catch {
126
- // If we can't determine, assume there are changes to be safe
127
- return true;
117
+ return 0;
128
118
  }
129
119
  }
130
120
  async createBranch(repoPath, branchName, startPoint) {
@@ -6,54 +6,56 @@ export class StatusService {
6
6
  constructor(git) {
7
7
  this.git = git;
8
8
  }
9
- async getWorktreeStatus(worktreePath, baseBranch) {
9
+ async getWorktreeStatus(worktreePath) {
10
10
  try {
11
- // Get branch information
12
11
  const currentBranch = await this.git.getCurrentBranch(worktreePath);
13
12
  const upstreamBranch = await this.git.getUpstreamBranch(worktreePath);
14
- // Check for uncommitted changes first
15
- const hasUncommitted = await this.git.hasUncommittedChanges(worktreePath);
16
- if (hasUncommitted) {
17
- return { type: 'uncommitted', currentBranch, upstreamBranch };
18
- }
19
- // Compare against base branch using git cherry (handles squash merges)
20
- const isAhead = await this.git.isAheadOfMain(worktreePath, baseBranch);
21
- if (isAhead) {
22
- return { type: 'ahead', comparedTo: 'main', currentBranch, upstreamBranch };
23
- }
24
- return { type: 'clean', comparedTo: 'main', currentBranch, upstreamBranch };
13
+ const { untracked, uncommitted } = await this.git.getStatusCounts(worktreePath);
14
+ const unpushed = await this.git.getUnpushedCommitCount(worktreePath);
15
+ const type = untracked > 0 || uncommitted > 0 ? 'dirty' : 'clean';
16
+ return { type, untracked, uncommitted, unpushed, currentBranch, upstreamBranch };
25
17
  }
26
18
  catch (err) {
27
19
  return {
28
20
  type: 'error',
21
+ untracked: 0,
22
+ uncommitted: 0,
23
+ unpushed: 0,
29
24
  error: err.stderr || err.message,
30
25
  };
31
26
  }
32
27
  }
33
- static getStatusMessage(status, baseBranch) {
34
- switch (status.type) {
35
- case 'clean':
36
- return 'up to date';
37
- case 'uncommitted':
38
- return 'uncommitted changes';
39
- case 'ahead':
40
- return `ahead of ${baseBranch}`;
41
- case 'error':
42
- return `error: ${status.error}`;
28
+ static getStatusMessage(status) {
29
+ if (status.type === 'error') {
30
+ return `error: ${status.error}`;
43
31
  }
32
+ if (status.type === 'clean' && status.unpushed === 0) {
33
+ return 'clean';
34
+ }
35
+ const parts = [];
36
+ if (status.untracked > 0) {
37
+ parts.push(`${status.untracked} untracked`);
38
+ }
39
+ if (status.uncommitted > 0) {
40
+ parts.push(`${status.uncommitted} modified`);
41
+ }
42
+ if (status.unpushed > 0) {
43
+ parts.push(`${status.unpushed} unpushed commit${status.unpushed === 1 ? '' : 's'}`);
44
+ }
45
+ return parts.length > 0 ? parts.join(', ') : 'clean';
44
46
  }
45
47
  static hasIssues(status) {
46
- return (status.type === 'uncommitted' ||
48
+ return (status.untracked > 0 ||
49
+ status.uncommitted > 0 ||
47
50
  status.type === 'error');
48
51
  }
49
52
  /**
50
53
  * Check status of all worktrees in parallel
51
54
  */
52
- async checkAllWorktrees(worktreeDirs, getBaseBranch) {
55
+ async checkAllWorktrees(worktreeDirs) {
53
56
  const results = await Promise.all(worktreeDirs.map(async (worktreePath) => {
54
57
  const repoName = worktreePath.split('/').pop() || worktreePath;
55
- const baseBranch = getBaseBranch(repoName);
56
- const status = await this.getWorktreeStatus(worktreePath, baseBranch);
58
+ const status = await this.getWorktreeStatus(worktreePath);
57
59
  return { repoName, status };
58
60
  }));
59
61
  return results;
@@ -61,8 +63,8 @@ export class StatusService {
61
63
  /**
62
64
  * Find repos with issues (for removal validation)
63
65
  */
64
- async findReposWithIssues(worktreeDirs, getBaseBranch) {
65
- const results = await this.checkAllWorktrees(worktreeDirs, getBaseBranch);
66
+ async findReposWithIssues(worktreeDirs) {
67
+ const results = await this.checkAllWorktrees(worktreeDirs);
66
68
  return results
67
69
  .filter(({ status }) => StatusService.hasIssues(status))
68
70
  .map(({ repoName }) => repoName);
package/dist/lib/tmux.js CHANGED
@@ -54,18 +54,14 @@ export class TmuxService {
54
54
  ]);
55
55
  }
56
56
  async addPane(sessionName, worktreePath) {
57
- await this.shell.execFile('tmux', [
57
+ const { stdout } = await this.shell.execFile('tmux', [
58
58
  'split-window',
59
59
  '-t',
60
60
  sessionName,
61
61
  '-c',
62
62
  worktreePath,
63
- ]);
64
- const { stdout } = await this.shell.execFile('tmux', [
65
- 'display-message',
66
- '-p',
67
- '-t',
68
- sessionName,
63
+ '-P',
64
+ '-F',
69
65
  '#{pane_index}',
70
66
  ]);
71
67
  await this.shell.execFile('tmux', [
@@ -23,6 +23,12 @@ export class WorkspaceDirectoryService {
23
23
  this.fs.copyFileSync(agentsPath, path.join(workspacePath, 'AGENTS.md'));
24
24
  }
25
25
  }
26
+ copyDevcontainer(sourcePath, workspacePath) {
27
+ const devcontainerPath = path.join(sourcePath, '.devcontainer');
28
+ if (this.fs.existsSync(devcontainerPath)) {
29
+ this.fs.cpSync(devcontainerPath, path.join(workspacePath, '.devcontainer'), { recursive: true });
30
+ }
31
+ }
26
32
  detectWorkspace(cwd, destPath) {
27
33
  const normalizedCwd = path.resolve(cwd);
28
34
  const normalizedDest = path.resolve(destPath);
@@ -3,19 +3,14 @@
3
3
  */
4
4
  export class CheckWorkspaceStatusUseCase {
5
5
  workspaceDir;
6
- workspaceConfig;
7
6
  status;
8
- constructor(workspaceDir, workspaceConfig, status) {
7
+ constructor(workspaceDir, status) {
9
8
  this.workspaceDir = workspaceDir;
10
- this.workspaceConfig = workspaceConfig;
11
9
  this.status = status;
12
10
  }
13
11
  async execute(params) {
14
12
  const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
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);
13
+ const statuses = await this.status.checkAllWorktrees(worktreeDirs);
19
14
  return { statuses };
20
15
  }
21
16
  }
@@ -25,8 +25,10 @@ export class CreateBranchUseCase {
25
25
  // 3. If the target branch already exists, skip creation and use it as-is
26
26
  const targetBranchExists = await this.git.localRemoteBranchExists(params.repoPath, params.branchName);
27
27
  if (!targetBranchExists) {
28
- // 4. Create the branch from origin/<actualBaseBranch>
29
- await this.git.createBranch(params.repoPath, params.branchName, `origin/${actualBaseBranch}`);
28
+ // 4. Prefer origin/<actualBaseBranch> as start point; fall back to local branch
29
+ const originExists = await this.git.remoteTrackingBranchExists(params.repoPath, actualBaseBranch);
30
+ const startPoint = originExists ? `origin/${actualBaseBranch}` : actualBaseBranch;
31
+ await this.git.createBranch(params.repoPath, params.branchName, startPoint);
30
32
  }
31
33
  // 5. Return the actual base branch used
32
34
  return {
@@ -1,6 +1,6 @@
1
1
  /**
2
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).
3
+ * .devcontainer copy, and an optional tmux session (root pane only, no worktrees yet).
4
4
  */
5
5
  export class CreateWorkspaceUseCase {
6
6
  workspaceDir;
@@ -18,7 +18,9 @@ export class CreateWorkspaceUseCase {
18
18
  this.workspaceConfig.savePlaceholder(workspacePath);
19
19
  // 3. Copy AGENTS.md if it exists in source-path
20
20
  this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
21
- // 4. Create tmux session (root pane only) if enabled
21
+ // 4. Copy .devcontainer if it exists in source-path
22
+ this.workspaceDir.copyDevcontainer(params.sourcePath, workspacePath);
23
+ // 5. Create tmux session (root pane only) if enabled
22
24
  let tmuxCreated = false;
23
25
  if (params.tmux) {
24
26
  try {
@@ -5,12 +5,10 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
5
5
  */
6
6
  export class DiscoverPrunableWorkspacesUseCase {
7
7
  workspaceDir;
8
- workspaceConfig;
9
8
  status;
10
9
  git;
11
- constructor(workspaceDir, workspaceConfig, status, git) {
10
+ constructor(workspaceDir, status, git) {
12
11
  this.workspaceDir = workspaceDir;
13
- this.workspaceConfig = workspaceConfig;
14
12
  this.status = status;
15
13
  this.git = git;
16
14
  }
@@ -25,11 +23,8 @@ export class DiscoverPrunableWorkspacesUseCase {
25
23
  if (!worktreeDirs || worktreeDirs.length === 0) {
26
24
  continue;
27
25
  }
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';
31
26
  // Check status for all worktrees
32
- const statuses = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
27
+ const statuses = await this.status.checkAllWorktrees(worktreeDirs);
33
28
  // Skip if any worktree has issues
34
29
  const hasAnyIssues = statuses.some(({ status }) => StatusServiceClass.hasIssues(status));
35
30
  if (hasAnyIssues) {
@@ -4,11 +4,9 @@
4
4
  */
5
5
  export class ListWorkspacesWithStatusUseCase {
6
6
  workspaceDir;
7
- workspaceConfig;
8
7
  status;
9
- constructor(workspaceDir, workspaceConfig, status) {
8
+ constructor(workspaceDir, status) {
10
9
  this.workspaceDir = workspaceDir;
11
- this.workspaceConfig = workspaceConfig;
12
10
  this.status = status;
13
11
  }
14
12
  async execute(params) {
@@ -26,11 +24,8 @@ export class ListWorkspacesWithStatusUseCase {
26
24
  }
27
25
  // Check if this workspace is active (contains current working directory)
28
26
  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';
32
27
  // Check status for all worktrees
33
- const statuses = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
28
+ const statuses = await this.status.checkAllWorktrees(worktreeDirs);
34
29
  workspacesWithStatus.push({
35
30
  name: workspace.name,
36
31
  path: workspace.path,
@@ -7,14 +7,12 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
7
7
  */
8
8
  export class RemoveWorkspaceUseCase {
9
9
  workspaceDir;
10
- workspaceConfig;
11
10
  worktree;
12
11
  repos;
13
12
  status;
14
13
  tmux;
15
- constructor(workspaceDir, workspaceConfig, worktree, repos, status, tmux) {
14
+ constructor(workspaceDir, worktree, repos, status, tmux) {
16
15
  this.workspaceDir = workspaceDir;
17
- this.workspaceConfig = workspaceConfig;
18
16
  this.worktree = worktree;
19
17
  this.repos = repos;
20
18
  this.status = status;
@@ -25,16 +23,12 @@ export class RemoveWorkspaceUseCase {
25
23
  const issuesFound = [];
26
24
  // 1. Check status if worktrees exist
27
25
  if (worktreeDirs.length > 0) {
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);
26
+ const results = await this.status.checkAllWorktrees(worktreeDirs);
32
27
  for (const { repoName, status } of results) {
33
28
  if (StatusServiceClass.hasIssues(status)) {
34
- const baseBranch = getBaseBranch(repoName);
35
29
  issuesFound.push({
36
30
  repoName,
37
- issue: StatusServiceClass.getStatusMessage(status, baseBranch),
31
+ issue: StatusServiceClass.getStatusMessage(status),
38
32
  });
39
33
  }
40
34
  }
@@ -21,12 +21,12 @@ export function createUseCases(services) {
21
21
  fetchAllRepos: new FetchAllReposUseCase(services.fetch, services.repos),
22
22
  fetchWorkspaceRepos: new FetchWorkspaceReposUseCase(services.workspaceDir, services.fetch),
23
23
  fetchUsedRepos: new FetchUsedReposUseCase(services.workspaceDir, services.fetch),
24
- removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.status, services.tmux),
24
+ removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.status, services.tmux),
25
25
  pushWorkspace: new PushWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
26
26
  pullWorkspace: new PullWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
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),
27
+ checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.status),
28
+ discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.status, services.git),
29
+ listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.status),
30
30
  resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux),
31
31
  createWorkspace: new CreateWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.tmux),
32
32
  createBranch: new CreateBranchUseCase(services.git),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-flow",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "description": "Manage git worktrees across a poly-repo environment",
5
5
  "type": "module",
6
6
  "bin": {