worktree-flow 0.0.8 → 0.0.10
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 +4 -4
- package/dist/commands/helpers.js +32 -0
- package/dist/commands/list.js +1 -29
- package/dist/commands/prune.js +36 -26
- package/dist/commands/remove.js +7 -4
- package/dist/commands/status.js +6 -3
- package/dist/lib/config.js +1 -9
- package/dist/lib/git.js +17 -3
- package/dist/lib/services.js +3 -0
- package/dist/lib/status.js +11 -35
- package/dist/lib/workspaceConfig.js +41 -0
- package/dist/usecases/checkWorkspaceStatus.js +7 -2
- package/dist/usecases/checkoutWorkspace.js +16 -3
- package/dist/usecases/createBranchWorkspace.js +25 -4
- package/dist/usecases/discoverPrunableWorkspaces.js +7 -2
- package/dist/usecases/listWorkspacesWithStatus.js +7 -2
- package/dist/usecases/removeWorkspace.js +10 -4
- package/dist/usecases/usecases.js +6 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,18 +73,18 @@ Check the status of all repos in a workspace. Shows uncommitted changes, commits
|
|
|
73
73
|
|
|
74
74
|
List all workspaces with status indicators. Shows:
|
|
75
75
|
- Active workspace (marked with `*`) based on current directory
|
|
76
|
-
- Overall status: `clean`, `uncommitted`, `ahead`,
|
|
76
|
+
- Overall status: `clean`, `uncommitted`, `ahead`, or `mixed`
|
|
77
77
|
- Repo count for each workspace
|
|
78
78
|
|
|
79
|
-
Fetches all repos before checking status to ensure accurate information.
|
|
79
|
+
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.
|
|
80
80
|
|
|
81
81
|
### `flow remove <name>`
|
|
82
82
|
|
|
83
|
-
Remove a workspace and all its worktrees. Fetches latest, checks for uncommitted changes
|
|
83
|
+
Remove a workspace and all its worktrees. Fetches latest, checks for uncommitted changes, and prompts for confirmation before removing. Committed changes are safe to remove since they're preserved in git history.
|
|
84
84
|
|
|
85
85
|
### `flow prune` (alias: `clean`)
|
|
86
86
|
|
|
87
|
-
Remove
|
|
87
|
+
Remove workspaces interactively. Shows all workspaces with their status (similar to `flow list`) and lets you select which ones to prune. Only workspaces with no uncommitted changes can be successfully removed.
|
|
88
88
|
|
|
89
89
|
### `flow tmux resume`
|
|
90
90
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
/**
|
|
3
|
+
* Get a human-readable status indicator for a workspace based on its worktree statuses.
|
|
4
|
+
*/
|
|
5
|
+
export function getStatusIndicator(workspace) {
|
|
6
|
+
const hasUncommitted = workspace.statuses.some(s => s.status.type === 'uncommitted');
|
|
7
|
+
const hasAhead = workspace.statuses.some(s => s.status.type === 'ahead');
|
|
8
|
+
const hasBehind = workspace.statuses.some(s => s.status.type === 'behind');
|
|
9
|
+
const hasDiverged = workspace.statuses.some(s => s.status.type === 'diverged');
|
|
10
|
+
const hasError = workspace.statuses.some(s => s.status.type === 'error');
|
|
11
|
+
if (hasUncommitted) {
|
|
12
|
+
return chalk.yellow('uncommitted');
|
|
13
|
+
}
|
|
14
|
+
else if (hasDiverged) {
|
|
15
|
+
return chalk.red('diverged');
|
|
16
|
+
}
|
|
17
|
+
else if (hasAhead && hasBehind) {
|
|
18
|
+
return chalk.yellow('ahead');
|
|
19
|
+
}
|
|
20
|
+
else if (hasAhead) {
|
|
21
|
+
return chalk.yellow('ahead');
|
|
22
|
+
}
|
|
23
|
+
else if (hasBehind) {
|
|
24
|
+
return chalk.blue('behind');
|
|
25
|
+
}
|
|
26
|
+
else if (hasError) {
|
|
27
|
+
return chalk.red('error');
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
return chalk.green('clean');
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/commands/list.js
CHANGED
|
@@ -1,34 +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
|
-
|
|
5
|
-
const hasUncommitted = workspace.statuses.some(s => s.status.type === 'uncommitted');
|
|
6
|
-
const hasAhead = workspace.statuses.some(s => s.status.type === 'ahead');
|
|
7
|
-
const hasBehind = workspace.statuses.some(s => s.status.type === 'behind');
|
|
8
|
-
const hasDiverged = workspace.statuses.some(s => s.status.type === 'diverged');
|
|
9
|
-
const hasError = workspace.statuses.some(s => s.status.type === 'error');
|
|
10
|
-
if (hasUncommitted) {
|
|
11
|
-
return chalk.yellow('uncommitted');
|
|
12
|
-
}
|
|
13
|
-
else if (hasDiverged) {
|
|
14
|
-
return chalk.red('diverged');
|
|
15
|
-
}
|
|
16
|
-
else if (hasAhead && hasBehind) {
|
|
17
|
-
return chalk.yellow('ahead');
|
|
18
|
-
}
|
|
19
|
-
else if (hasAhead) {
|
|
20
|
-
return chalk.yellow('ahead');
|
|
21
|
-
}
|
|
22
|
-
else if (hasBehind) {
|
|
23
|
-
return chalk.blue('behind');
|
|
24
|
-
}
|
|
25
|
-
else if (hasError) {
|
|
26
|
-
return chalk.red('error');
|
|
27
|
-
}
|
|
28
|
-
else {
|
|
29
|
-
return chalk.green('clean');
|
|
30
|
-
}
|
|
31
|
-
}
|
|
4
|
+
import { getStatusIndicator } from './helpers.js';
|
|
32
5
|
export async function runList(useCases, services) {
|
|
33
6
|
const { destPath, sourcePath } = services.config.getRequired();
|
|
34
7
|
const config = services.config.load();
|
|
@@ -57,7 +30,6 @@ export async function runList(useCases, services) {
|
|
|
57
30
|
const result = await useCases.listWorkspacesWithStatus.execute({
|
|
58
31
|
destPath,
|
|
59
32
|
sourcePath,
|
|
60
|
-
mainBranch: config.mainBranch,
|
|
61
33
|
cwd,
|
|
62
34
|
});
|
|
63
35
|
// Phase 4: Clear previous output and re-print with status
|
package/dist/commands/prune.js
CHANGED
|
@@ -1,44 +1,55 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import checkbox from '@inquirer/checkbox';
|
|
2
3
|
import confirm from '@inquirer/confirm';
|
|
3
4
|
import { createServices } from '../lib/services.js';
|
|
4
5
|
import { createUseCases } from '../usecases/usecases.js';
|
|
6
|
+
import { getStatusIndicator } from './helpers.js';
|
|
5
7
|
export async function runPrune(useCases, services, deps) {
|
|
6
8
|
const { sourcePath, destPath } = services.config.getRequired();
|
|
7
9
|
const config = services.config.load();
|
|
8
|
-
services.
|
|
10
|
+
const cwd = services.process.cwd();
|
|
11
|
+
// Check if workspaces exist
|
|
12
|
+
const basicWorkspaces = services.workspaceDir.listWorkspaces(destPath);
|
|
13
|
+
if (basicWorkspaces.length === 0) {
|
|
14
|
+
services.console.log('No workspaces found.');
|
|
15
|
+
services.process.exit(0);
|
|
16
|
+
}
|
|
9
17
|
// Fetch repos used across all workspaces
|
|
10
18
|
await useCases.fetchUsedRepos.execute({
|
|
11
19
|
destPath,
|
|
12
20
|
sourcePath,
|
|
13
21
|
fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
|
|
14
22
|
});
|
|
15
|
-
//
|
|
16
|
-
const result = await useCases.
|
|
23
|
+
// Get full status for all workspaces
|
|
24
|
+
const result = await useCases.listWorkspacesWithStatus.execute({
|
|
17
25
|
destPath,
|
|
18
26
|
sourcePath,
|
|
19
|
-
|
|
20
|
-
|
|
27
|
+
cwd,
|
|
28
|
+
});
|
|
29
|
+
// Format choices for checkbox prompt
|
|
30
|
+
const choices = result.workspaces.map(workspace => {
|
|
31
|
+
const statusIndicator = getStatusIndicator(workspace);
|
|
32
|
+
const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
|
|
33
|
+
return {
|
|
34
|
+
name: `${chalk.cyan(workspace.name)} ${repoCount} ${statusIndicator}`,
|
|
35
|
+
value: workspace.name,
|
|
36
|
+
};
|
|
21
37
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
// Let user select workspaces to prune
|
|
39
|
+
const selected = await deps.checkbox({
|
|
40
|
+
message: 'Select workspaces to prune:',
|
|
41
|
+
choices,
|
|
42
|
+
pageSize: 20,
|
|
43
|
+
});
|
|
44
|
+
if (selected.length === 0) {
|
|
45
|
+
services.console.log('No workspaces selected.');
|
|
28
46
|
services.process.exit(0);
|
|
29
47
|
}
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const daysOld = Math.floor((Date.now() - workspace.oldestCommitDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
34
|
-
services.console.log(` ${chalk.cyan(workspace.name)}`);
|
|
35
|
-
services.console.log(` Repos: ${workspace.repoCount}`);
|
|
36
|
-
services.console.log(` Last commit: ${daysOld} days ago`);
|
|
37
|
-
services.console.log(` Status: ${chalk.green('clean')}`);
|
|
38
|
-
services.console.log('');
|
|
39
|
-
}
|
|
48
|
+
// Get full workspace objects for selected items
|
|
49
|
+
const selectedWorkspaces = result.workspaces.filter(w => selected.includes(w.name));
|
|
50
|
+
// Confirm deletion
|
|
40
51
|
const confirmed = await deps.confirm({
|
|
41
|
-
message:
|
|
52
|
+
message: `Are you sure you want to prune ${selected.length} workspace(s)?`,
|
|
42
53
|
default: false,
|
|
43
54
|
});
|
|
44
55
|
if (!confirmed) {
|
|
@@ -49,14 +60,13 @@ export async function runPrune(useCases, services, deps) {
|
|
|
49
60
|
services.console.log('\nPruning workspaces...\n');
|
|
50
61
|
let successCount = 0;
|
|
51
62
|
let errorCount = 0;
|
|
52
|
-
for (const workspace of
|
|
63
|
+
for (const workspace of selectedWorkspaces) {
|
|
53
64
|
try {
|
|
54
65
|
services.console.log(`Removing ${chalk.cyan(workspace.name)}...`);
|
|
55
66
|
await useCases.removeWorkspace.execute({
|
|
56
67
|
workspacePath: workspace.path,
|
|
57
68
|
branchName: workspace.name,
|
|
58
69
|
sourcePath,
|
|
59
|
-
mainBranch: config.mainBranch,
|
|
60
70
|
tmux: config.tmux,
|
|
61
71
|
});
|
|
62
72
|
services.console.log(` ${chalk.green('✓')} Removed ${workspace.name}`);
|
|
@@ -80,12 +90,12 @@ export function registerPruneCommand(program) {
|
|
|
80
90
|
program
|
|
81
91
|
.command('prune')
|
|
82
92
|
.alias('clean')
|
|
83
|
-
.description('
|
|
93
|
+
.description('Select and remove workspaces')
|
|
84
94
|
.action(async () => {
|
|
85
95
|
const services = createServices();
|
|
86
96
|
const useCases = createUseCases(services);
|
|
87
97
|
try {
|
|
88
|
-
await runPrune(useCases, services, { confirm });
|
|
98
|
+
await runPrune(useCases, services, { checkbox, confirm });
|
|
89
99
|
}
|
|
90
100
|
catch (error) {
|
|
91
101
|
services.console.error(error.message);
|
package/dist/commands/remove.js
CHANGED
|
@@ -18,10 +18,14 @@ export async function runRemove(branchName, useCases, services, deps) {
|
|
|
18
18
|
sourcePath,
|
|
19
19
|
fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
|
|
20
20
|
});
|
|
21
|
-
services.console.log(`\nChecking for uncommitted changes
|
|
22
|
-
|
|
21
|
+
services.console.log(`\nChecking for uncommitted changes...`);
|
|
22
|
+
// Load workspace config to get per-repo base branches
|
|
23
|
+
const workspaceConfig = services.workspaceConfig.load(workspacePath);
|
|
24
|
+
const getBaseBranch = (repoName) => workspaceConfig.baseBranches[repoName] || 'master';
|
|
25
|
+
const results = await services.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
|
|
23
26
|
for (const { repoName, status } of results) {
|
|
24
|
-
const
|
|
27
|
+
const baseBranch = getBaseBranch(repoName);
|
|
28
|
+
const message = StatusService.getStatusMessage(status, baseBranch);
|
|
25
29
|
if (StatusService.hasIssues(status)) {
|
|
26
30
|
services.console.log(`${repoName}... ${chalk.red(message)}`);
|
|
27
31
|
}
|
|
@@ -56,7 +60,6 @@ export async function runRemove(branchName, useCases, services, deps) {
|
|
|
56
60
|
workspacePath,
|
|
57
61
|
branchName: branchNameForDisplay,
|
|
58
62
|
sourcePath,
|
|
59
|
-
mainBranch: config.mainBranch,
|
|
60
63
|
tmux: config.tmux,
|
|
61
64
|
});
|
|
62
65
|
// Display worktree removal results
|
package/dist/commands/status.js
CHANGED
|
@@ -20,15 +20,18 @@ export async function runStatus(branchName, useCases, services) {
|
|
|
20
20
|
fetchCacheTtlSeconds: config.fetchCacheTtlSeconds,
|
|
21
21
|
});
|
|
22
22
|
services.console.log('');
|
|
23
|
-
services.console.log(`\nStatus
|
|
23
|
+
services.console.log(`\nStatus:\n`);
|
|
24
24
|
const result = await useCases.checkWorkspaceStatus.execute({
|
|
25
25
|
workspacePath,
|
|
26
|
-
mainBranch: config.mainBranch,
|
|
27
26
|
});
|
|
27
|
+
// Load workspace config to get per-repo base branches
|
|
28
|
+
const workspaceConfig = services.workspaceConfig.load(workspacePath);
|
|
29
|
+
const getBaseBranch = (repoName) => workspaceConfig.baseBranches[repoName] || 'master';
|
|
28
30
|
let cleanCount = 0;
|
|
29
31
|
let issuesCount = 0;
|
|
30
32
|
for (const { repoName, status } of result.statuses) {
|
|
31
|
-
const
|
|
33
|
+
const baseBranch = getBaseBranch(repoName);
|
|
34
|
+
const message = StatusService.getStatusMessage(status, baseBranch);
|
|
32
35
|
if (StatusService.hasIssues(status)) {
|
|
33
36
|
services.console.log(` ${chalk.red('✗')} ${repoName}: ${chalk.red(message)}`);
|
|
34
37
|
issuesCount++;
|
package/dist/lib/config.js
CHANGED
|
@@ -8,7 +8,6 @@ const RawConfigSchema = z.object({
|
|
|
8
8
|
'dest-path': z.string().optional(),
|
|
9
9
|
'copy-files': z.string().optional(),
|
|
10
10
|
'tmux': z.enum(['true', 'false']).optional(),
|
|
11
|
-
'main-branch': z.string().optional(),
|
|
12
11
|
'post-checkout': z.string().optional(),
|
|
13
12
|
'fetch-cache-ttl-seconds': z.string().optional(),
|
|
14
13
|
'per-repo-post-checkout': z.record(z.string(), z.string()).optional(),
|
|
@@ -19,7 +18,6 @@ const ParsedConfigSchema = z.object({
|
|
|
19
18
|
destPath: z.string().optional(),
|
|
20
19
|
copyFiles: z.string().default('.env'),
|
|
21
20
|
tmux: z.boolean().default(false),
|
|
22
|
-
mainBranch: z.string().default('master'),
|
|
23
21
|
postCheckout: z.string().optional(),
|
|
24
22
|
fetchCacheTtlSeconds: z.number().default(300),
|
|
25
23
|
perRepoPostCheckout: z.record(z.string(), z.string()).default({}),
|
|
@@ -35,14 +33,13 @@ const ConfigValueSchemas = {
|
|
|
35
33
|
'dest-path': z.string().transform((val) => path.resolve(val)),
|
|
36
34
|
'copy-files': z.string(),
|
|
37
35
|
'tmux': z.enum(['true', 'false']),
|
|
38
|
-
'main-branch': z.string(),
|
|
39
36
|
'post-checkout': z.string(),
|
|
40
37
|
'fetch-cache-ttl-seconds': z.string().refine((val) => {
|
|
41
38
|
const num = parseInt(val, 10);
|
|
42
39
|
return !isNaN(num) && num >= 0;
|
|
43
40
|
}, { message: 'Must be a non-negative integer' }),
|
|
44
41
|
};
|
|
45
|
-
export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', '
|
|
42
|
+
export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'post-checkout', 'fetch-cache-ttl-seconds'];
|
|
46
43
|
/**
|
|
47
44
|
* Pure utility functions (no I/O)
|
|
48
45
|
*/
|
|
@@ -86,7 +83,6 @@ export class ConfigService {
|
|
|
86
83
|
destPath: raw['dest-path'],
|
|
87
84
|
copyFiles: raw['copy-files'],
|
|
88
85
|
tmux: raw.tmux === 'true',
|
|
89
|
-
mainBranch: raw['main-branch'],
|
|
90
86
|
postCheckout: raw['post-checkout'],
|
|
91
87
|
fetchCacheTtlSeconds: raw['fetch-cache-ttl-seconds']
|
|
92
88
|
? parseInt(raw['fetch-cache-ttl-seconds'], 10)
|
|
@@ -130,10 +126,6 @@ export class ConfigService {
|
|
|
130
126
|
value: raw.tmux ?? `${config.tmux} (default)`,
|
|
131
127
|
isDefault: !raw.tmux,
|
|
132
128
|
},
|
|
133
|
-
'main-branch': {
|
|
134
|
-
value: raw['main-branch'] ?? `${config.mainBranch} (default)`,
|
|
135
|
-
isDefault: !raw['main-branch'],
|
|
136
|
-
},
|
|
137
129
|
'post-checkout': {
|
|
138
130
|
value: raw['post-checkout'] ?? '(not set)',
|
|
139
131
|
isDefault: !raw['post-checkout'],
|
package/dist/lib/git.js
CHANGED
|
@@ -28,6 +28,14 @@ export class GitService {
|
|
|
28
28
|
return false;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
async findFirstExistingBranch(repoPath, candidates) {
|
|
32
|
+
for (const branch of candidates) {
|
|
33
|
+
const exists = await this.localRemoteBranchExists(repoPath, branch);
|
|
34
|
+
if (exists)
|
|
35
|
+
return branch;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
31
39
|
async addWorktreeNewBranch(repoPath, worktreePath, branch, sourceBranch) {
|
|
32
40
|
const args = ['worktree', 'add', '-b', branch, worktreePath];
|
|
33
41
|
if (sourceBranch) {
|
|
@@ -87,9 +95,15 @@ export class GitService {
|
|
|
87
95
|
}
|
|
88
96
|
async isAheadOfMain(repoPath, mainBranch) {
|
|
89
97
|
try {
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
// Use git cherry to detect if commits exist in main via patch equivalence
|
|
99
|
+
// cherry outputs nothing if all commits are equivalent to main
|
|
100
|
+
// Format: "+ hash message" for unmerged, "- hash message" for already merged
|
|
101
|
+
const output = await this.exec(repoPath, ['cherry', `origin/${mainBranch}`, 'HEAD']);
|
|
102
|
+
// Filter to only unmerged commits (those starting with +)
|
|
103
|
+
const unmergedCommits = output
|
|
104
|
+
.split('\n')
|
|
105
|
+
.filter(line => line.trim().startsWith('+'));
|
|
106
|
+
return unmergedCommits.length > 0;
|
|
93
107
|
}
|
|
94
108
|
catch {
|
|
95
109
|
// If we can't determine, assume there are changes to be safe
|
package/dist/lib/services.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ConfigService } from './config.js';
|
|
|
6
6
|
import { GitService } from './git.js';
|
|
7
7
|
import { RepoService } from './repos.js';
|
|
8
8
|
import { WorkspaceDirectoryService } from './workspaceDirectory.js';
|
|
9
|
+
import { WorkspaceConfigService } from './workspaceConfig.js';
|
|
9
10
|
import { WorktreeService } from './worktree.js';
|
|
10
11
|
import { PostCheckoutService } from './postCheckout.js';
|
|
11
12
|
import { FetchService } from './fetch.js';
|
|
@@ -31,12 +32,14 @@ export function createServices() {
|
|
|
31
32
|
const postCheckout = new PostCheckoutService(shell);
|
|
32
33
|
// Focused workspace services
|
|
33
34
|
const workspaceDir = new WorkspaceDirectoryService(fs);
|
|
35
|
+
const workspaceConfig = new WorkspaceConfigService(fs);
|
|
34
36
|
const worktree = new WorktreeService(fs, git);
|
|
35
37
|
return {
|
|
36
38
|
config,
|
|
37
39
|
git,
|
|
38
40
|
repos,
|
|
39
41
|
workspaceDir,
|
|
42
|
+
workspaceConfig,
|
|
40
43
|
worktree,
|
|
41
44
|
postCheckout,
|
|
42
45
|
fetch,
|
package/dist/lib/status.js
CHANGED
|
@@ -6,32 +6,15 @@ export class StatusService {
|
|
|
6
6
|
constructor(git) {
|
|
7
7
|
this.git = git;
|
|
8
8
|
}
|
|
9
|
-
async getWorktreeStatus(worktreePath,
|
|
9
|
+
async getWorktreeStatus(worktreePath, baseBranch) {
|
|
10
10
|
try {
|
|
11
|
-
//
|
|
11
|
+
// Check for uncommitted changes first
|
|
12
12
|
const hasUncommitted = await this.git.hasUncommittedChanges(worktreePath);
|
|
13
13
|
if (hasUncommitted) {
|
|
14
14
|
return { type: 'uncommitted' };
|
|
15
15
|
}
|
|
16
|
-
//
|
|
17
|
-
const
|
|
18
|
-
if (hasOriginBranch) {
|
|
19
|
-
// Compare against origin branch
|
|
20
|
-
const isAhead = await this.git.isAheadOfOrigin(worktreePath);
|
|
21
|
-
const isBehind = await this.git.isBehindOrigin(worktreePath);
|
|
22
|
-
if (isAhead && isBehind) {
|
|
23
|
-
return { type: 'diverged', comparedTo: 'origin' };
|
|
24
|
-
}
|
|
25
|
-
if (isAhead) {
|
|
26
|
-
return { type: 'ahead', comparedTo: 'origin' };
|
|
27
|
-
}
|
|
28
|
-
if (isBehind) {
|
|
29
|
-
return { type: 'behind', comparedTo: 'origin' };
|
|
30
|
-
}
|
|
31
|
-
return { type: 'clean', comparedTo: 'origin' };
|
|
32
|
-
}
|
|
33
|
-
// No origin branch, compare against main
|
|
34
|
-
const isAhead = await this.git.isAheadOfMain(worktreePath, mainBranch);
|
|
16
|
+
// Compare against base branch using git cherry (handles squash merges)
|
|
17
|
+
const isAhead = await this.git.isAheadOfMain(worktreePath, baseBranch);
|
|
35
18
|
if (isAhead) {
|
|
36
19
|
return { type: 'ahead', comparedTo: 'main' };
|
|
37
20
|
}
|
|
@@ -44,37 +27,30 @@ export class StatusService {
|
|
|
44
27
|
};
|
|
45
28
|
}
|
|
46
29
|
}
|
|
47
|
-
static getStatusMessage(status,
|
|
30
|
+
static getStatusMessage(status, baseBranch) {
|
|
48
31
|
switch (status.type) {
|
|
49
32
|
case 'clean':
|
|
50
33
|
return 'up to date';
|
|
51
34
|
case 'uncommitted':
|
|
52
35
|
return 'uncommitted changes';
|
|
53
36
|
case 'ahead':
|
|
54
|
-
return
|
|
55
|
-
? 'ahead of origin'
|
|
56
|
-
: `ahead of ${mainBranch}`;
|
|
57
|
-
case 'behind':
|
|
58
|
-
return 'behind origin';
|
|
59
|
-
case 'diverged':
|
|
60
|
-
return 'diverged from origin';
|
|
37
|
+
return `ahead of ${baseBranch}`;
|
|
61
38
|
case 'error':
|
|
62
39
|
return `error: ${status.error}`;
|
|
63
40
|
}
|
|
64
41
|
}
|
|
65
42
|
static hasIssues(status) {
|
|
66
43
|
return (status.type === 'uncommitted' ||
|
|
67
|
-
status.type === 'ahead' ||
|
|
68
|
-
status.type === 'diverged' ||
|
|
69
44
|
status.type === 'error');
|
|
70
45
|
}
|
|
71
46
|
/**
|
|
72
47
|
* Check status of all worktrees in parallel
|
|
73
48
|
*/
|
|
74
|
-
async checkAllWorktrees(worktreeDirs,
|
|
49
|
+
async checkAllWorktrees(worktreeDirs, getBaseBranch) {
|
|
75
50
|
const results = await Promise.all(worktreeDirs.map(async (worktreePath) => {
|
|
76
51
|
const repoName = worktreePath.split('/').pop() || worktreePath;
|
|
77
|
-
const
|
|
52
|
+
const baseBranch = getBaseBranch(repoName);
|
|
53
|
+
const status = await this.getWorktreeStatus(worktreePath, baseBranch);
|
|
78
54
|
return { repoName, status };
|
|
79
55
|
}));
|
|
80
56
|
return results;
|
|
@@ -82,8 +58,8 @@ export class StatusService {
|
|
|
82
58
|
/**
|
|
83
59
|
* Find repos with issues (for removal validation)
|
|
84
60
|
*/
|
|
85
|
-
async findReposWithIssues(worktreeDirs,
|
|
86
|
-
const results = await this.checkAllWorktrees(worktreeDirs,
|
|
61
|
+
async findReposWithIssues(worktreeDirs, getBaseBranch) {
|
|
62
|
+
const results = await this.checkAllWorktrees(worktreeDirs, getBaseBranch);
|
|
87
63
|
return results
|
|
88
64
|
.filter(({ status }) => StatusService.hasIssues(status))
|
|
89
65
|
.map(({ repoName }) => repoName);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
// Schema for workspace config
|
|
4
|
+
const WorkspaceConfigSchema = z.object({
|
|
5
|
+
baseBranches: z.record(z.string(), z.string()),
|
|
6
|
+
});
|
|
7
|
+
/**
|
|
8
|
+
* WorkspaceConfigService manages flow-config.json files in workspace directories.
|
|
9
|
+
* These files track the base branch used for each repository in a workspace.
|
|
10
|
+
*/
|
|
11
|
+
export class WorkspaceConfigService {
|
|
12
|
+
fs;
|
|
13
|
+
constructor(fs) {
|
|
14
|
+
this.fs = fs;
|
|
15
|
+
}
|
|
16
|
+
getConfigPath(workspacePath) {
|
|
17
|
+
return path.join(workspacePath, 'flow-config.json');
|
|
18
|
+
}
|
|
19
|
+
exists(workspacePath) {
|
|
20
|
+
const configPath = this.getConfigPath(workspacePath);
|
|
21
|
+
return this.fs.existsSync(configPath);
|
|
22
|
+
}
|
|
23
|
+
load(workspacePath) {
|
|
24
|
+
const configPath = this.getConfigPath(workspacePath);
|
|
25
|
+
if (!this.fs.existsSync(configPath)) {
|
|
26
|
+
return { baseBranches: {} };
|
|
27
|
+
}
|
|
28
|
+
const raw = this.fs.readFileSync(configPath, 'utf-8');
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
return WorkspaceConfigSchema.parse(parsed);
|
|
31
|
+
}
|
|
32
|
+
save(workspacePath, config) {
|
|
33
|
+
const configPath = this.getConfigPath(workspacePath);
|
|
34
|
+
const validated = WorkspaceConfigSchema.parse(config);
|
|
35
|
+
this.fs.writeFileSync(configPath, JSON.stringify(validated, null, 2) + '\n');
|
|
36
|
+
}
|
|
37
|
+
getBaseBranch(workspacePath, repoName) {
|
|
38
|
+
const config = this.load(workspacePath);
|
|
39
|
+
return config.baseBranches[repoName] || 'master';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export class CheckWorkspaceStatusUseCase {
|
|
5
5
|
workspaceDir;
|
|
6
|
+
workspaceConfig;
|
|
6
7
|
status;
|
|
7
|
-
constructor(workspaceDir, status) {
|
|
8
|
+
constructor(workspaceDir, workspaceConfig, status) {
|
|
8
9
|
this.workspaceDir = workspaceDir;
|
|
10
|
+
this.workspaceConfig = workspaceConfig;
|
|
9
11
|
this.status = status;
|
|
10
12
|
}
|
|
11
13
|
async execute(params) {
|
|
12
14
|
const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
|
|
13
|
-
|
|
15
|
+
// Load workspace config to get per-repo base branches
|
|
16
|
+
const config = this.workspaceConfig.load(params.workspacePath);
|
|
17
|
+
const getBaseBranch = (repoName) => config.baseBranches[repoName] || 'master';
|
|
18
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
|
|
14
19
|
return { statuses };
|
|
15
20
|
}
|
|
16
21
|
}
|
|
@@ -7,15 +7,19 @@ import { NoReposFoundError } from '../lib/errors.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export class CheckoutWorkspaceUseCase {
|
|
9
9
|
workspaceDir;
|
|
10
|
+
workspaceConfig;
|
|
10
11
|
worktree;
|
|
11
12
|
repos;
|
|
13
|
+
git;
|
|
12
14
|
parallel;
|
|
13
15
|
tmux;
|
|
14
16
|
postCheckout;
|
|
15
|
-
constructor(workspaceDir, worktree, repos, parallel, tmux, postCheckout) {
|
|
17
|
+
constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, postCheckout) {
|
|
16
18
|
this.workspaceDir = workspaceDir;
|
|
19
|
+
this.workspaceConfig = workspaceConfig;
|
|
17
20
|
this.worktree = worktree;
|
|
18
21
|
this.repos = repos;
|
|
22
|
+
this.git = git;
|
|
19
23
|
this.parallel = parallel;
|
|
20
24
|
this.tmux = tmux;
|
|
21
25
|
this.postCheckout = postCheckout;
|
|
@@ -43,7 +47,16 @@ export class CheckoutWorkspaceUseCase {
|
|
|
43
47
|
});
|
|
44
48
|
// 5. Copy AGENTS.md
|
|
45
49
|
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
46
|
-
// 6.
|
|
50
|
+
// 6. Detect base branches for each repo
|
|
51
|
+
const baseBranches = {};
|
|
52
|
+
for (const repoPath of matching) {
|
|
53
|
+
const repoName = RepoService.getRepoName(repoPath);
|
|
54
|
+
const baseBranch = await this.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']);
|
|
55
|
+
baseBranches[repoName] = baseBranch || 'master';
|
|
56
|
+
}
|
|
57
|
+
// 7. Save workspace config with base branches
|
|
58
|
+
this.workspaceConfig.save(workspacePath, { baseBranches });
|
|
59
|
+
// 8. Create tmux session if enabled
|
|
47
60
|
let tmuxCreated = false;
|
|
48
61
|
if (params.tmux) {
|
|
49
62
|
try {
|
|
@@ -55,7 +68,7 @@ export class CheckoutWorkspaceUseCase {
|
|
|
55
68
|
// Don't fail
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
|
-
//
|
|
71
|
+
// 9. Run post-checkout if configured
|
|
59
72
|
let postCheckoutResult;
|
|
60
73
|
if (params.postCheckout || params.perRepoPostCheckout) {
|
|
61
74
|
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
|
|
@@ -6,15 +6,19 @@ import { RepoService } from '../lib/repos.js';
|
|
|
6
6
|
*/
|
|
7
7
|
export class CreateBranchWorkspaceUseCase {
|
|
8
8
|
workspaceDir;
|
|
9
|
+
workspaceConfig;
|
|
9
10
|
worktree;
|
|
10
11
|
repos;
|
|
12
|
+
git;
|
|
11
13
|
parallel;
|
|
12
14
|
tmux;
|
|
13
15
|
postCheckout;
|
|
14
|
-
constructor(workspaceDir, worktree, repos, parallel, tmux, postCheckout) {
|
|
16
|
+
constructor(workspaceDir, workspaceConfig, worktree, repos, git, parallel, tmux, postCheckout) {
|
|
15
17
|
this.workspaceDir = workspaceDir;
|
|
18
|
+
this.workspaceConfig = workspaceConfig;
|
|
16
19
|
this.worktree = worktree;
|
|
17
20
|
this.repos = repos;
|
|
21
|
+
this.git = git;
|
|
18
22
|
this.parallel = parallel;
|
|
19
23
|
this.tmux = tmux;
|
|
20
24
|
this.postCheckout = postCheckout;
|
|
@@ -22,17 +26,34 @@ export class CreateBranchWorkspaceUseCase {
|
|
|
22
26
|
async execute(params) {
|
|
23
27
|
// 1. Create workspace directory
|
|
24
28
|
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
29
|
+
// Track base branches for each repo
|
|
30
|
+
const baseBranches = {};
|
|
25
31
|
// 2. Create worktrees in parallel
|
|
26
32
|
const successCount = await this.parallel.processInParallel(params.repos, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
|
|
27
33
|
const name = RepoService.getRepoName(repoPath);
|
|
28
34
|
const worktreeDest = path.join(workspacePath, name);
|
|
29
|
-
|
|
35
|
+
// Determine which base branch to use
|
|
36
|
+
let actualBaseBranch = params.sourceBranch;
|
|
37
|
+
// Try the user-specified source branch first, fall back if it doesn't exist
|
|
38
|
+
const branchExists = await this.git.localRemoteBranchExists(repoPath, params.sourceBranch);
|
|
39
|
+
if (!branchExists) {
|
|
40
|
+
const fallbackBranch = await this.git.findFirstExistingBranch(repoPath, ['master', 'main', 'trunk', 'develop']);
|
|
41
|
+
if (fallbackBranch) {
|
|
42
|
+
actualBaseBranch = fallbackBranch;
|
|
43
|
+
}
|
|
44
|
+
// If no fallback found, still try with the original (will fail with clear error)
|
|
45
|
+
}
|
|
46
|
+
// Track the actual base branch used
|
|
47
|
+
baseBranches[name] = actualBaseBranch;
|
|
48
|
+
await this.worktree.createWorktreeWithBranch(repoPath, worktreeDest, params.branchName, `origin/${actualBaseBranch}`);
|
|
30
49
|
this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
|
|
31
50
|
return 'created';
|
|
32
51
|
});
|
|
33
52
|
// 3. Copy AGENTS.md if exists
|
|
34
53
|
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
35
|
-
// 4.
|
|
54
|
+
// 4. Save workspace config with base branches
|
|
55
|
+
this.workspaceConfig.save(workspacePath, { baseBranches });
|
|
56
|
+
// 5. Create tmux session if enabled
|
|
36
57
|
let tmuxCreated = false;
|
|
37
58
|
if (params.tmux) {
|
|
38
59
|
try {
|
|
@@ -44,7 +65,7 @@ export class CreateBranchWorkspaceUseCase {
|
|
|
44
65
|
// Don't fail, just return false
|
|
45
66
|
}
|
|
46
67
|
}
|
|
47
|
-
//
|
|
68
|
+
// 6. Run post-checkout command if configured
|
|
48
69
|
let postCheckoutResult;
|
|
49
70
|
if (params.postCheckout || params.perRepoPostCheckout) {
|
|
50
71
|
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
|
|
@@ -5,10 +5,12 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
|
|
|
5
5
|
*/
|
|
6
6
|
export class DiscoverPrunableWorkspacesUseCase {
|
|
7
7
|
workspaceDir;
|
|
8
|
+
workspaceConfig;
|
|
8
9
|
status;
|
|
9
10
|
git;
|
|
10
|
-
constructor(workspaceDir, status, git) {
|
|
11
|
+
constructor(workspaceDir, workspaceConfig, status, git) {
|
|
11
12
|
this.workspaceDir = workspaceDir;
|
|
13
|
+
this.workspaceConfig = workspaceConfig;
|
|
12
14
|
this.status = status;
|
|
13
15
|
this.git = git;
|
|
14
16
|
}
|
|
@@ -23,8 +25,11 @@ export class DiscoverPrunableWorkspacesUseCase {
|
|
|
23
25
|
if (!worktreeDirs || worktreeDirs.length === 0) {
|
|
24
26
|
continue;
|
|
25
27
|
}
|
|
28
|
+
// Load workspace config to get per-repo base branches
|
|
29
|
+
const config = this.workspaceConfig.load(workspace.path);
|
|
30
|
+
const getBaseBranch = (repoName) => config.baseBranches[repoName] || 'master';
|
|
26
31
|
// Check status for all worktrees
|
|
27
|
-
const statuses = await this.status.checkAllWorktrees(worktreeDirs,
|
|
32
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
|
|
28
33
|
// Skip if any worktree has issues
|
|
29
34
|
const hasAnyIssues = statuses.some(({ status }) => StatusServiceClass.hasIssues(status));
|
|
30
35
|
if (hasAnyIssues) {
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export class ListWorkspacesWithStatusUseCase {
|
|
6
6
|
workspaceDir;
|
|
7
|
+
workspaceConfig;
|
|
7
8
|
status;
|
|
8
|
-
constructor(workspaceDir, status) {
|
|
9
|
+
constructor(workspaceDir, workspaceConfig, status) {
|
|
9
10
|
this.workspaceDir = workspaceDir;
|
|
11
|
+
this.workspaceConfig = workspaceConfig;
|
|
10
12
|
this.status = status;
|
|
11
13
|
}
|
|
12
14
|
async execute(params) {
|
|
@@ -24,8 +26,11 @@ export class ListWorkspacesWithStatusUseCase {
|
|
|
24
26
|
}
|
|
25
27
|
// Check if this workspace is active (contains current working directory)
|
|
26
28
|
const isActive = this.workspaceDir.detectWorkspace(params.cwd, params.destPath) === workspace.path;
|
|
29
|
+
// Load workspace config to get per-repo base branches
|
|
30
|
+
const config = this.workspaceConfig.load(workspace.path);
|
|
31
|
+
const getBaseBranch = (repoName) => config.baseBranches[repoName] || 'master';
|
|
27
32
|
// Check status for all worktrees
|
|
28
|
-
const statuses = await this.status.checkAllWorktrees(worktreeDirs,
|
|
33
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
|
|
29
34
|
workspacesWithStatus.push({
|
|
30
35
|
name: workspace.name,
|
|
31
36
|
path: workspace.path,
|
|
@@ -7,12 +7,14 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export class RemoveWorkspaceUseCase {
|
|
9
9
|
workspaceDir;
|
|
10
|
+
workspaceConfig;
|
|
10
11
|
worktree;
|
|
11
12
|
repos;
|
|
12
13
|
status;
|
|
13
14
|
tmux;
|
|
14
|
-
constructor(workspaceDir, worktree, repos, status, tmux) {
|
|
15
|
+
constructor(workspaceDir, workspaceConfig, worktree, repos, status, tmux) {
|
|
15
16
|
this.workspaceDir = workspaceDir;
|
|
17
|
+
this.workspaceConfig = workspaceConfig;
|
|
16
18
|
this.worktree = worktree;
|
|
17
19
|
this.repos = repos;
|
|
18
20
|
this.status = status;
|
|
@@ -23,17 +25,21 @@ export class RemoveWorkspaceUseCase {
|
|
|
23
25
|
const issuesFound = [];
|
|
24
26
|
// 1. Check status if worktrees exist
|
|
25
27
|
if (worktreeDirs.length > 0) {
|
|
26
|
-
|
|
28
|
+
// Load workspace config to get per-repo base branches
|
|
29
|
+
const config = this.workspaceConfig.load(params.workspacePath);
|
|
30
|
+
const getBaseBranch = (repoName) => config.baseBranches[repoName] || 'master';
|
|
31
|
+
const results = await this.status.checkAllWorktrees(worktreeDirs, getBaseBranch);
|
|
27
32
|
for (const { repoName, status } of results) {
|
|
28
33
|
if (StatusServiceClass.hasIssues(status)) {
|
|
34
|
+
const baseBranch = getBaseBranch(repoName);
|
|
29
35
|
issuesFound.push({
|
|
30
36
|
repoName,
|
|
31
|
-
issue: StatusServiceClass.getStatusMessage(status,
|
|
37
|
+
issue: StatusServiceClass.getStatusMessage(status, baseBranch),
|
|
32
38
|
});
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
if (issuesFound.length > 0) {
|
|
36
|
-
throw new WorkspaceHasIssuesError(`${issuesFound.length} repo(s) have uncommitted or
|
|
42
|
+
throw new WorkspaceHasIssuesError(`${issuesFound.length} repo(s) have uncommitted changes or errors.`);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
// 2. Remove all worktrees
|
|
@@ -19,14 +19,14 @@ export function createUseCases(services) {
|
|
|
19
19
|
fetchAllRepos: new FetchAllReposUseCase(services.fetch, services.repos),
|
|
20
20
|
fetchWorkspaceRepos: new FetchWorkspaceReposUseCase(services.workspaceDir, services.fetch),
|
|
21
21
|
fetchUsedRepos: new FetchUsedReposUseCase(services.workspaceDir, services.fetch),
|
|
22
|
-
createBranchWorkspace: new CreateBranchWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.parallel, services.tmux, services.postCheckout),
|
|
23
|
-
checkoutWorkspace: new CheckoutWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.parallel, services.tmux, services.postCheckout),
|
|
24
|
-
removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.worktree, services.repos, services.status, services.tmux),
|
|
22
|
+
createBranchWorkspace: new CreateBranchWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.git, services.parallel, services.tmux, services.postCheckout),
|
|
23
|
+
checkoutWorkspace: new CheckoutWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.git, services.parallel, services.tmux, services.postCheckout),
|
|
24
|
+
removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.worktree, services.repos, services.status, services.tmux),
|
|
25
25
|
pushWorkspace: new PushWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
|
|
26
26
|
pullWorkspace: new PullWorkspaceUseCase(services.workspaceDir, services.git, services.parallel),
|
|
27
|
-
checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.status),
|
|
28
|
-
discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.status, services.git),
|
|
29
|
-
listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.status),
|
|
27
|
+
checkWorkspaceStatus: new CheckWorkspaceStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
|
|
28
|
+
discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.workspaceConfig, services.status, services.git),
|
|
29
|
+
listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.workspaceConfig, services.status),
|
|
30
30
|
resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux),
|
|
31
31
|
};
|
|
32
32
|
}
|