worktree-flow 0.0.15 → 0.0.17
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 +20 -0
- package/dist/cli.js +2 -0
- package/dist/commands/add.js +120 -0
- package/dist/commands/branch.js +44 -30
- package/dist/commands/checkout.js +55 -15
- package/dist/commands/helpers.js +58 -0
- package/dist/commands/list.js +4 -34
- package/dist/commands/status.js +9 -21
- package/dist/lib/git.js +3 -0
- package/dist/lib/repoConfig.js +60 -0
- package/dist/lib/services.js +3 -0
- package/dist/lib/tmux.js +23 -0
- package/dist/usecases/addToWorkspace.js +75 -0
- package/dist/usecases/createBranch.js +33 -0
- package/dist/usecases/createWorkspace.js +37 -0
- package/dist/usecases/discoverReposWithBranch.js +25 -0
- package/dist/usecases/usecases.js +8 -8
- package/package.json +1 -1
- package/dist/usecases/checkoutWorkspace.js +0 -91
- package/dist/usecases/createBranchWorkspace.js +0 -86
- package/dist/usecases/runPostCheckout.js +0 -49
package/README.md
CHANGED
|
@@ -56,6 +56,10 @@ Create a new branch across selected repos. Interactively select which repos to i
|
|
|
56
56
|
|
|
57
57
|
Checkout an existing branch. Fetches all repos, detects which have the branch, and creates worktrees.
|
|
58
58
|
|
|
59
|
+
### `flow add [name]`
|
|
60
|
+
|
|
61
|
+
Add repos to an existing workspace. Discovers available repos not yet in the workspace, presents an interactive picker, creates worktrees with new branches, copies config files, and runs post-checkout commands. Auto-detects the workspace from the current directory, or specify a branch name explicitly.
|
|
62
|
+
|
|
59
63
|
### `flow pull`
|
|
60
64
|
|
|
61
65
|
Pull latest changes for all repos in the current workspace. Run from anywhere inside a workspace.
|
|
@@ -126,6 +130,22 @@ Repos with per-repo commands use those; others fall back to the global `post-che
|
|
|
126
130
|
|
|
127
131
|
When tmux is enabled, post-checkout commands run in the corresponding tmux panes instead of executing directly. This lets you see the output in real-time within your tmux session.
|
|
128
132
|
|
|
133
|
+
### Repo-level configuration
|
|
134
|
+
|
|
135
|
+
Individual repos can define a `flow-config.json` at their root to override global settings:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"copy-files": ".env,.env.local",
|
|
140
|
+
"post-checkout": "yarn install"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Both fields are optional. Precedence (highest to lowest):
|
|
145
|
+
|
|
146
|
+
- **post-checkout**: repo's `flow-config.json` > global `post-checkout`
|
|
147
|
+
- **copy-files**: repo's `flow-config.json` > global `copy-files`
|
|
148
|
+
|
|
129
149
|
## AGENTS.md
|
|
130
150
|
|
|
131
151
|
If an `AGENTS.md` file exists at the root of your source-path, it will be copied into each workspace. This is useful for providing AI coding agents with context about your multi-repo setup.
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { registerConfigCommand } from './commands/config.js';
|
|
4
|
+
import { registerAddCommand } from './commands/add.js';
|
|
4
5
|
import { registerBranchCommand } from './commands/branch.js';
|
|
5
6
|
import { registerCheckoutCommand } from './commands/checkout.js';
|
|
6
7
|
import { registerListCommand } from './commands/list.js';
|
|
@@ -18,6 +19,7 @@ program
|
|
|
18
19
|
.description('Manage git worktrees across a poly-repo environment')
|
|
19
20
|
.version('0.1.0');
|
|
20
21
|
registerConfigCommand(program);
|
|
22
|
+
registerAddCommand(program);
|
|
21
23
|
registerBranchCommand(program);
|
|
22
24
|
registerCheckoutCommand(program);
|
|
23
25
|
registerListCommand(program);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import checkbox, { Separator } from '@inquirer/checkbox';
|
|
3
|
+
import input from '@inquirer/input';
|
|
4
|
+
import confirm from '@inquirer/confirm';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { createServices } from '../lib/services.js';
|
|
7
|
+
import { createUseCases } from '../usecases/usecases.js';
|
|
8
|
+
import { NoReposFoundError } from '../lib/errors.js';
|
|
9
|
+
import { resolveWorkspace } from '../lib/workspaceResolver.js';
|
|
10
|
+
import { buildRepoCheckboxChoices } from './helpers.js';
|
|
11
|
+
export async function runAdd(branchName, useCases, services, deps) {
|
|
12
|
+
// 1. Resolve workspace (from arg or cwd)
|
|
13
|
+
const { workspacePath, displayName } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
14
|
+
const { sourcePath } = services.config.getRequired();
|
|
15
|
+
const config = services.config.load();
|
|
16
|
+
// 2. Discover all repos
|
|
17
|
+
const repos = services.repos.discoverRepos(sourcePath);
|
|
18
|
+
if (repos.length === 0) {
|
|
19
|
+
throw new NoReposFoundError(sourcePath);
|
|
20
|
+
}
|
|
21
|
+
// 3. Filter out repos already in the workspace
|
|
22
|
+
const existingWorktrees = services.workspaceDir
|
|
23
|
+
.getWorktreeDirs(workspacePath)
|
|
24
|
+
.map((dir) => path.basename(dir));
|
|
25
|
+
const existingSet = new Set(existingWorktrees);
|
|
26
|
+
const availableRepos = repos.filter((repoPath) => !existingSet.has(path.basename(repoPath)));
|
|
27
|
+
if (availableRepos.length === 0) {
|
|
28
|
+
services.console.log('All repos are already in this workspace.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// 4. Repo picker (same pattern as branch command)
|
|
32
|
+
const checkboxChoices = buildRepoCheckboxChoices(availableRepos, services, config, (label) => new Separator(label));
|
|
33
|
+
const selected = await deps.checkbox({
|
|
34
|
+
message: `Select repos to add to "${displayName}":`,
|
|
35
|
+
choices: checkboxChoices,
|
|
36
|
+
pageSize: 20,
|
|
37
|
+
loop: false,
|
|
38
|
+
});
|
|
39
|
+
if (selected.length === 0) {
|
|
40
|
+
services.console.log('No repos selected.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// 5. Ask for source branch
|
|
44
|
+
const sourceBranch = await deps.input({
|
|
45
|
+
message: 'Branch from which branch?',
|
|
46
|
+
default: 'master',
|
|
47
|
+
});
|
|
48
|
+
// 6. Post-checkout confirmation
|
|
49
|
+
let shouldRunPostCheckout = false;
|
|
50
|
+
if (config.postCheckout) {
|
|
51
|
+
shouldRunPostCheckout = await deps.confirm({
|
|
52
|
+
message: `Run "${config.postCheckout}" in new workspaces?`,
|
|
53
|
+
default: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
services.console.log('\nAdding repos to workspace...');
|
|
57
|
+
// 7. Fetch selected repos
|
|
58
|
+
await services.fetch.fetchRepos(selected, {
|
|
59
|
+
ttlSeconds: config.fetchCacheTtlSeconds,
|
|
60
|
+
});
|
|
61
|
+
// 8. For each selected repo in parallel: createBranch then addToWorkspace
|
|
62
|
+
// If tmux is enabled, use the workspace branch name as session name
|
|
63
|
+
const sessionName = config.tmux ? displayName : undefined;
|
|
64
|
+
const results = await Promise.allSettled(selected.map(async (repoPath) => {
|
|
65
|
+
const repoName = path.basename(repoPath);
|
|
66
|
+
// Resolve per-repo post-checkout command
|
|
67
|
+
const repoConf = services.repoConfig.load(repoPath);
|
|
68
|
+
const resolvedPostCheckout = shouldRunPostCheckout
|
|
69
|
+
? services.repoConfig.resolvePostCheckout(repoName, config.perRepoPostCheckout, repoConf, config.postCheckout)
|
|
70
|
+
: undefined;
|
|
71
|
+
// a. Create branch in source repo
|
|
72
|
+
const branchResult = await useCases.createBranch.execute({
|
|
73
|
+
repoPath,
|
|
74
|
+
branchName: displayName,
|
|
75
|
+
sourceBranch,
|
|
76
|
+
});
|
|
77
|
+
// b. Add repo to workspace (creates worktree, copies files, tmux pane, post-checkout)
|
|
78
|
+
return useCases.addToWorkspace.execute({
|
|
79
|
+
repoPath,
|
|
80
|
+
workspacePath,
|
|
81
|
+
branchName: displayName,
|
|
82
|
+
baseBranch: branchResult.baseBranch,
|
|
83
|
+
sessionName,
|
|
84
|
+
copyFiles: config.copyFiles,
|
|
85
|
+
postCheckout: resolvedPostCheckout,
|
|
86
|
+
});
|
|
87
|
+
}));
|
|
88
|
+
// 9. Track usage
|
|
89
|
+
services.fetchCache.trackBranchUsage(selected.map((r) => path.basename(r)));
|
|
90
|
+
// Tally results
|
|
91
|
+
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
|
92
|
+
const totalCount = results.length;
|
|
93
|
+
const postCheckoutResults = results
|
|
94
|
+
.filter((r) => r.status === 'fulfilled')
|
|
95
|
+
.map((r) => r.value)
|
|
96
|
+
.filter((r) => r.postCheckoutRan);
|
|
97
|
+
const postCheckoutSuccess = postCheckoutResults.filter((r) => r.postCheckoutSuccess).length;
|
|
98
|
+
const postCheckoutTotal = postCheckoutResults.length;
|
|
99
|
+
// 10. Display results
|
|
100
|
+
services.console.log(`\nAdded ${successCount}/${totalCount} repos to ${chalk.cyan(workspacePath)}.`);
|
|
101
|
+
if (postCheckoutTotal > 0) {
|
|
102
|
+
services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function registerAddCommand(program) {
|
|
106
|
+
program
|
|
107
|
+
.command('add [branch-name]')
|
|
108
|
+
.description('Add repos to an existing workspace (auto-detects from current directory if branch not provided)')
|
|
109
|
+
.action(async (branchName) => {
|
|
110
|
+
const services = createServices();
|
|
111
|
+
const useCases = createUseCases(services);
|
|
112
|
+
try {
|
|
113
|
+
await runAdd(branchName, useCases, services, { checkbox, input, confirm });
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
services.console.error(error.message);
|
|
117
|
+
services.process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
package/dist/commands/branch.js
CHANGED
|
@@ -6,6 +6,7 @@ import chalk from 'chalk';
|
|
|
6
6
|
import { createServices } from '../lib/services.js';
|
|
7
7
|
import { createUseCases } from '../usecases/usecases.js';
|
|
8
8
|
import { NoReposFoundError } from '../lib/errors.js';
|
|
9
|
+
import { buildRepoCheckboxChoices } from './helpers.js';
|
|
9
10
|
export async function runBranch(branchName, useCases, services, deps) {
|
|
10
11
|
const { sourcePath, destPath } = services.config.getRequired();
|
|
11
12
|
const config = services.config.load();
|
|
@@ -14,25 +15,7 @@ export async function runBranch(branchName, useCases, services, deps) {
|
|
|
14
15
|
throw new NoReposFoundError(sourcePath);
|
|
15
16
|
}
|
|
16
17
|
// User prompts
|
|
17
|
-
const
|
|
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
|
-
}
|
|
18
|
+
const checkboxChoices = buildRepoCheckboxChoices(repos, services, config, (label) => new Separator(label));
|
|
36
19
|
const selected = await deps.checkbox({
|
|
37
20
|
message: `Select repos for branch "${branchName}":`,
|
|
38
21
|
choices: checkboxChoices,
|
|
@@ -59,27 +42,58 @@ export async function runBranch(branchName, useCases, services, deps) {
|
|
|
59
42
|
await services.fetch.fetchRepos(selected, {
|
|
60
43
|
ttlSeconds: config.fetchCacheTtlSeconds,
|
|
61
44
|
});
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
repos: selected,
|
|
45
|
+
// 1. Create workspace directory, placeholder config, AGENTS.md, tmux session
|
|
46
|
+
const workspaceResult = await useCases.createWorkspace.execute({
|
|
65
47
|
branchName,
|
|
66
|
-
sourceBranch,
|
|
67
48
|
sourcePath,
|
|
68
49
|
destPath,
|
|
69
|
-
copyFiles: config.copyFiles,
|
|
70
50
|
tmux: config.tmux,
|
|
71
|
-
postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
|
|
72
|
-
perRepoPostCheckout: shouldRunPostCheckout ? config.perRepoPostCheckout : {},
|
|
73
51
|
});
|
|
52
|
+
const { workspacePath, tmuxCreated } = workspaceResult;
|
|
53
|
+
const sessionName = tmuxCreated ? branchName : undefined;
|
|
54
|
+
// 2. For each selected repo in parallel: createBranch then addToWorkspace
|
|
55
|
+
const results = await Promise.allSettled(selected.map(async (repoPath) => {
|
|
56
|
+
const repoName = path.basename(repoPath);
|
|
57
|
+
// Resolve per-repo post-checkout command
|
|
58
|
+
const repoConf = services.repoConfig.load(repoPath);
|
|
59
|
+
const resolvedPostCheckout = shouldRunPostCheckout
|
|
60
|
+
? services.repoConfig.resolvePostCheckout(repoName, config.perRepoPostCheckout, repoConf, config.postCheckout)
|
|
61
|
+
: undefined;
|
|
62
|
+
// a. Create branch in source repo
|
|
63
|
+
const branchResult = await useCases.createBranch.execute({
|
|
64
|
+
repoPath,
|
|
65
|
+
branchName,
|
|
66
|
+
sourceBranch,
|
|
67
|
+
});
|
|
68
|
+
// b. Add repo to workspace (creates worktree, copies files, tmux pane, post-checkout)
|
|
69
|
+
return useCases.addToWorkspace.execute({
|
|
70
|
+
repoPath,
|
|
71
|
+
workspacePath,
|
|
72
|
+
branchName,
|
|
73
|
+
baseBranch: branchResult.baseBranch,
|
|
74
|
+
sessionName,
|
|
75
|
+
copyFiles: config.copyFiles,
|
|
76
|
+
postCheckout: resolvedPostCheckout,
|
|
77
|
+
});
|
|
78
|
+
}));
|
|
74
79
|
// Track which repos were branched from
|
|
75
80
|
services.fetchCache.trackBranchUsage(selected.map((r) => path.basename(r)));
|
|
81
|
+
// Tally results
|
|
82
|
+
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
|
83
|
+
const totalCount = results.length;
|
|
84
|
+
const postCheckoutResults = results
|
|
85
|
+
.filter((r) => r.status === 'fulfilled')
|
|
86
|
+
.map((r) => r.value)
|
|
87
|
+
.filter((r) => r.postCheckoutRan);
|
|
88
|
+
const postCheckoutSuccess = postCheckoutResults.filter((r) => r.postCheckoutSuccess).length;
|
|
89
|
+
const postCheckoutTotal = postCheckoutResults.length;
|
|
76
90
|
// Display results
|
|
77
|
-
services.console.log(`\nCreated workspace at ${chalk.cyan(
|
|
78
|
-
if (
|
|
91
|
+
services.console.log(`\nCreated workspace at ${chalk.cyan(workspacePath)} with ${successCount}/${totalCount} repos.`);
|
|
92
|
+
if (tmuxCreated) {
|
|
79
93
|
services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
|
|
80
94
|
}
|
|
81
|
-
if (
|
|
82
|
-
services.console.log(`\nCompleted post-checkout in ${
|
|
95
|
+
if (postCheckoutTotal > 0) {
|
|
96
|
+
services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
|
|
83
97
|
}
|
|
84
98
|
else if (!config.postCheckout) {
|
|
85
99
|
services.console.log('\nTip: Configure a post-checkout command to run automatically after branching/checkout.');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import confirm from '@inquirer/confirm';
|
|
2
3
|
import chalk from 'chalk';
|
|
3
4
|
import { createServices } from '../lib/services.js';
|
|
@@ -14,23 +15,18 @@ export async function runCheckout(branchName, useCases, services, deps) {
|
|
|
14
15
|
}
|
|
15
16
|
services.console.log('\nChecking for branch...');
|
|
16
17
|
try {
|
|
17
|
-
// Fetch all repos from source-path
|
|
18
|
+
// 1. Fetch all repos from source-path
|
|
18
19
|
await useCases.fetchAllRepos.execute({
|
|
19
20
|
sourcePath,
|
|
20
21
|
fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
|
|
21
22
|
});
|
|
22
|
-
//
|
|
23
|
-
const
|
|
24
|
-
branchName,
|
|
23
|
+
// 2. Discover repos and find which ones have the branch
|
|
24
|
+
const discoverResult = await useCases.discoverReposWithBranch.execute({
|
|
25
25
|
sourcePath,
|
|
26
|
-
|
|
27
|
-
copyFiles: config.copyFiles,
|
|
28
|
-
tmux: config.tmux,
|
|
29
|
-
postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
|
|
30
|
-
perRepoPostCheckout: shouldRunPostCheckout ? config.perRepoPostCheckout : {},
|
|
26
|
+
branchName,
|
|
31
27
|
});
|
|
32
|
-
// Display branch check results
|
|
33
|
-
for (const checkResult of
|
|
28
|
+
// 3. Display per-repo branch check results
|
|
29
|
+
for (const checkResult of discoverResult.branchCheckResults) {
|
|
34
30
|
if (checkResult.error) {
|
|
35
31
|
services.console.log(`${checkResult.repoName}... ${chalk.red(`error: ${checkResult.error}`)}`);
|
|
36
32
|
}
|
|
@@ -41,12 +37,56 @@ export async function runCheckout(branchName, useCases, services, deps) {
|
|
|
41
37
|
services.console.log(`${checkResult.repoName}... ${chalk.dim('no branch')}`);
|
|
42
38
|
}
|
|
43
39
|
}
|
|
44
|
-
|
|
45
|
-
if (
|
|
40
|
+
// 4. Throw error if no repos match
|
|
41
|
+
if (discoverResult.matchingRepos.length === 0) {
|
|
42
|
+
throw new Error(`Branch "${branchName}" not found in any repo.`);
|
|
43
|
+
}
|
|
44
|
+
// 5. Create workspace directory, placeholder config, AGENTS.md, tmux session
|
|
45
|
+
const workspaceResult = await useCases.createWorkspace.execute({
|
|
46
|
+
branchName,
|
|
47
|
+
sourcePath,
|
|
48
|
+
destPath,
|
|
49
|
+
tmux: config.tmux,
|
|
50
|
+
});
|
|
51
|
+
const { workspacePath, tmuxCreated } = workspaceResult;
|
|
52
|
+
const sessionName = tmuxCreated ? branchName : undefined;
|
|
53
|
+
// 6. For each matching repo in parallel: detect base branch, then addToWorkspace
|
|
54
|
+
const results = await Promise.allSettled(discoverResult.matchingRepos.map(async (repoPath) => {
|
|
55
|
+
const repoName = path.basename(repoPath);
|
|
56
|
+
// Resolve per-repo post-checkout command
|
|
57
|
+
const repoConf = services.repoConfig.load(repoPath);
|
|
58
|
+
const resolvedPostCheckout = shouldRunPostCheckout
|
|
59
|
+
? services.repoConfig.resolvePostCheckout(repoName, config.perRepoPostCheckout, repoConf, config.postCheckout)
|
|
60
|
+
: undefined;
|
|
61
|
+
// Detect base branch for this repo
|
|
62
|
+
const baseBranch = await services.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']) ?? 'master';
|
|
63
|
+
// Add repo to workspace (creates worktree, copies files, tmux pane, post-checkout)
|
|
64
|
+
return useCases.addToWorkspace.execute({
|
|
65
|
+
repoPath,
|
|
66
|
+
workspacePath,
|
|
67
|
+
branchName,
|
|
68
|
+
baseBranch,
|
|
69
|
+
sessionName,
|
|
70
|
+
copyFiles: config.copyFiles,
|
|
71
|
+
postCheckout: resolvedPostCheckout,
|
|
72
|
+
});
|
|
73
|
+
}));
|
|
74
|
+
// Tally results
|
|
75
|
+
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
|
76
|
+
const totalCount = results.length;
|
|
77
|
+
const postCheckoutResults = results
|
|
78
|
+
.filter((r) => r.status === 'fulfilled')
|
|
79
|
+
.map((r) => r.value)
|
|
80
|
+
.filter((r) => r.postCheckoutRan);
|
|
81
|
+
const postCheckoutSuccess = postCheckoutResults.filter((r) => r.postCheckoutSuccess).length;
|
|
82
|
+
const postCheckoutTotal = postCheckoutResults.length;
|
|
83
|
+
// 7. Display results
|
|
84
|
+
services.console.log(`\nCreated workspace at ${chalk.cyan(workspacePath)} with ${successCount}/${totalCount} repos.`);
|
|
85
|
+
if (tmuxCreated) {
|
|
46
86
|
services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
|
|
47
87
|
}
|
|
48
|
-
if (
|
|
49
|
-
services.console.log(`\nCompleted post-checkout in ${
|
|
88
|
+
if (postCheckoutTotal > 0) {
|
|
89
|
+
services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
|
|
50
90
|
}
|
|
51
91
|
else if (!config.postCheckout) {
|
|
52
92
|
services.console.log('\nTip: Configure a post-checkout command to run automatically after branching/checkout.');
|
package/dist/commands/helpers.js
CHANGED
|
@@ -13,6 +13,64 @@ export function formatRepoStatusLine(repoName, status, baseBranch) {
|
|
|
13
13
|
: chalk.dim(' (no upstream)');
|
|
14
14
|
return ` ${indicator} ${chalk.yellow(repoName)}: ${message}${trackingInfo}`;
|
|
15
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
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the ordered list of checkbox choices for a repo picker, grouping recently used
|
|
52
|
+
* repos under a "Recently Used" separator and placing the rest below.
|
|
53
|
+
*
|
|
54
|
+
* @param createSeparator - factory from the display layer (e.g. inquirer's Separator constructor)
|
|
55
|
+
*/
|
|
56
|
+
export function buildRepoCheckboxChoices(repos, services, config, createSeparator) {
|
|
57
|
+
const choices = services.repos.formatRepoChoices(repos).map((choice) => ({
|
|
58
|
+
...choice,
|
|
59
|
+
checked: config.branchAutoSelectRepos.includes(choice.name),
|
|
60
|
+
}));
|
|
61
|
+
const recentlyUsed = new Set(services.fetchCache.getRecentlyUsedRepos(8));
|
|
62
|
+
const commonlyUsed = choices.filter((c) => recentlyUsed.has(c.name));
|
|
63
|
+
if (commonlyUsed.length > 0) {
|
|
64
|
+
const commonlyUsedNames = new Set(commonlyUsed.map((c) => c.name));
|
|
65
|
+
const remaining = choices.filter((c) => !commonlyUsedNames.has(c.name));
|
|
66
|
+
return [
|
|
67
|
+
createSeparator('Recently Used'),
|
|
68
|
+
...commonlyUsed,
|
|
69
|
+
...(remaining.length > 0 ? [createSeparator(), ...remaining] : []),
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
return choices;
|
|
73
|
+
}
|
|
16
74
|
/**
|
|
17
75
|
* Get a human-readable status indicator for a workspace based on its worktree statuses.
|
|
18
76
|
*/
|
package/dist/commands/list.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,32 +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
|
-
|
|
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
|
-
services.console.log(formatRepoStatusLine(repoName, status, baseBranch));
|
|
58
|
-
}
|
|
59
|
-
services.console.log(''); // Blank line between workspaces
|
|
60
|
-
}
|
|
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);
|
|
61
31
|
}
|
|
62
32
|
export function registerListCommand(program) {
|
|
63
33
|
program
|
package/dist/commands/status.js
CHANGED
|
@@ -1,47 +1,35 @@
|
|
|
1
|
-
import
|
|
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';
|
|
6
|
-
import {
|
|
5
|
+
import { logStatusFetching, logStatus } from './helpers.js';
|
|
7
6
|
export async function runStatus(branchName, useCases, services) {
|
|
8
7
|
const { sourcePath } = services.config.getRequired();
|
|
9
8
|
const config = services.config.load();
|
|
10
9
|
const { workspacePath } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
11
|
-
services.console.log(`Workspace: ${chalk.cyan(workspacePath)}`);
|
|
12
10
|
const worktreeDirs = services.workspaceDir.getWorktreeDirs(workspacePath);
|
|
13
11
|
if (worktreeDirs.length === 0) {
|
|
14
12
|
services.console.log('\nNo worktrees found in workspace.');
|
|
15
13
|
return;
|
|
16
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);
|
|
17
19
|
// Fetch workspace repos
|
|
18
20
|
await useCases.fetchWorkspaceRepos.execute({
|
|
19
21
|
workspacePath,
|
|
20
22
|
sourcePath,
|
|
21
23
|
fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
|
|
24
|
+
silent: true,
|
|
22
25
|
});
|
|
23
|
-
services.console.log('');
|
|
24
|
-
services.console.log(`\nStatus:\n`);
|
|
25
26
|
const result = await useCases.checkWorkspaceStatus.execute({
|
|
26
27
|
workspacePath,
|
|
27
28
|
});
|
|
28
29
|
// Load workspace config to get per-repo base branches
|
|
29
30
|
const workspaceConfig = services.workspaceConfig.load(workspacePath);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
let issuesCount = 0;
|
|
33
|
-
for (const { repoName, status } of result.statuses) {
|
|
34
|
-
const baseBranch = getBaseBranch(repoName);
|
|
35
|
-
services.console.log(formatRepoStatusLine(repoName, status, baseBranch));
|
|
36
|
-
if (StatusService.hasIssues(status)) {
|
|
37
|
-
issuesCount++;
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
cleanCount++;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
services.console.log('');
|
|
44
|
-
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);
|
|
45
33
|
}
|
|
46
34
|
export function registerStatusCommand(program) {
|
|
47
35
|
program
|
package/dist/lib/git.js
CHANGED
|
@@ -127,6 +127,9 @@ export class GitService {
|
|
|
127
127
|
return true;
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
async createBranch(repoPath, branchName, startPoint) {
|
|
131
|
+
await this.exec(repoPath, ['branch', '--no-track', branchName, startPoint]);
|
|
132
|
+
}
|
|
130
133
|
async removeWorktree(repoPath, worktreePath) {
|
|
131
134
|
await this.exec(repoPath, ['worktree', 'remove', worktreePath]);
|
|
132
135
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const RepoConfigSchema = z.object({
|
|
4
|
+
'copy-files': z.string().optional(),
|
|
5
|
+
'post-checkout': z.string().optional(),
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* RepoConfigService handles loading per-repo flow-config.json files
|
|
9
|
+
* from source repositories.
|
|
10
|
+
*/
|
|
11
|
+
export class RepoConfigService {
|
|
12
|
+
fs;
|
|
13
|
+
constructor(fs) {
|
|
14
|
+
this.fs = fs;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Load flow-config.json from a repo's root directory.
|
|
18
|
+
* Returns undefined if the file doesn't exist, is invalid JSON, or has no relevant fields.
|
|
19
|
+
*/
|
|
20
|
+
load(repoPath) {
|
|
21
|
+
const configPath = path.join(repoPath, 'flow-config.json');
|
|
22
|
+
if (!this.fs.existsSync(configPath)) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const raw = this.fs.readFileSync(configPath, 'utf-8');
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
const validated = RepoConfigSchema.parse(parsed);
|
|
29
|
+
const copyFiles = validated['copy-files'];
|
|
30
|
+
const postCheckout = validated['post-checkout'];
|
|
31
|
+
// Return undefined if no relevant fields are set
|
|
32
|
+
if (!copyFiles && !postCheckout) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return { copyFiles, postCheckout };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the post-checkout command for a repo with 3-level precedence:
|
|
43
|
+
* 1. per-repo-post-checkout from central config (highest priority)
|
|
44
|
+
* 2. post-checkout from repo's flow-config.json
|
|
45
|
+
* 3. global post-checkout from central config (lowest priority)
|
|
46
|
+
*/
|
|
47
|
+
resolvePostCheckout(repoName, perRepoPostCheckout, repoConfig, globalPostCheckout) {
|
|
48
|
+
return (perRepoPostCheckout?.[repoName] ??
|
|
49
|
+
repoConfig?.postCheckout ??
|
|
50
|
+
globalPostCheckout);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the copy-files for a repo with 2-level precedence:
|
|
54
|
+
* 1. copy-files from repo's flow-config.json (overrides global)
|
|
55
|
+
* 2. global copy-files from central config
|
|
56
|
+
*/
|
|
57
|
+
resolveCopyFiles(repoConfig, globalCopyFiles) {
|
|
58
|
+
return repoConfig?.copyFiles ?? globalCopyFiles;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/lib/services.js
CHANGED
|
@@ -14,6 +14,7 @@ import { FetchCacheService } from './fetchCache.js';
|
|
|
14
14
|
import { ParallelService } from './parallel.js';
|
|
15
15
|
import { StatusService } from './status.js';
|
|
16
16
|
import { TmuxService } from './tmux.js';
|
|
17
|
+
import { RepoConfigService } from './repoConfig.js';
|
|
17
18
|
export function createServices() {
|
|
18
19
|
// Create adapters
|
|
19
20
|
const fs = new NodeFileSystem();
|
|
@@ -30,6 +31,7 @@ export function createServices() {
|
|
|
30
31
|
const fetch = new FetchService(git, console, fetchCache);
|
|
31
32
|
const repos = new RepoService(fs, git);
|
|
32
33
|
const postCheckout = new PostCheckoutService(shell);
|
|
34
|
+
const repoConfig = new RepoConfigService(fs);
|
|
33
35
|
// Focused workspace services
|
|
34
36
|
const workspaceDir = new WorkspaceDirectoryService(fs);
|
|
35
37
|
const workspaceConfig = new WorkspaceConfigService(fs);
|
|
@@ -42,6 +44,7 @@ export function createServices() {
|
|
|
42
44
|
workspaceConfig,
|
|
43
45
|
worktree,
|
|
44
46
|
postCheckout,
|
|
47
|
+
repoConfig,
|
|
45
48
|
fetch,
|
|
46
49
|
fetchCache,
|
|
47
50
|
parallel,
|
package/dist/lib/tmux.js
CHANGED
|
@@ -53,6 +53,29 @@ export class TmuxService {
|
|
|
53
53
|
'Enter',
|
|
54
54
|
]);
|
|
55
55
|
}
|
|
56
|
+
async addPane(sessionName, worktreePath) {
|
|
57
|
+
await this.shell.execFile('tmux', [
|
|
58
|
+
'split-window',
|
|
59
|
+
'-t',
|
|
60
|
+
sessionName,
|
|
61
|
+
'-c',
|
|
62
|
+
worktreePath,
|
|
63
|
+
]);
|
|
64
|
+
const { stdout } = await this.shell.execFile('tmux', [
|
|
65
|
+
'display-message',
|
|
66
|
+
'-p',
|
|
67
|
+
'-t',
|
|
68
|
+
sessionName,
|
|
69
|
+
'#{pane_index}',
|
|
70
|
+
]);
|
|
71
|
+
await this.shell.execFile('tmux', [
|
|
72
|
+
'select-layout',
|
|
73
|
+
'-t',
|
|
74
|
+
sessionName,
|
|
75
|
+
'tiled',
|
|
76
|
+
]);
|
|
77
|
+
return parseInt(stdout.trim(), 10);
|
|
78
|
+
}
|
|
56
79
|
async killSession(sessionName) {
|
|
57
80
|
try {
|
|
58
81
|
await this.shell.execFile('tmux', ['kill-session', '-t', sessionName]);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Use case for adding a single repo to an existing workspace.
|
|
4
|
+
* Creates a worktree for an existing branch, copies config files,
|
|
5
|
+
* saves base branch to workspace config, adds a tmux pane (if enabled),
|
|
6
|
+
* and runs post-checkout (if configured).
|
|
7
|
+
*/
|
|
8
|
+
export class AddToWorkspaceUseCase {
|
|
9
|
+
worktree;
|
|
10
|
+
workspaceConfig;
|
|
11
|
+
repoConfig;
|
|
12
|
+
postCheckout;
|
|
13
|
+
tmux;
|
|
14
|
+
constructor(worktree, workspaceConfig, repoConfig, postCheckout, tmux) {
|
|
15
|
+
this.worktree = worktree;
|
|
16
|
+
this.workspaceConfig = workspaceConfig;
|
|
17
|
+
this.repoConfig = repoConfig;
|
|
18
|
+
this.postCheckout = postCheckout;
|
|
19
|
+
this.tmux = tmux;
|
|
20
|
+
}
|
|
21
|
+
async execute(params) {
|
|
22
|
+
const repoName = path.basename(params.repoPath);
|
|
23
|
+
const worktreeDest = path.join(params.workspacePath, repoName);
|
|
24
|
+
// 1. Create worktree by checking out the existing branch
|
|
25
|
+
await this.worktree.createWorktreeCheckout(params.repoPath, worktreeDest, params.branchName);
|
|
26
|
+
// 2. Resolve and copy config files using repo-level overrides with global fallback
|
|
27
|
+
const repoConf = this.repoConfig.load(params.repoPath);
|
|
28
|
+
const resolvedCopyFiles = this.repoConfig.resolveCopyFiles(repoConf, params.copyFiles);
|
|
29
|
+
this.worktree.copyConfigFilesToWorktree(params.repoPath, worktreeDest, resolvedCopyFiles);
|
|
30
|
+
// 3. Save base branch to workspace config
|
|
31
|
+
this.workspaceConfig.save(params.workspacePath, {
|
|
32
|
+
baseBranches: { [repoName]: params.baseBranch },
|
|
33
|
+
});
|
|
34
|
+
// 4. Add tmux pane (if session name provided)
|
|
35
|
+
let paneIndex;
|
|
36
|
+
let tmuxPaneAdded = false;
|
|
37
|
+
if (params.sessionName) {
|
|
38
|
+
try {
|
|
39
|
+
paneIndex = await this.tmux.addPane(params.sessionName, worktreeDest);
|
|
40
|
+
tmuxPaneAdded = true;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Don't fail if tmux pane addition fails
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// 5. Run post-checkout command (if configured)
|
|
47
|
+
const postCheckoutCommand = params.postCheckout;
|
|
48
|
+
let postCheckoutRan = false;
|
|
49
|
+
let postCheckoutSuccess = false;
|
|
50
|
+
if (postCheckoutCommand) {
|
|
51
|
+
postCheckoutRan = true;
|
|
52
|
+
try {
|
|
53
|
+
if (params.sessionName !== undefined && paneIndex !== undefined) {
|
|
54
|
+
// tmux enabled: send keys to the pane
|
|
55
|
+
await this.tmux.sendKeysToPane(params.sessionName, paneIndex, postCheckoutCommand);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
// tmux disabled: run command directly in worktree directory
|
|
59
|
+
await this.postCheckout.runCommandInDirectory(worktreeDest, postCheckoutCommand);
|
|
60
|
+
}
|
|
61
|
+
postCheckoutSuccess = true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Post-checkout failure is non-fatal; reported via postCheckoutSuccess: false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
repoName,
|
|
69
|
+
worktreePath: worktreeDest,
|
|
70
|
+
postCheckoutRan,
|
|
71
|
+
postCheckoutSuccess,
|
|
72
|
+
tmuxPaneAdded,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { RepoService } from '../lib/repos.js';
|
|
2
|
+
const DEFAULT_BRANCH_CANDIDATES = ['master', 'main', 'trunk', 'develop'];
|
|
3
|
+
/**
|
|
4
|
+
* Use case for creating a git branch in a single repo.
|
|
5
|
+
* Handles fallback to default branches when the source branch doesn't exist.
|
|
6
|
+
*/
|
|
7
|
+
export class CreateBranchUseCase {
|
|
8
|
+
git;
|
|
9
|
+
constructor(git) {
|
|
10
|
+
this.git = git;
|
|
11
|
+
}
|
|
12
|
+
async execute(params) {
|
|
13
|
+
const repoName = RepoService.getRepoName(params.repoPath);
|
|
14
|
+
// 1. Check if the source branch exists as a remote-tracking branch
|
|
15
|
+
const sourceBranchExists = await this.git.localRemoteBranchExists(params.repoPath, params.sourceBranch);
|
|
16
|
+
let actualBaseBranch = params.sourceBranch;
|
|
17
|
+
// 2. Fall back to first existing default branch if source branch doesn't exist
|
|
18
|
+
if (!sourceBranchExists) {
|
|
19
|
+
const fallback = await this.git.findFirstExistingBranch(params.repoPath, DEFAULT_BRANCH_CANDIDATES);
|
|
20
|
+
if (fallback === null) {
|
|
21
|
+
throw new Error(`Cannot create branch in ${repoName}: source branch "${params.sourceBranch}" not found and no fallback branch exists (tried: ${DEFAULT_BRANCH_CANDIDATES.join(', ')})`);
|
|
22
|
+
}
|
|
23
|
+
actualBaseBranch = fallback;
|
|
24
|
+
}
|
|
25
|
+
// 3. Create the branch from origin/<actualBaseBranch>
|
|
26
|
+
await this.git.createBranch(params.repoPath, params.branchName, `origin/${actualBaseBranch}`);
|
|
27
|
+
// 4. Return the actual base branch used
|
|
28
|
+
return {
|
|
29
|
+
repoName,
|
|
30
|
+
baseBranch: actualBaseBranch,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use case for creating a new workspace directory with initial config, AGENTS.md copy,
|
|
3
|
+
* and an optional tmux session (root pane only, no worktrees yet).
|
|
4
|
+
*/
|
|
5
|
+
export class CreateWorkspaceUseCase {
|
|
6
|
+
workspaceDir;
|
|
7
|
+
workspaceConfig;
|
|
8
|
+
tmux;
|
|
9
|
+
constructor(workspaceDir, workspaceConfig, tmux) {
|
|
10
|
+
this.workspaceDir = workspaceDir;
|
|
11
|
+
this.workspaceConfig = workspaceConfig;
|
|
12
|
+
this.tmux = tmux;
|
|
13
|
+
}
|
|
14
|
+
async execute(params) {
|
|
15
|
+
// 1. Create workspace directory
|
|
16
|
+
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
17
|
+
// 2. Save placeholder config
|
|
18
|
+
this.workspaceConfig.savePlaceholder(workspacePath);
|
|
19
|
+
// 3. Copy AGENTS.md if it exists in source-path
|
|
20
|
+
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
21
|
+
// 4. Create tmux session (root pane only) if enabled
|
|
22
|
+
let tmuxCreated = false;
|
|
23
|
+
if (params.tmux) {
|
|
24
|
+
try {
|
|
25
|
+
await this.tmux.createSession(workspacePath, params.branchName, []);
|
|
26
|
+
tmuxCreated = true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Don't fail the workspace creation, just report tmux wasn't created
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
workspacePath,
|
|
34
|
+
tmuxCreated,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NoReposFoundError } from '../lib/errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Use case for discovering repos in source-path and checking which ones have a given branch.
|
|
4
|
+
* Extracts repo discovery + branch checking logic into a focused, reusable use case.
|
|
5
|
+
*/
|
|
6
|
+
export class DiscoverReposWithBranchUseCase {
|
|
7
|
+
repos;
|
|
8
|
+
constructor(repos) {
|
|
9
|
+
this.repos = repos;
|
|
10
|
+
}
|
|
11
|
+
async execute(params) {
|
|
12
|
+
// 1. Discover all repos
|
|
13
|
+
const allRepos = this.repos.discoverRepos(params.sourcePath);
|
|
14
|
+
if (allRepos.length === 0) {
|
|
15
|
+
throw new NoReposFoundError(params.sourcePath);
|
|
16
|
+
}
|
|
17
|
+
// 2. Check which repos have the branch
|
|
18
|
+
const { matching, results } = await this.repos.findReposWithBranch(allRepos, params.branchName);
|
|
19
|
+
return {
|
|
20
|
+
allRepos,
|
|
21
|
+
matchingRepos: matching,
|
|
22
|
+
branchCheckResults: results,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { CreateBranchWorkspaceUseCase } from './createBranchWorkspace.js';
|
|
2
|
-
import { CheckoutWorkspaceUseCase } from './checkoutWorkspace.js';
|
|
3
1
|
import { RemoveWorkspaceUseCase } from './removeWorkspace.js';
|
|
4
2
|
import { PushWorkspaceUseCase } from './pushWorkspace.js';
|
|
5
3
|
import { PullWorkspaceUseCase } from './pullWorkspace.js';
|
|
@@ -10,20 +8,19 @@ import { FetchAllReposUseCase } from './fetchAllRepos.js';
|
|
|
10
8
|
import { FetchWorkspaceReposUseCase } from './fetchWorkspaceRepos.js';
|
|
11
9
|
import { FetchUsedReposUseCase } from './fetchUsedRepos.js';
|
|
12
10
|
import { ResumeTmuxSessionsUseCase } from './resumeTmuxSessions.js';
|
|
13
|
-
import {
|
|
11
|
+
import { CreateWorkspaceUseCase } from './createWorkspace.js';
|
|
12
|
+
import { CreateBranchUseCase } from './createBranch.js';
|
|
13
|
+
import { AddToWorkspaceUseCase } from './addToWorkspace.js';
|
|
14
|
+
import { DiscoverReposWithBranchUseCase } from './discoverReposWithBranch.js';
|
|
14
15
|
/**
|
|
15
16
|
* Factory function for creating all use cases with their service dependencies.
|
|
16
17
|
* Use cases orchestrate workflows by coordinating multiple services.
|
|
17
18
|
*/
|
|
18
19
|
export function createUseCases(services) {
|
|
19
|
-
// Create use cases that are dependencies first
|
|
20
|
-
const runPostCheckout = new RunPostCheckoutUseCase(services.workspaceDir, services.postCheckout, services.tmux);
|
|
21
20
|
return {
|
|
22
21
|
fetchAllRepos: new FetchAllReposUseCase(services.fetch, services.repos),
|
|
23
22
|
fetchWorkspaceRepos: new FetchWorkspaceReposUseCase(services.workspaceDir, services.fetch),
|
|
24
23
|
fetchUsedRepos: new FetchUsedReposUseCase(services.workspaceDir, services.fetch),
|
|
25
|
-
createBranchWorkspace: new CreateBranchWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.git, services.parallel, services.tmux, runPostCheckout),
|
|
26
|
-
checkoutWorkspace: new CheckoutWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.git, services.parallel, services.tmux, runPostCheckout),
|
|
27
24
|
removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.status, services.tmux),
|
|
28
25
|
pushWorkspace: new PushWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
|
|
29
26
|
pullWorkspace: new PullWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
|
|
@@ -31,6 +28,9 @@ export function createUseCases(services) {
|
|
|
31
28
|
discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.workspaceConfig, services.status, services.git),
|
|
32
29
|
listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
|
|
33
30
|
resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux),
|
|
34
|
-
|
|
31
|
+
createWorkspace: new CreateWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.tmux),
|
|
32
|
+
createBranch: new CreateBranchUseCase(services.git),
|
|
33
|
+
addToWorkspace: new AddToWorkspaceUseCase(services.worktree, services.workspaceConfig, services.repoConfig, services.postCheckout, services.tmux),
|
|
34
|
+
discoverReposWithBranch: new DiscoverReposWithBranchUseCase(services.repos),
|
|
35
35
|
};
|
|
36
36
|
}
|
package/package.json
CHANGED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { RepoService } from '../lib/repos.js';
|
|
3
|
-
import { NoReposFoundError } from '../lib/errors.js';
|
|
4
|
-
/**
|
|
5
|
-
* Use case for checking out an existing branch across multiple repos.
|
|
6
|
-
* Orchestrates repo discovery, branch checking, and workspace creation.
|
|
7
|
-
*/
|
|
8
|
-
export class CheckoutWorkspaceUseCase {
|
|
9
|
-
workspaceDir;
|
|
10
|
-
workspaceConfig;
|
|
11
|
-
worktree;
|
|
12
|
-
repos;
|
|
13
|
-
git;
|
|
14
|
-
parallel;
|
|
15
|
-
tmux;
|
|
16
|
-
runPostCheckout;
|
|
17
|
-
constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, runPostCheckout) {
|
|
18
|
-
this.workspaceDir = workspaceDir;
|
|
19
|
-
this.workspaceConfig = workspaceConfig;
|
|
20
|
-
this.worktree = worktree;
|
|
21
|
-
this.repos = repos;
|
|
22
|
-
this.git = git;
|
|
23
|
-
this.parallel = parallel;
|
|
24
|
-
this.tmux = tmux;
|
|
25
|
-
this.runPostCheckout = runPostCheckout;
|
|
26
|
-
}
|
|
27
|
-
async execute(params) {
|
|
28
|
-
// 1. Discover all repos
|
|
29
|
-
const allRepos = this.repos.discoverRepos(params.sourcePath);
|
|
30
|
-
if (allRepos.length === 0) {
|
|
31
|
-
throw new NoReposFoundError(params.sourcePath);
|
|
32
|
-
}
|
|
33
|
-
// 2. Find repos that have this branch
|
|
34
|
-
const { matching, results } = await this.repos.findReposWithBranch(allRepos, params.branchName);
|
|
35
|
-
if (matching.length === 0) {
|
|
36
|
-
throw new Error(`Branch "${params.branchName}" not found in any repo.`);
|
|
37
|
-
}
|
|
38
|
-
// 3. Create workspace directory
|
|
39
|
-
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
40
|
-
this.workspaceConfig.savePlaceholder(workspacePath);
|
|
41
|
-
// 4. Create worktrees in parallel
|
|
42
|
-
const successCount = await this.parallel.processInParallel(matching, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
|
|
43
|
-
const name = RepoService.getRepoName(repoPath);
|
|
44
|
-
const worktreeDest = path.join(workspacePath, name);
|
|
45
|
-
await this.worktree.createWorktreeCheckout(repoPath, worktreeDest, params.branchName);
|
|
46
|
-
this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
|
|
47
|
-
return 'created';
|
|
48
|
-
});
|
|
49
|
-
// 5. Copy AGENTS.md
|
|
50
|
-
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
51
|
-
// 6. Detect base branches for each repo
|
|
52
|
-
const baseBranches = {};
|
|
53
|
-
for (const repoPath of matching) {
|
|
54
|
-
const repoName = RepoService.getRepoName(repoPath);
|
|
55
|
-
const baseBranch = await this.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']);
|
|
56
|
-
baseBranches[repoName] = baseBranch || 'master';
|
|
57
|
-
}
|
|
58
|
-
// 7. Save workspace config with base branches
|
|
59
|
-
this.workspaceConfig.save(workspacePath, { baseBranches });
|
|
60
|
-
// 8. Create tmux session if enabled
|
|
61
|
-
let tmuxCreated = false;
|
|
62
|
-
if (params.tmux) {
|
|
63
|
-
try {
|
|
64
|
-
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
|
|
65
|
-
await this.tmux.createSession(workspacePath, params.branchName, worktreeDirs);
|
|
66
|
-
tmuxCreated = true;
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
// Don't fail
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
// 9. Run post-checkout if configured
|
|
73
|
-
const postCheckoutResult = await this.runPostCheckout.execute({
|
|
74
|
-
workspacePath,
|
|
75
|
-
sessionName: tmuxCreated ? params.branchName : undefined,
|
|
76
|
-
tmuxEnabled: tmuxCreated,
|
|
77
|
-
postCheckout: params.postCheckout,
|
|
78
|
-
perRepoPostCheckout: params.perRepoPostCheckout,
|
|
79
|
-
});
|
|
80
|
-
return {
|
|
81
|
-
workspacePath,
|
|
82
|
-
matchingRepos: matching.length,
|
|
83
|
-
successCount,
|
|
84
|
-
totalCount: matching.length,
|
|
85
|
-
tmuxCreated,
|
|
86
|
-
postCheckoutSuccess: postCheckoutResult?.successCount,
|
|
87
|
-
postCheckoutTotal: postCheckoutResult?.totalCount,
|
|
88
|
-
branchCheckResults: results,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { RepoService } from '../lib/repos.js';
|
|
3
|
-
/**
|
|
4
|
-
* Use case for creating a workspace with new branches across multiple repos.
|
|
5
|
-
* Orchestrates the entire workflow from workspace creation to post-checkout commands.
|
|
6
|
-
*/
|
|
7
|
-
export class CreateBranchWorkspaceUseCase {
|
|
8
|
-
workspaceDir;
|
|
9
|
-
workspaceConfig;
|
|
10
|
-
worktree;
|
|
11
|
-
repos;
|
|
12
|
-
git;
|
|
13
|
-
parallel;
|
|
14
|
-
tmux;
|
|
15
|
-
runPostCheckout;
|
|
16
|
-
constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, runPostCheckout) {
|
|
17
|
-
this.workspaceDir = workspaceDir;
|
|
18
|
-
this.workspaceConfig = workspaceConfig;
|
|
19
|
-
this.worktree = worktree;
|
|
20
|
-
this.repos = repos;
|
|
21
|
-
this.git = git;
|
|
22
|
-
this.parallel = parallel;
|
|
23
|
-
this.tmux = tmux;
|
|
24
|
-
this.runPostCheckout = runPostCheckout;
|
|
25
|
-
}
|
|
26
|
-
async execute(params) {
|
|
27
|
-
// 1. Create workspace directory
|
|
28
|
-
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
29
|
-
this.workspaceConfig.savePlaceholder(workspacePath);
|
|
30
|
-
// Track base branches for each repo
|
|
31
|
-
const baseBranches = {};
|
|
32
|
-
// 2. Create worktrees in parallel
|
|
33
|
-
const successCount = await this.parallel.processInParallel(params.repos, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
|
|
34
|
-
const name = RepoService.getRepoName(repoPath);
|
|
35
|
-
const worktreeDest = path.join(workspacePath, name);
|
|
36
|
-
// Determine which base branch to use
|
|
37
|
-
let actualBaseBranch = params.sourceBranch;
|
|
38
|
-
// Try the user-specified source branch first, fall back if it doesn't exist
|
|
39
|
-
const branchExists = await this.git.localRemoteBranchExists(repoPath, params.sourceBranch);
|
|
40
|
-
if (!branchExists) {
|
|
41
|
-
const fallbackBranch = await this.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']);
|
|
42
|
-
if (fallbackBranch) {
|
|
43
|
-
actualBaseBranch = fallbackBranch;
|
|
44
|
-
}
|
|
45
|
-
// If no fallback found, still try with the original (will fail with clear error)
|
|
46
|
-
}
|
|
47
|
-
// Track the actual base branch used
|
|
48
|
-
baseBranches[name] = actualBaseBranch;
|
|
49
|
-
await this.worktree.createWorktreeWithBranch(repoPath, worktreeDest, params.branchName, `origin/${actualBaseBranch}`);
|
|
50
|
-
this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
|
|
51
|
-
return 'created';
|
|
52
|
-
});
|
|
53
|
-
// 3. Copy AGENTS.md if exists
|
|
54
|
-
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
55
|
-
// 4. Save workspace config with base branches
|
|
56
|
-
this.workspaceConfig.save(workspacePath, { baseBranches });
|
|
57
|
-
// 5. Create tmux session if enabled
|
|
58
|
-
let tmuxCreated = false;
|
|
59
|
-
if (params.tmux) {
|
|
60
|
-
try {
|
|
61
|
-
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
|
|
62
|
-
await this.tmux.createSession(workspacePath, params.branchName, worktreeDirs);
|
|
63
|
-
tmuxCreated = true;
|
|
64
|
-
}
|
|
65
|
-
catch (error) {
|
|
66
|
-
// Don't fail, just return false
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
// 6. Run post-checkout command if configured
|
|
70
|
-
const postCheckoutResult = await this.runPostCheckout.execute({
|
|
71
|
-
workspacePath,
|
|
72
|
-
sessionName: tmuxCreated ? params.branchName : undefined,
|
|
73
|
-
tmuxEnabled: tmuxCreated,
|
|
74
|
-
postCheckout: params.postCheckout,
|
|
75
|
-
perRepoPostCheckout: params.perRepoPostCheckout,
|
|
76
|
-
});
|
|
77
|
-
return {
|
|
78
|
-
workspacePath,
|
|
79
|
-
successCount,
|
|
80
|
-
totalCount: params.repos.length,
|
|
81
|
-
tmuxCreated,
|
|
82
|
-
postCheckoutSuccess: postCheckoutResult?.successCount,
|
|
83
|
-
postCheckoutTotal: postCheckoutResult?.totalCount,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
/**
|
|
3
|
-
* Use case for running post-checkout commands.
|
|
4
|
-
* Runs commands either directly in worktree directories or in tmux panes.
|
|
5
|
-
*/
|
|
6
|
-
export class RunPostCheckoutUseCase {
|
|
7
|
-
workspaceDir;
|
|
8
|
-
postCheckout;
|
|
9
|
-
tmux;
|
|
10
|
-
constructor(workspaceDir, postCheckout, tmux) {
|
|
11
|
-
this.workspaceDir = workspaceDir;
|
|
12
|
-
this.postCheckout = postCheckout;
|
|
13
|
-
this.tmux = tmux;
|
|
14
|
-
}
|
|
15
|
-
async execute(params) {
|
|
16
|
-
// Skip if no commands configured
|
|
17
|
-
if (!params.postCheckout && (!params.perRepoPostCheckout || Object.keys(params.perRepoPostCheckout).length === 0)) {
|
|
18
|
-
return undefined;
|
|
19
|
-
}
|
|
20
|
-
// Get worktree directories
|
|
21
|
-
const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
|
|
22
|
-
// Map each worktree to its command (per-repo overrides global)
|
|
23
|
-
const commandsToRun = worktreeDirs
|
|
24
|
-
.map((dir, index) => {
|
|
25
|
-
const repoName = path.basename(dir);
|
|
26
|
-
const command = params.perRepoPostCheckout?.[repoName] ?? params.postCheckout;
|
|
27
|
-
return command ? { dir, command, paneIndex: index + 1 } : null;
|
|
28
|
-
})
|
|
29
|
-
.filter((x) => x !== null);
|
|
30
|
-
// Run commands in parallel
|
|
31
|
-
let successCount = 0;
|
|
32
|
-
await Promise.allSettled(commandsToRun.map(async ({ dir, command, paneIndex }) => {
|
|
33
|
-
try {
|
|
34
|
-
if (params.tmuxEnabled && params.sessionName) {
|
|
35
|
-
// Panes are indexed from 0, first pane (root) is 0, worktrees start at 1
|
|
36
|
-
await this.tmux.sendKeysToPane(params.sessionName, paneIndex, command);
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
await this.postCheckout.runCommandInDirectory(dir, command);
|
|
40
|
-
}
|
|
41
|
-
successCount++;
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
// Error handled by counting successes
|
|
45
|
-
}
|
|
46
|
-
}));
|
|
47
|
-
return { successCount, totalCount: commandsToRun.length };
|
|
48
|
-
}
|
|
49
|
-
}
|