Skip to content

Commit 7db8db1

Browse files
committed
feat: Add CleanupCommand for workspace management with enhanced option parsing and validation. Fixes #45
1 parent 409b8d0 commit 7db8db1

File tree

5 files changed

+768
-51
lines changed

5 files changed

+768
-51
lines changed

src/cli.ts

Lines changed: 15 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { program } from 'commander'
22
import { logger } from './utils/logger.js'
33
import { GitWorktreeManager } from './lib/GitWorktreeManager.js'
4-
import type { StartOptions } from './types/index.js'
4+
import type { StartOptions, CleanupOptions } from './types/index.js'
55
import { readFileSync } from 'fs'
66
import { fileURLToPath } from 'url'
77
import { dirname, join } from 'path'
@@ -98,58 +98,23 @@ program
9898
program
9999
.command('cleanup')
100100
.description('Remove workspaces')
101-
.argument('[identifier]', 'Specific workspace to cleanup (optional)')
102-
.option('--all', 'Remove all workspaces')
103-
.option('--force', 'Force removal even with uncommitted changes')
104-
.option('--remove-branch', 'Also remove the associated branch')
105-
.action(async (identifier?: string, options?: { all?: boolean; force?: boolean; removeBranch?: boolean }) => {
101+
.argument('[identifier]', 'Branch name or issue number to cleanup (auto-detected)')
102+
.option('-l, --list', 'List all worktrees')
103+
.option('-a, --all', 'Remove all worktrees (interactive confirmation)')
104+
.option('-i, --issue <number>', 'Cleanup by issue number', parseInt)
105+
.option('-f, --force', 'Skip confirmations and force removal')
106+
.option('--dry-run', 'Show what would be done without doing it')
107+
.action(async (identifier?: string, options?: CleanupOptions) => {
106108
try {
107-
const manager = new GitWorktreeManager()
108-
109-
// Determine which worktrees to remove
110-
let toRemove = identifier
111-
? await manager.findWorktreesByIdentifier(identifier)
112-
: await manager.listWorktrees({ porcelain: true })
113-
114-
// Validate input
115-
if (!identifier && !options?.all) {
116-
logger.error('Either provide an identifier or use --all flag')
117-
process.exit(1)
118-
}
119-
120-
if (identifier && toRemove.length === 0) {
121-
logger.error(`No worktree found matching: ${identifier}`)
122-
process.exit(1)
109+
const { CleanupCommand } = await import('./commands/cleanup.js')
110+
const command = new CleanupCommand()
111+
const input: { identifier?: string; options: CleanupOptions } = {
112+
options: options ?? {}
123113
}
124-
125-
logger.info(`Removing ${toRemove.length} worktree(s)...`)
126-
127-
// Remove worktrees
128-
const { successes, failures, skipped } = await manager.removeWorktrees(toRemove, {
129-
force: options?.force ?? false,
130-
removeBranch: options?.removeBranch ?? false,
131-
})
132-
133-
// Report results
134-
for (const { worktree } of successes) {
135-
logger.success(`Removed: ${worktree.branch}`)
136-
}
137-
138-
for (const { worktree, reason } of skipped) {
139-
logger.info(`Skipped: ${worktree.branch} (${reason})`)
140-
}
141-
142-
for (const { worktree, error } of failures) {
143-
logger.error(`Failed to remove ${worktree.branch}: ${error}`)
144-
}
145-
146-
if (successes.length === 0 && failures.length === 0) {
147-
logger.info('No worktrees to remove')
148-
}
149-
150-
if (failures.length > 0) {
151-
process.exit(1)
114+
if (identifier) {
115+
input.identifier = identifier
152116
}
117+
await command.execute(input)
153118
} catch (error) {
154119
logger.error(`Failed to cleanup worktrees: ${error instanceof Error ? error.message : 'Unknown error'}`)
155120
process.exit(1)

src/commands/cleanup.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { logger } from '../utils/logger.js'
2+
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
3+
import type { CleanupOptions } from '../types/index.js'
4+
5+
/**
6+
* Input structure for CleanupCommand.execute()
7+
*/
8+
export interface CleanupCommandInput {
9+
identifier?: string
10+
options: CleanupOptions
11+
}
12+
13+
/**
14+
* Parsed and validated cleanup command input
15+
* Mode determines which cleanup operation to perform
16+
*/
17+
export interface ParsedCleanupInput {
18+
mode: 'list' | 'single' | 'issue' | 'all'
19+
identifier?: string
20+
issueNumber?: number
21+
branchName?: string
22+
originalInput?: string
23+
options: CleanupOptions
24+
}
25+
26+
/**
27+
* Manages cleanup command execution with option parsing and validation
28+
* Follows the command pattern established by StartCommand
29+
*
30+
* This implementation handles ONLY parsing, validation, and mode determination.
31+
* Actual cleanup operations are deferred to subsequent sub-issues.
32+
*/
33+
export class CleanupCommand {
34+
// Will be used in subsequent sub-issues for actual cleanup operations
35+
// @ts-expect-error - Intentionally unused until sub-issues 2-5 implement cleanup operations
36+
private readonly gitWorktreeManager: GitWorktreeManager
37+
38+
constructor(gitWorktreeManager?: GitWorktreeManager) {
39+
this.gitWorktreeManager = gitWorktreeManager ?? new GitWorktreeManager()
40+
}
41+
42+
/**
43+
* Main entry point for the cleanup command
44+
* Parses input, validates options, and determines operation mode
45+
*/
46+
public async execute(input: CleanupCommandInput): Promise<void> {
47+
try {
48+
// Step 1: Parse input and determine mode
49+
const parsed = this.parseInput(input)
50+
51+
// Step 2: Validate option combinations
52+
this.validateInput(parsed)
53+
54+
// Step 3: Log what mode was determined (for now - actual operations in later issues)
55+
logger.info(`Cleanup mode: ${parsed.mode}`)
56+
if (parsed.mode === 'list') {
57+
logger.info('Would list all worktrees')
58+
} else if (parsed.mode === 'all') {
59+
logger.info('Would remove all worktrees')
60+
} else if (parsed.mode === 'issue') {
61+
logger.info(`Would cleanup worktrees for issue #${parsed.issueNumber}`)
62+
} else if (parsed.mode === 'single') {
63+
logger.info(`Would cleanup worktree: ${parsed.branchName}`)
64+
}
65+
66+
// Actual cleanup operations will be implemented in subsequent sub-issues:
67+
// - Sub-issue #2: List functionality
68+
// - Sub-issue #3: Single worktree removal
69+
// - Sub-issue #4: Issue-based cleanup
70+
// - Sub-issue #5: Bulk cleanup (all)
71+
72+
logger.success('Command parsing and validation successful')
73+
} catch (error) {
74+
if (error instanceof Error) {
75+
logger.error(`${error.message}`)
76+
} else {
77+
logger.error('An unknown error occurred')
78+
}
79+
throw error
80+
}
81+
}
82+
83+
/**
84+
* Parse input to determine cleanup mode and extract relevant data
85+
* Implements auto-detection: numeric input = issue number, non-numeric = branch name
86+
*
87+
* @private
88+
*/
89+
private parseInput(input: CleanupCommandInput): ParsedCleanupInput {
90+
const { identifier, options } = input
91+
92+
// Trim identifier if present
93+
const trimmedIdentifier = identifier?.trim() ?? undefined
94+
95+
// Mode: List (takes priority - it's informational only)
96+
if (options.list) {
97+
const result: ParsedCleanupInput = {
98+
mode: 'list',
99+
options
100+
}
101+
if (trimmedIdentifier) {
102+
result.identifier = trimmedIdentifier
103+
}
104+
return result
105+
}
106+
107+
// Mode: All (remove everything)
108+
if (options.all) {
109+
const result: ParsedCleanupInput = {
110+
mode: 'all',
111+
options
112+
}
113+
if (trimmedIdentifier) {
114+
result.identifier = trimmedIdentifier
115+
}
116+
if (options.issue !== undefined) {
117+
result.issueNumber = options.issue
118+
}
119+
return result
120+
}
121+
122+
// Mode: Explicit issue number via --issue flag
123+
if (options.issue !== undefined) {
124+
// Need to determine if identifier is branch or numeric to set branchName
125+
if (trimmedIdentifier) {
126+
const numericPattern = /^[0-9]+$/
127+
if (!numericPattern.test(trimmedIdentifier)) {
128+
// Identifier is a branch name with explicit --issue flag
129+
return {
130+
mode: 'issue',
131+
issueNumber: options.issue,
132+
branchName: trimmedIdentifier,
133+
identifier: trimmedIdentifier,
134+
originalInput: trimmedIdentifier,
135+
options
136+
}
137+
}
138+
}
139+
const result: ParsedCleanupInput = {
140+
mode: 'issue',
141+
issueNumber: options.issue,
142+
options
143+
}
144+
if (trimmedIdentifier) {
145+
result.identifier = trimmedIdentifier
146+
}
147+
return result
148+
}
149+
150+
// Mode: Auto-detect from identifier
151+
if (!trimmedIdentifier) {
152+
throw new Error('Missing required argument: identifier. Use --all to remove all worktrees or --list to list them.')
153+
}
154+
155+
// Auto-detection: Check if identifier is purely numeric
156+
// Pattern from bash script line 364: ^[0-9]+$
157+
const numericPattern = /^[0-9]+$/
158+
if (numericPattern.test(trimmedIdentifier)) {
159+
// Numeric input = issue number
160+
return {
161+
mode: 'issue',
162+
issueNumber: parseInt(trimmedIdentifier, 10),
163+
identifier: trimmedIdentifier,
164+
originalInput: trimmedIdentifier,
165+
options
166+
}
167+
} else {
168+
// Non-numeric = branch name
169+
return {
170+
mode: 'single',
171+
branchName: trimmedIdentifier,
172+
identifier: trimmedIdentifier,
173+
originalInput: trimmedIdentifier,
174+
options
175+
}
176+
}
177+
}
178+
179+
/**
180+
* Validate parsed input for option conflicts
181+
* Throws descriptive errors for invalid option combinations
182+
*
183+
* @private
184+
*/
185+
private validateInput(parsed: ParsedCleanupInput): void {
186+
const { mode, options, branchName } = parsed
187+
188+
// Conflict: --list is informational only, incompatible with destructive operations
189+
if (mode === 'list') {
190+
if (options.all) {
191+
throw new Error('Cannot use --list with --all (list is informational only)')
192+
}
193+
if (options.issue !== undefined) {
194+
throw new Error('Cannot use --list with --issue (list is informational only)')
195+
}
196+
if (parsed.identifier) {
197+
throw new Error('Cannot use --list with a specific identifier (list shows all worktrees)')
198+
}
199+
}
200+
201+
// Conflict: --all removes everything, can't combine with specific identifier or --issue
202+
if (mode === 'all') {
203+
if (parsed.identifier) {
204+
throw new Error('Cannot use --all with a specific identifier. Use one or the other.')
205+
}
206+
if (parsed.issueNumber !== undefined) {
207+
throw new Error('Cannot use --all with a specific identifier. Use one or the other.')
208+
}
209+
}
210+
211+
// Conflict: explicit --issue flag with branch name identifier
212+
// (This prevents confusion when user provides both)
213+
if (options.issue !== undefined && branchName) {
214+
throw new Error('Cannot use --issue flag with branch name identifier. Use numeric identifier or --issue flag alone.')
215+
}
216+
217+
// Note: --force and --dry-run are compatible with all modes (no conflicts)
218+
}
219+
}

src/commands/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
export { StartCommand } from './start.js'
22
export type { StartCommandInput, ParsedInput } from './start.js'
3+
4+
export { CleanupCommand } from './cleanup.js'
5+
export type { CleanupCommandInput, ParsedCleanupInput } from './cleanup.js'

src/types/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,21 @@ export interface FinishOptions {
102102
force?: boolean
103103
}
104104

105+
/**
106+
* Options for the cleanup command
107+
* All flags are optional and can be combined (subject to validation)
108+
*/
105109
export interface CleanupOptions {
110+
/** List all worktrees without removing anything */
111+
list?: boolean
112+
/** Remove all worktrees (interactive confirmation required unless --force) */
106113
all?: boolean
107-
issue?: string
114+
/** Cleanup by specific issue number */
115+
issue?: number
116+
/** Skip confirmations and force removal */
108117
force?: boolean
118+
/** Show what would be done without actually doing it */
119+
dryRun?: boolean
109120
}
110121

111122
export interface ListOptions {

0 commit comments

Comments
 (0)