worktree-flow 0.0.9 → 0.0.11

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
@@ -80,15 +80,15 @@ Fetches all repos before checking status to ensure accurate information. The sta
80
80
 
81
81
  ### `flow remove <name>`
82
82
 
83
- Remove a workspace and all its worktrees. Fetches latest, checks for uncommitted changes and unpushed commits, and prompts for confirmation before removing.
83
+ Remove a workspace and all its worktrees. Fetches latest, checks for uncommitted changes, and prompts for confirmation before removing. Committed changes are safe to remove since they're preserved in git history.
84
84
 
85
85
  ### `flow prune` (alias: `clean`)
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
+ 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.
88
88
 
89
89
  ### `flow tmux resume`
90
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.
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 that matches a repo from source-path) using a tiled layout. Skips workspaces that already have active sessions.
92
92
 
93
93
  Requires `tmux` to be installed and the `tmux` config option to be enabled.
94
94
 
@@ -0,0 +1,32 @@
1
+ import chalk from 'chalk';
2
+ /**
3
+ * Get a human-readable status indicator for a workspace based on its worktree statuses.
4
+ */
5
+ export function getStatusIndicator(workspace) {
6
+ const hasUncommitted = workspace.statuses.some(s => s.status.type === 'uncommitted');
7
+ const hasAhead = workspace.statuses.some(s => s.status.type === 'ahead');
8
+ const hasBehind = workspace.statuses.some(s => s.status.type === 'behind');
9
+ const hasDiverged = workspace.statuses.some(s => s.status.type === 'diverged');
10
+ const hasError = workspace.statuses.some(s => s.status.type === 'error');
11
+ if (hasUncommitted) {
12
+ return chalk.yellow('uncommitted');
13
+ }
14
+ else if (hasDiverged) {
15
+ return chalk.red('diverged');
16
+ }
17
+ else if (hasAhead && hasBehind) {
18
+ return chalk.yellow('ahead');
19
+ }
20
+ else if (hasAhead) {
21
+ return chalk.yellow('ahead');
22
+ }
23
+ else if (hasBehind) {
24
+ return chalk.blue('behind');
25
+ }
26
+ else if (hasError) {
27
+ return chalk.red('error');
28
+ }
29
+ else {
30
+ return chalk.green('clean');
31
+ }
32
+ }
@@ -1,34 +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
- function getStatusIndicator(workspace) {
5
- const hasUncommitted = workspace.statuses.some(s => s.status.type === 'uncommitted');
6
- const hasAhead = workspace.statuses.some(s => s.status.type === 'ahead');
7
- const hasBehind = workspace.statuses.some(s => s.status.type === 'behind');
8
- const hasDiverged = workspace.statuses.some(s => s.status.type === 'diverged');
9
- const hasError = workspace.statuses.some(s => s.status.type === 'error');
10
- if (hasUncommitted) {
11
- return chalk.yellow('uncommitted');
12
- }
13
- else if (hasDiverged) {
14
- return chalk.red('diverged');
15
- }
16
- else if (hasAhead && hasBehind) {
17
- return chalk.yellow('ahead');
18
- }
19
- else if (hasAhead) {
20
- return chalk.yellow('ahead');
21
- }
22
- else if (hasBehind) {
23
- return chalk.blue('behind');
24
- }
25
- else if (hasError) {
26
- return chalk.red('error');
27
- }
28
- else {
29
- return chalk.green('clean');
30
- }
31
- }
4
+ import { getStatusIndicator } from './helpers.js';
32
5
  export async function runList(useCases, services) {
33
6
  const { destPath, sourcePath } = services.config.getRequired();
34
7
  const config = services.config.load();
@@ -1,43 +1,55 @@
1
1
  import chalk from 'chalk';
2
+ import checkbox from '@inquirer/checkbox';
2
3
  import confirm from '@inquirer/confirm';
3
4
  import { createServices } from '../lib/services.js';
4
5
  import { createUseCases } from '../usecases/usecases.js';
6
+ import { getStatusIndicator } from './helpers.js';
5
7
  export async function runPrune(useCases, services, deps) {
6
8
  const { sourcePath, destPath } = services.config.getRequired();
7
9
  const config = services.config.load();
8
- services.console.log('Analyzing workspaces for pruning...\n');
10
+ const cwd = services.process.cwd();
11
+ // Check if workspaces exist
12
+ const basicWorkspaces = services.workspaceDir.listWorkspaces(destPath);
13
+ if (basicWorkspaces.length === 0) {
14
+ services.console.log('No workspaces found.');
15
+ services.process.exit(0);
16
+ }
9
17
  // Fetch repos used across all workspaces
10
18
  await useCases.fetchUsedRepos.execute({
11
19
  destPath,
12
20
  sourcePath,
13
21
  fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
14
22
  });
15
- // Analyze workspaces to find prunable ones
16
- const result = await useCases.discoverPrunableWorkspaces.execute({
23
+ // Get full status for all workspaces
24
+ const result = await useCases.listWorkspacesWithStatus.execute({
17
25
  destPath,
18
26
  sourcePath,
19
- daysOld: 7,
27
+ cwd,
28
+ });
29
+ // Format choices for checkbox prompt
30
+ const choices = result.workspaces.map(workspace => {
31
+ const statusIndicator = getStatusIndicator(workspace);
32
+ const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
33
+ return {
34
+ name: `${chalk.cyan(workspace.name)} ${repoCount} ${statusIndicator}`,
35
+ value: workspace.name,
36
+ };
20
37
  });
21
- if (result.prunable.length === 0) {
22
- services.console.log('No workspaces to prune.');
23
- services.console.log(`\nWorkspaces are prunable when:`);
24
- services.console.log(` - All worktrees have no uncommitted changes`);
25
- services.console.log(` - All worktrees are not ahead of their base branch`);
26
- services.console.log(` - Last commit is more than 7 days old`);
38
+ // Let user select workspaces to prune
39
+ const selected = await deps.checkbox({
40
+ message: 'Select workspaces to prune:',
41
+ choices,
42
+ pageSize: 20,
43
+ });
44
+ if (selected.length === 0) {
45
+ services.console.log('No workspaces selected.');
27
46
  services.process.exit(0);
28
47
  }
29
- // Display prunable workspaces
30
- services.console.log(`${chalk.yellow('Found')} ${chalk.cyan(result.prunable.length)} ${chalk.yellow('workspace(s) that can be pruned:')}\n`);
31
- for (const workspace of result.prunable) {
32
- const daysOld = Math.floor((Date.now() - workspace.oldestCommitDate.getTime()) / (1000 * 60 * 60 * 24));
33
- services.console.log(` ${chalk.cyan(workspace.name)}`);
34
- services.console.log(` Repos: ${workspace.repoCount}`);
35
- services.console.log(` Last commit: ${daysOld} days ago`);
36
- services.console.log(` Status: ${chalk.green('clean')}`);
37
- services.console.log('');
38
- }
48
+ // Get full workspace objects for selected items
49
+ const selectedWorkspaces = result.workspaces.filter(w => selected.includes(w.name));
50
+ // Confirm deletion
39
51
  const confirmed = await deps.confirm({
40
- message: 'Are you sure you want to prune these workspaces?',
52
+ message: `Are you sure you want to prune ${selected.length} workspace(s)?`,
41
53
  default: false,
42
54
  });
43
55
  if (!confirmed) {
@@ -48,7 +60,7 @@ export async function runPrune(useCases, services, deps) {
48
60
  services.console.log('\nPruning workspaces...\n');
49
61
  let successCount = 0;
50
62
  let errorCount = 0;
51
- for (const workspace of result.prunable) {
63
+ for (const workspace of selectedWorkspaces) {
52
64
  try {
53
65
  services.console.log(`Removing ${chalk.cyan(workspace.name)}...`);
54
66
  await useCases.removeWorkspace.execute({
@@ -78,12 +90,12 @@ export function registerPruneCommand(program) {
78
90
  program
79
91
  .command('prune')
80
92
  .alias('clean')
81
- .description('Remove old workspaces with no uncommitted changes and commits older than 7 days')
93
+ .description('Select and remove workspaces')
82
94
  .action(async () => {
83
95
  const services = createServices();
84
96
  const useCases = createUseCases(services);
85
97
  try {
86
- await runPrune(useCases, services, { confirm });
98
+ await runPrune(useCases, services, { checkbox, confirm });
87
99
  }
88
100
  catch (error) {
89
101
  services.console.error(error.message);
@@ -18,7 +18,7 @@ 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 base branch...`);
21
+ services.console.log(`\nChecking for uncommitted changes...`);
22
22
  // Load workspace config to get per-repo base branches
23
23
  const workspaceConfig = services.workspaceConfig.load(workspacePath);
24
24
  const getBaseBranch = (repoName) => workspaceConfig.baseBranches[repoName] || 'master';
@@ -41,7 +41,6 @@ export class StatusService {
41
41
  }
42
42
  static hasIssues(status) {
43
43
  return (status.type === 'uncommitted' ||
44
- status.type === 'ahead' ||
45
44
  status.type === 'error');
46
45
  }
47
46
  /**
@@ -45,7 +45,7 @@ export class CreateBranchWorkspaceUseCase {
45
45
  }
46
46
  // Track the actual base branch used
47
47
  baseBranches[name] = actualBaseBranch;
48
- await this.worktree.createWorktreeWithBranch(repoPath, worktreeDest, params.branchName, actualBaseBranch);
48
+ await this.worktree.createWorktreeWithBranch(repoPath, worktreeDest, params.branchName, `origin/${actualBaseBranch}`);
49
49
  this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
50
50
  return 'created';
51
51
  });
@@ -39,7 +39,7 @@ export class RemoveWorkspaceUseCase {
39
39
  }
40
40
  }
41
41
  if (issuesFound.length > 0) {
42
- throw new WorkspaceHasIssuesError(`${issuesFound.length} repo(s) have uncommitted or unmerged changes.`);
42
+ throw new WorkspaceHasIssuesError(`${issuesFound.length} repo(s) have uncommitted changes or errors.`);
43
43
  }
44
44
  }
45
45
  // 2. Remove all worktrees
@@ -1,26 +1,50 @@
1
+ import path from 'node:path';
1
2
  /**
2
3
  * Use case for resuming tmux sessions across all workspaces.
3
4
  * Creates sessions for workspaces that don't already have one.
5
+ * Only creates splits for directories that match repos from source-path.
4
6
  */
5
7
  export class ResumeTmuxSessionsUseCase {
6
8
  workspaceDir;
7
9
  tmux;
8
- constructor(workspaceDir, tmux) {
10
+ repos;
11
+ config;
12
+ constructor(workspaceDir, tmux, repos, config) {
9
13
  this.workspaceDir = workspaceDir;
10
14
  this.tmux = tmux;
15
+ this.repos = repos;
16
+ this.config = config;
11
17
  }
12
18
  async execute(params) {
13
19
  // 1. List all workspaces
14
20
  const workspaces = this.workspaceDir.listWorkspaces(params.destPath);
21
+ // Early return if no workspaces
22
+ if (workspaces.length === 0) {
23
+ return {
24
+ totalWorkspaces: 0,
25
+ sessionsCreated: 0,
26
+ sessionsSkipped: 0,
27
+ errors: [],
28
+ };
29
+ }
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)));
15
34
  let sessionsCreated = 0;
16
35
  let sessionsSkipped = 0;
17
36
  const errors = [];
18
- // 2. Try to create tmux session for each workspace
37
+ // 3. Try to create tmux session for each workspace
19
38
  for (const workspace of workspaces) {
20
39
  try {
21
- const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
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
+ });
22
46
  // Try to create session - will throw on duplicate session
23
- await this.tmux.createSession(workspace.path, workspace.name, worktreeDirs);
47
+ await this.tmux.createSession(workspace.path, workspace.name, filteredWorktreeDirs);
24
48
  sessionsCreated++;
25
49
  }
26
50
  catch (error) {
@@ -27,6 +27,6 @@ export function createUseCases(services) {
27
27
  checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
28
28
  discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.workspaceConfig, services.status, services.git),
29
29
  listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
30
- resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux),
30
+ resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux, services.repos, services.config),
31
31
  };
32
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-flow",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Manage git worktrees across a poly-repo environment",
5
5
  "type": "module",
6
6
  "bin": {