worktree-flow 0.0.4 → 0.0.7

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
@@ -101,6 +101,23 @@ Settings are stored in `~/.config/flow/config.json`.
101
101
  | `tmux` | Create tmux sessions with split panes (root + each worktree) | `false` |
102
102
  | `copy-files` | Files to copy from source repos to worktrees | `.env` |
103
103
  | `post-checkout` | Command to run after checkout (e.g. `npm ci`) | *none* |
104
+ | `per-repo-post-checkout` | Per-repo commands (see below) | `{}` |
105
+
106
+ ### Per-repo post-checkout commands
107
+
108
+ Configure different commands for specific repos by editing `~/.config/flow/config.json`:
109
+
110
+ ```json
111
+ {
112
+ "post-checkout": "npm ci",
113
+ "per-repo-post-checkout": {
114
+ "api-service": "npm ci && npm run build",
115
+ "frontend": "yarn install"
116
+ }
117
+ }
118
+ ```
119
+
120
+ Repos with per-repo commands use those; others fall back to the global `post-checkout` command.
104
121
 
105
122
  ## AGENTS.md
106
123
 
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { registerPushCommand } from './commands/push.js';
9
9
  import { registerRemoveCommand } from './commands/remove.js';
10
10
  import { registerStatusCommand } from './commands/status.js';
11
11
  import { registerPruneCommand } from './commands/prune.js';
12
+ import { registerFetchCommand } from './commands/fetch.js';
12
13
  const program = new Command();
13
14
  program
14
15
  .name('flow')
@@ -23,4 +24,5 @@ registerPushCommand(program);
23
24
  registerRemoveCommand(program);
24
25
  registerStatusCommand(program);
25
26
  registerPruneCommand(program);
27
+ registerFetchCommand(program);
26
28
  program.parse();
@@ -34,6 +34,10 @@ export async function runBranch(branchName, useCases, services, deps) {
34
34
  });
35
35
  }
36
36
  services.console.log('\nCreating workspace...');
37
+ // Fetch all selected repos
38
+ await services.fetch.fetchRepos(selected, {
39
+ ttlSeconds: config.fetchCacheTtlSeconds,
40
+ });
37
41
  // Execute use case
38
42
  const result = await useCases.createBranchWorkspace.execute({
39
43
  repos: selected,
@@ -44,6 +48,7 @@ export async function runBranch(branchName, useCases, services, deps) {
44
48
  copyFiles: config.copyFiles,
45
49
  tmux: config.tmux,
46
50
  postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
51
+ perRepoPostCheckout: shouldRunPostCheckout ? config.perRepoPostCheckout : {},
47
52
  });
48
53
  // Display results
49
54
  services.console.log(`\nCreated workspace at ${chalk.cyan(result.workspacePath)} with ${result.successCount}/${result.totalCount} repos.`);
@@ -14,6 +14,11 @@ export async function runCheckout(branchName, useCases, services, deps) {
14
14
  }
15
15
  services.console.log('\nChecking for branch...');
16
16
  try {
17
+ // Fetch all repos from source-path
18
+ await useCases.fetchAllRepos.execute({
19
+ sourcePath,
20
+ fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
21
+ });
17
22
  // Execute use case
18
23
  const result = await useCases.checkoutWorkspace.execute({
19
24
  branchName,
@@ -22,6 +27,7 @@ export async function runCheckout(branchName, useCases, services, deps) {
22
27
  copyFiles: config.copyFiles,
23
28
  tmux: config.tmux,
24
29
  postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
30
+ perRepoPostCheckout: shouldRunPostCheckout ? config.perRepoPostCheckout : {},
25
31
  });
26
32
  // Display branch check results
27
33
  for (const checkResult of result.branchCheckResults) {
@@ -0,0 +1,30 @@
1
+ import chalk from 'chalk';
2
+ import { createServices } from '../lib/services.js';
3
+ import { createUseCases } from '../usecases/usecases.js';
4
+ export async function runFetch(useCases, services) {
5
+ const { destPath, sourcePath } = services.config.getRequired();
6
+ services.console.log('Fetching all repos used across workspaces...\n');
7
+ await useCases.fetchUsedRepos.execute({
8
+ destPath,
9
+ sourcePath,
10
+ fetchCacheTtlSeconds: 0, // Bypass cache
11
+ silent: false,
12
+ });
13
+ services.console.log(`\n${chalk.green('✓')} Fetch complete`);
14
+ }
15
+ export function registerFetchCommand(program) {
16
+ program
17
+ .command('fetch')
18
+ .description('Fetch all repos used across workspaces (bypasses cache)')
19
+ .action(async () => {
20
+ const services = createServices();
21
+ const useCases = createUseCases(services);
22
+ try {
23
+ await runFetch(useCases, services);
24
+ }
25
+ catch (error) {
26
+ services.console.error(error.message);
27
+ services.process.exit(1);
28
+ }
29
+ });
30
+ }
@@ -46,14 +46,21 @@ export async function runList(useCases, services) {
46
46
  services.console.log(` ${chalk.cyan(workspace.name)} ${repoCount} ${chalk.dim('fetching...')}`);
47
47
  }
48
48
  services.console.log('');
49
- // Phase 2: Fetch and check status
49
+ // Phase 2: Fetch repos used across all workspaces
50
+ await useCases.fetchUsedRepos.execute({
51
+ destPath,
52
+ sourcePath,
53
+ fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
54
+ silent: true,
55
+ });
56
+ // Phase 3: Check status for all workspaces
50
57
  const result = await useCases.listWorkspacesWithStatus.execute({
51
58
  destPath,
52
59
  sourcePath,
53
60
  mainBranch: config.mainBranch,
54
61
  cwd,
55
62
  });
56
- // Phase 3: Clear previous output and re-print with status
63
+ // Phase 4: Clear previous output and re-print with status
57
64
  // Lines to clear:
58
65
  // - 2 lines from '\nWorkspaces:' (blank line + header)
59
66
  // - N workspace lines
@@ -6,6 +6,12 @@ export async function runPrune(useCases, services, deps) {
6
6
  const { sourcePath, destPath } = services.config.getRequired();
7
7
  const config = services.config.load();
8
8
  services.console.log('Analyzing workspaces for pruning...\n');
9
+ // Fetch repos used across all workspaces
10
+ await useCases.fetchUsedRepos.execute({
11
+ destPath,
12
+ sourcePath,
13
+ fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
14
+ });
9
15
  // Analyze workspaces to find prunable ones
10
16
  const result = await useCases.discoverPrunableWorkspaces.execute({
11
17
  destPath,
@@ -13,7 +13,11 @@ export async function runRemove(branchName, useCases, services, deps) {
13
13
  const worktreeDirs = services.workspaceDir.getWorktreeDirs(workspacePath);
14
14
  // Display status check if worktrees exist
15
15
  if (worktreeDirs.length > 0) {
16
- await services.fetch.fetchRepos(worktreeDirs);
16
+ await useCases.fetchWorkspaceRepos.execute({
17
+ workspacePath,
18
+ sourcePath,
19
+ fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
20
+ });
17
21
  services.console.log(`\nChecking for uncommitted changes and commits ahead of ${config.mainBranch}...`);
18
22
  const results = await services.status.checkAllWorktrees(worktreeDirs, config.mainBranch);
19
23
  for (const { repoName, status } of results) {
@@ -4,6 +4,7 @@ import { createUseCases } from '../usecases/usecases.js';
4
4
  import { StatusService } from '../lib/status.js';
5
5
  import { resolveWorkspace } from '../lib/workspaceResolver.js';
6
6
  export async function runStatus(branchName, useCases, services) {
7
+ const { sourcePath } = services.config.getRequired();
7
8
  const config = services.config.load();
8
9
  const { workspacePath } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
9
10
  services.console.log(`Workspace: ${chalk.cyan(workspacePath)}`);
@@ -12,6 +13,12 @@ export async function runStatus(branchName, useCases, services) {
12
13
  services.console.log('\nNo worktrees found in workspace.');
13
14
  return;
14
15
  }
16
+ // Fetch workspace repos
17
+ await useCases.fetchWorkspaceRepos.execute({
18
+ workspacePath,
19
+ sourcePath,
20
+ fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
21
+ });
15
22
  services.console.log('');
16
23
  services.console.log(`\nStatus (comparing against ${chalk.cyan(config.mainBranch)}):\n`);
17
24
  const result = await useCases.checkWorkspaceStatus.execute({
@@ -10,6 +10,8 @@ const RawConfigSchema = z.object({
10
10
  'tmux': z.enum(['true', 'false']).optional(),
11
11
  'main-branch': z.string().optional(),
12
12
  'post-checkout': z.string().optional(),
13
+ 'fetch-cache-ttl-seconds': z.string().optional(),
14
+ 'per-repo-post-checkout': z.record(z.string(), z.string()).optional(),
13
15
  });
14
16
  // Parsed config schema (with proper types)
15
17
  const ParsedConfigSchema = z.object({
@@ -19,6 +21,8 @@ const ParsedConfigSchema = z.object({
19
21
  tmux: z.boolean().default(false),
20
22
  mainBranch: z.string().default('master'),
21
23
  postCheckout: z.string().optional(),
24
+ fetchCacheTtlSeconds: z.number().default(300),
25
+ perRepoPostCheckout: z.record(z.string(), z.string()).default({}),
22
26
  });
23
27
  // Required config schema (for getRequired)
24
28
  const RequiredConfigSchema = z.object({
@@ -33,8 +37,12 @@ const ConfigValueSchemas = {
33
37
  'tmux': z.enum(['true', 'false']),
34
38
  'main-branch': z.string(),
35
39
  'post-checkout': z.string(),
40
+ 'fetch-cache-ttl-seconds': z.string().refine((val) => {
41
+ const num = parseInt(val, 10);
42
+ return !isNaN(num) && num >= 0;
43
+ }, { message: 'Must be a non-negative integer' }),
36
44
  };
37
- export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'main-branch', 'post-checkout'];
45
+ export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'main-branch', 'post-checkout', 'fetch-cache-ttl-seconds'];
38
46
  /**
39
47
  * Pure utility functions (no I/O)
40
48
  */
@@ -80,6 +88,10 @@ export class ConfigService {
80
88
  tmux: raw.tmux === 'true',
81
89
  mainBranch: raw['main-branch'],
82
90
  postCheckout: raw['post-checkout'],
91
+ fetchCacheTtlSeconds: raw['fetch-cache-ttl-seconds']
92
+ ? parseInt(raw['fetch-cache-ttl-seconds'], 10)
93
+ : undefined,
94
+ perRepoPostCheckout: raw['per-repo-post-checkout'],
83
95
  });
84
96
  }
85
97
  getRequired() {
@@ -126,6 +138,10 @@ export class ConfigService {
126
138
  value: raw['post-checkout'] ?? '(not set)',
127
139
  isDefault: !raw['post-checkout'],
128
140
  },
141
+ 'fetch-cache-ttl-seconds': {
142
+ value: raw['fetch-cache-ttl-seconds'] ?? `${config.fetchCacheTtlSeconds} (default)`,
143
+ isDefault: !raw['fetch-cache-ttl-seconds'],
144
+ },
129
145
  };
130
146
  }
131
147
  }
package/dist/lib/fetch.js CHANGED
@@ -6,32 +6,49 @@ const FETCH_CONCURRENCY = 8;
6
6
  export class FetchService {
7
7
  git;
8
8
  console;
9
- constructor(git, console) {
9
+ cache;
10
+ constructor(git, console, cache) {
10
11
  this.git = git;
11
12
  this.console = console;
13
+ this.cache = cache;
12
14
  }
13
15
  async fetchRepos(repoPaths, options) {
14
16
  if (repoPaths.length === 0) {
15
17
  return;
16
18
  }
17
19
  const silent = options?.silent ?? false;
18
- const total = repoPaths.length;
20
+ const ttlSeconds = options?.ttlSeconds ?? 300;
21
+ // Filter repos based on cache
22
+ const reposToFetch = this.cache.filterReposToFetch(repoPaths, ttlSeconds);
23
+ const cachedCount = repoPaths.length - reposToFetch.length;
24
+ // Early return if all repos are cached
25
+ if (reposToFetch.length === 0) {
26
+ if (!silent) {
27
+ this.console.log(`All ${repoPaths.length} repos up to date (cached) ${chalk.green('✓')}`);
28
+ }
29
+ return;
30
+ }
31
+ const total = reposToFetch.length;
19
32
  let completed = 0;
20
33
  let failed = 0;
21
34
  const updateProgress = () => {
22
35
  if (silent)
23
36
  return;
24
37
  const percent = Math.floor((completed / total) * 100);
25
- const message = `Fetching repos... ${completed}/${total} (${percent}%)`;
38
+ const cacheInfo = cachedCount > 0 ? ` (${cachedCount} cached)` : '';
39
+ const message = `Fetching ${completed}/${total} repos${cacheInfo} (${percent}%)`;
26
40
  this.console.write(`\r${message}`);
27
41
  };
28
42
  updateProgress();
29
43
  const processRepo = async (repoPath) => {
30
44
  try {
31
45
  await this.git.fetch(repoPath);
46
+ // Update cache on successful fetch
47
+ this.cache.markFetched(repoPath);
32
48
  }
33
49
  catch (err) {
34
50
  failed++;
51
+ // Do NOT update cache on failure - will retry next time
35
52
  }
36
53
  finally {
37
54
  completed++;
@@ -41,22 +58,23 @@ export class FetchService {
41
58
  // Process repos with controlled concurrency
42
59
  let index = 0;
43
60
  const worker = async () => {
44
- while (index < repoPaths.length) {
45
- const repoPath = repoPaths[index++];
61
+ while (index < reposToFetch.length) {
62
+ const repoPath = reposToFetch[index++];
46
63
  await processRepo(repoPath);
47
64
  }
48
65
  };
49
66
  // Start workers (up to concurrency limit)
50
- const workers = Array.from({ length: Math.min(FETCH_CONCURRENCY, repoPaths.length) }, () => worker());
67
+ const workers = Array.from({ length: Math.min(FETCH_CONCURRENCY, reposToFetch.length) }, () => worker());
51
68
  await Promise.all(workers);
52
69
  if (!silent) {
53
70
  // Clear the progress line and show summary
54
71
  this.console.write('\r');
72
+ const cacheInfo = cachedCount > 0 ? ` (${cachedCount} cached)` : '';
55
73
  if (failed > 0) {
56
- this.console.log(`Fetched ${total - failed}/${total} repos ${chalk.yellow(`(${failed} failed)`)}`);
74
+ this.console.log(`Fetched ${total - failed}/${total} repos${cacheInfo} ${chalk.yellow(`(${failed} failed)`)}`);
57
75
  }
58
76
  else {
59
- this.console.log(`Fetched ${total} repos ${chalk.green('✓')}`);
77
+ this.console.log(`Fetched ${total} repos${cacheInfo} ${chalk.green('✓')}`);
60
78
  }
61
79
  }
62
80
  }
@@ -0,0 +1,97 @@
1
+ import path from 'path';
2
+ export class FetchCacheService {
3
+ fs;
4
+ constructor(fs) {
5
+ this.fs = fs;
6
+ }
7
+ /**
8
+ * Check if a repository needs fetching based on TTL
9
+ * @param repoPath Absolute path to repository
10
+ * @param ttlSeconds TTL in seconds (0 = always fetch)
11
+ * @returns true if repo should be fetched
12
+ */
13
+ shouldFetch(repoPath, ttlSeconds) {
14
+ if (ttlSeconds === 0) {
15
+ return true; // Caching disabled
16
+ }
17
+ const cache = this.loadCache();
18
+ const cachedTimestamp = cache[repoPath];
19
+ if (!cachedTimestamp) {
20
+ return true; // Cache miss
21
+ }
22
+ const now = Date.now();
23
+ const age = now - cachedTimestamp;
24
+ const ttlMs = ttlSeconds * 1000;
25
+ return age > ttlMs; // Expired if older than TTL
26
+ }
27
+ /**
28
+ * Mark a repository as fetched with current timestamp
29
+ * @param repoPath Absolute path to repository
30
+ */
31
+ markFetched(repoPath) {
32
+ try {
33
+ const cache = this.loadCache();
34
+ cache[repoPath] = Date.now();
35
+ this.saveCache(cache);
36
+ }
37
+ catch (error) {
38
+ // Don't block operations on cache write errors
39
+ console.warn('Warning: Failed to update fetch cache:', error);
40
+ }
41
+ }
42
+ /**
43
+ * Filter repositories to only those that need fetching
44
+ * @param repoPaths Array of absolute repository paths
45
+ * @param ttlSeconds TTL in seconds
46
+ * @returns Array of repos that need fetching
47
+ */
48
+ filterReposToFetch(repoPaths, ttlSeconds) {
49
+ return repoPaths.filter(repoPath => this.shouldFetch(repoPath, ttlSeconds));
50
+ }
51
+ /**
52
+ * Load cache from disk
53
+ * @returns Cache data object
54
+ */
55
+ loadCache() {
56
+ const cachePath = this.getFetchCachePath();
57
+ try {
58
+ if (!this.fs.existsSync(cachePath)) {
59
+ return {};
60
+ }
61
+ const content = this.fs.readFileSync(cachePath, 'utf-8');
62
+ return JSON.parse(content);
63
+ }
64
+ catch (error) {
65
+ // Corrupted cache - delete and start fresh
66
+ console.warn('Warning: Corrupted fetch cache, resetting:', error);
67
+ try {
68
+ this.fs.rmSync(cachePath, { force: true });
69
+ }
70
+ catch {
71
+ // Ignore errors deleting corrupted cache
72
+ }
73
+ return {};
74
+ }
75
+ }
76
+ /**
77
+ * Save cache to disk
78
+ * @param cache Cache data to save
79
+ */
80
+ saveCache(cache) {
81
+ const cachePath = this.getFetchCachePath();
82
+ const cacheDir = path.dirname(cachePath);
83
+ // Ensure cache directory exists
84
+ if (!this.fs.existsSync(cacheDir)) {
85
+ this.fs.mkdirSync(cacheDir, { recursive: true });
86
+ }
87
+ this.fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
88
+ }
89
+ /**
90
+ * Get path to fetch cache file
91
+ * @returns Absolute path to ~/.config/flow/fetch-cache.json
92
+ */
93
+ getFetchCachePath() {
94
+ const home = process.env.HOME || process.env.USERPROFILE || '';
95
+ return path.join(home, '.config', 'flow', 'fetch-cache.json');
96
+ }
97
+ }
@@ -1,3 +1,4 @@
1
+ import path from 'node:path';
1
2
  /**
2
3
  * PostCheckoutService handles execution of post-checkout commands.
3
4
  */
@@ -6,17 +7,25 @@ export class PostCheckoutService {
6
7
  constructor(shell) {
7
8
  this.shell = shell;
8
9
  }
9
- async runCommand(worktreeDirs, command) {
10
+ async runCommand(worktreeDirs, globalCommand, perRepoCommands) {
11
+ // Determine command for each worktree (per-repo overrides global)
12
+ const commandsToRun = worktreeDirs
13
+ .map((dir) => {
14
+ const repoName = path.basename(dir);
15
+ const command = perRepoCommands[repoName] ?? globalCommand;
16
+ return command ? { dir, command } : null;
17
+ })
18
+ .filter((x) => x !== null);
10
19
  let successCount = 0;
11
- await Promise.allSettled(worktreeDirs.map(async (worktreeDir) => {
20
+ await Promise.allSettled(commandsToRun.map(async ({ dir, command }) => {
12
21
  try {
13
- await this.shell.execFile('sh', ['-c', command], { cwd: worktreeDir });
22
+ await this.shell.execFile('sh', ['-c', command], { cwd: dir });
14
23
  successCount++;
15
24
  }
16
25
  catch {
17
26
  // Error handled by caller
18
27
  }
19
28
  }));
20
- return { successCount, totalCount: worktreeDirs.length };
29
+ return { successCount, totalCount: commandsToRun.length };
21
30
  }
22
31
  }
@@ -9,6 +9,7 @@ import { WorkspaceDirectoryService } from './workspaceDirectory.js';
9
9
  import { WorktreeService } from './worktree.js';
10
10
  import { PostCheckoutService } from './postCheckout.js';
11
11
  import { FetchService } from './fetch.js';
12
+ import { FetchCacheService } from './fetchCache.js';
12
13
  import { ParallelService } from './parallel.js';
13
14
  import { StatusService } from './status.js';
14
15
  import { TmuxService } from './tmux.js';
@@ -24,7 +25,8 @@ export function createServices() {
24
25
  const parallel = new ParallelService(console);
25
26
  const status = new StatusService(git);
26
27
  const tmux = new TmuxService(shell);
27
- const fetch = new FetchService(git, console);
28
+ const fetchCache = new FetchCacheService(fs);
29
+ const fetch = new FetchService(git, console, fetchCache);
28
30
  const repos = new RepoService(fs, git);
29
31
  const postCheckout = new PostCheckoutService(shell);
30
32
  // Focused workspace services
@@ -38,6 +40,7 @@ export function createServices() {
38
40
  worktree,
39
41
  postCheckout,
40
42
  fetch,
43
+ fetchCache,
41
44
  parallel,
42
45
  status,
43
46
  tmux,
@@ -3,16 +3,13 @@
3
3
  */
4
4
  export class CheckWorkspaceStatusUseCase {
5
5
  workspaceDir;
6
- fetch;
7
6
  status;
8
- constructor(workspaceDir, fetch, status) {
7
+ constructor(workspaceDir, status) {
9
8
  this.workspaceDir = workspaceDir;
10
- this.fetch = fetch;
11
9
  this.status = status;
12
10
  }
13
11
  async execute(params) {
14
12
  const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
15
- await this.fetch.fetchRepos(worktreeDirs);
16
13
  const statuses = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
17
14
  return { statuses };
18
15
  }
@@ -9,15 +9,13 @@ export class CheckoutWorkspaceUseCase {
9
9
  workspaceDir;
10
10
  worktree;
11
11
  repos;
12
- fetch;
13
12
  parallel;
14
13
  tmux;
15
14
  postCheckout;
16
- constructor(workspaceDir, worktree, repos, fetch, parallel, tmux, postCheckout) {
15
+ constructor(workspaceDir, worktree, repos, parallel, tmux, postCheckout) {
17
16
  this.workspaceDir = workspaceDir;
18
17
  this.worktree = worktree;
19
18
  this.repos = repos;
20
- this.fetch = fetch;
21
19
  this.parallel = parallel;
22
20
  this.tmux = tmux;
23
21
  this.postCheckout = postCheckout;
@@ -28,16 +26,14 @@ export class CheckoutWorkspaceUseCase {
28
26
  if (allRepos.length === 0) {
29
27
  throw new NoReposFoundError(params.sourcePath);
30
28
  }
31
- // 2. Fetch all repos
32
- await this.fetch.fetchRepos(allRepos);
33
- // 3. Find repos that have this branch
29
+ // 2. Find repos that have this branch
34
30
  const { matching, results } = await this.repos.findReposWithBranch(allRepos, params.branchName);
35
31
  if (matching.length === 0) {
36
32
  throw new Error(`Branch "${params.branchName}" not found in any repo.`);
37
33
  }
38
- // 4. Create workspace directory
34
+ // 3. Create workspace directory
39
35
  const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
40
- // 5. Create worktrees in parallel
36
+ // 4. Create worktrees in parallel
41
37
  const successCount = await this.parallel.processInParallel(matching, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
42
38
  const name = RepoService.getRepoName(repoPath);
43
39
  const worktreeDest = path.join(workspacePath, name);
@@ -45,9 +41,9 @@ export class CheckoutWorkspaceUseCase {
45
41
  this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
46
42
  return 'created';
47
43
  });
48
- // 6. Copy AGENTS.md
44
+ // 5. Copy AGENTS.md
49
45
  this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
50
- // 7. Create tmux session if enabled
46
+ // 6. Create tmux session if enabled
51
47
  let tmuxCreated = false;
52
48
  if (params.tmux) {
53
49
  try {
@@ -59,11 +55,11 @@ export class CheckoutWorkspaceUseCase {
59
55
  // Don't fail
60
56
  }
61
57
  }
62
- // 8. Run post-checkout if configured
58
+ // 7. Run post-checkout if configured
63
59
  let postCheckoutResult;
64
- if (params.postCheckout) {
60
+ if (params.postCheckout || params.perRepoPostCheckout) {
65
61
  const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
66
- postCheckoutResult = await this.postCheckout.runCommand(worktreeDirs, params.postCheckout);
62
+ postCheckoutResult = await this.postCheckout.runCommand(worktreeDirs, params.postCheckout, params.perRepoPostCheckout ?? {});
67
63
  }
68
64
  return {
69
65
  workspacePath,
@@ -2,31 +2,27 @@ import path from 'node:path';
2
2
  import { RepoService } from '../lib/repos.js';
3
3
  /**
4
4
  * Use case for creating a workspace with new branches across multiple repos.
5
- * Orchestrates the entire workflow from fetching to post-checkout commands.
5
+ * Orchestrates the entire workflow from workspace creation to post-checkout commands.
6
6
  */
7
7
  export class CreateBranchWorkspaceUseCase {
8
8
  workspaceDir;
9
9
  worktree;
10
10
  repos;
11
- fetch;
12
11
  parallel;
13
12
  tmux;
14
13
  postCheckout;
15
- constructor(workspaceDir, worktree, repos, fetch, parallel, tmux, postCheckout) {
14
+ constructor(workspaceDir, worktree, repos, parallel, tmux, postCheckout) {
16
15
  this.workspaceDir = workspaceDir;
17
16
  this.worktree = worktree;
18
17
  this.repos = repos;
19
- this.fetch = fetch;
20
18
  this.parallel = parallel;
21
19
  this.tmux = tmux;
22
20
  this.postCheckout = postCheckout;
23
21
  }
24
22
  async execute(params) {
25
- // 1. Fetch all repos
26
- await this.fetch.fetchRepos(params.repos);
27
- // 2. Create workspace directory
23
+ // 1. Create workspace directory
28
24
  const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
29
- // 3. Create worktrees in parallel
25
+ // 2. Create worktrees in parallel
30
26
  const successCount = await this.parallel.processInParallel(params.repos, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
31
27
  const name = RepoService.getRepoName(repoPath);
32
28
  const worktreeDest = path.join(workspacePath, name);
@@ -34,9 +30,9 @@ export class CreateBranchWorkspaceUseCase {
34
30
  this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
35
31
  return 'created';
36
32
  });
37
- // 4. Copy AGENTS.md if exists
33
+ // 3. Copy AGENTS.md if exists
38
34
  this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
39
- // 5. Create tmux session if enabled
35
+ // 4. Create tmux session if enabled
40
36
  let tmuxCreated = false;
41
37
  if (params.tmux) {
42
38
  try {
@@ -48,11 +44,11 @@ export class CreateBranchWorkspaceUseCase {
48
44
  // Don't fail, just return false
49
45
  }
50
46
  }
51
- // 6. Run post-checkout command if configured
47
+ // 5. Run post-checkout command if configured
52
48
  let postCheckoutResult;
53
- if (params.postCheckout) {
49
+ if (params.postCheckout || params.perRepoPostCheckout) {
54
50
  const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
55
- postCheckoutResult = await this.postCheckout.runCommand(worktreeDirs, params.postCheckout);
51
+ postCheckoutResult = await this.postCheckout.runCommand(worktreeDirs, params.postCheckout, params.perRepoPostCheckout ?? {});
56
52
  }
57
53
  return {
58
54
  workspacePath,
@@ -1,17 +1,14 @@
1
1
  import { StatusService as StatusServiceClass } from '../lib/status.js';
2
- import { collectWorkspaceRepos } from '../lib/workspaceReposCollector.js';
3
2
  /**
4
3
  * Use case for analyzing workspaces to find candidates for pruning.
5
4
  * Returns workspaces where all worktrees have no issues and commits are older than specified days.
6
5
  */
7
6
  export class DiscoverPrunableWorkspacesUseCase {
8
7
  workspaceDir;
9
- fetch;
10
8
  status;
11
9
  git;
12
- constructor(workspaceDir, fetch, status, git) {
10
+ constructor(workspaceDir, status, git) {
13
11
  this.workspaceDir = workspaceDir;
14
- this.fetch = fetch;
15
12
  this.status = status;
16
13
  this.git = git;
17
14
  }
@@ -19,17 +16,9 @@ export class DiscoverPrunableWorkspacesUseCase {
19
16
  const workspaces = this.workspaceDir.listWorkspaces(params.destPath);
20
17
  const prunable = [];
21
18
  const cutoffDate = new Date(Date.now() - params.daysOld * 24 * 60 * 60 * 1000);
22
- // Collect all worktree directories and unique source repos across all workspaces
23
- const { uniqueSourceRepos, workspaceWorktrees } = collectWorkspaceRepos(workspaces, this.workspaceDir, params.sourcePath);
24
- // Fetch all unique source repos once
25
- // Since worktrees share the same git repository, fetching in one worktree
26
- // updates the remote refs for all worktrees of that repository
27
- if (uniqueSourceRepos.length > 0) {
28
- await this.fetch.fetchRepos(uniqueSourceRepos);
29
- }
30
- // Now analyze each workspace
19
+ // Analyze each workspace
31
20
  for (const workspace of workspaces) {
32
- const worktreeDirs = workspaceWorktrees.get(workspace.path);
21
+ const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
33
22
  // Skip workspaces with no worktrees
34
23
  if (!worktreeDirs || worktreeDirs.length === 0) {
35
24
  continue;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Use case for fetching multiple repositories from source-path.
3
+ * Centralizes fetch logic that was previously scattered across multiple use cases.
4
+ */
5
+ export class FetchAllReposUseCase {
6
+ fetch;
7
+ repos;
8
+ constructor(fetch, repos) {
9
+ this.fetch = fetch;
10
+ this.repos = repos;
11
+ }
12
+ async execute(params) {
13
+ const allRepos = this.repos.discoverRepos(params.sourcePath);
14
+ await this.fetch.fetchRepos(allRepos, { ttlSeconds: params.fetchCacheTtlSeconds });
15
+ }
16
+ }
@@ -0,0 +1,25 @@
1
+ import { collectWorkspaceRepos } from '../lib/workspaceReposCollector.js';
2
+ /**
3
+ * Use case for fetching all source repos used across all workspaces.
4
+ * This discovers all workspaces, collects their repos, and fetches them.
5
+ * Used by commands that need to analyze all workspaces (prune, list).
6
+ */
7
+ export class FetchUsedReposUseCase {
8
+ workspaceDir;
9
+ fetch;
10
+ constructor(workspaceDir, fetch) {
11
+ this.workspaceDir = workspaceDir;
12
+ this.fetch = fetch;
13
+ }
14
+ async execute(params) {
15
+ // Discover all workspaces
16
+ const workspaces = this.workspaceDir.listWorkspaces(params.destPath);
17
+ // Collect unique repos used across all workspaces
18
+ const { uniqueSourceRepos } = collectWorkspaceRepos(workspaces, this.workspaceDir, params.sourcePath);
19
+ // Fetch source repos
20
+ await this.fetch.fetchRepos(uniqueSourceRepos, {
21
+ silent: params.silent ?? false,
22
+ ttlSeconds: params.fetchCacheTtlSeconds,
23
+ });
24
+ }
25
+ }
@@ -0,0 +1,30 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Use case for fetching all source repos needed by a single workspace.
4
+ * Discovers worktrees in the workspace and fetches their corresponding source repos.
5
+ * Used by commands operating on a specific workspace (remove, status).
6
+ */
7
+ export class FetchWorkspaceReposUseCase {
8
+ workspaceDir;
9
+ fetch;
10
+ constructor(workspaceDir, fetch) {
11
+ this.workspaceDir = workspaceDir;
12
+ this.fetch = fetch;
13
+ }
14
+ async execute(params) {
15
+ // Get all worktree directories in the workspace
16
+ const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
17
+ // Map worktree directories to source repo paths
18
+ const sourceRepos = worktreeDirs.map((worktreePath) => {
19
+ const repoName = path.basename(worktreePath);
20
+ return path.join(params.sourcePath, repoName);
21
+ });
22
+ // Deduplicate repos (defensive - shouldn't have duplicates but be safe)
23
+ const uniqueSourceRepos = Array.from(new Set(sourceRepos));
24
+ // Fetch source repos
25
+ await this.fetch.fetchRepos(uniqueSourceRepos, {
26
+ silent: params.silent ?? false,
27
+ ttlSeconds: params.fetchCacheTtlSeconds,
28
+ });
29
+ }
30
+ }
@@ -1,15 +1,12 @@
1
- import { collectWorkspaceRepos } from '../lib/workspaceReposCollector.js';
2
1
  /**
3
2
  * Use case for listing all workspaces with their status information.
4
- * Fetches all repos and checks status for each workspace's worktrees.
3
+ * Checks status for each workspace's worktrees.
5
4
  */
6
5
  export class ListWorkspacesWithStatusUseCase {
7
6
  workspaceDir;
8
- fetch;
9
7
  status;
10
- constructor(workspaceDir, fetch, status) {
8
+ constructor(workspaceDir, status) {
11
9
  this.workspaceDir = workspaceDir;
12
- this.fetch = fetch;
13
10
  this.status = status;
14
11
  }
15
12
  async execute(params) {
@@ -17,16 +14,10 @@ export class ListWorkspacesWithStatusUseCase {
17
14
  if (workspaces.length === 0) {
18
15
  return { workspaces: [] };
19
16
  }
20
- // Collect all worktree directories and unique source repos across all workspaces
21
- const { uniqueSourceRepos, workspaceWorktrees } = collectWorkspaceRepos(workspaces, this.workspaceDir, params.sourcePath);
22
- // Fetch all unique source repos once (silently to avoid UI jumping)
23
- if (uniqueSourceRepos.length > 0) {
24
- await this.fetch.fetchRepos(uniqueSourceRepos, { silent: true });
25
- }
26
17
  // Check status and detect active workspace
27
18
  const workspacesWithStatus = [];
28
19
  for (const workspace of workspaces) {
29
- const worktreeDirs = workspaceWorktrees.get(workspace.path);
20
+ const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
30
21
  // Skip workspaces with no worktrees
31
22
  if (!worktreeDirs || worktreeDirs.length === 0) {
32
23
  continue;
@@ -9,23 +9,20 @@ export class RemoveWorkspaceUseCase {
9
9
  workspaceDir;
10
10
  worktree;
11
11
  repos;
12
- fetch;
13
12
  status;
14
13
  tmux;
15
- constructor(workspaceDir, worktree, repos, fetch, status, tmux) {
14
+ constructor(workspaceDir, worktree, repos, status, tmux) {
16
15
  this.workspaceDir = workspaceDir;
17
16
  this.worktree = worktree;
18
17
  this.repos = repos;
19
- this.fetch = fetch;
20
18
  this.status = status;
21
19
  this.tmux = tmux;
22
20
  }
23
21
  async execute(params) {
24
22
  const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
25
23
  const issuesFound = [];
26
- // 1. Fetch and check status if worktrees exist
24
+ // 1. Check status if worktrees exist
27
25
  if (worktreeDirs.length > 0) {
28
- await this.fetch.fetchRepos(worktreeDirs);
29
26
  const results = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
30
27
  for (const { repoName, status } of results) {
31
28
  if (StatusServiceClass.hasIssues(status)) {
@@ -6,19 +6,25 @@ import { PullWorkspaceUseCase } from './pullWorkspace.js';
6
6
  import { CheckWorkspaceStatusUseCase } from './checkWorkspaceStatus.js';
7
7
  import { DiscoverPrunableWorkspacesUseCase } from './discoverPrunableWorkspaces.js';
8
8
  import { ListWorkspacesWithStatusUseCase } from './listWorkspacesWithStatus.js';
9
+ import { FetchAllReposUseCase } from './fetchAllRepos.js';
10
+ import { FetchWorkspaceReposUseCase } from './fetchWorkspaceRepos.js';
11
+ import { FetchUsedReposUseCase } from './fetchUsedRepos.js';
9
12
  /**
10
13
  * Factory function for creating all use cases with their service dependencies.
11
14
  * Use cases orchestrate workflows by coordinating multiple services.
12
15
  */
13
16
  export function createUseCases(services) {
14
17
  return {
15
- createBranchWorkspace: new CreateBranchWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.fetch, services.parallel, services.tmux, services.postCheckout),
16
- checkoutWorkspace: new CheckoutWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.fetch, services.parallel, services.tmux, services.postCheckout),
17
- removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.fetch, services.status, services.tmux),
18
+ fetchAllRepos: new FetchAllReposUseCase(services.fetch, services.repos),
19
+ fetchWorkspaceRepos: new FetchWorkspaceReposUseCase(services.workspaceDir, services.fetch),
20
+ fetchUsedRepos: new FetchUsedReposUseCase(services.workspaceDir, services.fetch),
21
+ createBranchWorkspace: new CreateBranchWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.parallel, services.tmux, services.postCheckout),
22
+ checkoutWorkspace: new CheckoutWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.parallel, services.tmux, services.postCheckout),
23
+ removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.status, services.tmux),
18
24
  pushWorkspace: new PushWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
19
25
  pullWorkspace: new PullWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
20
- checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.fetch, services.status),
21
- discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.fetch, services.status, services.git),
22
- listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.fetch, services.status),
26
+ checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.status),
27
+ discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.status, services.git),
28
+ listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.status),
23
29
  };
24
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktree-flow",
3
- "version": "0.0.4",
3
+ "version": "0.0.7",
4
4
  "description": "Manage git worktrees across a poly-repo environment",
5
5
  "type": "module",
6
6
  "bin": {