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.
@@ -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
+ }
@@ -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
+ }