Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 46 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1758,7 +1758,7 @@ program

// Test command for GitHub integration
program
.command('test-github')
.command('test-github', { hidden: true })
.description('Test GitHub integration (Issue #3)')
.argument('<identifier>', 'Issue number or PR number')
.option('--no-claude', 'Skip Claude for branch name generation')
Expand Down Expand Up @@ -1844,7 +1844,7 @@ program

// Test command for Claude integration
program
.command('test-claude')
.command('test-claude', { hidden: true })
.description('Test Claude integration (Issue #10)')
.option('--detect', 'Test Claude CLI detection')
.option('--version', 'Get Claude CLI version')
Expand Down Expand Up @@ -2032,7 +2032,7 @@ program

// Test command for webserver detection
program
.command('test-webserver')
.command('test-webserver', { hidden: true })
.description('Test if a web server is running on a workspace port')
.argument('<issue-number>', 'Issue number (port will be calculated as 3000 + issue number)', parseInt)
.option('--kill', 'Kill the web server if detected')
Expand All @@ -2052,7 +2052,7 @@ program

// Test command for Git integration
program
.command('test-git')
.command('test-git', { hidden: true })
.description('Test Git integration - findMainWorktreePath() function (reads .iloom/settings.json)')
.action(async () => {
try {
Expand All @@ -2070,7 +2070,7 @@ program

// Test command for iTerm2 dual tab functionality
program
.command('test-tabs')
.command('test-tabs', { hidden: true })
.description('Test iTerm2 dual tab functionality - opens two tabs with test commands')
.action(async () => {
try {
Expand All @@ -2088,7 +2088,7 @@ program

// Test command for worktree prefix configuration
program
.command('test-prefix')
.command('test-prefix', { hidden: true })
.description('[DEPRECATED] Test worktree prefix configuration - preview worktree paths')
.action(async () => {
try {
Expand All @@ -2104,6 +2104,44 @@ program
}
})

// Test command for bare mode branch name generation
program
.command('test-branch-name', { hidden: true })
.description('Test bare mode branch name generation')
.option('--title <text>', 'Issue title to use for branch name generation')
.option('--description <text>', 'Issue description for additional context')
.action(async (options: { title?: string; description?: string }) => {
try {
const { TestBranchNameCommand } = await import('./commands/test-branch-name.js')
const command = new TestBranchNameCommand()
await command.execute(options)
} catch (error) {
logger.error(`Test branch name failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
if (error instanceof Error && error.stack) {
logger.debug(error.stack)
}
process.exit(1)
}
})

// Test command for bare mode commit message generation
program
.command('test-commit-msg', { hidden: true })
.description('Test bare mode commit message generation')
.action(async () => {
try {
const { TestCommitMsgCommand } = await import('./commands/test-commit-msg.js')
const command = new TestCommitMsgCommand()
await command.execute()
} catch (error) {
logger.error(`Test commit msg failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
if (error instanceof Error && error.stack) {
logger.debug(error.stack)
}
process.exit(1)
}
})

// Command for session summary generation
program
.command('summary')
Expand Down Expand Up @@ -2191,7 +2229,7 @@ program

// Test command for Jira integration (hidden from help output)
const testJiraCommand = program
.command('test-jira')
.command('test-jira', { hidden: true })
.description('Test Jira integration methods against a real Jira instance')

testJiraCommand
Expand Down Expand Up @@ -2268,7 +2306,7 @@ testJiraCommand

// Test command for Neon integration
program
.command('test-neon')
.command('test-neon', { hidden: true })
.description('Test Neon integration and debug configuration')
.action(async () => {
try {
Expand Down
39 changes: 39 additions & 0 deletions src/commands/test-branch-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { logger } from '../utils/logger.js'
import { resolveBareModeConfig, generateBranchName } from '../utils/claude.js'

/**
* Test command to demonstrate bare mode branch name generation.
* Shows whether bare mode is available and generates a sample branch name.
*/
export class TestBranchNameCommand {
public async execute(options: { title?: string; description?: string }): Promise<void> {
const title = options.title ?? 'Add dark mode support to settings page'
const description = options.description ?? 'Users have requested a dark mode toggle in the settings page that persists across sessions.'

logger.info('Testing Branch Name Generation (Bare Mode)\n')

// Show bare mode resolution
logger.info('Resolving bare mode configuration...')
const config = await resolveBareModeConfig()

if (config.bare && config.oauthToken) {
logger.success('Using bare mode with OAuth token')
} else if (config.bare) {
logger.success('Using bare mode with API key')
} else {
logger.warn('Using standard mode (no bare)')
}

logger.info('')
logger.info(`Title: ${title}`)
logger.info(`Description: ${description}`)
logger.info('Generating branch name...\n')

const startTime = Date.now()
const branchName = await generateBranchName(title, '999')
const duration = Date.now() - startTime

logger.success(`Branch name: ${branchName}`)
logger.info(`Duration: ${duration}ms`)
}
}
49 changes: 49 additions & 0 deletions src/commands/test-commit-msg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { logger } from '../utils/logger.js'
import { resolveBareModeConfig } from '../utils/claude.js'
import { CommitManager } from '../lib/CommitManager.js'

/**
* Test command to demonstrate bare mode commit message generation.
* Uses the real CommitManager code path to generate a commit message.
*/
export class TestCommitMsgCommand {
public async execute(): Promise<void> {
logger.info('Testing Commit Message Generation (Bare Mode)\n')

// Show bare mode resolution
logger.info('Resolving bare mode configuration...')
const config = await resolveBareModeConfig()

if (config.bare && config.oauthToken) {
logger.success('Using bare mode with OAuth token')
} else if (config.bare) {
logger.success('Using bare mode with API key')
} else {
logger.warn('Using standard mode (no bare)')
}

logger.info('')
logger.info('Generating commit message via CommitManager...\n')

const commitManager = new CommitManager()
const worktreePath = process.cwd()

const startTime = Date.now()
const result = await commitManager.generateClaudeCommitMessage(
worktreePath,
undefined,
'#'
)
const duration = Date.now() - startTime

if (result) {
logger.info('Generated commit message:')
logger.info('---')
logger.info(result)
logger.info('---')
} else {
logger.warn('No commit message generated (Claude unavailable or failed)')
}
logger.success(`\nDuration: ${duration}ms`)
}
}
41 changes: 34 additions & 7 deletions src/lib/CommitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,18 +595,17 @@ describe('CommitManager', () => {
expect(commitCall?.[0][3]).not.toContain('Fixes #ENG-456')
})

it('should pass worktree path to Claude via addDir option', async () => {
it('should embed staged diff in prompt instead of using addDir', async () => {
vi.mocked(claude.launchClaude).mockResolvedValue('Add feature')
vi.mocked(git.executeGitCommand).mockResolvedValue('')
vi.mocked(git.executeGitCommand).mockResolvedValue('diff output here')

await manager.commitChanges(mockWorktreePath, { issuePrefix: '#', dryRun: false })

const claudeCall = vi.mocked(claude.launchClaude).mock.calls[0]
expect(claudeCall[1]).toEqual(
expect.objectContaining({
addDir: mockWorktreePath,
})
)
// addDir should NOT be set — diff is embedded in the prompt
expect(claudeCall[1]).not.toHaveProperty('addDir')
// Prompt should contain the diff
expect(claudeCall[0]).toContain('StagedChanges')
})

it('should use headless mode for Claude execution', async () => {
Expand Down Expand Up @@ -728,6 +727,34 @@ describe('CommitManager', () => {
})
})

describe('Claude Integration - Bare Mode (auto-applied by launchClaude)', () => {
beforeEach(() => {
vi.mocked(claude.detectClaudeCli).mockResolvedValue(true)
vi.mocked(claude.launchClaude).mockResolvedValue('Add feature')
vi.mocked(git.executeGitCommand).mockResolvedValue('')
})

it('should not pass bare option to launchClaude (bare mode is now auto-applied internally)', async () => {
await manager.commitChanges(mockWorktreePath, { issuePrefix: '#', dryRun: false })

const claudeCall = vi.mocked(claude.launchClaude).mock.calls[0]
// CommitManager no longer sets bare - launchClaude auto-applies it when headless + noSessionPersistence
expect(claudeCall[1]).not.toHaveProperty('bare')
})

it('should pass headless and noSessionPersistence so launchClaude can auto-apply bare', async () => {
await manager.commitChanges(mockWorktreePath, { issuePrefix: '#', dryRun: false })

const claudeCall = vi.mocked(claude.launchClaude).mock.calls[0]
expect(claudeCall[1]).toEqual(
expect.objectContaining({
headless: true,
noSessionPersistence: true,
})
)
})
})

describe('Claude Output Acceptance', () => {
beforeEach(() => {
vi.mocked(claude.detectClaudeCli).mockResolvedValue(true)
Expand Down
54 changes: 42 additions & 12 deletions src/lib/CommitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ export class CommitManager {
* Claude examines the git repository directly via --add-dir option
* Returns null if Claude unavailable or fails validation
*/
private async generateClaudeCommitMessage(
public async generateClaudeCommitMessage(
worktreePath: string,
issueNumber: string | number | undefined,
issuePrefix: string,
Expand All @@ -322,9 +322,28 @@ export class CommitManager {
}
getLogger().debug('Claude CLI is available')

// Build XML-based structured prompt
// Fetch the staged diff upfront so Claude doesn't need to use tools
getLogger().debug('Fetching staged diff...')
let stagedDiff: string
let stagedStat: string
try {
const statResult = await executeGitCommand(['diff', '--staged', '--stat'], { cwd: worktreePath })
stagedStat = statResult.trim()
const diffResult = await executeGitCommand(['diff', '--staged'], { cwd: worktreePath })
// Truncate large diffs to avoid token limits
const maxDiffLength = 50000
stagedDiff = diffResult.length > maxDiffLength
? diffResult.substring(0, maxDiffLength) + '\n\n... [diff truncated, showing first 50KB]'
: diffResult
} catch {
getLogger().warn('Failed to fetch staged diff, falling back to Claude tool use')
stagedDiff = ''
stagedStat = ''
}

// Build XML-based structured prompt with embedded diff
getLogger().debug('Building commit message prompt...')
const prompt = this.buildCommitMessagePrompt(issueNumber, issuePrefix, trailerType)
const prompt = this.buildCommitMessagePrompt(issueNumber, issuePrefix, trailerType, stagedStat, stagedDiff)
getLogger().debug('Prompt built', { promptLength: prompt.length })

// Debug log the actual prompt content for troubleshooting
Expand All @@ -339,19 +358,17 @@ export class CommitManager {
// Debug log the Claude call parameters
const claudeOptions = {
headless: true,
addDir: worktreePath,
model: 'claude-haiku-4-5-20251001', // Fast, cost-effective model
timeout: 120000, // 120 second timeout
appendSystemPrompt: 'Output only the requested content. Never include preamble, analysis, or meta-commentary. Your response is used verbatim.',
noSessionPersistence: true, // Utility operation - don't persist session
systemPrompt: 'You are a git commit message generator. Generate a concise commit message in imperative mood with subject line under 72 characters. Your entire response is used verbatim as the commit message — output ONLY the raw commit message, no explanatory text.',
noSessionPersistence: true, // Utility operation - bare mode auto-applied by launchClaude
effort: 'low', // Minimize turns for fast commit message generation
}
getLogger().debug('Claude CLI call parameters:', {
options: claudeOptions,
worktreePathForAnalysis: worktreePath,
addDirContents: 'Will include entire worktree directory for analysis'
})

// Launch Claude in headless mode with repository access and shorter timeout for commit messages
// Launch Claude in headless mode — diff is embedded in prompt, no addDir needed
const result = await launchClaude(prompt, claudeOptions)

const claudeDuration = Date.now() - claudeStartTime
Expand Down Expand Up @@ -436,7 +453,9 @@ export class CommitManager {
private buildCommitMessagePrompt(
issueNumber: string | number | undefined,
issuePrefix: string,
trailerType?: 'Refs' | 'Fixes'
trailerType?: 'Refs' | 'Fixes',
stagedStat?: string,
stagedDiff?: string
): string {
const trailer = trailerType ?? 'Fixes'
const issueContext = issueNumber
Expand All @@ -446,11 +465,22 @@ ${trailer === 'Fixes' ? 'If the changes appear to resolve the issue, include' :
</IssueContext>`
: ''

const diffSection = stagedStat || stagedDiff
? `\n<StagedChanges>
<Stat>
${stagedStat ?? '(not available)'}
</Stat>
<Diff>
${stagedDiff ?? '(not available)'}
</Diff>
</StagedChanges>`
: ''

const examplePrefix = issuePrefix || '' // Use empty string for Linear examples
return `<Task>
You are a software engineer writing a commit message for this repository.
Examine the staged changes in the git repository and generate a concise, meaningful commit message.
Generate a concise, meaningful commit message for the following staged changes.
</Task>
${diffSection}

<Requirements>
<Format>The first line must be a brief summary of the changes made as a full sentence. If it references an issue, include "${trailer} ${examplePrefix}N" at the end of this line.
Expand Down
Loading
Loading