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
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
import { StatusService } from '../lib/status.js';
|
|
6
|
+
import { resolveWorkspace } from '../lib/workspaceResolver.js';
|
|
7
|
+
export async function runRemove(branchName, useCases, services, deps) {
|
|
8
|
+
const { sourcePath } = services.config.getRequired();
|
|
9
|
+
const config = services.config.load();
|
|
10
|
+
const { workspacePath, displayName: branchNameForDisplay } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
11
|
+
services.console.log(`Checking workspace: ${chalk.cyan(workspacePath)}`);
|
|
12
|
+
// Get worktree dirs to show what will be removed
|
|
13
|
+
const worktreeDirs = services.workspaceDir.getWorktreeDirs(workspacePath);
|
|
14
|
+
// Display status check if worktrees exist
|
|
15
|
+
if (worktreeDirs.length > 0) {
|
|
16
|
+
await services.fetch.fetchRepos(worktreeDirs);
|
|
17
|
+
services.console.log(`\nChecking for uncommitted changes and commits ahead of ${config.mainBranch}...`);
|
|
18
|
+
const results = await services.status.checkAllWorktrees(worktreeDirs, config.mainBranch);
|
|
19
|
+
for (const { repoName, status } of results) {
|
|
20
|
+
const message = StatusService.getStatusMessage(status, config.mainBranch);
|
|
21
|
+
if (StatusService.hasIssues(status)) {
|
|
22
|
+
services.console.log(`${repoName}... ${chalk.red(message)}`);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
services.console.log(`${repoName}... ${chalk.green(message)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
services.console.log('No worktrees found in workspace.');
|
|
31
|
+
}
|
|
32
|
+
// Show what will be removed
|
|
33
|
+
services.console.log(`\n${chalk.yellow('This will remove:')}`);
|
|
34
|
+
services.console.log(` Directory: ${chalk.cyan(workspacePath)}`);
|
|
35
|
+
if (worktreeDirs.length > 0) {
|
|
36
|
+
services.console.log(` Worktrees: ${worktreeDirs.length} repo(s)`);
|
|
37
|
+
}
|
|
38
|
+
if (config.tmux) {
|
|
39
|
+
services.console.log(` Tmux session: ${chalk.cyan(branchNameForDisplay)}`);
|
|
40
|
+
}
|
|
41
|
+
const confirmed = await deps.confirm({
|
|
42
|
+
message: 'Are you sure you want to remove this workspace?',
|
|
43
|
+
default: false,
|
|
44
|
+
});
|
|
45
|
+
if (!confirmed) {
|
|
46
|
+
services.console.log('\nCancelled.');
|
|
47
|
+
services.process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
// Execute use case (will throw if there are issues)
|
|
50
|
+
services.console.log('\nRemoving workspace...');
|
|
51
|
+
const result = await useCases.removeWorkspace.execute({
|
|
52
|
+
workspacePath,
|
|
53
|
+
branchName: branchNameForDisplay,
|
|
54
|
+
sourcePath,
|
|
55
|
+
mainBranch: config.mainBranch,
|
|
56
|
+
tmux: config.tmux,
|
|
57
|
+
});
|
|
58
|
+
// Display worktree removal results
|
|
59
|
+
if (worktreeDirs.length > 0) {
|
|
60
|
+
for (const { repo, error } of result.removalErrors) {
|
|
61
|
+
if (error === 'source repo not found') {
|
|
62
|
+
services.console.log(`${repo}... ${chalk.yellow('source repo not found, skipping worktree removal')}`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
services.console.log(`${repo}... ${chalk.red(`error: ${error}`)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const successfulRemovals = result.worktreesTotal - result.removalErrors.length;
|
|
69
|
+
for (let i = 0; i < successfulRemovals; i++) {
|
|
70
|
+
services.console.log(`${worktreeDirs[i].split('/').pop()}... ${chalk.green('removed')}`);
|
|
71
|
+
}
|
|
72
|
+
services.console.log(`\nRemoved ${result.worktreesRemoved}/${result.worktreesTotal} worktree(s).`);
|
|
73
|
+
}
|
|
74
|
+
// Display results
|
|
75
|
+
if (result.workspaceDirRemoved) {
|
|
76
|
+
services.console.log(`${chalk.green('Removed:')} ${workspacePath}`);
|
|
77
|
+
}
|
|
78
|
+
if (result.tmuxKilled) {
|
|
79
|
+
services.console.log(`${chalk.green('Killed tmux session:')} ${branchNameForDisplay}`);
|
|
80
|
+
}
|
|
81
|
+
services.console.log(`\n${chalk.green('Successfully removed workspace:')} ${branchNameForDisplay}`);
|
|
82
|
+
}
|
|
83
|
+
export function registerRemoveCommand(program) {
|
|
84
|
+
program
|
|
85
|
+
.command('remove [branch-name]')
|
|
86
|
+
.description('Remove a workspace and all its worktrees (auto-detects from current directory if branch not provided)')
|
|
87
|
+
.action(async (branchName) => {
|
|
88
|
+
const services = createServices();
|
|
89
|
+
const useCases = createUseCases(services);
|
|
90
|
+
try {
|
|
91
|
+
await runRemove(branchName, useCases, services, { confirm });
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
services.console.error(error.message);
|
|
95
|
+
services.process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { createServices } from '../lib/services.js';
|
|
3
|
+
import { createUseCases } from '../usecases/usecases.js';
|
|
4
|
+
import { StatusService } from '../lib/status.js';
|
|
5
|
+
import { resolveWorkspace } from '../lib/workspaceResolver.js';
|
|
6
|
+
export async function runStatus(branchName, useCases, services) {
|
|
7
|
+
const config = services.config.load();
|
|
8
|
+
const { workspacePath } = resolveWorkspace(branchName, services.workspaceDir, services.config, services.process);
|
|
9
|
+
services.console.log(`Workspace: ${chalk.cyan(workspacePath)}`);
|
|
10
|
+
const worktreeDirs = services.workspaceDir.getWorktreeDirs(workspacePath);
|
|
11
|
+
if (worktreeDirs.length === 0) {
|
|
12
|
+
services.console.log('\nNo worktrees found in workspace.');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
services.console.log('');
|
|
16
|
+
services.console.log(`\nStatus (comparing against ${chalk.cyan(config.mainBranch)}):\n`);
|
|
17
|
+
const result = await useCases.checkWorkspaceStatus.execute({
|
|
18
|
+
workspacePath,
|
|
19
|
+
mainBranch: config.mainBranch,
|
|
20
|
+
});
|
|
21
|
+
let cleanCount = 0;
|
|
22
|
+
let issuesCount = 0;
|
|
23
|
+
for (const { repoName, status } of result.statuses) {
|
|
24
|
+
const message = StatusService.getStatusMessage(status, config.mainBranch);
|
|
25
|
+
if (StatusService.hasIssues(status)) {
|
|
26
|
+
services.console.log(` ${chalk.red('✗')} ${repoName}: ${chalk.red(message)}`);
|
|
27
|
+
issuesCount++;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
services.console.log(` ${chalk.green('✓')} ${repoName}: ${chalk.green(message)}`);
|
|
31
|
+
cleanCount++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
services.console.log('');
|
|
35
|
+
services.console.log(`Summary: ${chalk.green(`${cleanCount} up to date`)}, ${issuesCount > 0 ? chalk.red(`${issuesCount} with issues`) : chalk.green('0 with issues')}`);
|
|
36
|
+
}
|
|
37
|
+
export function registerStatusCommand(program) {
|
|
38
|
+
program
|
|
39
|
+
.command('status [branch-name]')
|
|
40
|
+
.description('Show status of all worktrees in a workspace (auto-detects from current directory if branch not provided)')
|
|
41
|
+
.action(async (branchName) => {
|
|
42
|
+
const services = createServices();
|
|
43
|
+
const useCases = createUseCases(services);
|
|
44
|
+
try {
|
|
45
|
+
await runStatus(branchName, useCases, services);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
services.console.error(error.message);
|
|
49
|
+
services.process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ConfigNotSetError } from './errors.js';
|
|
5
|
+
// Raw config schema (as stored in JSON)
|
|
6
|
+
const RawConfigSchema = z.object({
|
|
7
|
+
'source-path': z.string().optional(),
|
|
8
|
+
'dest-path': z.string().optional(),
|
|
9
|
+
'copy-files': z.string().optional(),
|
|
10
|
+
'tmux': z.enum(['true', 'false']).optional(),
|
|
11
|
+
'main-branch': z.string().optional(),
|
|
12
|
+
'post-checkout': z.string().optional(),
|
|
13
|
+
});
|
|
14
|
+
// Parsed config schema (with proper types)
|
|
15
|
+
const ParsedConfigSchema = z.object({
|
|
16
|
+
sourcePath: z.string().optional(),
|
|
17
|
+
destPath: z.string().optional(),
|
|
18
|
+
copyFiles: z.string().default('.env'),
|
|
19
|
+
tmux: z.boolean().default(false),
|
|
20
|
+
mainBranch: z.string().default('master'),
|
|
21
|
+
postCheckout: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
// Required config schema (for getRequired)
|
|
24
|
+
const RequiredConfigSchema = z.object({
|
|
25
|
+
sourcePath: z.string(),
|
|
26
|
+
destPath: z.string(),
|
|
27
|
+
});
|
|
28
|
+
// Schema for setting individual config values
|
|
29
|
+
const ConfigValueSchemas = {
|
|
30
|
+
'source-path': z.string().transform((val) => path.resolve(val)),
|
|
31
|
+
'dest-path': z.string().transform((val) => path.resolve(val)),
|
|
32
|
+
'copy-files': z.string(),
|
|
33
|
+
'tmux': z.enum(['true', 'false']),
|
|
34
|
+
'main-branch': z.string(),
|
|
35
|
+
'post-checkout': z.string(),
|
|
36
|
+
};
|
|
37
|
+
export const CONFIG_KEYS = ['source-path', 'dest-path', 'copy-files', 'tmux', 'main-branch', 'post-checkout'];
|
|
38
|
+
/**
|
|
39
|
+
* Pure utility functions (no I/O)
|
|
40
|
+
*/
|
|
41
|
+
export function validateAndTransformConfigValue(key, value) {
|
|
42
|
+
const schema = ConfigValueSchemas[key];
|
|
43
|
+
return schema.parse(value);
|
|
44
|
+
}
|
|
45
|
+
export function isValidKey(key) {
|
|
46
|
+
return CONFIG_KEYS.includes(key);
|
|
47
|
+
}
|
|
48
|
+
export function getConfigPath() {
|
|
49
|
+
return path.join(os.homedir(), '.config', 'flow', 'config.json');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* ConfigService handles all configuration operations.
|
|
53
|
+
*/
|
|
54
|
+
export class ConfigService {
|
|
55
|
+
fs;
|
|
56
|
+
constructor(fs) {
|
|
57
|
+
this.fs = fs;
|
|
58
|
+
}
|
|
59
|
+
loadRaw() {
|
|
60
|
+
const configPath = getConfigPath();
|
|
61
|
+
if (!this.fs.existsSync(configPath)) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
const raw = this.fs.readFileSync(configPath, 'utf-8');
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
return RawConfigSchema.parse(parsed);
|
|
67
|
+
}
|
|
68
|
+
saveRaw(config) {
|
|
69
|
+
const configPath = getConfigPath();
|
|
70
|
+
this.fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
71
|
+
const validated = RawConfigSchema.parse(config);
|
|
72
|
+
this.fs.writeFileSync(configPath, JSON.stringify(validated, null, 2) + '\n');
|
|
73
|
+
}
|
|
74
|
+
load() {
|
|
75
|
+
const raw = this.loadRaw();
|
|
76
|
+
return ParsedConfigSchema.parse({
|
|
77
|
+
sourcePath: raw['source-path'],
|
|
78
|
+
destPath: raw['dest-path'],
|
|
79
|
+
copyFiles: raw['copy-files'],
|
|
80
|
+
tmux: raw.tmux === 'true',
|
|
81
|
+
mainBranch: raw['main-branch'],
|
|
82
|
+
postCheckout: raw['post-checkout'],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
getRequired() {
|
|
86
|
+
const config = this.load();
|
|
87
|
+
const result = RequiredConfigSchema.safeParse({
|
|
88
|
+
sourcePath: config.sourcePath,
|
|
89
|
+
destPath: config.destPath,
|
|
90
|
+
});
|
|
91
|
+
if (!result.success) {
|
|
92
|
+
throw new ConfigNotSetError('flow is not configured. Run:\n' +
|
|
93
|
+
' flow config set source-path <path>\n' +
|
|
94
|
+
' flow config set dest-path <path>');
|
|
95
|
+
}
|
|
96
|
+
return result.data;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Get config display values (formatted for CLI output)
|
|
100
|
+
*/
|
|
101
|
+
getDisplayConfig() {
|
|
102
|
+
const raw = this.loadRaw();
|
|
103
|
+
const config = this.load();
|
|
104
|
+
return {
|
|
105
|
+
'source-path': {
|
|
106
|
+
value: raw['source-path'] ?? '(not set)',
|
|
107
|
+
isDefault: !raw['source-path'],
|
|
108
|
+
},
|
|
109
|
+
'dest-path': {
|
|
110
|
+
value: raw['dest-path'] ?? '(not set)',
|
|
111
|
+
isDefault: !raw['dest-path'],
|
|
112
|
+
},
|
|
113
|
+
'copy-files': {
|
|
114
|
+
value: raw['copy-files'] ?? `${config.copyFiles} (default)`,
|
|
115
|
+
isDefault: !raw['copy-files'],
|
|
116
|
+
},
|
|
117
|
+
'tmux': {
|
|
118
|
+
value: raw.tmux ?? `${config.tmux} (default)`,
|
|
119
|
+
isDefault: !raw.tmux,
|
|
120
|
+
},
|
|
121
|
+
'main-branch': {
|
|
122
|
+
value: raw['main-branch'] ?? `${config.mainBranch} (default)`,
|
|
123
|
+
isDefault: !raw['main-branch'],
|
|
124
|
+
},
|
|
125
|
+
'post-checkout': {
|
|
126
|
+
value: raw['post-checkout'] ?? '(not set)',
|
|
127
|
+
isDefault: !raw['post-checkout'],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain-specific error classes.
|
|
3
|
+
*/
|
|
4
|
+
export class ConfigNotSetError extends Error {
|
|
5
|
+
constructor(message = 'Configuration not set') {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'ConfigNotSetError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class WorkspaceNotFoundError extends Error {
|
|
11
|
+
constructor(path) {
|
|
12
|
+
super(`Workspace not found: ${path}`);
|
|
13
|
+
this.name = 'WorkspaceNotFoundError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class WorkspaceAlreadyExistsError extends Error {
|
|
17
|
+
constructor(path) {
|
|
18
|
+
super(`Workspace already exists: ${path}`);
|
|
19
|
+
this.name = 'WorkspaceAlreadyExistsError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class NoReposFoundError extends Error {
|
|
23
|
+
constructor(path) {
|
|
24
|
+
super(`No git repositories found in ${path}`);
|
|
25
|
+
this.name = 'NoReposFoundError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class WorkspaceHasIssuesError extends Error {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'WorkspaceHasIssuesError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class NotInWorkspaceError extends Error {
|
|
35
|
+
constructor(destPath) {
|
|
36
|
+
super(`Not inside a flow workspace.\nNavigate to a directory under ${destPath}/.`);
|
|
37
|
+
this.name = 'NotInWorkspaceError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
const FETCH_CONCURRENCY = 8;
|
|
3
|
+
/**
|
|
4
|
+
* FetchService handles parallel fetching of multiple repos.
|
|
5
|
+
*/
|
|
6
|
+
export class FetchService {
|
|
7
|
+
git;
|
|
8
|
+
console;
|
|
9
|
+
constructor(git, console) {
|
|
10
|
+
this.git = git;
|
|
11
|
+
this.console = console;
|
|
12
|
+
}
|
|
13
|
+
async fetchRepos(repoPaths) {
|
|
14
|
+
if (repoPaths.length === 0) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const total = repoPaths.length;
|
|
18
|
+
let completed = 0;
|
|
19
|
+
let failed = 0;
|
|
20
|
+
const updateProgress = () => {
|
|
21
|
+
const percent = Math.floor((completed / total) * 100);
|
|
22
|
+
const message = `Fetching repos... ${completed}/${total} (${percent}%)`;
|
|
23
|
+
this.console.write(`\r${message}`);
|
|
24
|
+
};
|
|
25
|
+
updateProgress();
|
|
26
|
+
const processRepo = async (repoPath) => {
|
|
27
|
+
try {
|
|
28
|
+
await this.git.fetch(repoPath);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
failed++;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
completed++;
|
|
35
|
+
updateProgress();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
// Process repos with controlled concurrency
|
|
39
|
+
let index = 0;
|
|
40
|
+
const worker = async () => {
|
|
41
|
+
while (index < repoPaths.length) {
|
|
42
|
+
const repoPath = repoPaths[index++];
|
|
43
|
+
await processRepo(repoPath);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
// Start workers (up to concurrency limit)
|
|
47
|
+
const workers = Array.from({ length: Math.min(FETCH_CONCURRENCY, repoPaths.length) }, () => worker());
|
|
48
|
+
await Promise.all(workers);
|
|
49
|
+
// Clear the progress line and show summary
|
|
50
|
+
this.console.write('\r');
|
|
51
|
+
if (failed > 0) {
|
|
52
|
+
this.console.log(`Fetched ${total - failed}/${total} repos ${chalk.yellow(`(${failed} failed)`)}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
this.console.log(`Fetched ${total} repos ${chalk.green('✓')}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitService handles all git operations.
|
|
3
|
+
*/
|
|
4
|
+
export class GitService {
|
|
5
|
+
shell;
|
|
6
|
+
constructor(shell) {
|
|
7
|
+
this.shell = shell;
|
|
8
|
+
}
|
|
9
|
+
async exec(repoPath, args) {
|
|
10
|
+
const { stdout } = await this.shell.execFile('git', ['-C', repoPath, ...args], {
|
|
11
|
+
encoding: 'utf-8',
|
|
12
|
+
});
|
|
13
|
+
return stdout;
|
|
14
|
+
}
|
|
15
|
+
async fetch(repoPath) {
|
|
16
|
+
await this.exec(repoPath, ['fetch', '--all', '--prune']);
|
|
17
|
+
}
|
|
18
|
+
async remoteBranchExists(repoPath, branch) {
|
|
19
|
+
const output = await this.exec(repoPath, ['ls-remote', '--heads', 'origin', branch]);
|
|
20
|
+
return output.length > 0;
|
|
21
|
+
}
|
|
22
|
+
async localRemoteBranchExists(repoPath, branch) {
|
|
23
|
+
try {
|
|
24
|
+
await this.exec(repoPath, ['rev-parse', '--verify', `origin/${branch}`]);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async addWorktreeNewBranch(repoPath, worktreePath, branch, sourceBranch) {
|
|
32
|
+
const args = ['worktree', 'add', '-b', branch, worktreePath];
|
|
33
|
+
if (sourceBranch) {
|
|
34
|
+
args.push(sourceBranch);
|
|
35
|
+
}
|
|
36
|
+
await this.exec(repoPath, args);
|
|
37
|
+
}
|
|
38
|
+
async addWorktree(repoPath, worktreePath, branch) {
|
|
39
|
+
await this.exec(repoPath, ['worktree', 'add', worktreePath, branch]);
|
|
40
|
+
}
|
|
41
|
+
async pull(worktreePath) {
|
|
42
|
+
await this.exec(worktreePath, ['pull']);
|
|
43
|
+
}
|
|
44
|
+
async push(worktreePath) {
|
|
45
|
+
await this.exec(worktreePath, ['push']);
|
|
46
|
+
}
|
|
47
|
+
async pushSetUpstream(worktreePath, branch) {
|
|
48
|
+
await this.exec(worktreePath, ['push', '--set-upstream', 'origin', branch]);
|
|
49
|
+
}
|
|
50
|
+
async getCurrentBranch(worktreePath) {
|
|
51
|
+
const output = await this.exec(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
52
|
+
return output.trim();
|
|
53
|
+
}
|
|
54
|
+
async hasUncommittedChanges(repoPath) {
|
|
55
|
+
const output = await this.exec(repoPath, ['status', '--porcelain']);
|
|
56
|
+
return output.length > 0;
|
|
57
|
+
}
|
|
58
|
+
async originBranchExists(repoPath) {
|
|
59
|
+
try {
|
|
60
|
+
const branch = await this.getCurrentBranch(repoPath);
|
|
61
|
+
await this.exec(repoPath, ['rev-parse', '--verify', `origin/${branch}`]);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async isAheadOfOrigin(repoPath) {
|
|
69
|
+
try {
|
|
70
|
+
const branch = await this.getCurrentBranch(repoPath);
|
|
71
|
+
const output = await this.exec(repoPath, ['rev-list', '--count', `origin/${branch}..HEAD`]);
|
|
72
|
+
return parseInt(output, 10) > 0;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async isBehindOrigin(repoPath) {
|
|
79
|
+
try {
|
|
80
|
+
const branch = await this.getCurrentBranch(repoPath);
|
|
81
|
+
const output = await this.exec(repoPath, ['rev-list', '--count', `HEAD..origin/${branch}`]);
|
|
82
|
+
return parseInt(output, 10) > 0;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async isAheadOfMain(repoPath, mainBranch) {
|
|
89
|
+
try {
|
|
90
|
+
// Check if there's a diff between current branch and main
|
|
91
|
+
const output = await this.exec(repoPath, ['diff', `origin/${mainBranch}...HEAD`]);
|
|
92
|
+
return output.length > 0;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// If we can't determine, assume there are changes to be safe
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async removeWorktree(repoPath, worktreePath) {
|
|
100
|
+
await this.exec(repoPath, ['worktree', 'remove', worktreePath]);
|
|
101
|
+
}
|
|
102
|
+
async getLastCommitDate(repoPath) {
|
|
103
|
+
const output = await this.exec(repoPath, ['log', '-1', '--format=%aI', 'HEAD']);
|
|
104
|
+
return new Date(output.trim());
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Push with automatic set-upstream retry if no upstream is configured
|
|
108
|
+
*/
|
|
109
|
+
async pushWithRetry(worktreePath) {
|
|
110
|
+
try {
|
|
111
|
+
await this.push(worktreePath);
|
|
112
|
+
return 'pushed';
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const stderr = err.stderr || err.message || '';
|
|
116
|
+
if (stderr.includes('no upstream') || stderr.includes('has no upstream')) {
|
|
117
|
+
const branch = (await this.getCurrentBranch(worktreePath)).trim();
|
|
118
|
+
await this.pushSetUpstream(worktreePath, branch);
|
|
119
|
+
return 'pushed (set upstream)';
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
/**
|
|
3
|
+
* ParallelService handles parallel processing with console feedback.
|
|
4
|
+
*/
|
|
5
|
+
export class ParallelService {
|
|
6
|
+
console;
|
|
7
|
+
constructor(console) {
|
|
8
|
+
this.console = console;
|
|
9
|
+
}
|
|
10
|
+
async processInParallel(items, getName, processor) {
|
|
11
|
+
const results = await Promise.allSettled(items.map(async (item) => {
|
|
12
|
+
const name = getName(item);
|
|
13
|
+
try {
|
|
14
|
+
const message = await processor(item, name);
|
|
15
|
+
this.console.log(chalk.green(` ${name}: ${message}`));
|
|
16
|
+
return { success: true };
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
const errorMsg = err.stderr || err.message || 'unknown error';
|
|
20
|
+
this.console.error(chalk.red(` ${name}: ${errorMsg}`));
|
|
21
|
+
return { success: false };
|
|
22
|
+
}
|
|
23
|
+
}));
|
|
24
|
+
return results.filter((r) => r.status === 'fulfilled' && r.value.success).length;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostCheckoutService handles execution of post-checkout commands.
|
|
3
|
+
*/
|
|
4
|
+
export class PostCheckoutService {
|
|
5
|
+
shell;
|
|
6
|
+
constructor(shell) {
|
|
7
|
+
this.shell = shell;
|
|
8
|
+
}
|
|
9
|
+
async runCommand(worktreeDirs, command) {
|
|
10
|
+
let successCount = 0;
|
|
11
|
+
await Promise.allSettled(worktreeDirs.map(async (worktreeDir) => {
|
|
12
|
+
try {
|
|
13
|
+
await this.shell.execFile('sh', ['-c', command], { cwd: worktreeDir });
|
|
14
|
+
successCount++;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Error handled by caller
|
|
18
|
+
}
|
|
19
|
+
}));
|
|
20
|
+
return { successCount, totalCount: worktreeDirs.length };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* RepoService handles repository discovery and operations.
|
|
4
|
+
*/
|
|
5
|
+
export class RepoService {
|
|
6
|
+
fs;
|
|
7
|
+
git;
|
|
8
|
+
constructor(fs, git) {
|
|
9
|
+
this.fs = fs;
|
|
10
|
+
this.git = git;
|
|
11
|
+
}
|
|
12
|
+
discoverRepos(sourcePath) {
|
|
13
|
+
const entries = this.fs.readdirSync(sourcePath, { withFileTypes: true });
|
|
14
|
+
return entries
|
|
15
|
+
.filter((entry) => entry.isDirectory())
|
|
16
|
+
.map((entry) => path.join(sourcePath, entry.name))
|
|
17
|
+
.filter((dirPath) => this.fs.existsSync(path.join(dirPath, '.git')))
|
|
18
|
+
.sort();
|
|
19
|
+
}
|
|
20
|
+
static getRepoName(repoPath) {
|
|
21
|
+
return path.basename(repoPath);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Find all repos that have a specific branch (checking local remote-tracking branches)
|
|
25
|
+
*/
|
|
26
|
+
async findReposWithBranch(repos, branchName) {
|
|
27
|
+
if (!this.git) {
|
|
28
|
+
throw new Error('RepoService not configured with git service');
|
|
29
|
+
}
|
|
30
|
+
const results = [];
|
|
31
|
+
const matching = [];
|
|
32
|
+
for (const repoPath of repos) {
|
|
33
|
+
const repoName = RepoService.getRepoName(repoPath);
|
|
34
|
+
try {
|
|
35
|
+
const hasBranch = await this.git.localRemoteBranchExists(repoPath, branchName);
|
|
36
|
+
results.push({ repoPath, repoName, hasBranch });
|
|
37
|
+
if (hasBranch) {
|
|
38
|
+
matching.push(repoPath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
results.push({
|
|
43
|
+
repoPath,
|
|
44
|
+
repoName,
|
|
45
|
+
hasBranch: false,
|
|
46
|
+
error: err.stderr || err.message,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { matching, results };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Format repo choices for checkbox selection
|
|
54
|
+
*/
|
|
55
|
+
formatRepoChoices(repos) {
|
|
56
|
+
return repos
|
|
57
|
+
.map(repoPath => ({
|
|
58
|
+
name: RepoService.getRepoName(repoPath),
|
|
59
|
+
value: repoPath,
|
|
60
|
+
}))
|
|
61
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service factory for creating all services with production adapters.
|
|
3
|
+
*/
|
|
4
|
+
import { NodeFileSystem, NodeShell, NodeConsole, NodeProcess } from '../adapters/node.js';
|
|
5
|
+
import { ConfigService } from './config.js';
|
|
6
|
+
import { GitService } from './git.js';
|
|
7
|
+
import { RepoService } from './repos.js';
|
|
8
|
+
import { WorkspaceDirectoryService } from './workspaceDirectory.js';
|
|
9
|
+
import { WorktreeService } from './worktree.js';
|
|
10
|
+
import { PostCheckoutService } from './postCheckout.js';
|
|
11
|
+
import { FetchService } from './fetch.js';
|
|
12
|
+
import { ParallelService } from './parallel.js';
|
|
13
|
+
import { StatusService } from './status.js';
|
|
14
|
+
import { TmuxService } from './tmux.js';
|
|
15
|
+
export function createServices() {
|
|
16
|
+
// Create adapters
|
|
17
|
+
const fs = new NodeFileSystem();
|
|
18
|
+
const shell = new NodeShell();
|
|
19
|
+
const console = new NodeConsole();
|
|
20
|
+
const process = new NodeProcess();
|
|
21
|
+
// Create services (no service-to-service dependencies)
|
|
22
|
+
const config = new ConfigService(fs);
|
|
23
|
+
const git = new GitService(shell);
|
|
24
|
+
const parallel = new ParallelService(console);
|
|
25
|
+
const status = new StatusService(git);
|
|
26
|
+
const tmux = new TmuxService(shell);
|
|
27
|
+
const fetch = new FetchService(git, console);
|
|
28
|
+
const repos = new RepoService(fs, git);
|
|
29
|
+
const postCheckout = new PostCheckoutService(shell);
|
|
30
|
+
// Focused workspace services
|
|
31
|
+
const workspaceDir = new WorkspaceDirectoryService(fs);
|
|
32
|
+
const worktree = new WorktreeService(fs, git);
|
|
33
|
+
return {
|
|
34
|
+
config,
|
|
35
|
+
git,
|
|
36
|
+
repos,
|
|
37
|
+
workspaceDir,
|
|
38
|
+
worktree,
|
|
39
|
+
postCheckout,
|
|
40
|
+
fetch,
|
|
41
|
+
parallel,
|
|
42
|
+
status,
|
|
43
|
+
tmux,
|
|
44
|
+
console,
|
|
45
|
+
process,
|
|
46
|
+
};
|
|
47
|
+
}
|