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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatusService handles worktree status checking.
|
|
3
|
+
*/
|
|
4
|
+
export class StatusService {
|
|
5
|
+
git;
|
|
6
|
+
constructor(git) {
|
|
7
|
+
this.git = git;
|
|
8
|
+
}
|
|
9
|
+
async getWorktreeStatus(worktreePath, mainBranch) {
|
|
10
|
+
try {
|
|
11
|
+
// Always check for uncommitted changes first
|
|
12
|
+
const hasUncommitted = await this.git.hasUncommittedChanges(worktreePath);
|
|
13
|
+
if (hasUncommitted) {
|
|
14
|
+
return { type: 'uncommitted' };
|
|
15
|
+
}
|
|
16
|
+
// Check if origin branch exists
|
|
17
|
+
const hasOriginBranch = await this.git.originBranchExists(worktreePath);
|
|
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);
|
|
35
|
+
if (isAhead) {
|
|
36
|
+
return { type: 'ahead', comparedTo: 'main' };
|
|
37
|
+
}
|
|
38
|
+
return { type: 'clean', comparedTo: 'main' };
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return {
|
|
42
|
+
type: 'error',
|
|
43
|
+
error: err.stderr || err.message,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
static getStatusMessage(status, mainBranch) {
|
|
48
|
+
switch (status.type) {
|
|
49
|
+
case 'clean':
|
|
50
|
+
return 'up to date';
|
|
51
|
+
case 'uncommitted':
|
|
52
|
+
return 'uncommitted changes';
|
|
53
|
+
case 'ahead':
|
|
54
|
+
return status.comparedTo === 'origin'
|
|
55
|
+
? 'ahead of origin'
|
|
56
|
+
: `ahead of ${mainBranch}`;
|
|
57
|
+
case 'behind':
|
|
58
|
+
return 'behind origin';
|
|
59
|
+
case 'diverged':
|
|
60
|
+
return 'diverged from origin';
|
|
61
|
+
case 'error':
|
|
62
|
+
return `error: ${status.error}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
static hasIssues(status) {
|
|
66
|
+
return (status.type === 'uncommitted' ||
|
|
67
|
+
status.type === 'ahead' ||
|
|
68
|
+
status.type === 'diverged' ||
|
|
69
|
+
status.type === 'error');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Check status of all worktrees in parallel
|
|
73
|
+
*/
|
|
74
|
+
async checkAllWorktrees(worktreeDirs, mainBranch) {
|
|
75
|
+
const results = await Promise.all(worktreeDirs.map(async (worktreePath) => {
|
|
76
|
+
const repoName = worktreePath.split('/').pop() || worktreePath;
|
|
77
|
+
const status = await this.getWorktreeStatus(worktreePath, mainBranch);
|
|
78
|
+
return { repoName, status };
|
|
79
|
+
}));
|
|
80
|
+
return results;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Find repos with issues (for removal validation)
|
|
84
|
+
*/
|
|
85
|
+
async findReposWithIssues(worktreeDirs, mainBranch) {
|
|
86
|
+
const results = await this.checkAllWorktrees(worktreeDirs, mainBranch);
|
|
87
|
+
return results
|
|
88
|
+
.filter(({ status }) => StatusService.hasIssues(status))
|
|
89
|
+
.map(({ repoName }) => repoName);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/lib/tmux.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TmuxService handles tmux session operations.
|
|
3
|
+
*/
|
|
4
|
+
export class TmuxService {
|
|
5
|
+
shell;
|
|
6
|
+
constructor(shell) {
|
|
7
|
+
this.shell = shell;
|
|
8
|
+
}
|
|
9
|
+
async createSession(workspacePath, sessionName) {
|
|
10
|
+
try {
|
|
11
|
+
await this.shell.execFile('tmux', [
|
|
12
|
+
'new-session',
|
|
13
|
+
'-d',
|
|
14
|
+
'-s',
|
|
15
|
+
sessionName,
|
|
16
|
+
'-c',
|
|
17
|
+
workspacePath,
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
// If session already exists, ignore the error
|
|
22
|
+
if (!error.message?.includes('duplicate session')) {
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async killSession(sessionName) {
|
|
28
|
+
try {
|
|
29
|
+
await this.shell.execFile('tmux', ['kill-session', '-t', sessionName]);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// If session doesn't exist, ignore the error
|
|
33
|
+
if (!error.message?.includes('no such session')) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { WorkspaceAlreadyExistsError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* WorkspaceDirectoryService handles workspace directory operations.
|
|
5
|
+
* Pure directory management, no orchestration.
|
|
6
|
+
*/
|
|
7
|
+
export class WorkspaceDirectoryService {
|
|
8
|
+
fs;
|
|
9
|
+
constructor(fs) {
|
|
10
|
+
this.fs = fs;
|
|
11
|
+
}
|
|
12
|
+
createWorkspaceDir(destPath, branch) {
|
|
13
|
+
const workspacePath = path.join(destPath, branch);
|
|
14
|
+
if (this.fs.existsSync(workspacePath)) {
|
|
15
|
+
throw new WorkspaceAlreadyExistsError(workspacePath);
|
|
16
|
+
}
|
|
17
|
+
this.fs.mkdirSync(workspacePath, { recursive: true });
|
|
18
|
+
return workspacePath;
|
|
19
|
+
}
|
|
20
|
+
copyAgentsMd(sourcePath, workspacePath) {
|
|
21
|
+
const agentsPath = path.join(sourcePath, 'AGENTS.md');
|
|
22
|
+
if (this.fs.existsSync(agentsPath)) {
|
|
23
|
+
this.fs.copyFileSync(agentsPath, path.join(workspacePath, 'AGENTS.md'));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
detectWorkspace(cwd, destPath) {
|
|
27
|
+
const normalizedCwd = path.resolve(cwd);
|
|
28
|
+
const normalizedDest = path.resolve(destPath);
|
|
29
|
+
if (!normalizedCwd.startsWith(normalizedDest + path.sep) &&
|
|
30
|
+
normalizedCwd !== normalizedDest) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const relative = path.relative(normalizedDest, normalizedCwd);
|
|
34
|
+
const segments = relative.split(path.sep);
|
|
35
|
+
if (segments.length === 0 || segments[0] === '') {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const workspacePath = path.join(normalizedDest, segments[0]);
|
|
39
|
+
if (!this.fs.existsSync(workspacePath)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return workspacePath;
|
|
43
|
+
}
|
|
44
|
+
getWorktreeDirs(workspacePath) {
|
|
45
|
+
const entries = this.fs.readdirSync(workspacePath, { withFileTypes: true });
|
|
46
|
+
return entries
|
|
47
|
+
.filter((entry) => entry.isDirectory())
|
|
48
|
+
.map((entry) => path.join(workspacePath, entry.name));
|
|
49
|
+
}
|
|
50
|
+
listWorkspaces(destPath) {
|
|
51
|
+
if (!this.fs.existsSync(destPath)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const entries = this.fs.readdirSync(destPath, { withFileTypes: true });
|
|
55
|
+
return entries
|
|
56
|
+
.filter((entry) => entry.isDirectory())
|
|
57
|
+
.map((entry) => {
|
|
58
|
+
const workspacePath = path.join(destPath, entry.name);
|
|
59
|
+
try {
|
|
60
|
+
const worktrees = this.getWorktreeDirs(workspacePath);
|
|
61
|
+
return {
|
|
62
|
+
name: entry.name,
|
|
63
|
+
path: workspacePath,
|
|
64
|
+
repoCount: worktrees.length,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
.filter((ws) => ws !== null && ws.repoCount > 0);
|
|
72
|
+
}
|
|
73
|
+
removeWorkspaceDir(workspacePath) {
|
|
74
|
+
this.fs.rmSync(workspacePath, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
findWorkspace(destPath, branchName) {
|
|
77
|
+
const workspaces = this.listWorkspaces(destPath);
|
|
78
|
+
return workspaces.find(ws => ws.name === branchName) || null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { NotInWorkspaceError, WorkspaceNotFoundError } from './errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a workspace path from either an explicit branch name or by auto-detecting
|
|
5
|
+
* from the current working directory.
|
|
6
|
+
*
|
|
7
|
+
* @param branchName - Optional branch name. If provided, looks for workspace with this name.
|
|
8
|
+
* If undefined, auto-detects from current directory.
|
|
9
|
+
* @param workspaceDir - WorkspaceDirectoryService instance
|
|
10
|
+
* @param config - ConfigService instance
|
|
11
|
+
* @param process - IProcess instance for getting cwd
|
|
12
|
+
* @returns WorkspaceResolution containing the workspace path and display name
|
|
13
|
+
* @throws NotInWorkspaceError if auto-detecting and cwd is outside dest-path
|
|
14
|
+
* @throws WorkspaceNotFoundError if explicit branch provided but workspace doesn't exist
|
|
15
|
+
*/
|
|
16
|
+
export function resolveWorkspace(branchName, workspaceDir, config, process) {
|
|
17
|
+
const { destPath } = config.getRequired();
|
|
18
|
+
if (branchName) {
|
|
19
|
+
// Explicit branch provided
|
|
20
|
+
const workspacePath = path.join(destPath, branchName);
|
|
21
|
+
const workspace = workspaceDir.findWorkspace(destPath, branchName);
|
|
22
|
+
if (!workspace) {
|
|
23
|
+
throw new WorkspaceNotFoundError(workspacePath);
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
workspacePath,
|
|
27
|
+
displayName: branchName,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Auto-detect from current directory
|
|
32
|
+
const detectedPath = workspaceDir.detectWorkspace(process.cwd(), destPath);
|
|
33
|
+
if (!detectedPath) {
|
|
34
|
+
throw new NotInWorkspaceError(destPath);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
workspacePath: detectedPath,
|
|
38
|
+
displayName: path.basename(detectedPath),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* WorktreeService handles worktree creation and file copying.
|
|
4
|
+
* No orchestration - just individual worktree operations.
|
|
5
|
+
*/
|
|
6
|
+
export class WorktreeService {
|
|
7
|
+
fs;
|
|
8
|
+
git;
|
|
9
|
+
constructor(fs, git) {
|
|
10
|
+
this.fs = fs;
|
|
11
|
+
this.git = git;
|
|
12
|
+
}
|
|
13
|
+
copyConfigFilesToWorktree(sourceRepoPath, worktreePath, copyFiles) {
|
|
14
|
+
// Default to no files if not specified
|
|
15
|
+
const files = (copyFiles || '')
|
|
16
|
+
.split(',')
|
|
17
|
+
.map(f => f.trim())
|
|
18
|
+
.filter(f => f.length > 0);
|
|
19
|
+
// Copy each config file from source repo to worktree
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const sourceFilePath = path.join(sourceRepoPath, file);
|
|
22
|
+
const destFilePath = path.join(worktreePath, file);
|
|
23
|
+
// Skip if file doesn't exist in source repo
|
|
24
|
+
if (!this.fs.existsSync(sourceFilePath)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// Copy file to worktree
|
|
28
|
+
try {
|
|
29
|
+
this.fs.copyFileSync(sourceFilePath, destFilePath);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Silently ignore copy errors
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async createWorktreeWithBranch(repoPath, worktreePath, branchName, sourceBranch) {
|
|
37
|
+
await this.git.addWorktreeNewBranch(repoPath, worktreePath, branchName, sourceBranch);
|
|
38
|
+
}
|
|
39
|
+
async createWorktreeCheckout(repoPath, worktreePath, branchName) {
|
|
40
|
+
await this.git.addWorktree(repoPath, worktreePath, branchName);
|
|
41
|
+
}
|
|
42
|
+
async removeWorktree(repoPath, worktreePath) {
|
|
43
|
+
await this.git.removeWorktree(repoPath, worktreePath);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use case for checking status of all worktrees in a workspace.
|
|
3
|
+
*/
|
|
4
|
+
export class CheckWorkspaceStatusUseCase {
|
|
5
|
+
workspaceDir;
|
|
6
|
+
fetch;
|
|
7
|
+
status;
|
|
8
|
+
constructor(workspaceDir, fetch, status) {
|
|
9
|
+
this.workspaceDir = workspaceDir;
|
|
10
|
+
this.fetch = fetch;
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
async execute(params) {
|
|
14
|
+
const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
|
|
15
|
+
await this.fetch.fetchRepos(worktreeDirs);
|
|
16
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
|
|
17
|
+
return { statuses };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { RepoService } from '../lib/repos.js';
|
|
3
|
+
import { NoReposFoundError } from '../lib/errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* Use case for checking out an existing branch across multiple repos.
|
|
6
|
+
* Orchestrates repo discovery, branch checking, and workspace creation.
|
|
7
|
+
*/
|
|
8
|
+
export class CheckoutWorkspaceUseCase {
|
|
9
|
+
workspaceDir;
|
|
10
|
+
worktree;
|
|
11
|
+
repos;
|
|
12
|
+
fetch;
|
|
13
|
+
parallel;
|
|
14
|
+
tmux;
|
|
15
|
+
postCheckout;
|
|
16
|
+
constructor(workspaceDir, worktree, repos, fetch, parallel, tmux, postCheckout) {
|
|
17
|
+
this.workspaceDir = workspaceDir;
|
|
18
|
+
this.worktree = worktree;
|
|
19
|
+
this.repos = repos;
|
|
20
|
+
this.fetch = fetch;
|
|
21
|
+
this.parallel = parallel;
|
|
22
|
+
this.tmux = tmux;
|
|
23
|
+
this.postCheckout = postCheckout;
|
|
24
|
+
}
|
|
25
|
+
async execute(params) {
|
|
26
|
+
// 1. Discover all repos
|
|
27
|
+
const allRepos = this.repos.discoverRepos(params.sourcePath);
|
|
28
|
+
if (allRepos.length === 0) {
|
|
29
|
+
throw new NoReposFoundError(params.sourcePath);
|
|
30
|
+
}
|
|
31
|
+
// 2. Fetch all repos
|
|
32
|
+
await this.fetch.fetchRepos(allRepos);
|
|
33
|
+
// 3. Find repos that have this branch
|
|
34
|
+
const { matching, results } = await this.repos.findReposWithBranch(allRepos, params.branchName);
|
|
35
|
+
if (matching.length === 0) {
|
|
36
|
+
throw new Error(`Branch "${params.branchName}" not found in any repo.`);
|
|
37
|
+
}
|
|
38
|
+
// 4. Create workspace directory
|
|
39
|
+
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
40
|
+
// 5. Create worktrees in parallel
|
|
41
|
+
const successCount = await this.parallel.processInParallel(matching, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
|
|
42
|
+
const name = RepoService.getRepoName(repoPath);
|
|
43
|
+
const worktreeDest = path.join(workspacePath, name);
|
|
44
|
+
await this.worktree.createWorktreeCheckout(repoPath, worktreeDest, params.branchName);
|
|
45
|
+
this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
|
|
46
|
+
return 'created';
|
|
47
|
+
});
|
|
48
|
+
// 6. Copy AGENTS.md
|
|
49
|
+
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
50
|
+
// 7. Create tmux session if enabled
|
|
51
|
+
let tmuxCreated = false;
|
|
52
|
+
if (params.tmux) {
|
|
53
|
+
try {
|
|
54
|
+
await this.tmux.createSession(workspacePath, params.branchName);
|
|
55
|
+
tmuxCreated = true;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// Don't fail
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// 8. Run post-checkout if configured
|
|
62
|
+
let postCheckoutResult;
|
|
63
|
+
if (params.postCheckout) {
|
|
64
|
+
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
|
|
65
|
+
postCheckoutResult = await this.postCheckout.runCommand(worktreeDirs, params.postCheckout);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
workspacePath,
|
|
69
|
+
matchingRepos: matching.length,
|
|
70
|
+
successCount,
|
|
71
|
+
totalCount: matching.length,
|
|
72
|
+
tmuxCreated,
|
|
73
|
+
postCheckoutSuccess: postCheckoutResult?.successCount,
|
|
74
|
+
postCheckoutTotal: postCheckoutResult?.totalCount,
|
|
75
|
+
branchCheckResults: results,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { RepoService } from '../lib/repos.js';
|
|
3
|
+
/**
|
|
4
|
+
* Use case for creating a workspace with new branches across multiple repos.
|
|
5
|
+
* Orchestrates the entire workflow from fetching to post-checkout commands.
|
|
6
|
+
*/
|
|
7
|
+
export class CreateBranchWorkspaceUseCase {
|
|
8
|
+
workspaceDir;
|
|
9
|
+
worktree;
|
|
10
|
+
repos;
|
|
11
|
+
fetch;
|
|
12
|
+
parallel;
|
|
13
|
+
tmux;
|
|
14
|
+
postCheckout;
|
|
15
|
+
constructor(workspaceDir, worktree, repos, fetch, parallel, tmux, postCheckout) {
|
|
16
|
+
this.workspaceDir = workspaceDir;
|
|
17
|
+
this.worktree = worktree;
|
|
18
|
+
this.repos = repos;
|
|
19
|
+
this.fetch = fetch;
|
|
20
|
+
this.parallel = parallel;
|
|
21
|
+
this.tmux = tmux;
|
|
22
|
+
this.postCheckout = postCheckout;
|
|
23
|
+
}
|
|
24
|
+
async execute(params) {
|
|
25
|
+
// 1. Fetch all repos
|
|
26
|
+
await this.fetch.fetchRepos(params.repos);
|
|
27
|
+
// 2. Create workspace directory
|
|
28
|
+
const workspacePath = this.workspaceDir.createWorkspaceDir(params.destPath, params.branchName);
|
|
29
|
+
// 3. Create worktrees in parallel
|
|
30
|
+
const successCount = await this.parallel.processInParallel(params.repos, (repoPath) => RepoService.getRepoName(repoPath), async (repoPath) => {
|
|
31
|
+
const name = RepoService.getRepoName(repoPath);
|
|
32
|
+
const worktreeDest = path.join(workspacePath, name);
|
|
33
|
+
await this.worktree.createWorktreeWithBranch(repoPath, worktreeDest, params.branchName, params.sourceBranch);
|
|
34
|
+
this.worktree.copyConfigFilesToWorktree(repoPath, worktreeDest, params.copyFiles);
|
|
35
|
+
return 'created';
|
|
36
|
+
});
|
|
37
|
+
// 4. Copy AGENTS.md if exists
|
|
38
|
+
this.workspaceDir.copyAgentsMd(params.sourcePath, workspacePath);
|
|
39
|
+
// 5. Create tmux session if enabled
|
|
40
|
+
let tmuxCreated = false;
|
|
41
|
+
if (params.tmux) {
|
|
42
|
+
try {
|
|
43
|
+
await this.tmux.createSession(workspacePath, params.branchName);
|
|
44
|
+
tmuxCreated = true;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
// Don't fail, just return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 6. Run post-checkout command if configured
|
|
51
|
+
let postCheckoutResult;
|
|
52
|
+
if (params.postCheckout) {
|
|
53
|
+
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspacePath);
|
|
54
|
+
postCheckoutResult = await this.postCheckout.runCommand(worktreeDirs, params.postCheckout);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
workspacePath,
|
|
58
|
+
successCount,
|
|
59
|
+
totalCount: params.repos.length,
|
|
60
|
+
tmuxCreated,
|
|
61
|
+
postCheckoutSuccess: postCheckoutResult?.successCount,
|
|
62
|
+
postCheckoutTotal: postCheckoutResult?.totalCount,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { StatusService as StatusServiceClass } from '../lib/status.js';
|
|
3
|
+
/**
|
|
4
|
+
* Use case for analyzing workspaces to find candidates for pruning.
|
|
5
|
+
* Returns workspaces where all worktrees have no issues and commits are older than specified days.
|
|
6
|
+
*/
|
|
7
|
+
export class DiscoverPrunableWorkspacesUseCase {
|
|
8
|
+
workspaceDir;
|
|
9
|
+
fetch;
|
|
10
|
+
status;
|
|
11
|
+
git;
|
|
12
|
+
constructor(workspaceDir, fetch, status, git) {
|
|
13
|
+
this.workspaceDir = workspaceDir;
|
|
14
|
+
this.fetch = fetch;
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.git = git;
|
|
17
|
+
}
|
|
18
|
+
async execute(params) {
|
|
19
|
+
const workspaces = this.workspaceDir.listWorkspaces(params.destPath);
|
|
20
|
+
const prunable = [];
|
|
21
|
+
const cutoffDate = new Date(Date.now() - params.daysOld * 24 * 60 * 60 * 1000);
|
|
22
|
+
// Collect all worktree directories and unique source repos across all workspaces
|
|
23
|
+
const uniqueSourceRepos = new Set();
|
|
24
|
+
const workspaceWorktrees = new Map();
|
|
25
|
+
for (const workspace of workspaces) {
|
|
26
|
+
const worktreeDirs = this.workspaceDir.getWorktreeDirs(workspace.path);
|
|
27
|
+
if (worktreeDirs.length > 0) {
|
|
28
|
+
workspaceWorktrees.set(workspace.path, worktreeDirs);
|
|
29
|
+
// Extract repo names and build source repo paths
|
|
30
|
+
worktreeDirs.forEach((worktreePath) => {
|
|
31
|
+
const repoName = path.basename(worktreePath);
|
|
32
|
+
const sourceRepoPath = path.join(params.sourcePath, repoName);
|
|
33
|
+
uniqueSourceRepos.add(sourceRepoPath);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Fetch all unique source repos once
|
|
38
|
+
// Since worktrees share the same git repository, fetching in one worktree
|
|
39
|
+
// updates the remote refs for all worktrees of that repository
|
|
40
|
+
if (uniqueSourceRepos.size > 0) {
|
|
41
|
+
await this.fetch.fetchRepos(Array.from(uniqueSourceRepos));
|
|
42
|
+
}
|
|
43
|
+
// Now analyze each workspace
|
|
44
|
+
for (const workspace of workspaces) {
|
|
45
|
+
const worktreeDirs = workspaceWorktrees.get(workspace.path);
|
|
46
|
+
// Skip workspaces with no worktrees
|
|
47
|
+
if (!worktreeDirs || worktreeDirs.length === 0) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Check status for all worktrees
|
|
51
|
+
const statuses = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
|
|
52
|
+
// Skip if any worktree has issues
|
|
53
|
+
const hasAnyIssues = statuses.some(({ status }) => StatusServiceClass.hasIssues(status));
|
|
54
|
+
if (hasAnyIssues) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Get last commit dates for all worktrees
|
|
58
|
+
const commitDates = await Promise.all(worktreeDirs.map((worktreePath) => this.git.getLastCommitDate(worktreePath)));
|
|
59
|
+
// Find the most recent commit
|
|
60
|
+
const oldestCommitDate = commitDates.reduce((oldest, current) => current > oldest ? current : oldest);
|
|
61
|
+
// Skip if any commit is too recent
|
|
62
|
+
if (oldestCommitDate > cutoffDate) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// This workspace is prunable
|
|
66
|
+
prunable.push({
|
|
67
|
+
name: workspace.name,
|
|
68
|
+
path: workspace.path,
|
|
69
|
+
repoCount: workspace.repoCount,
|
|
70
|
+
oldestCommitDate,
|
|
71
|
+
statuses,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return { prunable };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Use case for pulling all worktrees in a workspace.
|
|
4
|
+
*/
|
|
5
|
+
export class PullWorkspaceUseCase {
|
|
6
|
+
workspaceDir;
|
|
7
|
+
git;
|
|
8
|
+
parallel;
|
|
9
|
+
constructor(workspaceDir, git, parallel) {
|
|
10
|
+
this.workspaceDir = workspaceDir;
|
|
11
|
+
this.git = git;
|
|
12
|
+
this.parallel = parallel;
|
|
13
|
+
}
|
|
14
|
+
async execute(params) {
|
|
15
|
+
const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
|
|
16
|
+
const successCount = await this.parallel.processInParallel(worktreeDirs, (worktreePath) => path.basename(worktreePath), async (worktreePath) => {
|
|
17
|
+
await this.git.pull(worktreePath);
|
|
18
|
+
return 'pulled';
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
successCount,
|
|
22
|
+
totalCount: worktreeDirs.length,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Use case for pushing all worktrees in a workspace.
|
|
4
|
+
*/
|
|
5
|
+
export class PushWorkspaceUseCase {
|
|
6
|
+
workspaceDir;
|
|
7
|
+
git;
|
|
8
|
+
parallel;
|
|
9
|
+
constructor(workspaceDir, git, parallel) {
|
|
10
|
+
this.workspaceDir = workspaceDir;
|
|
11
|
+
this.git = git;
|
|
12
|
+
this.parallel = parallel;
|
|
13
|
+
}
|
|
14
|
+
async execute(params) {
|
|
15
|
+
const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
|
|
16
|
+
const successCount = await this.parallel.processInParallel(worktreeDirs, (worktreePath) => path.basename(worktreePath), async (worktreePath) => {
|
|
17
|
+
return await this.git.pushWithRetry(worktreePath);
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
successCount,
|
|
21
|
+
totalCount: worktreeDirs.length,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { WorkspaceHasIssuesError } from '../lib/errors.js';
|
|
3
|
+
import { StatusService as StatusServiceClass } from '../lib/status.js';
|
|
4
|
+
/**
|
|
5
|
+
* Use case for removing a workspace with validation.
|
|
6
|
+
* Checks for uncommitted changes before removal.
|
|
7
|
+
*/
|
|
8
|
+
export class RemoveWorkspaceUseCase {
|
|
9
|
+
workspaceDir;
|
|
10
|
+
worktree;
|
|
11
|
+
repos;
|
|
12
|
+
fetch;
|
|
13
|
+
status;
|
|
14
|
+
tmux;
|
|
15
|
+
constructor(workspaceDir, worktree, repos, fetch, status, tmux) {
|
|
16
|
+
this.workspaceDir = workspaceDir;
|
|
17
|
+
this.worktree = worktree;
|
|
18
|
+
this.repos = repos;
|
|
19
|
+
this.fetch = fetch;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.tmux = tmux;
|
|
22
|
+
}
|
|
23
|
+
async execute(params) {
|
|
24
|
+
const worktreeDirs = this.workspaceDir.getWorktreeDirs(params.workspacePath);
|
|
25
|
+
const issuesFound = [];
|
|
26
|
+
// 1. Fetch and check status if worktrees exist
|
|
27
|
+
if (worktreeDirs.length > 0) {
|
|
28
|
+
await this.fetch.fetchRepos(worktreeDirs);
|
|
29
|
+
const results = await this.status.checkAllWorktrees(worktreeDirs, params.mainBranch);
|
|
30
|
+
for (const { repoName, status } of results) {
|
|
31
|
+
if (StatusServiceClass.hasIssues(status)) {
|
|
32
|
+
issuesFound.push({
|
|
33
|
+
repoName,
|
|
34
|
+
issue: StatusServiceClass.getStatusMessage(status, params.mainBranch),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (issuesFound.length > 0) {
|
|
39
|
+
throw new WorkspaceHasIssuesError(`${issuesFound.length} repo(s) have uncommitted or unmerged changes.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// 2. Remove all worktrees
|
|
43
|
+
const removalErrors = [];
|
|
44
|
+
let worktreesRemoved = 0;
|
|
45
|
+
for (const worktreePath of worktreeDirs) {
|
|
46
|
+
const repoName = path.basename(worktreePath);
|
|
47
|
+
const sourceRepoPath = path.join(params.sourcePath, repoName);
|
|
48
|
+
try {
|
|
49
|
+
const allRepos = this.repos.discoverRepos(params.sourcePath);
|
|
50
|
+
if (!allRepos.includes(sourceRepoPath)) {
|
|
51
|
+
removalErrors.push({ repo: repoName, error: 'source repo not found' });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
await this.worktree.removeWorktree(sourceRepoPath, worktreePath);
|
|
55
|
+
worktreesRemoved++;
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
removalErrors.push({ repo: repoName, error: err.stderr || err.message });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// 3. Remove workspace directory
|
|
62
|
+
let workspaceDirRemoved = false;
|
|
63
|
+
try {
|
|
64
|
+
this.workspaceDir.removeWorkspaceDir(params.workspacePath);
|
|
65
|
+
workspaceDirRemoved = true;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw err; // Re-throw directory removal errors
|
|
69
|
+
}
|
|
70
|
+
// 4. Kill tmux session if enabled
|
|
71
|
+
let tmuxKilled = false;
|
|
72
|
+
if (params.tmux) {
|
|
73
|
+
try {
|
|
74
|
+
await this.tmux.killSession(params.branchName);
|
|
75
|
+
tmuxKilled = true;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// Don't fail
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
worktreesRemoved,
|
|
83
|
+
worktreesTotal: worktreeDirs.length,
|
|
84
|
+
workspaceDirRemoved,
|
|
85
|
+
tmuxKilled,
|
|
86
|
+
issuesFound,
|
|
87
|
+
removalErrors,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|