Skip to content
Draft
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
22 changes: 22 additions & 0 deletions docs/iloom-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -2392,6 +2392,28 @@ iloom supports multiple version control providers for PR operations. By default,

**Note:** Draft PR mode (`mergeBehavior.mode: "draft-pr"`) is GitHub-only. BitBucket does not support draft pull requests.

**Database Branching Settings:**

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `skipBareMode` | boolean | `false` | Skip bare mode for Neon database branching. When `true`, iloom never attempts bare mode and goes straight to the standard (non-bare) code path. This avoids the try-then-fallback overhead on every `start`, `commit`, and `finish` for accounts where bare mode is not supported (e.g., Neon Enterprise accounts). |

**Example:**

`.iloom/settings.json` (project-level, shared with team):
```json
{
"skipBareMode": true
}
```

Or in `~/.config/iloom-ai/settings.json` (global, all projects):
```json
{
"skipBareMode": true
}
```

---

### il update
Expand Down
2 changes: 1 addition & 1 deletion src/commands/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export class CleanupCommand {
this.loomManager = new LoomManager(
this.gitWorktreeManager,
IssueTrackerFactory.create(settings),
new DefaultBranchNamingService({ useClaude: true }),
new DefaultBranchNamingService({ useClaude: true, skipBareMode: settings.skipBareMode }),
environmentManager,
new ClaudeContextManager(),
new ProjectCapabilityDetector(),
Expand Down
10 changes: 6 additions & 4 deletions src/commands/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,16 @@ export class CommitCommand {
return
}

// Step 5: Run validations unless --wip-commit is specified
// Step 5: Load settings (needed for validation and issue prefix)
const settings = await this.settingsManager.loadSettings(worktreePath)

// Step 6: Run validations unless --wip-commit is specified
let validationPassed = false
if (!input.wipCommit) {
logger.info('Running pre-commit validations...')
const validationResult = await this.validationRunner.runValidations(worktreePath, {
dryRun: false,
skipBareMode: settings.skipBareMode,
...(input.jsonStream !== undefined && { jsonStream: input.jsonStream }),
})
if (!validationResult.success) {
Expand All @@ -133,9 +137,6 @@ export class CommitCommand {
logger.success('All validations passed')
validationPassed = true
}

// Step 6: Load settings to get issue prefix
const settings = await this.settingsManager.loadSettings(worktreePath)
const providerType = settings.issueManagement?.provider ?? 'github'
const issuePrefix = IssueManagementProviderFactory.create(providerType, settings).issuePrefix

Expand Down Expand Up @@ -164,6 +165,7 @@ export class CommitCommand {
noReview: input.noReview ?? false,
trailerType,
timeout: settings.git?.commitTimeout,
skipBareMode: settings.skipBareMode,
...(commitMessage && { message: commitMessage }),
...(detected.issueNumber !== undefined && { issueNumber: detected.issueNumber }),
}
Expand Down
13 changes: 8 additions & 5 deletions src/commands/finish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export class FinishCommand {
this.loomManager ??= new LoomManager(
this.gitWorktreeManager,
this.issueTracker,
new DefaultBranchNamingService({ useClaude: true }),
new DefaultBranchNamingService({ useClaude: true, skipBareMode: settings.skipBareMode }),
environmentManager,
new ClaudeContextManager(),
new ProjectCapabilityDetector(),
Expand Down Expand Up @@ -689,15 +689,16 @@ export class FinishCommand {
result: FinishResult
): Promise<void> {
// Define merge options early so they're available for all code paths
// Early exit: if this is a draft-PR loom whose PR is already merged/closed,
// skip all rebase/validate/commit steps and go straight to cleanup
const earlySettings = await this.settingsManager.loadSettings(worktree.path)

const mergeOptions: MergeOptions = {
dryRun: options.dryRun ?? false,
force: options.force ?? false,
jsonStream: options.jsonStream ?? false,
skipBareMode: earlySettings.skipBareMode,
}

// Early exit: if this is a draft-PR loom whose PR is already merged/closed,
// skip all rebase/validate/commit steps and go straight to cleanup
const earlySettings = await this.settingsManager.loadSettings(worktree.path)
const earlyMergeBehavior = earlySettings.mergeBehavior ?? { mode: 'local' }
const earlyRawMode = earlyMergeBehavior.mode as string
const earlyMergeMode = earlyRawMode === 'github-draft-pr' ? 'draft-pr' : earlyRawMode
Expand Down Expand Up @@ -776,10 +777,12 @@ export class FinishCommand {
// Validates code with latest main changes integrated
if (!options.dryRun) {
getLogger().info('Running pre-merge validations...')
const validationSettings = await this.settingsManager.loadSettings(worktree.path)

await this.validationRunner.runValidations(worktree.path, {
dryRun: options.dryRun ?? false,
jsonStream: options.jsonStream ?? false,
skipBareMode: validationSettings.skipBareMode,
})
getLogger().success('All validations passed')
result.operations.push({
Expand Down
2 changes: 2 additions & 0 deletions src/commands/rebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,12 @@ export class RebaseCommand {
throw error
}

const settings = await this.settingsManager.loadSettings(worktreePath)
const mergeOptions: MergeOptions = {
dryRun: options.dryRun ?? false,
force: options.force ?? false,
jsonStream: options.jsonStream ?? false,
skipBareMode: settings.skipBareMode,
}

// MergeManager.rebaseOnMain() handles:
Expand Down
1 change: 1 addition & 0 deletions src/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,7 @@ describe('StartCommand', () => {
one_shot_mode: 'default',
complexity_override: false,
create_only: false,
skip_bare_mode: false,
})
})

Expand Down
3 changes: 2 additions & 1 deletion src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export class StartCommand {
const databaseManager = new DatabaseManager(neonProvider, environmentManager, databaseUrlEnvVarName)

// Create BranchNamingService (defaults to Claude-based strategy)
const branchNaming = new DefaultBranchNamingService({ useClaude: true })
const branchNaming = new DefaultBranchNamingService({ useClaude: true, skipBareMode: settings.skipBareMode })

this.loomManager = new LoomManager(
new GitWorktreeManager(mainWorktreePath),
Expand Down Expand Up @@ -403,6 +403,7 @@ export class StartCommand {
one_shot_mode: oneShotMap[input.options.oneShot ?? ''] ?? 'default',
complexity_override: !!input.options.complexity,
create_only: !!input.options.createOnly,
skip_bare_mode: !!settings.skipBareMode,
})
} catch (error: unknown) {
getLogger().debug(`Failed to track loom.created telemetry: ${error instanceof Error ? error.message : String(error)}`)
Expand Down
6 changes: 3 additions & 3 deletions src/lib/BranchNamingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe('BranchNamingService', () => {

// Verify the mock was called with correct arguments
const { generateBranchName } = await import('../utils/claude.js')
expect(generateBranchName).toHaveBeenCalledWith('Test Issue', 123, 'haiku')
expect(generateBranchName).toHaveBeenCalledWith('Test Issue', 123, 'haiku', undefined)
// The mock should return the mocked value
expect(branchName).toBe('feat/issue-123__ai-generated-branch')
})
Expand All @@ -180,15 +180,15 @@ describe('BranchNamingService', () => {
await strategy.generate(456, 'Another Issue')

const { generateBranchName } = await import('../utils/claude.js')
expect(generateBranchName).toHaveBeenCalledWith('Another Issue', 456, 'sonnet')
expect(generateBranchName).toHaveBeenCalledWith('Another Issue', 456, 'sonnet', undefined)
})

it('should use haiku model by default', async () => {
const strategy = new ClaudeBranchNameStrategy()
await strategy.generate(789, 'Default Model Test')

const { generateBranchName } = await import('../utils/claude.js')
expect(generateBranchName).toHaveBeenCalledWith('Default Model Test', 789, 'haiku')
expect(generateBranchName).toHaveBeenCalledWith('Default Model Test', 789, 'haiku', undefined)
})
})
})
7 changes: 4 additions & 3 deletions src/lib/BranchNamingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ export class SimpleBranchNameStrategy implements BranchNameStrategy {
* Uses Claude CLI to generate semantic branch names
*/
export class ClaudeBranchNameStrategy implements BranchNameStrategy {
constructor(private claudeModel = 'haiku') {}
constructor(private claudeModel = 'haiku', private skipBareMode?: boolean) {}

async generate(issueNumber: string | number, title: string): Promise<string> {
// Dynamic import to allow mocking in tests
const { generateBranchName } = await import('../utils/claude.js')
return generateBranchName(title, issueNumber, this.claudeModel)
return generateBranchName(title, issueNumber, this.claudeModel, this.skipBareMode)
}
}

Expand Down Expand Up @@ -61,12 +61,13 @@ export class DefaultBranchNamingService implements BranchNamingService {
strategy?: BranchNameStrategy
useClaude?: boolean
claudeModel?: string
skipBareMode?: boolean
}) {
// Set up default strategy based on options
if (options?.strategy) {
this.defaultStrategy = options.strategy
} else if (options?.useClaude !== false) {
this.defaultStrategy = new ClaudeBranchNameStrategy(options?.claudeModel)
this.defaultStrategy = new ClaudeBranchNameStrategy(options?.claudeModel, options?.skipBareMode)
} else {
this.defaultStrategy = new SimpleBranchNameStrategy()
}
Expand Down
6 changes: 4 additions & 2 deletions src/lib/CommitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class CommitManager {
// Skip Claude if custom message provided
if (!options.message) {
try {
message = await this.generateClaudeCommitMessage(worktreePath, options.issueNumber, options.issuePrefix, options.trailerType)
message = await this.generateClaudeCommitMessage(worktreePath, options.issueNumber, options.issuePrefix, options.trailerType, options.skipBareMode)
} catch (error) {
getLogger().debug('Claude commit message generation failed, using fallback', { error })
}
Expand Down Expand Up @@ -300,7 +300,8 @@ export class CommitManager {
worktreePath: string,
issueNumber: string | number | undefined,
issuePrefix: string,
trailerType?: 'Refs' | 'Fixes'
trailerType?: 'Refs' | 'Fixes',
skipBareMode?: boolean,
): Promise<string | null> {
const startTime = Date.now()

Expand Down Expand Up @@ -363,6 +364,7 @@ export class CommitManager {
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
...(skipBareMode != null && { skipBareMode }),
}
getLogger().debug('Claude CLI call parameters:', {
options: claudeOptions,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/IssueEnhancementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Your response should be the raw markdown that will become the issue body.`
model: 'sonnet',
agents,
noSessionPersistence: true, // Utility operation - don't persist session
skipBareMode: settings.skipBareMode,
})

if (enhanced && typeof enhanced === 'string') {
Expand Down Expand Up @@ -256,6 +257,7 @@ Press any key to open issue for editing...`
model: 'sonnet',
agents,
noSessionPersistence: true, // Headless operation - no session persistence needed
skipBareMode: settings.skipBareMode,
...(mcpConfig && { mcpConfig }),
...(allowedTools && { allowedTools }),
...(disallowedTools && { disallowedTools }),
Expand Down
9 changes: 5 additions & 4 deletions src/lib/MergeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class MergeManager {
* @throws Error if main branch doesn't exist, uncommitted changes exist, or conflicts occur
*/
async rebaseOnMain(worktreePath: string, options: MergeOptions = {}): Promise<RebaseOutcome> {
const { dryRun = false, force = false, jsonStream = false } = options
const { dryRun = false, force = false, jsonStream = false, skipBareMode } = options

// Pre-check: abort any in-progress rebase before starting a new one
await this.abortInProgressRebase(worktreePath)
Expand Down Expand Up @@ -231,7 +231,7 @@ export class MergeManager {
const resolved = await this.attemptClaudeConflictResolution(
worktreePath,
conflictedFiles,
{ jsonStream, conflictType: 'merge' }
{ jsonStream, conflictType: 'merge', ...(skipBareMode != null && { skipBareMode }) }
)

if (resolved) {
Expand Down Expand Up @@ -284,7 +284,7 @@ export class MergeManager {
const resolved = await this.attemptClaudeConflictResolution(
worktreePath,
conflictedFiles,
{ jsonStream }
{ jsonStream, ...(skipBareMode != null && { skipBareMode }) }
)

if (resolved) {
Expand Down Expand Up @@ -568,7 +568,7 @@ export class MergeManager {
private async attemptClaudeConflictResolution(
worktreePath: string,
conflictedFiles: string[],
options: { jsonStream?: boolean; conflictType?: 'rebase' | 'merge' } = {}
options: { jsonStream?: boolean; conflictType?: 'rebase' | 'merge'; skipBareMode?: boolean } = {}
): Promise<boolean> {
const conflictType = options.conflictType ?? 'rebase'

Expand Down Expand Up @@ -642,6 +642,7 @@ export class MergeManager {
}),
allowedTools,
noSessionPersistence: true, // Utility operation - no session persistence needed
...(options.skipBareMode != null && { skipBareMode: options.skipBareMode }),
})

// After Claude interaction completes, check if conflicts resolved
Expand Down
1 change: 1 addition & 0 deletions src/lib/PRManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class PRManager {
addDir: worktreePath,
timeout: 30000,
noSessionPersistence: true, // Utility operation - don't persist session
...(this.settings.skipBareMode && { skipBareMode: this.settings.skipBareMode }),
})

if (body && typeof body === 'string' && body.trim()) {
Expand Down
8 changes: 5 additions & 3 deletions src/lib/SessionSummaryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,13 @@ export class SessionSummaryService {
* causing a "Prompt is too long" error. This helper detects that condition (either as a
* thrown error or as a returned result string) and retries with opus[1m] (1M context).
*/
private async launchSessionSummary(prompt: string, model: string, sessionId: string): Promise<string | void> {
private async launchSessionSummary(prompt: string, model: string, sessionId: string, skipBareMode?: boolean): Promise<string | void> {
const launchOptions = {
headless: true,
model,
sessionId,
noSessionPersistence: true,
...(skipBareMode ? { skipBareMode } : {}),
}

try {
Expand Down Expand Up @@ -171,7 +172,7 @@ export class SessionSummaryService {
// 7. Invoke Claude headless to generate summary
// Use --resume with session ID so Claude knows which conversation to summarize
const summaryModel = this.settingsManager.getSummaryModel(settings)
const summaryResult = await this.launchSessionSummary(prompt, summaryModel, sessionId)
const summaryResult = await this.launchSessionSummary(prompt, summaryModel, sessionId, settings.skipBareMode)

if (!summaryResult || typeof summaryResult !== 'string' || summaryResult.trim() === '') {
logger.warn('Session summary generation returned empty result')
Expand Down Expand Up @@ -279,6 +280,7 @@ export class SessionSummaryService {
headless: true,
model: summaryModel,
noSessionPersistence: true,
...(settings.skipBareMode ? { skipBareMode: settings.skipBareMode } : {}),
})

if (!reportResult || typeof reportResult !== 'string' || reportResult.trim() === '') {
Expand Down Expand Up @@ -371,7 +373,7 @@ export class SessionSummaryService {

// 6. Invoke Claude headless to generate summary
const summaryModel = this.settingsManager.getSummaryModel(settings)
const summaryResult = await this.launchSessionSummary(prompt, summaryModel, sessionId)
const summaryResult = await this.launchSessionSummary(prompt, summaryModel, sessionId, settings.skipBareMode)

if (!summaryResult || typeof summaryResult !== 'string' || summaryResult.trim() === '') {
throw new Error('Session summary generation returned empty result')
Expand Down
1 change: 1 addition & 0 deletions src/lib/SettingsManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock('../utils/logger.js', () => ({
const defaultSettings = {
git: { commitTimeout: 60000 },
rebase: { maxCommitsForRebase: 20 },
skipBareMode: false,
}

describe('SettingsManager', () => {
Expand Down
16 changes: 16 additions & 0 deletions src/lib/SettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,14 @@ export const IloomSettingsSchema = z.object({
'(e.g., database URLs with ?, &, or other shell metacharacters). ' +
'Shell compatibility issues may cause processes to fail or behave unexpectedly.',
),
skipBareMode: z
.boolean()
.default(false)
.describe(
'Skip bare mode entirely for all Claude CLI operations. ' +
'When true, iloom never attempts --bare mode, avoiding the try-then-fallback overhead ' +
'on accounts where bare mode is incompatible (e.g., Enterprise accounts).',
),
worktreePrefix: z
.string()
.optional()
Expand Down Expand Up @@ -804,6 +812,14 @@ export const IloomSettingsSchemaNoDefaults = z.object({
'(e.g., database URLs with ?, &, or other shell metacharacters). ' +
'Shell compatibility issues may cause processes to fail or behave unexpectedly.',
),
skipBareMode: z
.boolean()
.optional()
.describe(
'Skip bare mode entirely for all Claude CLI operations. ' +
'When true, iloom never attempts --bare mode, avoiding the try-then-fallback overhead ' +
'on accounts where bare mode is incompatible (e.g., Enterprise accounts).',
),
worktreePrefix: z
.string()
.optional()
Expand Down
Loading
Loading