Skip to content

Commit fa9afb8

Browse files
committed
feat: Enhance branch validation and existence checks in HatchboxManager and StartCommand. Fixes #34
1 parent e148ed9 commit fa9afb8

File tree

5 files changed

+454
-12
lines changed

5 files changed

+454
-12
lines changed

plan.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -737,8 +737,8 @@ hatchbox-ai/
737737
- Depends on: #27, #28, #3, #4, #5, #11, #12
738738
- **Broken down into sub-issues:**
739739
- #33 - Core Start Command Structure & Input Validation ✅ DONE
740-
- #41 - HatchboxManager - Central Orchestrator (NEW - addresses architectural gap)
741-
- #34 - GitHub Integration for Start Command
740+
- #41 - HatchboxManager - Central Orchestrator (NEW - addresses architectural gap) - DONE
741+
- #34 - GitHub Integration for Start Command - DONE
742742
- #35 - Worktree Creation & Environment Setup
743743
- #36 - Claude Integration & Context Generation
744744
- #37 - Visual Workspace Distinction

src/commands/start.test.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ vi.mock('../lib/GitWorktreeManager.js')
2424
vi.mock('../lib/EnvironmentManager.js')
2525
vi.mock('../lib/ClaudeContextManager.js')
2626

27+
// Mock branchExists utility
28+
vi.mock('../utils/git.js', () => ({
29+
branchExists: vi.fn().mockResolvedValue(false),
30+
}))
31+
2732
// Mock the logger to prevent console output during tests
2833
vi.mock('../utils/logger.js', () => ({
2934
logger: {
@@ -533,5 +538,195 @@ describe('StartCommand', () => {
533538
).not.toHaveBeenCalled()
534539
})
535540
})
541+
542+
describe('GitHub state validation', () => {
543+
it('should call validateIssueState for issues', async () => {
544+
const mockIssue = {
545+
number: 123,
546+
title: 'Test Issue',
547+
body: 'Issue body',
548+
state: 'open' as const,
549+
labels: [],
550+
assignees: [],
551+
url: 'https://github.com/test/repo/issues/123',
552+
}
553+
554+
vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({
555+
type: 'issue',
556+
number: 123,
557+
rawInput: '123',
558+
})
559+
vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(mockIssue)
560+
vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue()
561+
562+
await command.execute({
563+
identifier: '123',
564+
options: {},
565+
})
566+
567+
expect(mockGitHubService.fetchIssue).toHaveBeenCalledWith(123)
568+
expect(mockGitHubService.validateIssueState).toHaveBeenCalledWith(mockIssue)
569+
})
570+
571+
it('should call validatePRState for PRs', async () => {
572+
const mockPR = {
573+
number: 456,
574+
title: 'Test PR',
575+
body: 'PR body',
576+
state: 'open' as const,
577+
branch: 'feature-branch',
578+
baseBranch: 'main',
579+
url: 'https://github.com/test/repo/pull/456',
580+
isDraft: false,
581+
}
582+
583+
vi.mocked(mockGitHubService.fetchPR).mockResolvedValue(mockPR)
584+
vi.mocked(mockGitHubService.validatePRState).mockResolvedValue()
585+
586+
await command.execute({
587+
identifier: 'pr-456',
588+
options: {},
589+
})
590+
591+
expect(mockGitHubService.fetchPR).toHaveBeenCalledWith(456)
592+
expect(mockGitHubService.validatePRState).toHaveBeenCalledWith(mockPR)
593+
})
594+
595+
it('should throw when validateIssueState rejects', async () => {
596+
vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({
597+
type: 'issue',
598+
number: 123,
599+
rawInput: '123',
600+
})
601+
vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue({
602+
number: 123,
603+
title: 'Closed Issue',
604+
body: '',
605+
state: 'closed',
606+
labels: [],
607+
assignees: [],
608+
url: 'https://github.com/test/repo/issues/123',
609+
})
610+
vi.mocked(mockGitHubService.validateIssueState).mockRejectedValue(
611+
new Error('User cancelled due to closed issue')
612+
)
613+
614+
await expect(
615+
command.execute({
616+
identifier: '123',
617+
options: {},
618+
})
619+
).rejects.toThrow('User cancelled due to closed issue')
620+
})
621+
622+
it('should throw when validatePRState rejects', async () => {
623+
const mockPR = {
624+
number: 456,
625+
title: 'Merged PR',
626+
body: '',
627+
state: 'closed' as const,
628+
branch: 'feature',
629+
baseBranch: 'main',
630+
url: 'https://github.com/test/repo/pull/456',
631+
isDraft: false,
632+
}
633+
634+
vi.mocked(mockGitHubService.fetchPR).mockResolvedValue(mockPR)
635+
vi.mocked(mockGitHubService.validatePRState).mockRejectedValue(
636+
new Error('User cancelled due to merged PR')
637+
)
638+
639+
await expect(
640+
command.execute({
641+
identifier: 'pr/456',
642+
options: {},
643+
})
644+
).rejects.toThrow('User cancelled due to merged PR')
645+
})
646+
})
647+
648+
describe('branch existence checking', () => {
649+
it('should check if branch exists before creation', async () => {
650+
const { branchExists } = await import('../utils/git.js')
651+
652+
vi.mocked(branchExists).mockResolvedValue(true)
653+
654+
await expect(
655+
command.execute({
656+
identifier: 'existing-branch',
657+
options: {},
658+
})
659+
).rejects.toThrow("Branch 'existing-branch' already exists")
660+
661+
expect(branchExists).toHaveBeenCalledWith('existing-branch')
662+
})
663+
664+
it('should not throw when branch does not exist', async () => {
665+
const { branchExists } = await import('../utils/git.js')
666+
667+
vi.mocked(branchExists).mockResolvedValue(false)
668+
669+
await expect(
670+
command.execute({
671+
identifier: 'new-branch',
672+
options: {},
673+
})
674+
).resolves.not.toThrow()
675+
676+
expect(branchExists).toHaveBeenCalledWith('new-branch')
677+
})
678+
679+
it('should not check branch existence for PRs', async () => {
680+
const mockPR = {
681+
number: 123,
682+
title: 'Test PR',
683+
body: '',
684+
state: 'open' as const,
685+
branch: 'feature-branch',
686+
baseBranch: 'main',
687+
url: 'https://github.com/test/repo/pull/123',
688+
isDraft: false,
689+
}
690+
691+
vi.mocked(mockGitHubService.fetchPR).mockResolvedValue(mockPR)
692+
vi.mocked(mockGitHubService.validatePRState).mockResolvedValue()
693+
694+
await command.execute({
695+
identifier: 'pr/123',
696+
options: {},
697+
})
698+
699+
// branchExists should not be called for PRs in validateInput
700+
// (it might be called in HatchboxManager but that's a different check)
701+
})
702+
703+
it('should not check branch existence for issues in validateInput', async () => {
704+
const mockIssue = {
705+
number: 123,
706+
title: 'Test Issue',
707+
body: '',
708+
state: 'open' as const,
709+
labels: [],
710+
assignees: [],
711+
url: 'https://github.com/test/repo/issues/123',
712+
}
713+
714+
vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({
715+
type: 'issue',
716+
number: 123,
717+
rawInput: '123',
718+
})
719+
vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(mockIssue)
720+
vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue()
721+
722+
await command.execute({
723+
identifier: '123',
724+
options: {},
725+
})
726+
727+
// branchExists is only called for branch-type inputs in validateInput
728+
// Issues get their branch checked in HatchboxManager.createWorktree
729+
})
730+
})
536731
})
537732
})

src/commands/start.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HatchboxManager } from '../lib/HatchboxManager.js'
44
import { GitWorktreeManager } from '../lib/GitWorktreeManager.js'
55
import { EnvironmentManager } from '../lib/EnvironmentManager.js'
66
import { ClaudeContextManager } from '../lib/ClaudeContextManager.js'
7+
import { branchExists } from '../utils/git.js'
78
import type { StartOptions } from '../types/index.js'
89

910
export interface StartCommandInput {
@@ -142,23 +143,29 @@ export class StartCommand {
142143
*/
143144
private async validateInput(parsed: ParsedInput): Promise<void> {
144145
switch (parsed.type) {
145-
case 'pr':
146+
case 'pr': {
146147
if (!parsed.number) {
147148
throw new Error('Invalid PR number')
148149
}
149-
// Additional PR validation will use GitHubService in full implementation
150+
// Fetch and validate PR state
151+
const pr = await this.gitHubService.fetchPR(parsed.number)
152+
await this.gitHubService.validatePRState(pr)
150153
logger.debug(`Validated PR #${parsed.number}`)
151154
break
155+
}
152156

153-
case 'issue':
157+
case 'issue': {
154158
if (!parsed.number) {
155159
throw new Error('Invalid issue number')
156160
}
157-
// Additional issue validation will use GitHubService in full implementation
161+
// Fetch and validate issue state
162+
const issue = await this.gitHubService.fetchIssue(parsed.number)
163+
await this.gitHubService.validateIssueState(issue)
158164
logger.debug(`Validated issue #${parsed.number}`)
159165
break
166+
}
160167

161-
case 'branch':
168+
case 'branch': {
162169
if (!parsed.branchName) {
163170
throw new Error('Invalid branch name')
164171
}
@@ -168,8 +175,14 @@ export class StartCommand {
168175
'Invalid branch name. Use only letters, numbers, hyphens, underscores, and slashes'
169176
)
170177
}
178+
// Check if branch already exists
179+
const exists = await branchExists(parsed.branchName)
180+
if (exists) {
181+
throw new Error(`Branch '${parsed.branchName}' already exists`)
182+
}
171183
logger.debug(`Validated branch name: ${parsed.branchName}`)
172184
break
185+
}
173186

174187
default: {
175188
const unknownType = parsed as { type: string }

0 commit comments

Comments
 (0)