worktree-flow 0.0.1
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 +156 -0
- package/dist/adapters/node.js +61 -0
- package/dist/adapters/types.js +5 -0
- package/dist/cli.js +26 -0
- package/dist/commands/branch.js +76 -0
- package/dist/commands/checkout.js +64 -0
- package/dist/commands/config.js +50 -0
- package/dist/commands/list.js +30 -0
- package/dist/commands/prune.js +93 -0
- package/dist/commands/pull.js +31 -0
- package/dist/commands/push.js +31 -0
- package/dist/commands/remove.js +98 -0
- package/dist/commands/status.js +52 -0
- package/dist/lib/config.js +131 -0
- package/dist/lib/errors.js +39 -0
- package/dist/lib/fetch.js +58 -0
- package/dist/lib/git.js +124 -0
- package/dist/lib/parallel.js +26 -0
- package/dist/lib/postCheckout.js +22 -0
- package/dist/lib/repos.js +63 -0
- package/dist/lib/services.js +47 -0
- package/dist/lib/status.js +91 -0
- package/dist/lib/tmux.js +38 -0
- package/dist/lib/workspaceDirectory.js +80 -0
- package/dist/lib/workspaceResolver.js +41 -0
- package/dist/lib/worktree.js +45 -0
- package/dist/usecases/checkWorkspaceStatus.js +19 -0
- package/dist/usecases/checkoutWorkspace.js +78 -0
- package/dist/usecases/createBranchWorkspace.js +65 -0
- package/dist/usecases/discoverPrunableWorkspaces.js +76 -0
- package/dist/usecases/pullWorkspace.js +25 -0
- package/dist/usecases/pushWorkspace.js +24 -0
- package/dist/usecases/removeWorkspace.js +90 -0
- package/dist/usecases/usecases.js +22 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# worktree-flow
|
|
2
|
+
|
|
3
|
+
Manage git worktrees across a poly-repo environment.
|
|
4
|
+
|
|
5
|
+
## What is worktree-flow?
|
|
6
|
+
|
|
7
|
+
`flow` helps you work on multi-repo features by creating isolated workspace directories with git worktrees. Instead of switching branches across multiple repositories manually, flow creates a workspace folder containing worktrees for all relevant repos on the same branch.
|
|
8
|
+
|
|
9
|
+
**Before:**
|
|
10
|
+
```
|
|
11
|
+
~/repos/
|
|
12
|
+
├── AGENTS.md
|
|
13
|
+
├── my-api-1/ (main branch)
|
|
14
|
+
├── my-api-2/ (main branch)
|
|
15
|
+
└── my-client/ (main branch)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**After running `flow branch TICKET-123`:**
|
|
19
|
+
```
|
|
20
|
+
~/workspaces/TICKET-123/
|
|
21
|
+
├── AGENTS.md (copied from ~/repos)
|
|
22
|
+
├── my-api-1/ (TICKET-123 branch - new worktree)
|
|
23
|
+
└── my-client/ (TICKET-123 branch - new worktree)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g worktree-flow
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install locally:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install worktree-flow
|
|
36
|
+
npm link
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
Set the paths where your repos live and where workspaces should be created:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
flow config set source-path ~/repos
|
|
45
|
+
flow config set dest-path ~/workspaces
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Optional configuration:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Set the main branch name (default: master)
|
|
52
|
+
flow config set main-branch main
|
|
53
|
+
|
|
54
|
+
# Enable tmux session creation (default: false)
|
|
55
|
+
flow config set tmux true
|
|
56
|
+
|
|
57
|
+
# Set config files to copy to worktrees (default: .env)
|
|
58
|
+
flow config set copy-files .env,.env.local
|
|
59
|
+
|
|
60
|
+
# Set command to run after checkout/branching (optional, prompts if set)
|
|
61
|
+
flow config set post-checkout "npm ci"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Configuration is stored in `~/.config/flow/config.json`.
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
### Create a new branch across repos
|
|
69
|
+
|
|
70
|
+
Interactively select which repos to branch:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
flow branch TICKET-123
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Use spacebar to select repos, enter to confirm. Creates new branches and worktrees.
|
|
77
|
+
|
|
78
|
+
If you've configured a post-checkout command, flow will prompt you to run it in all workspaces in parallel. This is useful for tasks like installing dependencies after branching.
|
|
79
|
+
|
|
80
|
+
### Checkout an existing branch
|
|
81
|
+
|
|
82
|
+
Automatically detects which repos have the branch:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
flow checkout TICKET-123
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Fetches all repos and creates worktrees for repos that have the branch.
|
|
89
|
+
|
|
90
|
+
If you've configured a post-checkout command, flow will prompt you to run it in all workspaces in parallel. This is useful for tasks like installing dependencies after switching branches.
|
|
91
|
+
|
|
92
|
+
### Pull changes in a workspace
|
|
93
|
+
|
|
94
|
+
From anywhere inside a workspace directory:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cd ~/workspaces/TICKET-123
|
|
98
|
+
flow pull
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Pulls latest changes for all repos in the workspace.
|
|
102
|
+
|
|
103
|
+
### Push changes in a workspace
|
|
104
|
+
|
|
105
|
+
From anywhere inside a workspace directory:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
cd ~/workspaces/TICKET-123/my-api-1
|
|
109
|
+
flow push
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Pushes all repos in the workspace. Automatically sets upstream on first push.
|
|
113
|
+
|
|
114
|
+
### Check workspace status
|
|
115
|
+
|
|
116
|
+
Check the status of all repos in a workspace:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
flow status TICKET-123
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Fetches latest changes from remote, then shows which repos have:
|
|
123
|
+
- Uncommitted changes
|
|
124
|
+
- Commits ahead of the main branch
|
|
125
|
+
- Are up to date
|
|
126
|
+
|
|
127
|
+
This helps you quickly see what needs attention before removing a workspace.
|
|
128
|
+
|
|
129
|
+
### Remove a workspace
|
|
130
|
+
|
|
131
|
+
Remove a workspace and all its worktrees:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
flow remove TICKET-123
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
This command will:
|
|
138
|
+
1. Fetch latest changes from remote
|
|
139
|
+
2. Check all worktrees for uncommitted changes and changes ahead of main (diff against configured main branch)
|
|
140
|
+
3. Abort if any uncommitted changes or commits ahead of main are found
|
|
141
|
+
4. Show what will be removed and ask for confirmation (y/n)
|
|
142
|
+
5. Remove all worktrees from their source repos
|
|
143
|
+
6. Delete the workspace folder
|
|
144
|
+
7. Kill the tmux session (if tmux is enabled)
|
|
145
|
+
|
|
146
|
+
The "ahead of main" check compares your branch against the configured main branch (default: `master`) to detect actual code differences, so it's safe to remove branches even after they've been squash-merged and the remote branch deleted.
|
|
147
|
+
|
|
148
|
+
Use this to clean up when you're done with a branch.
|
|
149
|
+
|
|
150
|
+
## AGENTS.md
|
|
151
|
+
|
|
152
|
+
If an `AGENTS.md` file exists at the root of your source-path, it will be copied to each workspace root.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production adapters using real Node.js APIs.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
export class NodeFileSystem {
|
|
9
|
+
existsSync(path) {
|
|
10
|
+
return fs.existsSync(path);
|
|
11
|
+
}
|
|
12
|
+
readFileSync(path, encoding) {
|
|
13
|
+
return fs.readFileSync(path, encoding);
|
|
14
|
+
}
|
|
15
|
+
writeFileSync(path, content) {
|
|
16
|
+
fs.writeFileSync(path, content);
|
|
17
|
+
}
|
|
18
|
+
mkdirSync(path, options) {
|
|
19
|
+
fs.mkdirSync(path, options);
|
|
20
|
+
}
|
|
21
|
+
readdirSync(path, options) {
|
|
22
|
+
return fs.readdirSync(path, options);
|
|
23
|
+
}
|
|
24
|
+
copyFileSync(src, dest) {
|
|
25
|
+
fs.copyFileSync(src, dest);
|
|
26
|
+
}
|
|
27
|
+
rmSync(path, options) {
|
|
28
|
+
fs.rmSync(path, options);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class NodeShell {
|
|
32
|
+
async execFile(command, args, options) {
|
|
33
|
+
const result = await execFileAsync(command, args, {
|
|
34
|
+
...options,
|
|
35
|
+
encoding: options?.encoding || 'utf-8',
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
stdout: result.stdout.trim(),
|
|
39
|
+
stderr: result.stderr.trim(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class NodeConsole {
|
|
44
|
+
log(message) {
|
|
45
|
+
console.log(message);
|
|
46
|
+
}
|
|
47
|
+
error(message) {
|
|
48
|
+
console.error(message);
|
|
49
|
+
}
|
|
50
|
+
write(message) {
|
|
51
|
+
process.stdout.write(message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export class NodeProcess {
|
|
55
|
+
exit(code) {
|
|
56
|
+
process.exit(code);
|
|
57
|
+
}
|
|
58
|
+
cwd() {
|
|
59
|
+
return process.cwd();
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { registerConfigCommand } from './commands/config.js';
|
|
4
|
+
import { registerBranchCommand } from './commands/branch.js';
|
|
5
|
+
import { registerCheckoutCommand } from './commands/checkout.js';
|
|
6
|
+
import { registerListCommand } from './commands/list.js';
|
|
7
|
+
import { registerPullCommand } from './commands/pull.js';
|
|
8
|
+
import { registerPushCommand } from './commands/push.js';
|
|
9
|
+
import { registerRemoveCommand } from './commands/remove.js';
|
|
10
|
+
import { registerStatusCommand } from './commands/status.js';
|
|
11
|
+
import { registerPruneCommand } from './commands/prune.js';
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name('flow')
|
|
15
|
+
.description('Manage git worktrees across a poly-repo environment')
|
|
16
|
+
.version('0.1.0');
|
|
17
|
+
registerConfigCommand(program);
|
|
18
|
+
registerBranchCommand(program);
|
|
19
|
+
registerCheckoutCommand(program);
|
|
20
|
+
registerListCommand(program);
|
|
21
|
+
registerPullCommand(program);
|
|
22
|
+
registerPushCommand(program);
|
|
23
|
+
registerRemoveCommand(program);
|
|
24
|
+
registerStatusCommand(program);
|
|
25
|
+
registerPruneCommand(program);
|
|
26
|
+
program.parse();
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import checkbox from '@inquirer/checkbox';
|
|
2
|
+
import input from '@inquirer/input';
|
|
3
|
+
import confirm from '@inquirer/confirm';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { createServices } from '../lib/services.js';
|
|
6
|
+
import { createUseCases } from '../usecases/usecases.js';
|
|
7
|
+
import { NoReposFoundError } from '../lib/errors.js';
|
|
8
|
+
export async function runBranch(branchName, useCases, services, deps) {
|
|
9
|
+
const { sourcePath, destPath } = services.config.getRequired();
|
|
10
|
+
const config = services.config.load();
|
|
11
|
+
const repos = services.repos.discoverRepos(sourcePath);
|
|
12
|
+
if (repos.length === 0) {
|
|
13
|
+
throw new NoReposFoundError(sourcePath);
|
|
14
|
+
}
|
|
15
|
+
// User prompts
|
|
16
|
+
const selected = await deps.checkbox({
|
|
17
|
+
message: `Select repos for branch "${branchName}":`,
|
|
18
|
+
choices: services.repos.formatRepoChoices(repos),
|
|
19
|
+
pageSize: 20,
|
|
20
|
+
});
|
|
21
|
+
if (selected.length === 0) {
|
|
22
|
+
services.console.log('No repos selected.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const sourceBranch = await deps.input({
|
|
26
|
+
message: 'Branch from which branch?',
|
|
27
|
+
default: 'master',
|
|
28
|
+
});
|
|
29
|
+
let shouldRunPostCheckout = false;
|
|
30
|
+
if (config.postCheckout) {
|
|
31
|
+
shouldRunPostCheckout = await deps.confirm({
|
|
32
|
+
message: `Run "${config.postCheckout}" in all workspaces?`,
|
|
33
|
+
default: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
services.console.log('\nCreating workspace...');
|
|
37
|
+
// Execute use case
|
|
38
|
+
const result = await useCases.createBranchWorkspace.execute({
|
|
39
|
+
repos: selected,
|
|
40
|
+
branchName,
|
|
41
|
+
sourceBranch,
|
|
42
|
+
sourcePath,
|
|
43
|
+
destPath,
|
|
44
|
+
copyFiles: config.copyFiles,
|
|
45
|
+
tmux: config.tmux,
|
|
46
|
+
postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
|
|
47
|
+
});
|
|
48
|
+
// Display results
|
|
49
|
+
services.console.log(`\nCreated workspace at ${chalk.cyan(result.workspacePath)} with ${result.successCount}/${result.totalCount} repos.`);
|
|
50
|
+
if (result.tmuxCreated) {
|
|
51
|
+
services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
|
|
52
|
+
}
|
|
53
|
+
if (result.postCheckoutSuccess !== undefined) {
|
|
54
|
+
services.console.log(`\nCompleted post-checkout in ${result.postCheckoutSuccess}/${result.postCheckoutTotal} workspace(s).`);
|
|
55
|
+
}
|
|
56
|
+
else if (!config.postCheckout) {
|
|
57
|
+
services.console.log('\nTip: Configure a post-checkout command to run automatically after branching/checkout.');
|
|
58
|
+
services.console.log(' Example: flow config set post-checkout "npm ci"');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function registerBranchCommand(program) {
|
|
62
|
+
program
|
|
63
|
+
.command('branch <branch-name>')
|
|
64
|
+
.description('Create branches and worktrees for selected repos')
|
|
65
|
+
.action(async (branchName) => {
|
|
66
|
+
const services = createServices();
|
|
67
|
+
const useCases = createUseCases(services);
|
|
68
|
+
try {
|
|
69
|
+
await runBranch(branchName, useCases, services, { checkbox, input, confirm });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
services.console.error(error.message);
|
|
73
|
+
services.process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import confirm from '@inquirer/confirm';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { createServices } from '../lib/services.js';
|
|
4
|
+
import { createUseCases } from '../usecases/usecases.js';
|
|
5
|
+
export async function runCheckout(branchName, useCases, services, deps) {
|
|
6
|
+
const { sourcePath, destPath } = services.config.getRequired();
|
|
7
|
+
const config = services.config.load();
|
|
8
|
+
let shouldRunPostCheckout = false;
|
|
9
|
+
if (config.postCheckout) {
|
|
10
|
+
shouldRunPostCheckout = await deps.confirm({
|
|
11
|
+
message: `Run "${config.postCheckout}" in all workspaces?`,
|
|
12
|
+
default: true,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
services.console.log('\nChecking for branch...');
|
|
16
|
+
try {
|
|
17
|
+
// Execute use case
|
|
18
|
+
const result = await useCases.checkoutWorkspace.execute({
|
|
19
|
+
branchName,
|
|
20
|
+
sourcePath,
|
|
21
|
+
destPath,
|
|
22
|
+
copyFiles: config.copyFiles,
|
|
23
|
+
tmux: config.tmux,
|
|
24
|
+
postCheckout: shouldRunPostCheckout ? config.postCheckout : undefined,
|
|
25
|
+
});
|
|
26
|
+
// Display branch check results
|
|
27
|
+
for (const checkResult of result.branchCheckResults) {
|
|
28
|
+
if (checkResult.error) {
|
|
29
|
+
services.console.log(`${checkResult.repoName}... ${chalk.red(`error: ${checkResult.error}`)}`);
|
|
30
|
+
}
|
|
31
|
+
else if (checkResult.hasBranch) {
|
|
32
|
+
services.console.log(`${checkResult.repoName}... ${chalk.green('found')}`);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
services.console.log(`${checkResult.repoName}... ${chalk.dim('no branch')}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
services.console.log(`\nCreated workspace at ${chalk.cyan(result.workspacePath)} with ${result.successCount}/${result.totalCount} repos.`);
|
|
39
|
+
if (result.tmuxCreated) {
|
|
40
|
+
services.console.log(`Created tmux session: ${chalk.cyan(branchName)}`);
|
|
41
|
+
}
|
|
42
|
+
if (result.postCheckoutSuccess !== undefined) {
|
|
43
|
+
services.console.log(`\nCompleted post-checkout in ${result.postCheckoutSuccess}/${result.postCheckoutTotal} workspace(s).`);
|
|
44
|
+
}
|
|
45
|
+
else if (!config.postCheckout) {
|
|
46
|
+
services.console.log('\nTip: Configure a post-checkout command to run automatically after branching/checkout.');
|
|
47
|
+
services.console.log(' Example: flow config set post-checkout "npm ci"');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
services.console.error(error.message);
|
|
52
|
+
services.process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function registerCheckoutCommand(program) {
|
|
56
|
+
program
|
|
57
|
+
.command('checkout <branch-name>')
|
|
58
|
+
.description('Checkout an existing branch across repos')
|
|
59
|
+
.action(async (branchName) => {
|
|
60
|
+
const services = createServices();
|
|
61
|
+
const useCases = createUseCases(services);
|
|
62
|
+
await runCheckout(branchName, useCases, services, { confirm });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { isValidKey, validateAndTransformConfigValue, CONFIG_KEYS, } from '../lib/config.js';
|
|
4
|
+
import { createServices } from '../lib/services.js';
|
|
5
|
+
export function registerConfigCommand(program) {
|
|
6
|
+
const configCmd = program
|
|
7
|
+
.command('config')
|
|
8
|
+
.description('Manage flow configuration');
|
|
9
|
+
configCmd
|
|
10
|
+
.command('set <key> <value>')
|
|
11
|
+
.description('Set a config value (source-path, dest-path, copy-files, tmux, main-branch, post-checkout)')
|
|
12
|
+
.action((key, value) => {
|
|
13
|
+
const services = createServices();
|
|
14
|
+
if (!isValidKey(key)) {
|
|
15
|
+
services.console.error(`Unknown config key: ${key}\nValid keys: ${CONFIG_KEYS.join(', ')}`);
|
|
16
|
+
services.process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const config = services.config.loadRaw();
|
|
20
|
+
const transformedValue = validateAndTransformConfigValue(key, value);
|
|
21
|
+
config[key] = transformedValue;
|
|
22
|
+
services.config.saveRaw(config);
|
|
23
|
+
services.console.log(chalk.green(`Set ${key} = ${transformedValue}`));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof z.ZodError) {
|
|
27
|
+
services.console.error(`Invalid value for ${key}: ${error.issues[0].message}`);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
services.console.error(`Error setting config: ${error}`);
|
|
31
|
+
}
|
|
32
|
+
services.process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
configCmd
|
|
36
|
+
.command('list')
|
|
37
|
+
.description('List all config options and their current values')
|
|
38
|
+
.action(() => {
|
|
39
|
+
const services = createServices();
|
|
40
|
+
services.console.log(chalk.bold('\nCurrent configuration:'));
|
|
41
|
+
services.console.log('');
|
|
42
|
+
const displayConfig = services.config.getDisplayConfig();
|
|
43
|
+
for (const key of CONFIG_KEYS) {
|
|
44
|
+
const { value, isDefault } = displayConfig[key];
|
|
45
|
+
const displayValue = isDefault ? chalk.gray(value) : chalk.green(value);
|
|
46
|
+
services.console.log(` ${chalk.cyan(key)}: ${displayValue}`);
|
|
47
|
+
}
|
|
48
|
+
services.console.log('');
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { createServices } from '../lib/services.js';
|
|
3
|
+
export async function runList(services) {
|
|
4
|
+
const { destPath } = services.config.getRequired();
|
|
5
|
+
const workspaces = services.workspaceDir.listWorkspaces(destPath);
|
|
6
|
+
if (workspaces.length === 0) {
|
|
7
|
+
services.console.log('No workspaces found.');
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
services.console.log(chalk.bold('\nWorkspaces:'));
|
|
11
|
+
for (const workspace of workspaces) {
|
|
12
|
+
services.console.log(` ${chalk.cyan(workspace.name)} ${chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`)}`);
|
|
13
|
+
}
|
|
14
|
+
services.console.log('');
|
|
15
|
+
}
|
|
16
|
+
export function registerListCommand(program) {
|
|
17
|
+
program
|
|
18
|
+
.command('list')
|
|
19
|
+
.description('List all workspaces in the target directory')
|
|
20
|
+
.action(async () => {
|
|
21
|
+
const services = createServices();
|
|
22
|
+
try {
|
|
23
|
+
await runList(services);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
services.console.error(error.message);
|
|
27
|
+
services.process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import confirm from '@inquirer/confirm';
|
|
3
|
+
import { createServices } from '../lib/services.js';
|
|
4
|
+
import { createUseCases } from '../usecases/usecases.js';
|
|
5
|
+
export async function runPrune(useCases, services, deps) {
|
|
6
|
+
const { sourcePath, destPath } = services.config.getRequired();
|
|
7
|
+
const config = services.config.load();
|
|
8
|
+
services.console.log('Analyzing workspaces for pruning...\n');
|
|
9
|
+
// Analyze workspaces to find prunable ones
|
|
10
|
+
const result = await useCases.discoverPrunableWorkspaces.execute({
|
|
11
|
+
destPath,
|
|
12
|
+
sourcePath,
|
|
13
|
+
mainBranch: config.mainBranch,
|
|
14
|
+
daysOld: 7,
|
|
15
|
+
});
|
|
16
|
+
if (result.prunable.length === 0) {
|
|
17
|
+
services.console.log('No workspaces to prune.');
|
|
18
|
+
services.console.log(`\nWorkspaces are prunable when:`);
|
|
19
|
+
services.console.log(` - All worktrees have no uncommitted changes`);
|
|
20
|
+
services.console.log(` - All worktrees are not ahead of ${config.mainBranch}`);
|
|
21
|
+
services.console.log(` - Last commit is more than 7 days old`);
|
|
22
|
+
services.process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
// Display prunable workspaces
|
|
25
|
+
services.console.log(`${chalk.yellow('Found')} ${chalk.cyan(result.prunable.length)} ${chalk.yellow('workspace(s) that can be pruned:')}\n`);
|
|
26
|
+
for (const workspace of result.prunable) {
|
|
27
|
+
const daysOld = Math.floor((Date.now() - workspace.oldestCommitDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
28
|
+
services.console.log(` ${chalk.cyan(workspace.name)}`);
|
|
29
|
+
services.console.log(` Repos: ${workspace.repoCount}`);
|
|
30
|
+
services.console.log(` Last commit: ${daysOld} days ago`);
|
|
31
|
+
services.console.log(` Status: ${chalk.green('clean')}`);
|
|
32
|
+
services.console.log('');
|
|
33
|
+
}
|
|
34
|
+
// Summary
|
|
35
|
+
const totalRepos = result.prunable.reduce((sum, ws) => sum + ws.repoCount, 0);
|
|
36
|
+
services.console.log(`${chalk.yellow('This will remove:')}`);
|
|
37
|
+
services.console.log(` ${result.prunable.length} workspace(s)`);
|
|
38
|
+
services.console.log(` ${totalRepos} worktree(s)`);
|
|
39
|
+
const confirmed = await deps.confirm({
|
|
40
|
+
message: 'Are you sure you want to prune these workspaces?',
|
|
41
|
+
default: false,
|
|
42
|
+
});
|
|
43
|
+
if (!confirmed) {
|
|
44
|
+
services.console.log('\nCancelled.');
|
|
45
|
+
services.process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
// Remove each workspace
|
|
48
|
+
services.console.log('\nPruning workspaces...\n');
|
|
49
|
+
let successCount = 0;
|
|
50
|
+
let errorCount = 0;
|
|
51
|
+
for (const workspace of result.prunable) {
|
|
52
|
+
try {
|
|
53
|
+
services.console.log(`Removing ${chalk.cyan(workspace.name)}...`);
|
|
54
|
+
await useCases.removeWorkspace.execute({
|
|
55
|
+
workspacePath: workspace.path,
|
|
56
|
+
branchName: workspace.name,
|
|
57
|
+
sourcePath,
|
|
58
|
+
mainBranch: config.mainBranch,
|
|
59
|
+
tmux: config.tmux,
|
|
60
|
+
});
|
|
61
|
+
services.console.log(` ${chalk.green('✓')} Removed ${workspace.name}`);
|
|
62
|
+
successCount++;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
services.console.log(` ${chalk.red('✗')} Failed to remove ${workspace.name}: ${error.message}`);
|
|
66
|
+
errorCount++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Display summary
|
|
70
|
+
services.console.log('');
|
|
71
|
+
if (successCount > 0) {
|
|
72
|
+
services.console.log(`${chalk.green('Successfully pruned')} ${successCount} workspace(s)`);
|
|
73
|
+
}
|
|
74
|
+
if (errorCount > 0) {
|
|
75
|
+
services.console.log(`${chalk.red('Failed to prune')} ${errorCount} workspace(s)`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function registerPruneCommand(program) {
|
|
79
|
+
program
|
|
80
|
+
.command('prune')
|
|
81
|
+
.description('Remove old workspaces with no uncommitted changes and commits older than 7 days')
|
|
82
|
+
.action(async () => {
|
|
83
|
+
const services = createServices();
|
|
84
|
+
const useCases = createUseCases(services);
|
|
85
|
+
try {
|
|
86
|
+
await runPrune(useCases, services, { confirm });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
services.console.error(error.message);
|
|
90
|
+
services.process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { createServices } from '../lib/services.js';
|
|
3
|
+
import { createUseCases } from '../usecases/usecases.js';
|
|
4
|
+
import { resolveWorkspace } from '../lib/workspaceResolver.js';
|
|
5
|
+
export async function runPull(branchName, useCases, services) {
|
|
6
|
+
const { workspacePath } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
7
|
+
const dirs = services.workspaceDir.getWorktreeDirs(workspacePath);
|
|
8
|
+
if (dirs.length === 0) {
|
|
9
|
+
services.console.error('No repos found in workspace.');
|
|
10
|
+
services.process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
services.console.log(`Pulling ${dirs.length} repo(s) in ${chalk.cyan(workspacePath)}...\n`);
|
|
13
|
+
const result = await useCases.pullWorkspace.execute({ workspacePath });
|
|
14
|
+
services.console.log(`\n${result.successCount}/${result.totalCount} repos pulled successfully.`);
|
|
15
|
+
}
|
|
16
|
+
export function registerPullCommand(program) {
|
|
17
|
+
program
|
|
18
|
+
.command('pull [branch-name]')
|
|
19
|
+
.description('Pull all repos in a workspace (auto-detects from current directory if branch not provided)')
|
|
20
|
+
.action(async (branchName) => {
|
|
21
|
+
const services = createServices();
|
|
22
|
+
const useCases = createUseCases(services);
|
|
23
|
+
try {
|
|
24
|
+
await runPull(branchName, useCases, services);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
services.console.error(error.message);
|
|
28
|
+
services.process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { createServices } from '../lib/services.js';
|
|
3
|
+
import { createUseCases } from '../usecases/usecases.js';
|
|
4
|
+
import { resolveWorkspace } from '../lib/workspaceResolver.js';
|
|
5
|
+
export async function runPush(branchName, useCases, services) {
|
|
6
|
+
const { workspacePath } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
7
|
+
const dirs = services.workspaceDir.getWorktreeDirs(workspacePath);
|
|
8
|
+
if (dirs.length === 0) {
|
|
9
|
+
services.console.error('No repos found in workspace.');
|
|
10
|
+
services.process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
services.console.log(`Pushing ${dirs.length} repo(s) in ${chalk.cyan(workspacePath)}...\n`);
|
|
13
|
+
const result = await useCases.pushWorkspace.execute({ workspacePath });
|
|
14
|
+
services.console.log(`\n${result.successCount}/${result.totalCount} repos pushed successfully.`);
|
|
15
|
+
}
|
|
16
|
+
export function registerPushCommand(program) {
|
|
17
|
+
program
|
|
18
|
+
.command('push [branch-name]')
|
|
19
|
+
.description('Push all repos in a workspace (auto-detects from current directory if branch not provided)')
|
|
20
|
+
.action(async (branchName) => {
|
|
21
|
+
const services = createServices();
|
|
22
|
+
const useCases = createUseCases(services);
|
|
23
|
+
try {
|
|
24
|
+
await runPush(branchName, useCases, services);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
services.console.error(error.message);
|
|
28
|
+
services.process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|