worktree-flow 0.0.18 → 0.0.20

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
@@ -87,7 +87,7 @@ Drop a workspace and all its worktrees. Fetches latest, checks for uncommitted c
87
87
 
88
88
  ### `flow prune`
89
89
 
90
- Remove workspaces interactively. Shows all workspaces with their status (similar to `flow list`) and lets you select which ones to prune. Only workspaces with no uncommitted changes can be successfully removed.
90
+ Remove workspaces interactively. Displays full workspace status (identical to `flow list`), then excludes any workspaces with uncommitted changes or errors from the selection prompt. Resolve issues before pruning those workspaces.
91
91
 
92
92
  ### `flow fetch [name]`
93
93
 
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();
@@ -101,6 +101,7 @@ export async function runCheckout(branchName, useCases, services, deps) {
101
101
  export function registerCheckoutCommand(program) {
102
102
  program
103
103
  .command('checkout <branch-name>')
104
+ .helpGroup('Workspaces')
104
105
  .description('Checkout an existing branch across repos')
105
106
  .action(async (branchName) => {
106
107
  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();
@@ -1,41 +1,44 @@
1
+ import path from 'node:path';
1
2
  import chalk from 'chalk';
2
3
  import confirm from '@inquirer/confirm';
3
4
  import { createServices } from '../lib/services.js';
4
5
  import { createUseCases } from '../usecases/usecases.js';
5
- import { StatusService } from '../lib/status.js';
6
6
  import { resolveWorkspace } from '../lib/workspaceResolver.js';
7
+ import { logStatusFetching, logStatus } from './helpers.js';
8
+ import { StatusService } from '../lib/status.js';
9
+ import { WorkspaceHasIssuesError } from '../lib/errors.js';
7
10
  export async function runDrop(branchName, useCases, services, deps) {
8
11
  const { sourcePath } = services.config.getRequired();
9
12
  const config = services.config.load();
10
13
  const { workspacePath, displayName: branchNameForDisplay } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
11
- services.console.log(`Checking workspace: ${chalk.cyan(workspacePath)}`);
12
14
  // Get worktree dirs to show what will be removed
13
15
  const worktreeDirs = services.workspaceDir.getWorktreeDirs(workspacePath);
14
16
  // Display status check if worktrees exist
15
17
  if (worktreeDirs.length > 0) {
18
+ const workspaceName = path.basename(workspacePath);
19
+ const repoCount = worktreeDirs.length;
20
+ // Phase 1: Show header with fetching indicator
21
+ const loadingLines = logStatusFetching('Workspace:', [{ name: workspaceName, repoCount }], services.console);
22
+ // Fetch workspace repos (silently)
16
23
  await useCases.fetchWorkspaceRepos.execute({
17
24
  workspacePath,
18
25
  sourcePath,
19
26
  fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
27
+ silent: true,
20
28
  });
21
- services.console.log(`\nChecking for uncommitted changes...`);
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);
26
- for (const { repoName, status } of results) {
27
- const baseBranch = getBaseBranch(repoName);
28
- const message = StatusService.getStatusMessage(status, baseBranch);
29
- if (StatusService.hasIssues(status)) {
30
- services.console.log(`${repoName}... ${chalk.red(message)}`);
31
- }
32
- else {
33
- services.console.log(`${repoName}... ${chalk.green(message)}`);
34
- }
29
+ const statusResult = await useCases.checkWorkspaceStatus.execute({
30
+ workspacePath,
31
+ });
32
+ // Phase 2: Clear Phase 1 lines and re-render with full status
33
+ logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: statusResult.statuses }], loadingLines, services.console);
34
+ // Block if any repos have uncommitted changes — user must resolve them first
35
+ const reposWithIssues = statusResult.statuses.filter(({ status }) => StatusService.hasIssues(status));
36
+ if (reposWithIssues.length > 0) {
37
+ throw new WorkspaceHasIssuesError(`${reposWithIssues.length} repo(s) have uncommitted changes or errors. Resolve them before dropping.`);
35
38
  }
36
39
  }
37
40
  else {
38
- services.console.log('No worktrees found in workspace.');
41
+ services.console.log('\nNo worktrees found in workspace.');
39
42
  }
40
43
  // Show what will be removed
41
44
  services.console.log(`\n${chalk.yellow('This will remove:')}`);
@@ -90,6 +93,7 @@ export async function runDrop(branchName, useCases, services, deps) {
90
93
  export function registerDropCommand(program) {
91
94
  program
92
95
  .command('drop [branch-name]')
96
+ .helpGroup('Workspaces')
93
97
  .description('Drop a workspace and all its worktrees (auto-detects from current directory if branch not provided)')
94
98
  .action(async (branchName) => {
95
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
  }
@@ -71,34 +66,3 @@ export function buildRepoCheckboxChoices(repos, services, preSelected, createSep
71
66
  }
72
67
  return choices;
73
68
  }
74
- /**
75
- * Get a human-readable status indicator for a workspace based on its worktree statuses.
76
- */
77
- export function getStatusIndicator(workspace) {
78
- const hasUncommitted = workspace.statuses.some(s => s.status.type === 'uncommitted');
79
- const hasAhead = workspace.statuses.some(s => s.status.type === 'ahead');
80
- const hasBehind = workspace.statuses.some(s => s.status.type === 'behind');
81
- const hasDiverged = workspace.statuses.some(s => s.status.type === 'diverged');
82
- const hasError = workspace.statuses.some(s => s.status.type === 'error');
83
- if (hasUncommitted) {
84
- return chalk.yellow('uncommitted');
85
- }
86
- else if (hasDiverged) {
87
- return chalk.red('diverged');
88
- }
89
- else if (hasAhead && hasBehind) {
90
- return chalk.yellow('ahead');
91
- }
92
- else if (hasAhead) {
93
- return chalk.yellow('ahead');
94
- }
95
- else if (hasBehind) {
96
- return chalk.blue('behind');
97
- }
98
- else if (hasError) {
99
- return chalk.red('error');
100
- }
101
- else {
102
- return chalk.green('clean');
103
- }
104
- }
@@ -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();
@@ -3,7 +3,8 @@ import checkbox from '@inquirer/checkbox';
3
3
  import confirm from '@inquirer/confirm';
4
4
  import { createServices } from '../lib/services.js';
5
5
  import { createUseCases } from '../usecases/usecases.js';
6
- import { getStatusIndicator } from './helpers.js';
6
+ import { logStatusFetching, logStatus } from './helpers.js';
7
+ import { StatusService } from '../lib/status.js';
7
8
  export async function runPrune(useCases, services, deps) {
8
9
  const { sourcePath, destPath } = services.config.getRequired();
9
10
  const config = services.config.load();
@@ -14,11 +15,14 @@ export async function runPrune(useCases, services, deps) {
14
15
  services.console.log('No workspaces found.');
15
16
  services.process.exit(0);
16
17
  }
18
+ // Phase 1: Show basic list with fetching indicator
19
+ const loadingLines = logStatusFetching('Workspaces:', basicWorkspaces, services.console);
17
20
  // Fetch repos used across all workspaces
18
21
  await useCases.fetchUsedRepos.execute({
19
22
  destPath,
20
23
  sourcePath,
21
24
  fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
25
+ silent: true,
22
26
  });
23
27
  // Get full status for all workspaces
24
28
  const result = await useCases.listWorkspacesWithStatus.execute({
@@ -26,12 +30,31 @@ export async function runPrune(useCases, services, deps) {
26
30
  sourcePath,
27
31
  cwd,
28
32
  });
29
- // Format choices for checkbox prompt
30
- const choices = result.workspaces.map(workspace => {
31
- const statusIndicator = getStatusIndicator(workspace);
33
+ // Phase 2: Clear Phase 1 and re-print with full status (same as `list` command)
34
+ logStatus('Workspaces:', result.workspaces, loadingLines, services.console);
35
+ // Partition workspaces into prunable (no issues) and skipped (has issues)
36
+ const prunableWorkspaces = result.workspaces.filter(ws => !ws.statuses.some(({ status }) => StatusService.hasIssues(status)));
37
+ const skippedWorkspaces = result.workspaces.filter(ws => ws.statuses.some(({ status }) => StatusService.hasIssues(status)));
38
+ // Log skipped workspaces with reason
39
+ for (const ws of skippedWorkspaces) {
40
+ const hasDirty = ws.statuses.some(s => s.status.type === 'dirty');
41
+ const hasError = ws.statuses.some(s => s.status.type === 'error');
42
+ const reason = hasDirty ? 'uncommitted changes' : hasError ? 'errors' : 'issues';
43
+ services.console.log(`${chalk.yellow('⚠')} Skipping ${chalk.cyan(ws.name)} (${reason})`);
44
+ }
45
+ // Newline if there were skipped workspaces
46
+ if (skippedWorkspaces.length)
47
+ console.log('');
48
+ // If no prunable workspaces remain, exit
49
+ if (prunableWorkspaces.length === 0) {
50
+ services.console.log('\nAll workspaces have uncommitted changes or errors. Resolve them before pruning.');
51
+ services.process.exit(0);
52
+ }
53
+ // Format choices for checkbox prompt (only prunable workspaces)
54
+ const choices = prunableWorkspaces.map(workspace => {
32
55
  const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
33
56
  return {
34
- name: `${chalk.cyan(workspace.name)} ${repoCount} ${statusIndicator}`,
57
+ name: `${chalk.cyan(workspace.name)} ${repoCount}`,
35
58
  value: workspace.name,
36
59
  };
37
60
  });
@@ -46,7 +69,7 @@ export async function runPrune(useCases, services, deps) {
46
69
  services.process.exit(0);
47
70
  }
48
71
  // Get full workspace objects for selected items
49
- const selectedWorkspaces = result.workspaces.filter(w => selected.includes(w.name));
72
+ const selectedWorkspaces = prunableWorkspaces.filter(w => selected.includes(w.name));
50
73
  // Confirm deletion
51
74
  const confirmed = await deps.confirm({
52
75
  message: `Are you sure you want to prune ${selected.length} workspace(s)?`,
@@ -89,6 +112,7 @@ export async function runPrune(useCases, services, deps) {
89
112
  export function registerPruneCommand(program) {
90
113
  program
91
114
  .command('prune')
115
+ .helpGroup('Workspaces')
92
116
  .description('Select and remove workspaces')
93
117
  .action(async () => {
94
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);
@@ -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 {
@@ -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.18",
3
+ "version": "0.0.20",
4
4
  "description": "Manage git worktrees across a poly-repo environment",
5
5
  "type": "module",
6
6
  "bin": {