worktree-flow 0.0.18 → 0.0.20
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 +1 -1
- package/dist/cli.js +11 -6
- package/dist/commands/attach.js +1 -0
- package/dist/commands/branch.js +1 -0
- package/dist/commands/checkout.js +1 -0
- package/dist/commands/config.js +1 -0
- package/dist/commands/create.js +1 -0
- package/dist/commands/drop.js +21 -17
- package/dist/commands/fetch.js +1 -0
- package/dist/commands/helpers.js +5 -41
- package/dist/commands/list.js +2 -1
- package/dist/commands/prune.js +30 -6
- package/dist/commands/pull.js +1 -0
- package/dist/commands/push.js +1 -0
- package/dist/commands/quickstart.js +1 -0
- package/dist/commands/status.js +2 -3
- package/dist/commands/tmux.js +1 -0
- package/dist/lib/git.js +32 -42
- package/dist/lib/status.js +31 -29
- package/dist/usecases/checkWorkspaceStatus.js +2 -7
- package/dist/usecases/createBranch.js +4 -2
- package/dist/usecases/discoverPrunableWorkspaces.js +2 -7
- package/dist/usecases/listWorkspacesWithStatus.js +2 -7
- package/dist/usecases/removeWorkspace.js +3 -9
- package/dist/usecases/usecases.js +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ Drop a workspace and all its worktrees. Fetches latest, checks for uncommitted c
|
|
|
87
87
|
|
|
88
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
92
|
### `flow fetch [name]`
|
|
93
93
|
|
package/dist/cli.js
CHANGED
|
@@ -19,18 +19,23 @@ program
|
|
|
19
19
|
.name('flow')
|
|
20
20
|
.description('Manage git worktrees across a poly-repo environment')
|
|
21
21
|
.version('0.1.0');
|
|
22
|
+
// Getting Started
|
|
23
|
+
registerQuickstartCommand(program);
|
|
22
24
|
registerConfigCommand(program);
|
|
25
|
+
// Workspaces
|
|
23
26
|
registerCreateCommand(program);
|
|
24
|
-
registerAttachCommand(program);
|
|
25
|
-
registerBranchCommand(program);
|
|
26
27
|
registerCheckoutCommand(program);
|
|
27
28
|
registerListCommand(program);
|
|
28
|
-
|
|
29
|
-
registerPushCommand(program);
|
|
29
|
+
registerAttachCommand(program);
|
|
30
30
|
registerDropCommand(program);
|
|
31
|
-
registerStatusCommand(program);
|
|
32
31
|
registerPruneCommand(program);
|
|
32
|
+
// Git Operations
|
|
33
|
+
registerStatusCommand(program);
|
|
34
|
+
registerPullCommand(program);
|
|
35
|
+
registerPushCommand(program);
|
|
33
36
|
registerFetchCommand(program);
|
|
37
|
+
// Extras
|
|
34
38
|
registerTmuxCommand(program);
|
|
35
|
-
|
|
39
|
+
// Deprecated
|
|
40
|
+
registerBranchCommand(program);
|
|
36
41
|
program.parse();
|
package/dist/commands/attach.js
CHANGED
|
@@ -105,6 +105,7 @@ export async function runAttach(branchName, useCases, services, deps) {
|
|
|
105
105
|
export function registerAttachCommand(program) {
|
|
106
106
|
program
|
|
107
107
|
.command('attach [branch-name]')
|
|
108
|
+
.helpGroup('Workspaces')
|
|
108
109
|
.description('Attach repos to an existing workspace (auto-detects from current directory if branch not provided)')
|
|
109
110
|
.action(async (branchName) => {
|
|
110
111
|
const services = createServices();
|
package/dist/commands/branch.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createServices } from '../lib/services.js';
|
|
|
3
3
|
export function registerBranchCommand(program) {
|
|
4
4
|
program
|
|
5
5
|
.command('branch <branch-name>')
|
|
6
|
+
.helpGroup('Deprecated')
|
|
6
7
|
.description(chalk.dim('Deprecated: use "flow create <branch-name>" instead'))
|
|
7
8
|
.action(async () => {
|
|
8
9
|
const services = createServices();
|
|
@@ -101,6 +101,7 @@ export async function runCheckout(branchName, useCases, services, deps) {
|
|
|
101
101
|
export function registerCheckoutCommand(program) {
|
|
102
102
|
program
|
|
103
103
|
.command('checkout <branch-name>')
|
|
104
|
+
.helpGroup('Workspaces')
|
|
104
105
|
.description('Checkout an existing branch across repos')
|
|
105
106
|
.action(async (branchName) => {
|
|
106
107
|
const services = createServices();
|
package/dist/commands/config.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createServices } from '../lib/services.js';
|
|
|
5
5
|
export function registerConfigCommand(program) {
|
|
6
6
|
const configCmd = program
|
|
7
7
|
.command('config')
|
|
8
|
+
.helpGroup('Getting Started')
|
|
8
9
|
.description('Manage flow configuration');
|
|
9
10
|
configCmd
|
|
10
11
|
.command('set <key> <value>')
|
package/dist/commands/create.js
CHANGED
|
@@ -103,6 +103,7 @@ export async function runCreate(branchName, useCases, services, deps) {
|
|
|
103
103
|
export function registerCreateCommand(program) {
|
|
104
104
|
program
|
|
105
105
|
.command('create <branch-name>')
|
|
106
|
+
.helpGroup('Workspaces')
|
|
106
107
|
.description('Create branches and worktrees for selected repos')
|
|
107
108
|
.action(async (branchName) => {
|
|
108
109
|
const services = createServices();
|
package/dist/commands/drop.js
CHANGED
|
@@ -1,41 +1,44 @@
|
|
|
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
|
+
import { logStatusFetching, logStatus } from './helpers.js';
|
|
8
|
+
import { StatusService } from '../lib/status.js';
|
|
9
|
+
import { WorkspaceHasIssuesError } from '../lib/errors.js';
|
|
7
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,
|
|
20
28
|
});
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
}
|
|
29
|
+
const statusResult = await useCases.checkWorkspaceStatus.execute({
|
|
30
|
+
workspacePath,
|
|
31
|
+
});
|
|
32
|
+
// Phase 2: Clear Phase 1 lines and re-render with full status
|
|
33
|
+
logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: statusResult.statuses }], loadingLines, services.console);
|
|
34
|
+
// Block if any repos have uncommitted changes — user must resolve them first
|
|
35
|
+
const reposWithIssues = statusResult.statuses.filter(({ status }) => StatusService.hasIssues(status));
|
|
36
|
+
if (reposWithIssues.length > 0) {
|
|
37
|
+
throw new WorkspaceHasIssuesError(`${reposWithIssues.length} repo(s) have uncommitted changes or errors. Resolve them before dropping.`);
|
|
35
38
|
}
|
|
36
39
|
}
|
|
37
40
|
else {
|
|
38
|
-
services.console.log('
|
|
41
|
+
services.console.log('\nNo worktrees found in workspace.');
|
|
39
42
|
}
|
|
40
43
|
// Show what will be removed
|
|
41
44
|
services.console.log(`\n${chalk.yellow('This will remove:')}`);
|
|
@@ -90,6 +93,7 @@ export async function runDrop(branchName, useCases, services, deps) {
|
|
|
90
93
|
export function registerDropCommand(program) {
|
|
91
94
|
program
|
|
92
95
|
.command('drop [branch-name]')
|
|
96
|
+
.helpGroup('Workspaces')
|
|
93
97
|
.description('Drop a workspace and all its worktrees (auto-detects from current directory if branch not provided)')
|
|
94
98
|
.action(async (branchName) => {
|
|
95
99
|
const services = createServices();
|
package/dist/commands/fetch.js
CHANGED
|
@@ -28,6 +28,7 @@ export async function runFetch(branchName, useCases, services) {
|
|
|
28
28
|
export function registerFetchCommand(program) {
|
|
29
29
|
program
|
|
30
30
|
.command('fetch [branch-name]')
|
|
31
|
+
.helpGroup('Git Operations')
|
|
31
32
|
.description('Fetch repos (workspace-scoped if branch provided, all workspaces otherwise)')
|
|
32
33
|
.action(async (branchName) => {
|
|
33
34
|
const services = createServices();
|
package/dist/commands/helpers.js
CHANGED
|
@@ -3,15 +3,11 @@ import { StatusService } from '../lib/status.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Format a single repo's status as a display line, consistent across list and status commands.
|
|
5
5
|
*/
|
|
6
|
-
export function formatRepoStatusLine(repoName, status
|
|
7
|
-
const statusMessage = StatusService.getStatusMessage(status
|
|
6
|
+
export function formatRepoStatusLine(repoName, status) {
|
|
7
|
+
const statusMessage = StatusService.getStatusMessage(status);
|
|
8
8
|
const hasIssues = StatusService.hasIssues(status);
|
|
9
|
-
const indicator = hasIssues ? chalk.red('✗') : chalk.green('✓');
|
|
10
9
|
const message = hasIssues ? chalk.red(statusMessage) : chalk.green(statusMessage);
|
|
11
|
-
|
|
12
|
-
? chalk.dim(` → ${status.upstreamBranch}`)
|
|
13
|
-
: chalk.dim(' (no upstream)');
|
|
14
|
-
return ` ${indicator} ${chalk.yellow(repoName)}: ${message}${trackingInfo}`;
|
|
10
|
+
return ` ${chalk.yellow(repoName)}: ${message}`;
|
|
15
11
|
}
|
|
16
12
|
/**
|
|
17
13
|
* Render Phase 1: print a header and workspace rows with a "fetching..." indicator.
|
|
@@ -30,7 +26,7 @@ export function logStatusFetching(header, workspaces, console) {
|
|
|
30
26
|
/**
|
|
31
27
|
* Render Phase 2: clear the Phase 1 lines then print the header and full workspace status.
|
|
32
28
|
*/
|
|
33
|
-
export function logStatus(header, workspaces, linesToClear,
|
|
29
|
+
export function logStatus(header, workspaces, linesToClear, console) {
|
|
34
30
|
for (let i = 0; i < linesToClear; i++) {
|
|
35
31
|
console.write('\x1b[1A'); // Move cursor up one line
|
|
36
32
|
console.write('\x1b[2K'); // Clear entire line
|
|
@@ -41,8 +37,7 @@ export function logStatus(header, workspaces, linesToClear, getBaseBranch, conso
|
|
|
41
37
|
const repoCount = chalk.dim(`(${workspace.repoCount} repo${workspace.repoCount === 1 ? '' : 's'})`);
|
|
42
38
|
console.log(`${activeIndicator}${chalk.cyan(workspace.name)} ${repoCount}`);
|
|
43
39
|
for (const { repoName, status } of workspace.statuses) {
|
|
44
|
-
|
|
45
|
-
console.log(formatRepoStatusLine(repoName, status, baseBranch));
|
|
40
|
+
console.log(formatRepoStatusLine(repoName, status));
|
|
46
41
|
}
|
|
47
42
|
console.log('');
|
|
48
43
|
}
|
|
@@ -71,34 +66,3 @@ export function buildRepoCheckboxChoices(repos, services, preSelected, createSep
|
|
|
71
66
|
}
|
|
72
67
|
return choices;
|
|
73
68
|
}
|
|
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/list.js
CHANGED
|
@@ -27,12 +27,13 @@ export async function runList(useCases, services) {
|
|
|
27
27
|
cwd,
|
|
28
28
|
});
|
|
29
29
|
// Phase 4: Clear previous output and re-print with full status
|
|
30
|
-
logStatus('Workspaces:', result.workspaces, loadingLines,
|
|
30
|
+
logStatus('Workspaces:', result.workspaces, loadingLines, services.console);
|
|
31
31
|
}
|
|
32
32
|
export function registerListCommand(program) {
|
|
33
33
|
program
|
|
34
34
|
.command('list')
|
|
35
35
|
.alias('ls')
|
|
36
|
+
.helpGroup('Workspaces')
|
|
36
37
|
.description('List all workspaces with status indicators')
|
|
37
38
|
.action(async () => {
|
|
38
39
|
const services = createServices();
|
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, 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 hasDirty = ws.statuses.some(s => s.status.type === 'dirty');
|
|
41
|
+
const hasError = ws.statuses.some(s => s.status.type === 'error');
|
|
42
|
+
const reason = hasDirty ? '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,6 +112,7 @@ export async function runPrune(useCases, services, deps) {
|
|
|
89
112
|
export function registerPruneCommand(program) {
|
|
90
113
|
program
|
|
91
114
|
.command('prune')
|
|
115
|
+
.helpGroup('Workspaces')
|
|
92
116
|
.description('Select and remove workspaces')
|
|
93
117
|
.action(async () => {
|
|
94
118
|
const services = createServices();
|
package/dist/commands/pull.js
CHANGED
|
@@ -16,6 +16,7 @@ export async function runPull(branchName, useCases, services) {
|
|
|
16
16
|
export function registerPullCommand(program) {
|
|
17
17
|
program
|
|
18
18
|
.command('pull [branch-name]')
|
|
19
|
+
.helpGroup('Git Operations')
|
|
19
20
|
.description('Pull all repos in a workspace (auto-detects from current directory if branch not provided)')
|
|
20
21
|
.action(async (branchName) => {
|
|
21
22
|
const services = createServices();
|
package/dist/commands/push.js
CHANGED
|
@@ -16,6 +16,7 @@ export async function runPush(branchName, useCases, services) {
|
|
|
16
16
|
export function registerPushCommand(program) {
|
|
17
17
|
program
|
|
18
18
|
.command('push [branch-name]')
|
|
19
|
+
.helpGroup('Git Operations')
|
|
19
20
|
.description('Push all repos in a workspace (auto-detects from current directory if branch not provided)')
|
|
20
21
|
.action(async (branchName) => {
|
|
21
22
|
const services = createServices();
|
|
@@ -143,6 +143,7 @@ export async function runQuickstart(services, deps) {
|
|
|
143
143
|
export function registerQuickstartCommand(program) {
|
|
144
144
|
program
|
|
145
145
|
.command('quickstart')
|
|
146
|
+
.helpGroup('Getting Started')
|
|
146
147
|
.description('Interactive setup wizard for first-time configuration')
|
|
147
148
|
.action(async () => {
|
|
148
149
|
const services = createServices();
|
package/dist/commands/status.js
CHANGED
|
@@ -26,14 +26,13 @@ export async function runStatus(branchName, useCases, services) {
|
|
|
26
26
|
const result = await useCases.checkWorkspaceStatus.execute({
|
|
27
27
|
workspacePath,
|
|
28
28
|
});
|
|
29
|
-
// Load workspace config to get per-repo base branches
|
|
30
|
-
const workspaceConfig = services.workspaceConfig.load(workspacePath);
|
|
31
29
|
// Phase 2: Clear Phase 1 lines and re-render with full status
|
|
32
|
-
logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: result.statuses }], loadingLines,
|
|
30
|
+
logStatus('Workspace:', [{ name: workspaceName, path: workspacePath, repoCount, isActive: false, statuses: result.statuses }], loadingLines, services.console);
|
|
33
31
|
}
|
|
34
32
|
export function registerStatusCommand(program) {
|
|
35
33
|
program
|
|
36
34
|
.command('status [branch-name]')
|
|
35
|
+
.helpGroup('Git Operations')
|
|
37
36
|
.description('Show status of all worktrees in a workspace (auto-detects from current directory if branch not provided)')
|
|
38
37
|
.action(async (branchName) => {
|
|
39
38
|
const services = createServices();
|
package/dist/commands/tmux.js
CHANGED
|
@@ -27,6 +27,7 @@ export async function runTmuxSync(useCases, services) {
|
|
|
27
27
|
export function registerTmuxCommand(program) {
|
|
28
28
|
const tmuxCommand = program
|
|
29
29
|
.command('tmux')
|
|
30
|
+
.helpGroup('Extras')
|
|
30
31
|
.description('Manage tmux sessions for workspaces');
|
|
31
32
|
tmuxCommand
|
|
32
33
|
.command('sync')
|
package/dist/lib/git.js
CHANGED
|
@@ -36,6 +36,15 @@ export class GitService {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
async remoteTrackingBranchExists(repoPath, branch) {
|
|
40
|
+
try {
|
|
41
|
+
await this.exec(repoPath, ['rev-parse', '--verify', `origin/${branch}`]);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
39
48
|
async findFirstExistingBranch(repoPath, candidates) {
|
|
40
49
|
for (const branch of candidates) {
|
|
41
50
|
const exists = await this.localRemoteBranchExists(repoPath, branch);
|
|
@@ -76,55 +85,36 @@ export class GitService {
|
|
|
76
85
|
return null;
|
|
77
86
|
}
|
|
78
87
|
}
|
|
79
|
-
async
|
|
88
|
+
async getStatusCounts(repoPath) {
|
|
80
89
|
const output = await this.exec(repoPath, ['status', '--porcelain']);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
async isAheadOfOrigin(repoPath) {
|
|
94
|
-
try {
|
|
95
|
-
const branch = await this.getCurrentBranch(repoPath);
|
|
96
|
-
const output = await this.exec(repoPath, ['rev-list', '--count', `origin/${branch}..HEAD`]);
|
|
97
|
-
return parseInt(output, 10) > 0;
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return false;
|
|
90
|
+
const lines = output.split('\n').filter(line => line.length > 0);
|
|
91
|
+
let untracked = 0;
|
|
92
|
+
let uncommitted = 0;
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (line.startsWith('??')) {
|
|
95
|
+
untracked++;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
uncommitted++;
|
|
99
|
+
}
|
|
101
100
|
}
|
|
101
|
+
return { untracked, uncommitted };
|
|
102
102
|
}
|
|
103
|
-
async
|
|
103
|
+
async getUnpushedCommitCount(repoPath) {
|
|
104
104
|
try {
|
|
105
105
|
const branch = await this.getCurrentBranch(repoPath);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// Use git cherry to detect if commits exist in main via patch equivalence
|
|
116
|
-
// cherry outputs nothing if all commits are equivalent to main
|
|
117
|
-
// Format: "+ hash message" for unmerged, "- hash message" for already merged
|
|
118
|
-
const output = await this.exec(repoPath, ['cherry', `origin/${mainBranch}`, 'HEAD']);
|
|
119
|
-
// Filter to only unmerged commits (those starting with +)
|
|
120
|
-
const unmergedCommits = output
|
|
121
|
-
.split('\n')
|
|
122
|
-
.filter(line => line.trim().startsWith('+'));
|
|
123
|
-
return unmergedCommits.length > 0;
|
|
106
|
+
try {
|
|
107
|
+
const output = await this.exec(repoPath, ['rev-list', '--count', `origin/${branch}..HEAD`]);
|
|
108
|
+
return parseInt(output.trim(), 10);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// origin/<branch> doesn't exist yet — count commits not reachable from any remote ref
|
|
112
|
+
const output = await this.exec(repoPath, ['rev-list', '--count', 'HEAD', '--not', '--remotes']);
|
|
113
|
+
return parseInt(output.trim(), 10);
|
|
114
|
+
}
|
|
124
115
|
}
|
|
125
116
|
catch {
|
|
126
|
-
|
|
127
|
-
return true;
|
|
117
|
+
return 0;
|
|
128
118
|
}
|
|
129
119
|
}
|
|
130
120
|
async createBranch(repoPath, branchName, startPoint) {
|
package/dist/lib/status.js
CHANGED
|
@@ -6,54 +6,56 @@ export class StatusService {
|
|
|
6
6
|
constructor(git) {
|
|
7
7
|
this.git = git;
|
|
8
8
|
}
|
|
9
|
-
async getWorktreeStatus(worktreePath
|
|
9
|
+
async getWorktreeStatus(worktreePath) {
|
|
10
10
|
try {
|
|
11
|
-
// Get branch information
|
|
12
11
|
const currentBranch = await this.git.getCurrentBranch(worktreePath);
|
|
13
12
|
const upstreamBranch = await this.git.getUpstreamBranch(worktreePath);
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
// Compare against base branch using git cherry (handles squash merges)
|
|
20
|
-
const isAhead = await this.git.isAheadOfMain(worktreePath, baseBranch);
|
|
21
|
-
if (isAhead) {
|
|
22
|
-
return { type: 'ahead', comparedTo: 'main', currentBranch, upstreamBranch };
|
|
23
|
-
}
|
|
24
|
-
return { type: 'clean', comparedTo: 'main', currentBranch, upstreamBranch };
|
|
13
|
+
const { untracked, uncommitted } = await this.git.getStatusCounts(worktreePath);
|
|
14
|
+
const unpushed = await this.git.getUnpushedCommitCount(worktreePath);
|
|
15
|
+
const type = untracked > 0 || uncommitted > 0 ? 'dirty' : 'clean';
|
|
16
|
+
return { type, untracked, uncommitted, unpushed, currentBranch, upstreamBranch };
|
|
25
17
|
}
|
|
26
18
|
catch (err) {
|
|
27
19
|
return {
|
|
28
20
|
type: 'error',
|
|
21
|
+
untracked: 0,
|
|
22
|
+
uncommitted: 0,
|
|
23
|
+
unpushed: 0,
|
|
29
24
|
error: err.stderr || err.message,
|
|
30
25
|
};
|
|
31
26
|
}
|
|
32
27
|
}
|
|
33
|
-
static getStatusMessage(status
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return 'up to date';
|
|
37
|
-
case 'uncommitted':
|
|
38
|
-
return 'uncommitted changes';
|
|
39
|
-
case 'ahead':
|
|
40
|
-
return `ahead of ${baseBranch}`;
|
|
41
|
-
case 'error':
|
|
42
|
-
return `error: ${status.error}`;
|
|
28
|
+
static getStatusMessage(status) {
|
|
29
|
+
if (status.type === 'error') {
|
|
30
|
+
return `error: ${status.error}`;
|
|
43
31
|
}
|
|
32
|
+
if (status.type === 'clean' && status.unpushed === 0) {
|
|
33
|
+
return 'clean';
|
|
34
|
+
}
|
|
35
|
+
const parts = [];
|
|
36
|
+
if (status.untracked > 0) {
|
|
37
|
+
parts.push(`${status.untracked} untracked`);
|
|
38
|
+
}
|
|
39
|
+
if (status.uncommitted > 0) {
|
|
40
|
+
parts.push(`${status.uncommitted} modified`);
|
|
41
|
+
}
|
|
42
|
+
if (status.unpushed > 0) {
|
|
43
|
+
parts.push(`${status.unpushed} unpushed commit${status.unpushed === 1 ? '' : 's'}`);
|
|
44
|
+
}
|
|
45
|
+
return parts.length > 0 ? parts.join(', ') : 'clean';
|
|
44
46
|
}
|
|
45
47
|
static hasIssues(status) {
|
|
46
|
-
return (status.
|
|
48
|
+
return (status.untracked > 0 ||
|
|
49
|
+
status.uncommitted > 0 ||
|
|
47
50
|
status.type === 'error');
|
|
48
51
|
}
|
|
49
52
|
/**
|
|
50
53
|
* Check status of all worktrees in parallel
|
|
51
54
|
*/
|
|
52
|
-
async checkAllWorktrees(worktreeDirs
|
|
55
|
+
async checkAllWorktrees(worktreeDirs) {
|
|
53
56
|
const results = await Promise.all(worktreeDirs.map(async (worktreePath) => {
|
|
54
57
|
const repoName = worktreePath.split('/').pop() || worktreePath;
|
|
55
|
-
const
|
|
56
|
-
const status = await this.getWorktreeStatus(worktreePath, baseBranch);
|
|
58
|
+
const status = await this.getWorktreeStatus(worktreePath);
|
|
57
59
|
return { repoName, status };
|
|
58
60
|
}));
|
|
59
61
|
return results;
|
|
@@ -61,8 +63,8 @@ export class StatusService {
|
|
|
61
63
|
/**
|
|
62
64
|
* Find repos with issues (for removal validation)
|
|
63
65
|
*/
|
|
64
|
-
async findReposWithIssues(worktreeDirs
|
|
65
|
-
const results = await this.checkAllWorktrees(worktreeDirs
|
|
66
|
+
async findReposWithIssues(worktreeDirs) {
|
|
67
|
+
const results = await this.checkAllWorktrees(worktreeDirs);
|
|
66
68
|
return results
|
|
67
69
|
.filter(({ status }) => StatusService.hasIssues(status))
|
|
68
70
|
.map(({ repoName }) => repoName);
|
|
@@ -3,19 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export class CheckWorkspaceStatusUseCase {
|
|
5
5
|
workspaceDir;
|
|
6
|
-
workspaceConfig;
|
|
7
6
|
status;
|
|
8
|
-
constructor(workspaceDir,
|
|
7
|
+
constructor(workspaceDir, status) {
|
|
9
8
|
this.workspaceDir = workspaceDir;
|
|
10
|
-
this.workspaceConfig = workspaceConfig;
|
|
11
9
|
this.status = status;
|
|
12
10
|
}
|
|
13
11
|
async execute(params) {
|
|
14
12
|
const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
|
|
15
|
-
|
|
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);
|
|
13
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs);
|
|
19
14
|
return { statuses };
|
|
20
15
|
}
|
|
21
16
|
}
|
|
@@ -25,8 +25,10 @@ export class CreateBranchUseCase {
|
|
|
25
25
|
// 3. If the target branch already exists, skip creation and use it as-is
|
|
26
26
|
const targetBranchExists = await this.git.localRemoteBranchExists(params.repoPath, params.branchName);
|
|
27
27
|
if (!targetBranchExists) {
|
|
28
|
-
// 4.
|
|
29
|
-
await this.git.
|
|
28
|
+
// 4. Prefer origin/<actualBaseBranch> as start point; fall back to local branch
|
|
29
|
+
const originExists = await this.git.remoteTrackingBranchExists(params.repoPath, actualBaseBranch);
|
|
30
|
+
const startPoint = originExists ? `origin/${actualBaseBranch}` : actualBaseBranch;
|
|
31
|
+
await this.git.createBranch(params.repoPath, params.branchName, startPoint);
|
|
30
32
|
}
|
|
31
33
|
// 5. Return the actual base branch used
|
|
32
34
|
return {
|
|
@@ -5,12 +5,10 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
|
|
|
5
5
|
*/
|
|
6
6
|
export class DiscoverPrunableWorkspacesUseCase {
|
|
7
7
|
workspaceDir;
|
|
8
|
-
workspaceConfig;
|
|
9
8
|
status;
|
|
10
9
|
git;
|
|
11
|
-
constructor(workspaceDir,
|
|
10
|
+
constructor(workspaceDir, status, git) {
|
|
12
11
|
this.workspaceDir = workspaceDir;
|
|
13
|
-
this.workspaceConfig = workspaceConfig;
|
|
14
12
|
this.status = status;
|
|
15
13
|
this.git = git;
|
|
16
14
|
}
|
|
@@ -25,11 +23,8 @@ export class DiscoverPrunableWorkspacesUseCase {
|
|
|
25
23
|
if (!worktreeDirs || worktreeDirs.length === 0) {
|
|
26
24
|
continue;
|
|
27
25
|
}
|
|
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';
|
|
31
26
|
// Check status for all worktrees
|
|
32
|
-
const statuses = await this.status.checkAllWorktrees(worktreeDirs
|
|
27
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs);
|
|
33
28
|
// Skip if any worktree has issues
|
|
34
29
|
const hasAnyIssues = statuses.some(({ status }) => StatusServiceClass.hasIssues(status));
|
|
35
30
|
if (hasAnyIssues) {
|
|
@@ -4,11 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export class ListWorkspacesWithStatusUseCase {
|
|
6
6
|
workspaceDir;
|
|
7
|
-
workspaceConfig;
|
|
8
7
|
status;
|
|
9
|
-
constructor(workspaceDir,
|
|
8
|
+
constructor(workspaceDir, status) {
|
|
10
9
|
this.workspaceDir = workspaceDir;
|
|
11
|
-
this.workspaceConfig = workspaceConfig;
|
|
12
10
|
this.status = status;
|
|
13
11
|
}
|
|
14
12
|
async execute(params) {
|
|
@@ -26,11 +24,8 @@ export class ListWorkspacesWithStatusUseCase {
|
|
|
26
24
|
}
|
|
27
25
|
// Check if this workspace is active (contains current working directory)
|
|
28
26
|
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';
|
|
32
27
|
// Check status for all worktrees
|
|
33
|
-
const statuses = await this.status.checkAllWorktrees(worktreeDirs
|
|
28
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs);
|
|
34
29
|
workspacesWithStatus.push({
|
|
35
30
|
name: workspace.name,
|
|
36
31
|
path: workspace.path,
|
|
@@ -7,14 +7,12 @@ import { StatusService as StatusServiceClass } from '../lib/status.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export class RemoveWorkspaceUseCase {
|
|
9
9
|
workspaceDir;
|
|
10
|
-
workspaceConfig;
|
|
11
10
|
worktree;
|
|
12
11
|
repos;
|
|
13
12
|
status;
|
|
14
13
|
tmux;
|
|
15
|
-
constructor(workspaceDir,
|
|
14
|
+
constructor(workspaceDir, worktree, repos, status, tmux) {
|
|
16
15
|
this.workspaceDir = workspaceDir;
|
|
17
|
-
this.workspaceConfig = workspaceConfig;
|
|
18
16
|
this.worktree = worktree;
|
|
19
17
|
this.repos = repos;
|
|
20
18
|
this.status = status;
|
|
@@ -25,16 +23,12 @@ export class RemoveWorkspaceUseCase {
|
|
|
25
23
|
const issuesFound = [];
|
|
26
24
|
// 1. Check status if worktrees exist
|
|
27
25
|
if (worktreeDirs.length > 0) {
|
|
28
|
-
|
|
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);
|
|
26
|
+
const results = await this.status.checkAllWorktrees(worktreeDirs);
|
|
32
27
|
for (const { repoName, status } of results) {
|
|
33
28
|
if (StatusServiceClass.hasIssues(status)) {
|
|
34
|
-
const baseBranch = getBaseBranch(repoName);
|
|
35
29
|
issuesFound.push({
|
|
36
30
|
repoName,
|
|
37
|
-
issue: StatusServiceClass.getStatusMessage(status
|
|
31
|
+
issue: StatusServiceClass.getStatusMessage(status),
|
|
38
32
|
});
|
|
39
33
|
}
|
|
40
34
|
}
|
|
@@ -21,12 +21,12 @@ export function createUseCases(services) {
|
|
|
21
21
|
fetchAllRepos: new FetchAllReposUseCase(services.fetch, services.repos),
|
|
22
22
|
fetchWorkspaceRepos: new FetchWorkspaceReposUseCase(services.workspaceDir, services.fetch),
|
|
23
23
|
fetchUsedRepos: new FetchUsedReposUseCase(services.workspaceDir, services.fetch),
|
|
24
|
-
removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, services.
|
|
24
|
+
removeWorkspace: new RemoveWorkspaceUseCase(services.workspaceDir, 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.
|
|
28
|
-
discoverPrunableWorkspaces: new DiscoverPrunableWorkspacesUseCase(services.workspaceDir, services.
|
|
29
|
-
listWorkspacesWithStatus: new ListWorkspacesWithStatusUseCase(services.workspaceDir, services.
|
|
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),
|
|
30
30
|
resumeTmuxSessions: new ResumeTmuxSessionsUseCase(services.workspaceDir, services.tmux),
|
|
31
31
|
createWorkspace: new CreateWorkspaceUseCase(services.workspaceDir, services.workspaceConfig, services.tmux),
|
|
32
32
|
createBranch: new CreateBranchUseCase(services.git),
|