worktree-flow 0.0.3 → 0.0.6
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 +25 -3
- package/dist/commands/branch.js +5 -0
- package/dist/commands/checkout.js +6 -0
- package/dist/commands/list.js +76 -8
- package/dist/commands/prune.js +7 -5
- package/dist/commands/remove.js +5 -1
- package/dist/commands/status.js +7 -0
- package/dist/lib/config.js +17 -1
- package/dist/lib/fetch.js +37 -14
- package/dist/lib/fetchCache.js +97 -0
- package/dist/lib/postCheckout.js +13 -4
- package/dist/lib/services.js +4 -1
- package/dist/lib/workspaceReposCollector.js +26 -0
- package/dist/usecases/checkWorkspaceStatus.js +1 -4
- package/dist/usecases/checkoutWorkspace.js +9 -13
- package/dist/usecases/createBranchWorkspace.js +9 -13
- package/dist/usecases/discoverPrunableWorkspaces.js +2 -26
- package/dist/usecases/fetchAllRepos.js +16 -0
- package/dist/usecases/fetchUsedRepos.js +25 -0
- package/dist/usecases/fetchWorkspaceRepos.js +30 -0
- package/dist/usecases/listWorkspacesWithStatus.js +39 -0
- package/dist/usecases/removeWorkspace.js +2 -5
- package/dist/usecases/usecases.js +13 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,15 +68,20 @@ Push all repos in the current workspace. Automatically sets upstream on first pu
|
|
|
68
68
|
|
|
69
69
|
Check the status of all repos in a workspace. Shows uncommitted changes, commits ahead of main, and up-to-date repos.
|
|
70
70
|
|
|
71
|
-
### `flow list`
|
|
71
|
+
### `flow list` (alias: `ls`)
|
|
72
72
|
|
|
73
|
-
List all workspaces
|
|
73
|
+
List all workspaces with status indicators. Shows:
|
|
74
|
+
- Active workspace (marked with `*`) based on current directory
|
|
75
|
+
- Overall status: `clean`, `uncommitted`, `ahead`, `behind`, `diverged`, or `mixed`
|
|
76
|
+
- Repo count for each workspace
|
|
77
|
+
|
|
78
|
+
Fetches all repos before checking status to ensure accurate information.
|
|
74
79
|
|
|
75
80
|
### `flow remove <name>`
|
|
76
81
|
|
|
77
82
|
Remove a workspace and all its worktrees. Fetches latest, checks for uncommitted changes and unpushed commits, and prompts for confirmation before removing.
|
|
78
83
|
|
|
79
|
-
### `flow prune`
|
|
84
|
+
### `flow prune` (alias: `clean`)
|
|
80
85
|
|
|
81
86
|
Remove stale workspaces in bulk. Finds workspaces where all worktrees are clean, fully merged, and haven't been committed to in over 7 days.
|
|
82
87
|
|
|
@@ -96,6 +101,23 @@ Settings are stored in `~/.config/flow/config.json`.
|
|
|
96
101
|
| `tmux` | Create tmux sessions with split panes (root + each worktree) | `false` |
|
|
97
102
|
| `copy-files` | Files to copy from source repos to worktrees | `.env` |
|
|
98
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.
|
|
99
121
|
|
|
100
122
|
## AGENTS.md
|
|
101
123
|
|
package/dist/commands/branch.js
CHANGED
|
@@ -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) {
|
package/dist/commands/list.js
CHANGED
|
@@ -1,26 +1,94 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { createServices } from '../lib/services.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
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
|
+
}
|
|
32
|
+
export async function runList(useCases, services) {
|
|
33
|
+
const { destPath, sourcePath } = services.config.getRequired();
|
|
34
|
+
const config = services.config.load();
|
|
35
|
+
const cwd = services.process.cwd();
|
|
36
|
+
// Get basic workspace list immediately
|
|
37
|
+
const basicWorkspaces = services.workspaceDir.listWorkspaces(destPath);
|
|
38
|
+
if (basicWorkspaces.length === 0) {
|
|
7
39
|
services.console.log('No workspaces found.');
|
|
8
40
|
return;
|
|
9
41
|
}
|
|
42
|
+
// Phase 1: Show basic list immediately
|
|
10
43
|
services.console.log(chalk.bold('\nWorkspaces:'));
|
|
11
|
-
for (const workspace of
|
|
12
|
-
|
|
44
|
+
for (const workspace of basicWorkspaces) {
|
|
45
|
+
const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
|
|
46
|
+
services.console.log(` ${chalk.cyan(workspace.name)} ${repoCount} ${chalk.dim('fetching...')}`);
|
|
13
47
|
}
|
|
14
48
|
services.console.log('');
|
|
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
|
|
57
|
+
const result = await useCases.listWorkspacesWithStatus.execute({
|
|
58
|
+
destPath,
|
|
59
|
+
sourcePath,
|
|
60
|
+
mainBranch: config.mainBranch,
|
|
61
|
+
cwd,
|
|
62
|
+
});
|
|
63
|
+
// Phase 4: Clear previous output and re-print with status
|
|
64
|
+
// Lines to clear:
|
|
65
|
+
// - 2 lines from '\nWorkspaces:' (blank line + header)
|
|
66
|
+
// - N workspace lines
|
|
67
|
+
// - 1 empty line after workspaces
|
|
68
|
+
const linesToClear = 2 + basicWorkspaces.length + 1;
|
|
69
|
+
for (let i = 0; i < linesToClear; i++) {
|
|
70
|
+
services.console.write('\x1b[1A'); // Move cursor up one line
|
|
71
|
+
services.console.write('\x1b[2K'); // Clear entire line
|
|
72
|
+
}
|
|
73
|
+
// Re-print with full status information
|
|
74
|
+
services.console.log(chalk.bold('\nWorkspaces:'));
|
|
75
|
+
for (const workspace of result.workspaces) {
|
|
76
|
+
const statusIndicator = getStatusIndicator(workspace);
|
|
77
|
+
const activeIndicator = workspace.isActive ? chalk.green('* ') : ' ';
|
|
78
|
+
const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
|
|
79
|
+
services.console.log(`${activeIndicator}${chalk.cyan(workspace.name)} ${repoCount} ${statusIndicator}`);
|
|
80
|
+
}
|
|
15
81
|
}
|
|
16
82
|
export function registerListCommand(program) {
|
|
17
83
|
program
|
|
18
84
|
.command('list')
|
|
19
|
-
.
|
|
85
|
+
.alias('ls')
|
|
86
|
+
.description('List all workspaces with status indicators')
|
|
20
87
|
.action(async () => {
|
|
21
88
|
const services = createServices();
|
|
89
|
+
const useCases = createUseCases(services);
|
|
22
90
|
try {
|
|
23
|
-
await runList(services);
|
|
91
|
+
await runList(useCases, services);
|
|
24
92
|
}
|
|
25
93
|
catch (error) {
|
|
26
94
|
services.console.error(error.message);
|
package/dist/commands/prune.js
CHANGED
|
@@ -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,
|
|
@@ -31,11 +37,6 @@ export async function runPrune(useCases, services, deps) {
|
|
|
31
37
|
services.console.log(` Status: ${chalk.green('clean')}`);
|
|
32
38
|
services.console.log('');
|
|
33
39
|
}
|
|
34
|
-
// Summary
|
|
35
|
-
const totalRepos = result.prunable.reduce((sum, ws) => sum + ws.repoCount, 0);
|
|
36
|
-
services.console.log(`${chalk.yellow('This will remove:')}`);
|
|
37
|
-
services.console.log(` ${result.prunable.length} workspace(s)`);
|
|
38
|
-
services.console.log(` ${totalRepos} worktree(s)`);
|
|
39
40
|
const confirmed = await deps.confirm({
|
|
40
41
|
message: 'Are you sure you want to prune these workspaces?',
|
|
41
42
|
default: false,
|
|
@@ -78,6 +79,7 @@ export async function runPrune(useCases, services, deps) {
|
|
|
78
79
|
export function registerPruneCommand(program) {
|
|
79
80
|
program
|
|
80
81
|
.command('prune')
|
|
82
|
+
.alias('clean')
|
|
81
83
|
.description('Remove old workspaces with no uncommitted changes and commits older than 7 days')
|
|
82
84
|
.action(async () => {
|
|
83
85
|
const services = createServices();
|
package/dist/commands/remove.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/commands/status.js
CHANGED
|
@@ -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({
|
package/dist/lib/config.js
CHANGED
|
@@ -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,29 +6,49 @@ const FETCH_CONCURRENCY = 8;
|
|
|
6
6
|
export class FetchService {
|
|
7
7
|
git;
|
|
8
8
|
console;
|
|
9
|
-
|
|
9
|
+
cache;
|
|
10
|
+
constructor(git, console, cache) {
|
|
10
11
|
this.git = git;
|
|
11
12
|
this.console = console;
|
|
13
|
+
this.cache = cache;
|
|
12
14
|
}
|
|
13
|
-
async fetchRepos(repoPaths) {
|
|
15
|
+
async fetchRepos(repoPaths, options) {
|
|
14
16
|
if (repoPaths.length === 0) {
|
|
15
17
|
return;
|
|
16
18
|
}
|
|
17
|
-
const
|
|
19
|
+
const silent = options?.silent ?? false;
|
|
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;
|
|
18
32
|
let completed = 0;
|
|
19
33
|
let failed = 0;
|
|
20
34
|
const updateProgress = () => {
|
|
35
|
+
if (silent)
|
|
36
|
+
return;
|
|
21
37
|
const percent = Math.floor((completed / total) * 100);
|
|
22
|
-
const
|
|
38
|
+
const cacheInfo = cachedCount > 0 ? ` (${cachedCount} cached)` : '';
|
|
39
|
+
const message = `Fetching ${completed}/${total} repos${cacheInfo} (${percent}%)`;
|
|
23
40
|
this.console.write(`\r${message}`);
|
|
24
41
|
};
|
|
25
42
|
updateProgress();
|
|
26
43
|
const processRepo = async (repoPath) => {
|
|
27
44
|
try {
|
|
28
45
|
await this.git.fetch(repoPath);
|
|
46
|
+
// Update cache on successful fetch
|
|
47
|
+
this.cache.markFetched(repoPath);
|
|
29
48
|
}
|
|
30
49
|
catch (err) {
|
|
31
50
|
failed++;
|
|
51
|
+
// Do NOT update cache on failure - will retry next time
|
|
32
52
|
}
|
|
33
53
|
finally {
|
|
34
54
|
completed++;
|
|
@@ -38,21 +58,24 @@ export class FetchService {
|
|
|
38
58
|
// Process repos with controlled concurrency
|
|
39
59
|
let index = 0;
|
|
40
60
|
const worker = async () => {
|
|
41
|
-
while (index <
|
|
42
|
-
const repoPath =
|
|
61
|
+
while (index < reposToFetch.length) {
|
|
62
|
+
const repoPath = reposToFetch[index++];
|
|
43
63
|
await processRepo(repoPath);
|
|
44
64
|
}
|
|
45
65
|
};
|
|
46
66
|
// Start workers (up to concurrency limit)
|
|
47
|
-
const workers = Array.from({ length: Math.min(FETCH_CONCURRENCY,
|
|
67
|
+
const workers = Array.from({ length: Math.min(FETCH_CONCURRENCY, reposToFetch.length) }, () => worker());
|
|
48
68
|
await Promise.all(workers);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
if (!silent) {
|
|
70
|
+
// Clear the progress line and show summary
|
|
71
|
+
this.console.write('\r');
|
|
72
|
+
const cacheInfo = cachedCount > 0 ? ` (${cachedCount} cached)` : '';
|
|
73
|
+
if (failed > 0) {
|
|
74
|
+
this.console.log(`Fetched ${total - failed}/${total} repos${cacheInfo} ${chalk.yellow(`(${failed} failed)`)}`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
this.console.log(`Fetched ${total} repos${cacheInfo} ${chalk.green('✓')}`);
|
|
78
|
+
}
|
|
56
79
|
}
|
|
57
80
|
}
|
|
58
81
|
}
|
|
@@ -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
|
+
}
|
package/dist/lib/postCheckout.js
CHANGED
|
@@ -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,
|
|
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(
|
|
20
|
+
await Promise.allSettled(commandsToRun.map(async ({ dir, command }) => {
|
|
12
21
|
try {
|
|
13
|
-
await this.shell.execFile('sh', ['-c', command], { cwd:
|
|
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:
|
|
29
|
+
return { successCount, totalCount: commandsToRun.length };
|
|
21
30
|
}
|
|
22
31
|
}
|
package/dist/lib/services.js
CHANGED
|
@@ -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
|
|
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,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Collects all worktree directories and unique source repos across workspaces.
|
|
4
|
+
* This helper extracts common logic used by multiple use cases that need to
|
|
5
|
+
* fetch repos before analyzing workspace status.
|
|
6
|
+
*/
|
|
7
|
+
export function collectWorkspaceRepos(workspaces, workspaceDir, sourcePath) {
|
|
8
|
+
const uniqueSourceRepos = new Set();
|
|
9
|
+
const workspaceWorktrees = new Map();
|
|
10
|
+
for (const workspace of workspaces) {
|
|
11
|
+
const worktreeDirs = workspaceDir.getWorktreeDirs(workspace.path);
|
|
12
|
+
if (worktreeDirs.length > 0) {
|
|
13
|
+
workspaceWorktrees.set(workspace.path, worktreeDirs);
|
|
14
|
+
// Extract repo names and build source repo paths
|
|
15
|
+
worktreeDirs.forEach((worktreePath) => {
|
|
16
|
+
const repoName = path.basename(worktreePath);
|
|
17
|
+
const sourceRepoPath = path.join(sourcePath, repoName);
|
|
18
|
+
uniqueSourceRepos.add(sourceRepoPath);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
uniqueSourceRepos: Array.from(uniqueSourceRepos),
|
|
24
|
+
workspaceWorktrees,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export class CheckWorkspaceStatusUseCase {
|
|
5
5
|
workspaceDir;
|
|
6
|
-
fetch;
|
|
7
6
|
status;
|
|
8
|
-
constructor(workspaceDir,
|
|
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,
|
|
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.
|
|
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
|
-
//
|
|
34
|
+
// 3. Create workspace directory
|
|
39
35
|
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
40
|
-
//
|
|
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
|
-
//
|
|
44
|
+
// 5. Copy AGENTS.md
|
|
49
45
|
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
50
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
33
|
+
// 3. Copy AGENTS.md if exists
|
|
38
34
|
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
39
|
-
//
|
|
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
|
-
//
|
|
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,4 +1,3 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import { StatusService as StatusServiceClass } from '../lib/status.js';
|
|
3
2
|
/**
|
|
4
3
|
* Use case for analyzing workspaces to find candidates for pruning.
|
|
@@ -6,12 +5,10 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
|
|
|
6
5
|
*/
|
|
7
6
|
export class DiscoverPrunableWorkspacesUseCase {
|
|
8
7
|
workspaceDir;
|
|
9
|
-
fetch;
|
|
10
8
|
status;
|
|
11
9
|
git;
|
|
12
|
-
constructor(workspaceDir,
|
|
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,30 +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
|
-
//
|
|
23
|
-
const uniqueSourceRepos = new Set();
|
|
24
|
-
const workspaceWorktrees = new Map();
|
|
19
|
+
// Analyze each workspace
|
|
25
20
|
for (const workspace of workspaces) {
|
|
26
21
|
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
|
|
27
|
-
if (worktreeDirs.length > 0) {
|
|
28
|
-
workspaceWorktrees.set(workspace.path, worktreeDirs);
|
|
29
|
-
// Extract repo names and build source repo paths
|
|
30
|
-
worktreeDirs.forEach((worktreePath) => {
|
|
31
|
-
const repoName = path.basename(worktreePath);
|
|
32
|
-
const sourceRepoPath = path.join(params.sourcePath, repoName);
|
|
33
|
-
uniqueSourceRepos.add(sourceRepoPath);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
// Fetch all unique source repos once
|
|
38
|
-
// Since worktrees share the same git repository, fetching in one worktree
|
|
39
|
-
// updates the remote refs for all worktrees of that repository
|
|
40
|
-
if (uniqueSourceRepos.size > 0) {
|
|
41
|
-
await this.fetch.fetchRepos(Array.from(uniqueSourceRepos));
|
|
42
|
-
}
|
|
43
|
-
// Now analyze each workspace
|
|
44
|
-
for (const workspace of workspaces) {
|
|
45
|
-
const worktreeDirs = workspaceWorktrees.get(workspace.path);
|
|
46
22
|
// Skip workspaces with no worktrees
|
|
47
23
|
if (!worktreeDirs || worktreeDirs.length === 0) {
|
|
48
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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use case for listing all workspaces with their status information.
|
|
3
|
+
* Checks status for each workspace's worktrees.
|
|
4
|
+
*/
|
|
5
|
+
export class ListWorkspacesWithStatusUseCase {
|
|
6
|
+
workspaceDir;
|
|
7
|
+
status;
|
|
8
|
+
constructor(workspaceDir, status) {
|
|
9
|
+
this.workspaceDir = workspaceDir;
|
|
10
|
+
this.status = status;
|
|
11
|
+
}
|
|
12
|
+
async execute(params) {
|
|
13
|
+
const workspaces = this.workspaceDir.listWorkspaces(params.destPath);
|
|
14
|
+
if (workspaces.length === 0) {
|
|
15
|
+
return { workspaces: [] };
|
|
16
|
+
}
|
|
17
|
+
// Check status and detect active workspace
|
|
18
|
+
const workspacesWithStatus = [];
|
|
19
|
+
for (const workspace of workspaces) {
|
|
20
|
+
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
|
|
21
|
+
// Skip workspaces with no worktrees
|
|
22
|
+
if (!worktreeDirs || worktreeDirs.length === 0) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// Check if this workspace is active (contains current working directory)
|
|
26
|
+
const isActive = this.workspaceDir.detectWorkspace(params.cwd, params.destPath) === workspace.path;
|
|
27
|
+
// Check status for all worktrees
|
|
28
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
|
|
29
|
+
workspacesWithStatus.push({
|
|
30
|
+
name: workspace.name,
|
|
31
|
+
path: workspace.path,
|
|
32
|
+
repoCount: workspace.repoCount,
|
|
33
|
+
isActive,
|
|
34
|
+
statuses,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return { workspaces: workspacesWithStatus };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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,
|
|
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.
|
|
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)) {
|
|
@@ -5,18 +5,26 @@ import { PushWorkspaceUseCase } from './pushWorkspace.js';
|
|
|
5
5
|
import { PullWorkspaceUseCase } from './pullWorkspace.js';
|
|
6
6
|
import { CheckWorkspaceStatusUseCase } from './checkWorkspaceStatus.js';
|
|
7
7
|
import { DiscoverPrunableWorkspacesUseCase } from './discoverPrunableWorkspaces.js';
|
|
8
|
+
import { ListWorkspacesWithStatusUseCase } from './listWorkspacesWithStatus.js';
|
|
9
|
+
import { FetchAllReposUseCase } from './fetchAllRepos.js';
|
|
10
|
+
import { FetchWorkspaceReposUseCase } from './fetchWorkspaceRepos.js';
|
|
11
|
+
import { FetchUsedReposUseCase } from './fetchUsedRepos.js';
|
|
8
12
|
/**
|
|
9
13
|
* Factory function for creating all use cases with their service dependencies.
|
|
10
14
|
* Use cases orchestrate workflows by coordinating multiple services.
|
|
11
15
|
*/
|
|
12
16
|
export function createUseCases(services) {
|
|
13
17
|
return {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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),
|
|
17
24
|
pushWorkspace: new PushWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
|
|
18
25
|
pullWorkspace: new PullWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
|
|
19
|
-
checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.
|
|
20
|
-
discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.
|
|
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),
|
|
21
29
|
};
|
|
22
30
|
}
|