worktree-flow 0.0.17 → 0.0.19
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 +13 -9
- package/dist/cli.js +6 -4
- package/dist/commands/{add.js → attach.js} +9 -9
- package/dist/commands/branch.js +3 -110
- package/dist/commands/create.js +118 -0
- package/dist/commands/{remove.js → drop.js} +28 -23
- package/dist/commands/fetch.js +25 -12
- package/dist/commands/helpers.js +2 -33
- package/dist/commands/prune.js +29 -7
- package/dist/commands/quickstart.js +1 -1
- package/dist/commands/tmux.js +3 -3
- package/dist/lib/workspaceResolver.js +13 -0
- package/dist/usecases/createBranch.js +7 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ npm install -g worktree-flow
|
|
|
26
26
|
flow quickstart
|
|
27
27
|
|
|
28
28
|
# Create a workspace with new branches
|
|
29
|
-
flow
|
|
29
|
+
flow create TICKET-123
|
|
30
30
|
# → Select repos interactively, creates branches + worktrees
|
|
31
31
|
|
|
32
32
|
# Or checkout an existing branch
|
|
@@ -48,7 +48,7 @@ Working on features that span multiple repositories means manually creating bran
|
|
|
48
48
|
|
|
49
49
|
## Commands
|
|
50
50
|
|
|
51
|
-
### `flow
|
|
51
|
+
### `flow create <name>`
|
|
52
52
|
|
|
53
53
|
Create a new branch across selected repos. Interactively select which repos to include, then creates branches and worktrees in a new workspace directory.
|
|
54
54
|
|
|
@@ -56,9 +56,9 @@ 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
|
|
59
|
+
### `flow attach [name]`
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Attach 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
62
|
|
|
63
63
|
### `flow pull`
|
|
64
64
|
|
|
@@ -81,15 +81,19 @@ List all workspaces with status indicators. Shows:
|
|
|
81
81
|
|
|
82
82
|
Fetches all repos before checking status to ensure accurate information. The status check uses git's patch-id comparison, which correctly handles squash-merged branches.
|
|
83
83
|
|
|
84
|
-
### `flow
|
|
84
|
+
### `flow drop <name>`
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
Drop a workspace and all its worktrees. Fetches latest, checks for uncommitted changes, and prompts for confirmation before removing. Committed changes are safe to drop since they're preserved in git history.
|
|
87
87
|
|
|
88
|
-
### `flow prune`
|
|
88
|
+
### `flow prune`
|
|
89
89
|
|
|
90
|
-
Remove workspaces interactively.
|
|
90
|
+
Remove workspaces interactively. Displays full workspace status (identical to `flow list`), then excludes any workspaces with uncommitted changes or errors from the selection prompt. Resolve issues before pruning those workspaces.
|
|
91
91
|
|
|
92
|
-
### `flow
|
|
92
|
+
### `flow fetch [name]`
|
|
93
|
+
|
|
94
|
+
Fetch repos for a specific workspace (when branch name provided), or fetch all repos used across all workspaces (when no branch given). Always bypasses the fetch cache.
|
|
95
|
+
|
|
96
|
+
### `flow tmux sync`
|
|
93
97
|
|
|
94
98
|
Create tmux sessions for all workspaces that don't already have one. Each session is created with split panes (one for the workspace root and one for each worktree that matches a repo from source-path) using a tiled layout. Skips workspaces that already have active sessions.
|
|
95
99
|
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { registerConfigCommand } from './commands/config.js';
|
|
4
|
-
import {
|
|
4
|
+
import { registerCreateCommand } from './commands/create.js';
|
|
5
|
+
import { registerAttachCommand } from './commands/attach.js';
|
|
5
6
|
import { registerBranchCommand } from './commands/branch.js';
|
|
6
7
|
import { registerCheckoutCommand } from './commands/checkout.js';
|
|
7
8
|
import { registerListCommand } from './commands/list.js';
|
|
8
9
|
import { registerPullCommand } from './commands/pull.js';
|
|
9
10
|
import { registerPushCommand } from './commands/push.js';
|
|
10
|
-
import {
|
|
11
|
+
import { registerDropCommand } from './commands/drop.js';
|
|
11
12
|
import { registerStatusCommand } from './commands/status.js';
|
|
12
13
|
import { registerPruneCommand } from './commands/prune.js';
|
|
13
14
|
import { registerFetchCommand } from './commands/fetch.js';
|
|
@@ -19,13 +20,14 @@ program
|
|
|
19
20
|
.description('Manage git worktrees across a poly-repo environment')
|
|
20
21
|
.version('0.1.0');
|
|
21
22
|
registerConfigCommand(program);
|
|
22
|
-
|
|
23
|
+
registerCreateCommand(program);
|
|
24
|
+
registerAttachCommand(program);
|
|
23
25
|
registerBranchCommand(program);
|
|
24
26
|
registerCheckoutCommand(program);
|
|
25
27
|
registerListCommand(program);
|
|
26
28
|
registerPullCommand(program);
|
|
27
29
|
registerPushCommand(program);
|
|
28
|
-
|
|
30
|
+
registerDropCommand(program);
|
|
29
31
|
registerStatusCommand(program);
|
|
30
32
|
registerPruneCommand(program);
|
|
31
33
|
registerFetchCommand(program);
|
|
@@ -8,7 +8,7 @@ import { createUseCases } from '../usecases/usecases.js';
|
|
|
8
8
|
import { NoReposFoundError } from '../lib/errors.js';
|
|
9
9
|
import { resolveWorkspace } from '../lib/workspaceResolver.js';
|
|
10
10
|
import { buildRepoCheckboxChoices } from './helpers.js';
|
|
11
|
-
export async function
|
|
11
|
+
export async function runAttach(branchName, useCases, services, deps) {
|
|
12
12
|
// 1. Resolve workspace (from arg or cwd)
|
|
13
13
|
const { workspacePath, displayName } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
14
14
|
const { sourcePath } = services.config.getRequired();
|
|
@@ -29,9 +29,9 @@ export async function runAdd(branchName, useCases, services, deps) {
|
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
// 4. Repo picker (same pattern as branch command)
|
|
32
|
-
const checkboxChoices = buildRepoCheckboxChoices(availableRepos, services,
|
|
32
|
+
const checkboxChoices = buildRepoCheckboxChoices(availableRepos, services, [], (label) => new Separator(label));
|
|
33
33
|
const selected = await deps.checkbox({
|
|
34
|
-
message: `Select repos to
|
|
34
|
+
message: `Select repos to attach to "${displayName}":`,
|
|
35
35
|
choices: checkboxChoices,
|
|
36
36
|
pageSize: 20,
|
|
37
37
|
loop: false,
|
|
@@ -53,7 +53,7 @@ export async function runAdd(branchName, useCases, services, deps) {
|
|
|
53
53
|
default: true,
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
-
services.console.log('\
|
|
56
|
+
services.console.log('\nAttaching repos to workspace...');
|
|
57
57
|
// 7. Fetch selected repos
|
|
58
58
|
await services.fetch.fetchRepos(selected, {
|
|
59
59
|
ttlSeconds: config.fetchCacheTtlSeconds,
|
|
@@ -97,20 +97,20 @@ export async function runAdd(branchName, useCases, services, deps) {
|
|
|
97
97
|
const postCheckoutSuccess = postCheckoutResults.filter((r) => r.postCheckoutSuccess).length;
|
|
98
98
|
const postCheckoutTotal = postCheckoutResults.length;
|
|
99
99
|
// 10. Display results
|
|
100
|
-
services.console.log(`\
|
|
100
|
+
services.console.log(`\nAttached ${successCount}/${totalCount} repos to ${chalk.cyan(workspacePath)}.`);
|
|
101
101
|
if (postCheckoutTotal > 0) {
|
|
102
102
|
services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
export function
|
|
105
|
+
export function registerAttachCommand(program) {
|
|
106
106
|
program
|
|
107
|
-
.command('
|
|
108
|
-
.description('
|
|
107
|
+
.command('attach [branch-name]')
|
|
108
|
+
.description('Attach repos to an existing workspace (auto-detects from current directory if branch not provided)')
|
|
109
109
|
.action(async (branchName) => {
|
|
110
110
|
const services = createServices();
|
|
111
111
|
const useCases = createUseCases(services);
|
|
112
112
|
try {
|
|
113
|
-
await
|
|
113
|
+
await runAttach(branchName, useCases, services, { checkbox, input, confirm });
|
|
114
114
|
}
|
|
115
115
|
catch (error) {
|
|
116
116
|
services.console.error(error.message);
|
package/dist/commands/branch.js
CHANGED
|
@@ -1,118 +1,11 @@
|
|
|
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
1
|
import chalk from 'chalk';
|
|
6
2
|
import { createServices } from '../lib/services.js';
|
|
7
|
-
import { createUseCases } from '../usecases/usecases.js';
|
|
8
|
-
import { NoReposFoundError } from '../lib/errors.js';
|
|
9
|
-
import { buildRepoCheckboxChoices } from './helpers.js';
|
|
10
|
-
export async function runBranch(branchName, useCases, services, deps) {
|
|
11
|
-
const { sourcePath, destPath } = services.config.getRequired();
|
|
12
|
-
const config = services.config.load();
|
|
13
|
-
const repos = services.repos.discoverRepos(sourcePath);
|
|
14
|
-
if (repos.length === 0) {
|
|
15
|
-
throw new NoReposFoundError(sourcePath);
|
|
16
|
-
}
|
|
17
|
-
// User prompts
|
|
18
|
-
const checkboxChoices = buildRepoCheckboxChoices(repos, services, config, (label) => new Separator(label));
|
|
19
|
-
const selected = await deps.checkbox({
|
|
20
|
-
message: `Select repos for branch "${branchName}":`,
|
|
21
|
-
choices: checkboxChoices,
|
|
22
|
-
pageSize: 20,
|
|
23
|
-
loop: false,
|
|
24
|
-
});
|
|
25
|
-
if (selected.length === 0) {
|
|
26
|
-
services.console.log('No repos selected.');
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
const sourceBranch = await deps.input({
|
|
30
|
-
message: 'Branch from which branch?',
|
|
31
|
-
default: 'master',
|
|
32
|
-
});
|
|
33
|
-
let shouldRunPostCheckout = false;
|
|
34
|
-
if (config.postCheckout) {
|
|
35
|
-
shouldRunPostCheckout = await deps.confirm({
|
|
36
|
-
message: `Run "${config.postCheckout}" in all workspaces?`,
|
|
37
|
-
default: true,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
services.console.log('\nCreating workspace...');
|
|
41
|
-
// Fetch all selected repos
|
|
42
|
-
await services.fetch.fetchRepos(selected, {
|
|
43
|
-
ttlSeconds: config.fetchCacheTtlSeconds,
|
|
44
|
-
});
|
|
45
|
-
// 1. Create workspace directory, placeholder config, AGENTS.md, tmux session
|
|
46
|
-
const workspaceResult = await useCases.createWorkspace.execute({
|
|
47
|
-
branchName,
|
|
48
|
-
sourcePath,
|
|
49
|
-
destPath,
|
|
50
|
-
tmux: config.tmux,
|
|
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
|
-
}));
|
|
79
|
-
// Track which repos were branched from
|
|
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;
|
|
90
|
-
// Display results
|
|
91
|
-
services.console.log(`\nCreated workspace at ${chalk.cyan(workspacePath)} with ${successCount}/${totalCount} repos.`);
|
|
92
|
-
if (tmuxCreated) {
|
|
93
|
-
services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
|
|
94
|
-
}
|
|
95
|
-
if (postCheckoutTotal > 0) {
|
|
96
|
-
services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
|
|
97
|
-
}
|
|
98
|
-
else if (!config.postCheckout) {
|
|
99
|
-
services.console.log('\nTip: Configure a post-checkout command to run automatically after branching/checkout.');
|
|
100
|
-
services.console.log(' Example: flow config set post-checkout "npm ci"');
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
3
|
export function registerBranchCommand(program) {
|
|
104
4
|
program
|
|
105
5
|
.command('branch <branch-name>')
|
|
106
|
-
.description('
|
|
107
|
-
.action(async (
|
|
6
|
+
.description(chalk.dim('Deprecated: use "flow create <branch-name>" instead'))
|
|
7
|
+
.action(async () => {
|
|
108
8
|
const services = createServices();
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
await runBranch(branchName, useCases, services, { checkbox, input, confirm });
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
services.console.error(error.message);
|
|
115
|
-
services.process.exit(1);
|
|
116
|
-
}
|
|
9
|
+
services.console.log(chalk.yellow('⚠ "flow branch" is deprecated. Use "flow create" instead.'));
|
|
117
10
|
});
|
|
118
11
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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 { buildRepoCheckboxChoices } from './helpers.js';
|
|
10
|
+
export async function runCreate(branchName, useCases, services, deps) {
|
|
11
|
+
const { sourcePath, destPath } = services.config.getRequired();
|
|
12
|
+
const config = services.config.load();
|
|
13
|
+
const repos = services.repos.discoverRepos(sourcePath);
|
|
14
|
+
if (repos.length === 0) {
|
|
15
|
+
throw new NoReposFoundError(sourcePath);
|
|
16
|
+
}
|
|
17
|
+
// User prompts
|
|
18
|
+
const checkboxChoices = buildRepoCheckboxChoices(repos, services, config.branchAutoSelectRepos, (label) => new Separator(label));
|
|
19
|
+
const selected = await deps.checkbox({
|
|
20
|
+
message: `Select repos for branch "${branchName}":`,
|
|
21
|
+
choices: checkboxChoices,
|
|
22
|
+
pageSize: 20,
|
|
23
|
+
loop: false,
|
|
24
|
+
});
|
|
25
|
+
if (selected.length === 0) {
|
|
26
|
+
services.console.log('No repos selected.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const sourceBranch = await deps.input({
|
|
30
|
+
message: 'Branch from which branch?',
|
|
31
|
+
default: 'master',
|
|
32
|
+
});
|
|
33
|
+
let shouldRunPostCheckout = false;
|
|
34
|
+
if (config.postCheckout) {
|
|
35
|
+
shouldRunPostCheckout = await deps.confirm({
|
|
36
|
+
message: `Run "${config.postCheckout}" in all workspaces?`,
|
|
37
|
+
default: true,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
services.console.log('\nCreating workspace...');
|
|
41
|
+
// Fetch all selected repos
|
|
42
|
+
await services.fetch.fetchRepos(selected, {
|
|
43
|
+
ttlSeconds: config.fetchCacheTtlSeconds,
|
|
44
|
+
});
|
|
45
|
+
// 1. Create workspace directory, placeholder config, AGENTS.md, tmux session
|
|
46
|
+
const workspaceResult = await useCases.createWorkspace.execute({
|
|
47
|
+
branchName,
|
|
48
|
+
sourcePath,
|
|
49
|
+
destPath,
|
|
50
|
+
tmux: config.tmux,
|
|
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
|
+
}));
|
|
79
|
+
// Track which repos were branched from
|
|
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;
|
|
90
|
+
// Display results
|
|
91
|
+
services.console.log(`\nCreated workspace at ${chalk.cyan(workspacePath)} with ${successCount}/${totalCount} repos.`);
|
|
92
|
+
if (tmuxCreated) {
|
|
93
|
+
services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
|
|
94
|
+
}
|
|
95
|
+
if (postCheckoutTotal > 0) {
|
|
96
|
+
services.console.log(`\nCompleted post-checkout in ${postCheckoutSuccess}/${postCheckoutTotal} workspace(s).`);
|
|
97
|
+
}
|
|
98
|
+
else if (!config.postCheckout) {
|
|
99
|
+
services.console.log('\nTip: Configure a post-checkout command to run automatically after branching/checkout.');
|
|
100
|
+
services.console.log(' Example: flow config set post-checkout "npm ci"');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function registerCreateCommand(program) {
|
|
104
|
+
program
|
|
105
|
+
.command('create <branch-name>')
|
|
106
|
+
.description('Create branches and worktrees for selected repos')
|
|
107
|
+
.action(async (branchName) => {
|
|
108
|
+
const services = createServices();
|
|
109
|
+
const useCases = createUseCases(services);
|
|
110
|
+
try {
|
|
111
|
+
await runCreate(branchName, useCases, services, { checkbox, input, confirm });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
services.console.error(error.message);
|
|
115
|
+
services.process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -1,41 +1,46 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import chalk from 'chalk';
|
|
2
3
|
import confirm from '@inquirer/confirm';
|
|
3
4
|
import { createServices } from '../lib/services.js';
|
|
4
5
|
import { createUseCases } from '../usecases/usecases.js';
|
|
5
|
-
import { StatusService } from '../lib/status.js';
|
|
6
6
|
import { resolveWorkspace } from '../lib/workspaceResolver.js';
|
|
7
|
-
|
|
7
|
+
import { logStatusFetching, logStatus } from './helpers.js';
|
|
8
|
+
import { StatusService } from '../lib/status.js';
|
|
9
|
+
import { WorkspaceHasIssuesError } from '../lib/errors.js';
|
|
10
|
+
export async function runDrop(branchName, useCases, services, deps) {
|
|
8
11
|
const { sourcePath } = services.config.getRequired();
|
|
9
12
|
const config = services.config.load();
|
|
10
13
|
const { workspacePath, displayName: branchNameForDisplay } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
11
|
-
services.console.log(`Checking workspace: ${chalk.cyan(workspacePath)}`);
|
|
12
14
|
// Get worktree dirs to show what will be removed
|
|
13
15
|
const worktreeDirs = services.workspaceDir.getWorktreeDirs(workspacePath);
|
|
14
16
|
// Display status check if worktrees exist
|
|
15
17
|
if (worktreeDirs.length > 0) {
|
|
18
|
+
const workspaceName = path.basename(workspacePath);
|
|
19
|
+
const repoCount = worktreeDirs.length;
|
|
20
|
+
// Phase 1: Show header with fetching indicator
|
|
21
|
+
const loadingLines = logStatusFetching('Workspace:', [{ name: workspaceName, repoCount }], services.console);
|
|
22
|
+
// Fetch workspace repos (silently)
|
|
16
23
|
await useCases.fetchWorkspaceRepos.execute({
|
|
17
24
|
workspacePath,
|
|
18
25
|
sourcePath,
|
|
19
26
|
fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
|
|
27
|
+
silent: true,
|
|
28
|
+
});
|
|
29
|
+
const statusResult = await useCases.checkWorkspaceStatus.execute({
|
|
30
|
+
workspacePath,
|
|
20
31
|
});
|
|
21
|
-
services.console.log(`\nChecking for uncommitted changes...`);
|
|
22
32
|
// Load workspace config to get per-repo base branches
|
|
23
33
|
const workspaceConfig = services.workspaceConfig.load(workspacePath);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
services.console.log(`${repoName}... ${chalk.red(message)}`);
|
|
31
|
-
}
|
|
32
|
-
else {
|
|
33
|
-
services.console.log(`${repoName}... ${chalk.green(message)}`);
|
|
34
|
-
}
|
|
34
|
+
// Phase 2: Clear Phase 1 lines and re-render with full status
|
|
35
|
+
logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: statusResult.statuses }], loadingLines, (_, repoName) => workspaceConfig.baseBranches[repoName] || 'master', services.console);
|
|
36
|
+
// Block if any repos have uncommitted changes — user must resolve them first
|
|
37
|
+
const reposWithIssues = statusResult.statuses.filter(({ status }) => StatusService.hasIssues(status));
|
|
38
|
+
if (reposWithIssues.length > 0) {
|
|
39
|
+
throw new WorkspaceHasIssuesError(`${reposWithIssues.length} repo(s) have uncommitted changes or errors. Resolve them before dropping.`);
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
else {
|
|
38
|
-
services.console.log('
|
|
43
|
+
services.console.log('\nNo worktrees found in workspace.');
|
|
39
44
|
}
|
|
40
45
|
// Show what will be removed
|
|
41
46
|
services.console.log(`\n${chalk.yellow('This will remove:')}`);
|
|
@@ -47,7 +52,7 @@ export async function runRemove(branchName, useCases, services, deps) {
|
|
|
47
52
|
services.console.log(` Tmux session: ${chalk.cyan(branchNameForDisplay)}`);
|
|
48
53
|
}
|
|
49
54
|
const confirmed = await deps.confirm({
|
|
50
|
-
message: 'Are you sure you want to
|
|
55
|
+
message: 'Are you sure you want to drop this workspace?',
|
|
51
56
|
default: false,
|
|
52
57
|
});
|
|
53
58
|
if (!confirmed) {
|
|
@@ -55,7 +60,7 @@ export async function runRemove(branchName, useCases, services, deps) {
|
|
|
55
60
|
services.process.exit(0);
|
|
56
61
|
}
|
|
57
62
|
// Execute use case (will throw if there are issues)
|
|
58
|
-
services.console.log('\
|
|
63
|
+
services.console.log('\nDropping workspace...');
|
|
59
64
|
const result = await useCases.removeWorkspace.execute({
|
|
60
65
|
workspacePath,
|
|
61
66
|
branchName: branchNameForDisplay,
|
|
@@ -85,17 +90,17 @@ export async function runRemove(branchName, useCases, services, deps) {
|
|
|
85
90
|
if (result.tmuxKilled) {
|
|
86
91
|
services.console.log(`${chalk.green('Killed tmux session:')} ${branchNameForDisplay}`);
|
|
87
92
|
}
|
|
88
|
-
services.console.log(`\n${chalk.green('Successfully
|
|
93
|
+
services.console.log(`\n${chalk.green('Successfully dropped workspace:')} ${branchNameForDisplay}`);
|
|
89
94
|
}
|
|
90
|
-
export function
|
|
95
|
+
export function registerDropCommand(program) {
|
|
91
96
|
program
|
|
92
|
-
.command('
|
|
93
|
-
.description('
|
|
97
|
+
.command('drop [branch-name]')
|
|
98
|
+
.description('Drop a workspace and all its worktrees (auto-detects from current directory if branch not provided)')
|
|
94
99
|
.action(async (branchName) => {
|
|
95
100
|
const services = createServices();
|
|
96
101
|
const useCases = createUseCases(services);
|
|
97
102
|
try {
|
|
98
|
-
await
|
|
103
|
+
await runDrop(branchName, useCases, services, { confirm });
|
|
99
104
|
}
|
|
100
105
|
catch (error) {
|
|
101
106
|
services.console.error(error.message);
|
package/dist/commands/fetch.js
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { createServices } from '../lib/services.js';
|
|
3
3
|
import { createUseCases } from '../usecases/usecases.js';
|
|
4
|
-
|
|
4
|
+
import { tryResolveWorkspace } from '../lib/workspaceResolver.js';
|
|
5
|
+
export async function runFetch(branchName, useCases, services) {
|
|
5
6
|
const { destPath, sourcePath } = services.config.getRequired();
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
const workspace = tryResolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
8
|
+
if (workspace) {
|
|
9
|
+
services.console.log(`Fetching repos for workspace ${chalk.cyan(workspace.displayName)}...\n`);
|
|
10
|
+
await useCases.fetchWorkspaceRepos.execute({
|
|
11
|
+
workspacePath: workspace.workspacePath,
|
|
12
|
+
sourcePath,
|
|
13
|
+
fetchCacheTtlSeconds: 0,
|
|
14
|
+
silent: false,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
services.console.log('Fetching all repos used across workspaces...\n');
|
|
19
|
+
await useCases.fetchUsedRepos.execute({
|
|
20
|
+
destPath,
|
|
21
|
+
sourcePath,
|
|
22
|
+
fetchCacheTtlSeconds: 0,
|
|
23
|
+
silent: false,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
13
26
|
services.console.log(`\n${chalk.green('✓')} Fetch complete`);
|
|
14
27
|
}
|
|
15
28
|
export function registerFetchCommand(program) {
|
|
16
29
|
program
|
|
17
|
-
.command('fetch')
|
|
18
|
-
.description('Fetch
|
|
19
|
-
.action(async () => {
|
|
30
|
+
.command('fetch [branch-name]')
|
|
31
|
+
.description('Fetch repos (workspace-scoped if branch provided, all workspaces otherwise)')
|
|
32
|
+
.action(async (branchName) => {
|
|
20
33
|
const services = createServices();
|
|
21
34
|
const useCases = createUseCases(services);
|
|
22
35
|
try {
|
|
23
|
-
await runFetch(useCases, services);
|
|
36
|
+
await runFetch(branchName, useCases, services);
|
|
24
37
|
}
|
|
25
38
|
catch (error) {
|
|
26
39
|
services.console.error(error.message);
|
package/dist/commands/helpers.js
CHANGED
|
@@ -53,10 +53,10 @@ export function logStatus(header, workspaces, linesToClear, getBaseBranch, conso
|
|
|
53
53
|
*
|
|
54
54
|
* @param createSeparator - factory from the display layer (e.g. inquirer's Separator constructor)
|
|
55
55
|
*/
|
|
56
|
-
export function buildRepoCheckboxChoices(repos, services,
|
|
56
|
+
export function buildRepoCheckboxChoices(repos, services, preSelected, createSeparator) {
|
|
57
57
|
const choices = services.repos.formatRepoChoices(repos).map((choice) => ({
|
|
58
58
|
...choice,
|
|
59
|
-
checked:
|
|
59
|
+
checked: preSelected.includes(choice.name),
|
|
60
60
|
}));
|
|
61
61
|
const recentlyUsed = new Set(services.fetchCache.getRecentlyUsedRepos(8));
|
|
62
62
|
const commonlyUsed = choices.filter((c) => recentlyUsed.has(c.name));
|
|
@@ -71,34 +71,3 @@ export function buildRepoCheckboxChoices(repos, services, config, createSeparato
|
|
|
71
71
|
}
|
|
72
72
|
return choices;
|
|
73
73
|
}
|
|
74
|
-
/**
|
|
75
|
-
* Get a human-readable status indicator for a workspace based on its worktree statuses.
|
|
76
|
-
*/
|
|
77
|
-
export function getStatusIndicator(workspace) {
|
|
78
|
-
const hasUncommitted = workspace.statuses.some(s => s.status.type === 'uncommitted');
|
|
79
|
-
const hasAhead = workspace.statuses.some(s => s.status.type === 'ahead');
|
|
80
|
-
const hasBehind = workspace.statuses.some(s => s.status.type === 'behind');
|
|
81
|
-
const hasDiverged = workspace.statuses.some(s => s.status.type === 'diverged');
|
|
82
|
-
const hasError = workspace.statuses.some(s => s.status.type === 'error');
|
|
83
|
-
if (hasUncommitted) {
|
|
84
|
-
return chalk.yellow('uncommitted');
|
|
85
|
-
}
|
|
86
|
-
else if (hasDiverged) {
|
|
87
|
-
return chalk.red('diverged');
|
|
88
|
-
}
|
|
89
|
-
else if (hasAhead && hasBehind) {
|
|
90
|
-
return chalk.yellow('ahead');
|
|
91
|
-
}
|
|
92
|
-
else if (hasAhead) {
|
|
93
|
-
return chalk.yellow('ahead');
|
|
94
|
-
}
|
|
95
|
-
else if (hasBehind) {
|
|
96
|
-
return chalk.blue('behind');
|
|
97
|
-
}
|
|
98
|
-
else if (hasError) {
|
|
99
|
-
return chalk.red('error');
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
return chalk.green('clean');
|
|
103
|
-
}
|
|
104
|
-
}
|
package/dist/commands/prune.js
CHANGED
|
@@ -3,7 +3,8 @@ import checkbox from '@inquirer/checkbox';
|
|
|
3
3
|
import confirm from '@inquirer/confirm';
|
|
4
4
|
import { createServices } from '../lib/services.js';
|
|
5
5
|
import { createUseCases } from '../usecases/usecases.js';
|
|
6
|
-
import {
|
|
6
|
+
import { logStatusFetching, logStatus } from './helpers.js';
|
|
7
|
+
import { StatusService } from '../lib/status.js';
|
|
7
8
|
export async function runPrune(useCases, services, deps) {
|
|
8
9
|
const { sourcePath, destPath } = services.config.getRequired();
|
|
9
10
|
const config = services.config.load();
|
|
@@ -14,11 +15,14 @@ export async function runPrune(useCases, services, deps) {
|
|
|
14
15
|
services.console.log('No workspaces found.');
|
|
15
16
|
services.process.exit(0);
|
|
16
17
|
}
|
|
18
|
+
// Phase 1: Show basic list with fetching indicator
|
|
19
|
+
const loadingLines = logStatusFetching('Workspaces:', basicWorkspaces, services.console);
|
|
17
20
|
// Fetch repos used across all workspaces
|
|
18
21
|
await useCases.fetchUsedRepos.execute({
|
|
19
22
|
destPath,
|
|
20
23
|
sourcePath,
|
|
21
24
|
fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
|
|
25
|
+
silent: true,
|
|
22
26
|
});
|
|
23
27
|
// Get full status for all workspaces
|
|
24
28
|
const result = await useCases.listWorkspacesWithStatus.execute({
|
|
@@ -26,12 +30,31 @@ export async function runPrune(useCases, services, deps) {
|
|
|
26
30
|
sourcePath,
|
|
27
31
|
cwd,
|
|
28
32
|
});
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
// Phase 2: Clear Phase 1 and re-print with full status (same as `list` command)
|
|
34
|
+
logStatus('Workspaces:', result.workspaces, loadingLines, (wsPath, repoName) => services.workspaceConfig.load(wsPath).baseBranches[repoName] || 'master', services.console);
|
|
35
|
+
// Partition workspaces into prunable (no issues) and skipped (has issues)
|
|
36
|
+
const prunableWorkspaces = result.workspaces.filter(ws => !ws.statuses.some(({ status }) => StatusService.hasIssues(status)));
|
|
37
|
+
const skippedWorkspaces = result.workspaces.filter(ws => ws.statuses.some(({ status }) => StatusService.hasIssues(status)));
|
|
38
|
+
// Log skipped workspaces with reason
|
|
39
|
+
for (const ws of skippedWorkspaces) {
|
|
40
|
+
const hasUncommitted = ws.statuses.some(s => s.status.type === 'uncommitted');
|
|
41
|
+
const hasError = ws.statuses.some(s => s.status.type === 'error');
|
|
42
|
+
const reason = hasUncommitted ? 'uncommitted changes' : hasError ? 'errors' : 'issues';
|
|
43
|
+
services.console.log(`${chalk.yellow('⚠')} Skipping ${chalk.cyan(ws.name)} (${reason})`);
|
|
44
|
+
}
|
|
45
|
+
// Newline if there were skipped workspaces
|
|
46
|
+
if (skippedWorkspaces.length)
|
|
47
|
+
console.log('');
|
|
48
|
+
// If no prunable workspaces remain, exit
|
|
49
|
+
if (prunableWorkspaces.length === 0) {
|
|
50
|
+
services.console.log('\nAll workspaces have uncommitted changes or errors. Resolve them before pruning.');
|
|
51
|
+
services.process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
// Format choices for checkbox prompt (only prunable workspaces)
|
|
54
|
+
const choices = prunableWorkspaces.map(workspace => {
|
|
32
55
|
const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
|
|
33
56
|
return {
|
|
34
|
-
name: `${chalk.cyan(workspace.name)} ${repoCount}
|
|
57
|
+
name: `${chalk.cyan(workspace.name)} ${repoCount}`,
|
|
35
58
|
value: workspace.name,
|
|
36
59
|
};
|
|
37
60
|
});
|
|
@@ -46,7 +69,7 @@ export async function runPrune(useCases, services, deps) {
|
|
|
46
69
|
services.process.exit(0);
|
|
47
70
|
}
|
|
48
71
|
// Get full workspace objects for selected items
|
|
49
|
-
const selectedWorkspaces =
|
|
72
|
+
const selectedWorkspaces = prunableWorkspaces.filter(w => selected.includes(w.name));
|
|
50
73
|
// Confirm deletion
|
|
51
74
|
const confirmed = await deps.confirm({
|
|
52
75
|
message: `Are you sure you want to prune ${selected.length} workspace(s)?`,
|
|
@@ -89,7 +112,6 @@ export async function runPrune(useCases, services, deps) {
|
|
|
89
112
|
export function registerPruneCommand(program) {
|
|
90
113
|
program
|
|
91
114
|
.command('prune')
|
|
92
|
-
.alias('clean')
|
|
93
115
|
.description('Select and remove workspaces')
|
|
94
116
|
.action(async () => {
|
|
95
117
|
const services = createServices();
|
|
@@ -133,7 +133,7 @@ export async function runQuickstart(services, deps) {
|
|
|
133
133
|
services.console.log('');
|
|
134
134
|
services.console.log(chalk.bold(' Get started:'));
|
|
135
135
|
services.console.log('');
|
|
136
|
-
services.console.log(` ${chalk.cyan('flow
|
|
136
|
+
services.console.log(` ${chalk.cyan('flow create my-feature')} Create a new branch across repos`);
|
|
137
137
|
services.console.log(` ${chalk.cyan('flow checkout my-feature')} Checkout an existing branch`);
|
|
138
138
|
services.console.log(` ${chalk.cyan('flow list')} See all your workspaces`);
|
|
139
139
|
services.console.log('');
|
package/dist/commands/tmux.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { createServices } from '../lib/services.js';
|
|
3
3
|
import { createUseCases } from '../usecases/usecases.js';
|
|
4
|
-
export async function
|
|
4
|
+
export async function runTmuxSync(useCases, services) {
|
|
5
5
|
const { destPath } = services.config.getRequired();
|
|
6
6
|
const result = await useCases.resumeTmuxSessions.execute({ destPath });
|
|
7
7
|
if (result.totalWorkspaces === 0) {
|
|
@@ -29,13 +29,13 @@ export function registerTmuxCommand(program) {
|
|
|
29
29
|
.command('tmux')
|
|
30
30
|
.description('Manage tmux sessions for workspaces');
|
|
31
31
|
tmuxCommand
|
|
32
|
-
.command('
|
|
32
|
+
.command('sync')
|
|
33
33
|
.description('Create tmux sessions for all workspaces that don\'t have one')
|
|
34
34
|
.action(async () => {
|
|
35
35
|
const services = createServices();
|
|
36
36
|
const useCases = createUseCases(services);
|
|
37
37
|
try {
|
|
38
|
-
await
|
|
38
|
+
await runTmuxSync(useCases, services);
|
|
39
39
|
}
|
|
40
40
|
catch (error) {
|
|
41
41
|
services.console.error(error.message);
|
|
@@ -39,3 +39,16 @@ export function resolveWorkspace(branchName, workspaceDir, config, process) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Like resolveWorkspace, but returns null instead of throwing when no workspace
|
|
44
|
+
* can be resolved. Useful when workspace resolution is optional (e.g. falling back
|
|
45
|
+
* to a broader operation when not in a workspace).
|
|
46
|
+
*/
|
|
47
|
+
export function tryResolveWorkspace(branchName, workspaceDir, config, process) {
|
|
48
|
+
try {
|
|
49
|
+
return resolveWorkspace(branchName, workspaceDir, config, process);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -22,9 +22,13 @@ export class CreateBranchUseCase {
|
|
|
22
22
|
}
|
|
23
23
|
actualBaseBranch = fallback;
|
|
24
24
|
}
|
|
25
|
-
// 3.
|
|
26
|
-
await this.git.
|
|
27
|
-
|
|
25
|
+
// 3. If the target branch already exists, skip creation and use it as-is
|
|
26
|
+
const targetBranchExists = await this.git.localRemoteBranchExists(params.repoPath, params.branchName);
|
|
27
|
+
if (!targetBranchExists) {
|
|
28
|
+
// 4. Create the branch from origin/<actualBaseBranch>
|
|
29
|
+
await this.git.createBranch(params.repoPath, params.branchName, `origin/${actualBaseBranch}`);
|
|
30
|
+
}
|
|
31
|
+
// 5. Return the actual base branch used
|
|
28
32
|
return {
|
|
29
33
|
repoName,
|
|
30
34
|
baseBranch: actualBaseBranch,
|