worktree-flow 0.0.14 → 0.0.16

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
@@ -22,9 +22,8 @@ Stop juggling branches across repos. `flow` creates isolated workspaces with git
22
22
  ```bash
23
23
  npm install -g worktree-flow
24
24
 
25
- # Point flow at your repos and workspace directory
26
- flow config set source-path ~/repos
27
- flow config set dest-path ~/workspaces
25
+ # Guided setup wizard
26
+ flow quickstart
28
27
 
29
28
  # Create a workspace with new branches
30
29
  flow branch TICKET-123
@@ -104,7 +103,6 @@ Settings are stored in `~/.config/flow/config.json`.
104
103
  |-----|-------------|---------|
105
104
  | `source-path` | Directory containing your source repos | *required* |
106
105
  | `dest-path` | Directory where workspaces are created | *required* |
107
- | `main-branch` | Main branch name | `master` |
108
106
  | `tmux` | Create tmux sessions with split panes (root + each worktree) | `false` |
109
107
  | `copy-files` | Files to copy from source repos to worktrees | `.env` |
110
108
  | `post-checkout` | Command to run after checkout (e.g. `npm ci`) | *none* |
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { registerStatusCommand } from './commands/status.js';
11
11
  import { registerPruneCommand } from './commands/prune.js';
12
12
  import { registerFetchCommand } from './commands/fetch.js';
13
13
  import { registerTmuxCommand } from './commands/tmux.js';
14
+ import { registerQuickstartCommand } from './commands/quickstart.js';
14
15
  const program = new Command();
15
16
  program
16
17
  .name('flow')
@@ -27,4 +28,5 @@ registerStatusCommand(program);
27
28
  registerPruneCommand(program);
28
29
  registerFetchCommand(program);
29
30
  registerTmuxCommand(program);
31
+ registerQuickstartCommand(program);
30
32
  program.parse();
@@ -1,4 +1,5 @@
1
- import checkbox from '@inquirer/checkbox';
1
+ import path from 'node:path';
2
+ import checkbox, { Separator } from '@inquirer/checkbox';
2
3
  import input from '@inquirer/input';
3
4
  import confirm from '@inquirer/confirm';
4
5
  import chalk from 'chalk';
@@ -13,10 +14,30 @@ export async function runBranch(branchName, useCases, services, deps) {
13
14
  throw new NoReposFoundError(sourcePath);
14
15
  }
15
16
  // User prompts
17
+ const choices = services.repos.formatRepoChoices(repos).map((choice) => ({
18
+ ...choice,
19
+ checked: config.branchAutoSelectRepos.includes(choice.name),
20
+ }));
21
+ const recentlyUsed = new Set(services.fetchCache.getRecentlyUsedRepos(8));
22
+ const commonlyUsed = choices.filter((c) => recentlyUsed.has(c.name));
23
+ let checkboxChoices;
24
+ if (commonlyUsed.length > 0) {
25
+ const commonlyUsedNames = new Set(commonlyUsed.map((c) => c.name));
26
+ const remaining = choices.filter((c) => !commonlyUsedNames.has(c.name));
27
+ checkboxChoices = [
28
+ new Separator('Recently Used'),
29
+ ...commonlyUsed,
30
+ ...(remaining.length > 0 ? [new Separator(), ...remaining] : []),
31
+ ];
32
+ }
33
+ else {
34
+ checkboxChoices = choices;
35
+ }
16
36
  const selected = await deps.checkbox({
17
37
  message: `Select repos for branch "${branchName}":`,
18
- choices: services.repos.formatRepoChoices(repos),
38
+ choices: checkboxChoices,
19
39
  pageSize: 20,
40
+ loop: false,
20
41
  });
21
42
  if (selected.length === 0) {
22
43
  services.console.log('No repos selected.');
@@ -50,6 +71,8 @@ export async function runBranch(branchName, useCases, services, deps) {
50
71
  postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
51
72
  perRepoPostCheckout: shouldRunPostCheckout ? config.perRepoPostCheckout : {},
52
73
  });
74
+ // Track which repos were branched from
75
+ services.fetchCache.trackBranchUsage(selected.map((r) => path.basename(r)));
53
76
  // Display results
54
77
  services.console.log(`\nCreated workspace at ${chalk.cyan(result.workspacePath)} with ${result.successCount}/${result.totalCount} repos.`);
55
78
  if (result.tmuxCreated) {
@@ -8,7 +8,7 @@ export function registerConfigCommand(program) {
8
8
  .description('Manage flow configuration');
9
9
  configCmd
10
10
  .command('set <key> <value>')
11
- .description('Set a config value (source-path, dest-path, copy-files, tmux, main-branch, post-checkout)')
11
+ .description('Set a config value (source-path, dest-path, copy-files, tmux, post-checkout)')
12
12
  .action((key, value) => {
13
13
  const services = createServices();
14
14
  if (!isValidKey(key)) {
@@ -34,6 +34,7 @@ export function registerConfigCommand(program) {
34
34
  });
35
35
  configCmd
36
36
  .command('list')
37
+ .alias('ls')
37
38
  .description('List all config options and their current values')
38
39
  .action(() => {
39
40
  const services = createServices();
@@ -1,4 +1,52 @@
1
1
  import chalk from 'chalk';
2
+ import { StatusService } from '../lib/status.js';
3
+ /**
4
+ * Format a single repo's status as a display line, consistent across list and status commands.
5
+ */
6
+ export function formatRepoStatusLine(repoName, status, baseBranch) {
7
+ const statusMessage = StatusService.getStatusMessage(status, baseBranch);
8
+ const hasIssues = StatusService.hasIssues(status);
9
+ const indicator = hasIssues ? chalk.red('✗') : chalk.green('✓');
10
+ 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}`;
15
+ }
16
+ /**
17
+ * Render Phase 1: print a header and workspace rows with a "fetching..." indicator.
18
+ * Returns the number of lines printed so the caller can clear them later.
19
+ */
20
+ export function logStatusFetching(header, workspaces, console) {
21
+ console.log(chalk.bold(`\n${header}`));
22
+ for (const ws of workspaces) {
23
+ const repoCount = chalk.dim(`(${ws.repoCount} repo${ws.repoCount === 1 ? '' : 's'})`);
24
+ console.log(` ${chalk.cyan(ws.name)} ${repoCount} ${chalk.dim('fetching...')}`);
25
+ }
26
+ console.log('');
27
+ // blank line + header + N workspace lines + trailing blank
28
+ return workspaces.length + 3;
29
+ }
30
+ /**
31
+ * Render Phase 2: clear the Phase 1 lines then print the header and full workspace status.
32
+ */
33
+ export function logStatus(header, workspaces, linesToClear, getBaseBranch, console) {
34
+ for (let i = 0; i < linesToClear; i++) {
35
+ console.write('\x1b[1A'); // Move cursor up one line
36
+ console.write('\x1b[2K'); // Clear entire line
37
+ }
38
+ console.log(chalk.bold(`\n${header}`));
39
+ for (const workspace of workspaces) {
40
+ const activeIndicator = workspace.isActive ? chalk.green('* ') : ' ';
41
+ const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
42
+ console.log(`${activeIndicator}${chalk.cyan(workspace.name)} ${repoCount}`);
43
+ for (const { repoName, status } of workspace.statuses) {
44
+ const baseBranch = getBaseBranch(workspace.path, repoName);
45
+ console.log(formatRepoStatusLine(repoName, status, baseBranch));
46
+ }
47
+ console.log('');
48
+ }
49
+ }
2
50
  /**
3
51
  * Get a human-readable status indicator for a workspace based on its worktree statuses.
4
52
  */
@@ -1,7 +1,6 @@
1
- import chalk from 'chalk';
2
1
  import { createServices } from '../lib/services.js';
3
2
  import { createUseCases } from '../usecases/usecases.js';
4
- import { StatusService } from '../lib/status.js';
3
+ import { logStatusFetching, logStatus } from './helpers.js';
5
4
  export async function runList(useCases, services) {
6
5
  const { destPath, sourcePath } = services.config.getRequired();
7
6
  const config = services.config.load();
@@ -13,12 +12,7 @@ export async function runList(useCases, services) {
13
12
  return;
14
13
  }
15
14
  // Phase 1: Show basic list immediately
16
- services.console.log(chalk.bold('\nWorkspaces:'));
17
- for (const workspace of basicWorkspaces) {
18
- const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
19
- services.console.log(` ${chalk.cyan(workspace.name)} ${repoCount} ${chalk.dim('fetching...')}`);
20
- }
21
- services.console.log('');
15
+ const loadingLines = logStatusFetching('Workspaces:', basicWorkspaces, services.console);
22
16
  // Phase 2: Fetch repos used across all workspaces
23
17
  await useCases.fetchUsedRepos.execute({
24
18
  destPath,
@@ -32,39 +26,8 @@ export async function runList(useCases, services) {
32
26
  sourcePath,
33
27
  cwd,
34
28
  });
35
- // Phase 4: Clear previous output and re-print with status
36
- // Lines to clear:
37
- // - 2 lines from '\nWorkspaces:' (blank line + header)
38
- // - N workspace lines
39
- // - 1 empty line after workspaces
40
- const linesToClear = 2 + basicWorkspaces.length + 1;
41
- for (let i = 0; i < linesToClear; i++) {
42
- services.console.write('\x1b[1A'); // Move cursor up one line
43
- services.console.write('\x1b[2K'); // Clear entire line
44
- }
45
- // Re-print with full status information
46
- services.console.log(chalk.bold('\nWorkspaces:'));
47
- for (const workspace of result.workspaces) {
48
- const activeIndicator = workspace.isActive ? chalk.green('* ') : ' ';
49
- const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
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
67
- }
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);
68
31
  }
69
32
  export function registerListCommand(program) {
70
33
  program
@@ -0,0 +1,161 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import input from '@inquirer/input';
4
+ import chalk from 'chalk';
5
+ import { createServices } from '../lib/services.js';
6
+ import { NodeShell } from '../adapters/node.js';
7
+ const DIVIDER = chalk.dim(' ─────────────────────────────────────────────────');
8
+ const DEFAULT_DEST_PATH = '~/dev/workspaces';
9
+ const MAX_NAMES_SHOWN = 3;
10
+ function expandTilde(p) {
11
+ return p.replace(/^~/, os.homedir());
12
+ }
13
+ function summariseNames(names, noun) {
14
+ const count = names.length;
15
+ if (count === 0)
16
+ return `no ${noun}s found`;
17
+ const shown = names.slice(0, MAX_NAMES_SHOWN).join(', ');
18
+ if (count <= MAX_NAMES_SHOWN)
19
+ return `${count} ${count === 1 ? noun : noun + 's'}: ${shown}`;
20
+ return `${count} ${noun}s including ${shown}`;
21
+ }
22
+ async function isTmuxInstalled(shell) {
23
+ try {
24
+ await shell.execFile('which', ['tmux']);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ export async function runQuickstart(services, deps) {
32
+ const existing = services.config.loadRaw();
33
+ services.console.log('');
34
+ services.console.log(chalk.bold(' Welcome to flow!'));
35
+ services.console.log('');
36
+ services.console.log(' flow creates isolated workspaces with git worktrees across multiple');
37
+ services.console.log(' repos — work on a feature branch in all your repos at once.');
38
+ services.console.log('');
39
+ services.console.log(DIVIDER);
40
+ services.console.log(chalk.bold(' Required'));
41
+ services.console.log(DIVIDER);
42
+ services.console.log('');
43
+ // Loop until user provides a source path that contains git repositories
44
+ services.console.log(chalk.dim(' Source directory containing your repos (e.g. ~/dev)'));
45
+ let sourcePath;
46
+ while (true) {
47
+ const sourcePathRaw = await deps.input({
48
+ message: 'Where are your git repositories?',
49
+ default: existing['source-path'] ?? '',
50
+ prefill: 'editable',
51
+ });
52
+ const resolved = path.resolve(expandTilde(sourcePathRaw));
53
+ try {
54
+ const repos = services.repos.discoverRepos(resolved);
55
+ if (repos.length === 0) {
56
+ services.console.log(chalk.yellow(' ⚠ No git repositories found — please try a different path \n'));
57
+ continue;
58
+ }
59
+ const names = repos.map(r => path.basename(r));
60
+ services.console.log(chalk.dim(` Found ${summariseNames(names, 'repository')}`));
61
+ sourcePath = resolved;
62
+ break;
63
+ }
64
+ catch (err) {
65
+ const msg = err.code === 'ENOENT'
66
+ ? ' ⚠ Directory not found — please try again \n'
67
+ : ' ⚠ Could not read directory — please try again \n';
68
+ services.console.log(chalk.yellow(msg));
69
+ }
70
+ }
71
+ services.console.log('');
72
+ services.console.log(chalk.dim(' Each branch gets its own folder here — should be an empty directory (e.g. ~/dev/workspaces)'));
73
+ const destPathRaw = await deps.input({
74
+ message: 'Where should workspaces be created?',
75
+ default: existing['dest-path'] ?? DEFAULT_DEST_PATH,
76
+ prefill: 'editable',
77
+ });
78
+ const destPath = path.resolve(expandTilde(destPathRaw));
79
+ const workspaces = services.workspaceDir.listWorkspaces(destPath);
80
+ if (workspaces.length > 0) {
81
+ const names = workspaces.map(w => w.name);
82
+ services.console.log(chalk.dim(` Found ${summariseNames(names, 'workspace')}`));
83
+ }
84
+ services.console.log('');
85
+ services.console.log(DIVIDER);
86
+ services.console.log(chalk.bold(' Optional') + chalk.dim(' (press Enter to skip)'));
87
+ services.console.log(DIVIDER);
88
+ services.console.log('');
89
+ services.console.log(chalk.dim(' Runs in each worktree after a workspace is created'));
90
+ const postCheckout = await deps.input({
91
+ message: 'Post-checkout command?',
92
+ default: existing['post-checkout'] ?? '',
93
+ prefill: 'editable',
94
+ });
95
+ let tmuxEnabled = existing['tmux'] === 'true';
96
+ const tmuxAvailable = await isTmuxInstalled(deps.shell);
97
+ if (tmuxAvailable) {
98
+ services.console.log('');
99
+ services.console.log(chalk.dim(' Opens a tmux session with split panes, one per worktree'));
100
+ const tmuxRaw = await deps.input({
101
+ message: 'Enable tmux integration (yes/no)?',
102
+ default: existing['tmux'] === 'true' ? 'yes' : 'no',
103
+ prefill: 'editable',
104
+ });
105
+ tmuxEnabled = /^(y|yes)$/i.test(tmuxRaw.trim());
106
+ }
107
+ // All-or-nothing save — only reached if all prompts complete without throwing
108
+ const newConfig = {
109
+ ...existing,
110
+ 'source-path': sourcePath,
111
+ 'dest-path': destPath,
112
+ 'tmux': tmuxEnabled ? 'true' : 'false',
113
+ };
114
+ if (postCheckout) {
115
+ newConfig['post-checkout'] = postCheckout;
116
+ }
117
+ else {
118
+ delete newConfig['post-checkout'];
119
+ }
120
+ services.config.saveRaw(newConfig);
121
+ // Summary
122
+ services.console.log('');
123
+ services.console.log(DIVIDER);
124
+ services.console.log('');
125
+ services.console.log(chalk.green(' ✓ Configuration saved!'));
126
+ services.console.log('');
127
+ services.console.log(` ${chalk.cyan('source-path')} ${sourcePath}`);
128
+ services.console.log(` ${chalk.cyan('dest-path')} ${destPath}`);
129
+ if (postCheckout) {
130
+ services.console.log(` ${chalk.cyan('post-checkout')} ${postCheckout}`);
131
+ }
132
+ services.console.log(` ${chalk.cyan('tmux')} ${tmuxEnabled}`);
133
+ services.console.log('');
134
+ services.console.log(chalk.bold(' Get started:'));
135
+ services.console.log('');
136
+ services.console.log(` ${chalk.cyan('flow branch my-feature')} Create a new branch across repos`);
137
+ services.console.log(` ${chalk.cyan('flow checkout my-feature')} Checkout an existing branch`);
138
+ services.console.log(` ${chalk.cyan('flow list')} See all your workspaces`);
139
+ services.console.log('');
140
+ services.console.log(chalk.dim(' Run `flow config list` to see all settings.'));
141
+ services.console.log('');
142
+ }
143
+ export function registerQuickstartCommand(program) {
144
+ program
145
+ .command('quickstart')
146
+ .description('Interactive setup wizard for first-time configuration')
147
+ .action(async () => {
148
+ const services = createServices();
149
+ try {
150
+ await runQuickstart(services, { input, shell: new NodeShell() });
151
+ }
152
+ catch (error) {
153
+ // Suppress ExitPromptError — user Ctrl-C'd a prompt, exit silently
154
+ if (error?.name === 'ExitPromptError') {
155
+ return;
156
+ }
157
+ services.console.error(error.message);
158
+ services.process.exit(1);
159
+ }
160
+ });
161
+ }
@@ -1,48 +1,35 @@
1
- import chalk from 'chalk';
1
+ import path from 'node:path';
2
2
  import { createServices } from '../lib/services.js';
3
3
  import { createUseCases } from '../usecases/usecases.js';
4
- import { StatusService } from '../lib/status.js';
5
4
  import { resolveWorkspace } from '../lib/workspaceResolver.js';
5
+ import { logStatusFetching, logStatus } from './helpers.js';
6
6
  export async function runStatus(branchName, useCases, services) {
7
7
  const { sourcePath } = services.config.getRequired();
8
8
  const config = services.config.load();
9
9
  const { workspacePath } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
10
- services.console.log(`Workspace: ${chalk.cyan(workspacePath)}`);
11
10
  const worktreeDirs = services.workspaceDir.getWorktreeDirs(workspacePath);
12
11
  if (worktreeDirs.length === 0) {
13
12
  services.console.log('\nNo worktrees found in workspace.');
14
13
  return;
15
14
  }
15
+ const workspaceName = path.basename(workspacePath);
16
+ const repoCount = worktreeDirs.length;
17
+ // Phase 1: Show header with fetching indicator
18
+ const loadingLines = logStatusFetching('Workspace:', [{ name: workspaceName, repoCount }], services.console);
16
19
  // Fetch workspace repos
17
20
  await useCases.fetchWorkspaceRepos.execute({
18
21
  workspacePath,
19
22
  sourcePath,
20
23
  fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
24
+ silent: true,
21
25
  });
22
- services.console.log('');
23
- services.console.log(`\nStatus:\n`);
24
26
  const result = await useCases.checkWorkspaceStatus.execute({
25
27
  workspacePath,
26
28
  });
27
29
  // Load workspace config to get per-repo base branches
28
30
  const workspaceConfig = services.workspaceConfig.load(workspacePath);
29
- const getBaseBranch = (repoName) => workspaceConfig.baseBranches[repoName] || 'master';
30
- let cleanCount = 0;
31
- let issuesCount = 0;
32
- for (const { repoName, status } of result.statuses) {
33
- const baseBranch = getBaseBranch(repoName);
34
- const message = StatusService.getStatusMessage(status, baseBranch);
35
- if (StatusService.hasIssues(status)) {
36
- services.console.log(` ${chalk.red('✗')} ${repoName}: ${chalk.red(message)}`);
37
- issuesCount++;
38
- }
39
- else {
40
- services.console.log(` ${chalk.green('✓')} ${repoName}: ${chalk.green(message)}`);
41
- cleanCount++;
42
- }
43
- }
44
- services.console.log('');
45
- services.console.log(`Summary: ${chalk.green(`${cleanCount} up to date`)}, ${issuesCount > 0 ? chalk.red(`${issuesCount} with issues`) : chalk.green('0 with issues')}`);
31
+ // 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);
46
33
  }
47
34
  export function registerStatusCommand(program) {
48
35
  program
@@ -11,6 +11,7 @@ const RawConfigSchema = z.object({
11
11
  'post-checkout': z.string().optional(),
12
12
  'fetch-cache-ttl-seconds': z.string().optional(),
13
13
  'per-repo-post-checkout': z.record(z.string(), z.string()).optional(),
14
+ 'branch-auto-select-repos': z.string().optional(),
14
15
  });
15
16
  // Parsed config schema (with proper types)
16
17
  const ParsedConfigSchema = z.object({
@@ -21,6 +22,7 @@ const ParsedConfigSchema = z.object({
21
22
  postCheckout: z.string().optional(),
22
23
  fetchCacheTtlSeconds: z.number().default(300),
23
24
  perRepoPostCheckout: z.record(z.string(), z.string()).default({}),
25
+ branchAutoSelectRepos: z.array(z.string()).default([]),
24
26
  });
25
27
  // Required config schema (for getRequired)
26
28
  const RequiredConfigSchema = z.object({
@@ -38,8 +40,9 @@ const ConfigValueSchemas = {
38
40
  const num = parseInt(val, 10);
39
41
  return !isNaN(num) && num >= 0;
40
42
  }, { message: 'Must be a non-negative integer' }),
43
+ 'branch-auto-select-repos': z.string(),
41
44
  };
42
- export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'post-checkout', 'fetch-cache-ttl-seconds'];
45
+ export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'post-checkout', 'fetch-cache-ttl-seconds', 'branch-auto-select-repos'];
43
46
  /**
44
47
  * Pure utility functions (no I/O)
45
48
  */
@@ -88,6 +91,9 @@ export class ConfigService {
88
91
  ? parseInt(raw['fetch-cache-ttl-seconds'], 10)
89
92
  : undefined,
90
93
  perRepoPostCheckout: raw['per-repo-post-checkout'],
94
+ branchAutoSelectRepos: raw['branch-auto-select-repos']
95
+ ? raw['branch-auto-select-repos'].split(',').map((s) => s.trim()).filter(Boolean)
96
+ : undefined,
91
97
  });
92
98
  }
93
99
  getRequired() {
@@ -134,6 +140,10 @@ export class ConfigService {
134
140
  value: raw['fetch-cache-ttl-seconds'] ?? `${config.fetchCacheTtlSeconds} (default)`,
135
141
  isDefault: !raw['fetch-cache-ttl-seconds'],
136
142
  },
143
+ 'branch-auto-select-repos': {
144
+ value: raw['branch-auto-select-repos'] ?? '(not set)',
145
+ isDefault: !raw['branch-auto-select-repos'],
146
+ },
137
147
  perRepoPostCheckout: config.perRepoPostCheckout,
138
148
  };
139
149
  }
@@ -15,7 +15,7 @@ export class FetchCacheService {
15
15
  return true; // Caching disabled
16
16
  }
17
17
  const cache = this.loadCache();
18
- const cachedTimestamp = cache[repoPath];
18
+ const cachedTimestamp = cache.fetchTimestamps[repoPath];
19
19
  if (!cachedTimestamp) {
20
20
  return true; // Cache miss
21
21
  }
@@ -31,7 +31,7 @@ export class FetchCacheService {
31
31
  markFetched(repoPath) {
32
32
  try {
33
33
  const cache = this.loadCache();
34
- cache[repoPath] = Date.now();
34
+ cache.fetchTimestamps[repoPath] = Date.now();
35
35
  this.saveCache(cache);
36
36
  }
37
37
  catch (error) {
@@ -48,18 +48,58 @@ export class FetchCacheService {
48
48
  filterReposToFetch(repoPaths, ttlSeconds) {
49
49
  return repoPaths.filter(repoPath => this.shouldFetch(repoPath, ttlSeconds));
50
50
  }
51
+ /**
52
+ * Record that a branch was created from these repos (appends raw events)
53
+ * @param repoNames Array of repository names (basenames)
54
+ */
55
+ trackBranchUsage(repoNames) {
56
+ try {
57
+ const cache = this.loadCache();
58
+ const date = new Date().toISOString();
59
+ for (const repo of repoNames) {
60
+ cache.branchRepoUsage.push({ repo, date });
61
+ }
62
+ this.saveCache(cache);
63
+ }
64
+ catch (error) {
65
+ // Don't block operations on cache write errors
66
+ console.warn('Warning: Failed to update branch usage cache:', error);
67
+ }
68
+ }
69
+ /**
70
+ * Get the most recently used repo names, derived from raw events
71
+ * @param limit Maximum number of repos to return
72
+ * @returns Repo names sorted by most recent usage, descending
73
+ */
74
+ getRecentlyUsedRepos(limit) {
75
+ const events = this.loadCache().branchRepoUsage;
76
+ const latestByRepo = new Map();
77
+ for (const event of events) {
78
+ const existing = latestByRepo.get(event.repo);
79
+ if (!existing || event.date > existing) {
80
+ latestByRepo.set(event.repo, event.date);
81
+ }
82
+ }
83
+ return [...latestByRepo.entries()]
84
+ .sort((a, b) => b[1].localeCompare(a[1]))
85
+ .slice(0, limit)
86
+ .map(([repo]) => repo);
87
+ }
51
88
  /**
52
89
  * Load cache from disk
53
- * @returns Cache data object
54
90
  */
55
91
  loadCache() {
56
92
  const cachePath = this.getFetchCachePath();
57
93
  try {
58
94
  if (!this.fs.existsSync(cachePath)) {
59
- return {};
95
+ return { fetchTimestamps: {}, branchRepoUsage: [] };
60
96
  }
61
97
  const content = this.fs.readFileSync(cachePath, 'utf-8');
62
- return JSON.parse(content);
98
+ const parsed = JSON.parse(content);
99
+ return {
100
+ fetchTimestamps: parsed.fetchTimestamps ?? {},
101
+ branchRepoUsage: parsed.branchRepoUsage ?? [],
102
+ };
63
103
  }
64
104
  catch (error) {
65
105
  // Corrupted cache - delete and start fresh
@@ -70,12 +110,11 @@ export class FetchCacheService {
70
110
  catch {
71
111
  // Ignore errors deleting corrupted cache
72
112
  }
73
- return {};
113
+ return { fetchTimestamps: {}, branchRepoUsage: [] };
74
114
  }
75
115
  }
76
116
  /**
77
117
  * Save cache to disk
78
- * @param cache Cache data to save
79
118
  */
80
119
  saveCache(cache) {
81
120
  const cachePath = this.getFetchCachePath();
@@ -88,10 +127,9 @@ export class FetchCacheService {
88
127
  }
89
128
  /**
90
129
  * Get path to fetch cache file
91
- * @returns Absolute path to ~/.config/flow/fetch-cache.json
92
130
  */
93
131
  getFetchCachePath() {
94
132
  const home = process.env.HOME || process.env.USERPROFILE || '';
95
- return path.join(home, '.config', 'flow', 'fetch-cache.json');
133
+ return path.join(home, '.config', 'flow', 'flow-cache.json');
96
134
  }
97
135
  }
@@ -29,10 +29,18 @@ export class WorkspaceConfigService {
29
29
  const parsed = JSON.parse(raw);
30
30
  return WorkspaceConfigSchema.parse(parsed);
31
31
  }
32
+ savePlaceholder(workspacePath) {
33
+ const configPath = this.getConfigPath(workspacePath);
34
+ this.fs.writeFileSync(configPath, JSON.stringify({ baseBranches: {} }, null, 2) + '\n');
35
+ }
32
36
  save(workspacePath, config) {
33
37
  const configPath = this.getConfigPath(workspacePath);
34
38
  const validated = WorkspaceConfigSchema.parse(config);
35
- this.fs.writeFileSync(configPath, JSON.stringify(validated, null, 2) + '\n');
39
+ const existing = this.load(workspacePath);
40
+ const merged = {
41
+ baseBranches: { ...existing.baseBranches, ...validated.baseBranches },
42
+ };
43
+ this.fs.writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n');
36
44
  }
37
45
  getBaseBranch(workspacePath, repoName) {
38
46
  const config = this.load(workspacePath);
@@ -39,8 +39,16 @@ export class WorkspaceDirectoryService {
39
39
  if (!this.fs.existsSync(workspacePath)) {
40
40
  return null;
41
41
  }
42
+ const configPath = path.join(workspacePath, 'flow-config.json');
43
+ if (!this.fs.existsSync(configPath)) {
44
+ return null;
45
+ }
42
46
  return workspacePath;
43
47
  }
48
+ /**
49
+ * Returns the paths of git worktrees (repos) inside a workspace directory.
50
+ * @example getWorktreeDirs('/workspaces/feature-123') → ['/workspaces/feature-123/repo-a', ...]
51
+ */
44
52
  getWorktreeDirs(workspacePath) {
45
53
  const entries = this.fs.readdirSync(workspacePath, { withFileTypes: true });
46
54
  return entries
@@ -54,13 +62,22 @@ export class WorkspaceDirectoryService {
54
62
  })
55
63
  .map((entry) => path.join(workspacePath, entry.name));
56
64
  }
65
+ /**
66
+ * Returns all workspaces under destPath, identified by the presence of flow-config.json.
67
+ * @example listWorkspaces('/workspaces') → [{ name: 'feature-123', path: '...', repoCount: 2 }, ...]
68
+ */
57
69
  listWorkspaces(destPath) {
58
70
  if (!this.fs.existsSync(destPath)) {
59
71
  return [];
60
72
  }
61
73
  const entries = this.fs.readdirSync(destPath, { withFileTypes: true });
62
74
  return entries
63
- .filter((entry) => entry.isDirectory())
75
+ .filter((entry) => {
76
+ if (!entry.isDirectory())
77
+ return false;
78
+ const configPath = path.join(destPath, entry.name, 'flow-config.json');
79
+ return this.fs.existsSync(configPath);
80
+ })
64
81
  .map((entry) => {
65
82
  const workspacePath = path.join(destPath, entry.name);
66
83
  try {
@@ -75,7 +92,7 @@ export class WorkspaceDirectoryService {
75
92
  return null;
76
93
  }
77
94
  })
78
- .filter((ws) => ws !== null && ws.repoCount > 0);
95
+ .filter((ws) => ws !== null);
79
96
  }
80
97
  removeWorkspaceDir(workspacePath) {
81
98
  this.fs.rmSync(workspacePath, { recursive: true, force: true });
@@ -37,6 +37,7 @@ export class CheckoutWorkspaceUseCase {
37
37
  }
38
38
  // 3. Create workspace directory
39
39
  const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
40
+ this.workspaceConfig.savePlaceholder(workspacePath);
40
41
  // 4. Create worktrees in parallel
41
42
  const successCount = await this.parallel.processInParallel(matching, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
42
43
  const name = RepoService.getRepoName(repoPath);
@@ -26,6 +26,7 @@ export class CreateBranchWorkspaceUseCase {
26
26
  async execute(params) {
27
27
  // 1. Create workspace directory
28
28
  const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
29
+ this.workspaceConfig.savePlaceholder(workspacePath);
29
30
  // Track base branches for each repo
30
31
  const baseBranches = {};
31
32
  // 2. Create worktrees in parallel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-flow",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Manage git worktrees across a poly-repo environment",
5
5
  "type": "module",
6
6
  "bin": {