From c733d1729da11343b197bfc2e5663ac1d644b671 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Wed, 17 Dec 2025 23:54:37 -0500 Subject: [PATCH 1/2] Add remote detection and configuration during init flow. Fixes #361 - Add `hasNoRemotes` utility function to detect missing remotes - Trigger first-run setup with custom message when no remotes detected - Support custom messages in `launchFirstRunSetup` function - Enhance init prompt to guide users through remote configuration - Update test mocks to support new SettingsManager dependency --- src/commands/cleanup.test.ts | 20 ++++++++++++++++++++ src/commands/start.test.ts | 1 + src/commands/start.ts | 9 ++++++++- src/utils/first-run-setup.ts | 8 ++++---- src/utils/remote.test.ts | 31 +++++++++++++++++++++++++++++++ src/utils/remote.ts | 19 +++++++++++++++++++ templates/prompts/init-prompt.txt | 26 +++++++++++++++++++++++++- 7 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/commands/cleanup.test.ts b/src/commands/cleanup.test.ts index 878c4a9..b19a5d1 100644 --- a/src/commands/cleanup.test.ts +++ b/src/commands/cleanup.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { CleanupCommand } from '../../src/commands/cleanup.js' import { GitWorktreeManager } from '../../src/lib/GitWorktreeManager.js' import { ResourceCleanup } from '../../src/lib/ResourceCleanup.js' +import { SettingsManager } from '../../src/lib/SettingsManager.js' import { logger } from '../../src/utils/logger.js' import { promptConfirmation } from '../../src/utils/prompt.js' import type { CleanupResult, SafetyCheck } from '../../src/types/cleanup.js' @@ -10,6 +11,7 @@ import type { CleanupResult, SafetyCheck } from '../../src/types/cleanup.js' vi.mock('../../src/lib/GitWorktreeManager.js') vi.mock('../../src/lib/ResourceCleanup.js') vi.mock('../../src/utils/prompt.js') +vi.mock('../../src/lib/SettingsManager.js') vi.mock('../../src/utils/logger.js', () => ({ logger: { info: vi.fn(), @@ -33,6 +35,24 @@ describe('CleanupCommand', () => { beforeEach(() => { vi.clearAllMocks() + + // Mock SettingsManager to prevent reading real config files + vi.mocked(SettingsManager).mockImplementation(() => ({ + loadSettings: vi.fn().mockResolvedValue({ + capabilities: { + database: { + databaseUrlEnvVarName: 'DATABASE_URL' + } + }, + mergeBehavior: { + mode: 'local' + } + }), + getProtectedBranches: vi.fn(), + getSpinModel: vi.fn(), + getSummaryModel: vi.fn(), + }) as any) + mockGitWorktreeManager = new GitWorktreeManager() as vi.Mocked // Mock listWorktrees by default to prevent executeIssueCleanup from failing mockGitWorktreeManager.listWorktrees = vi.fn().mockResolvedValue([]) diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index bd74a50..cae6aaa 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -50,6 +50,7 @@ vi.mock('../utils/git.js', async () => { // Mock remote utilities vi.mock('../utils/remote.js', () => ({ hasMultipleRemotes: vi.fn().mockResolvedValue(false), + hasNoRemotes: vi.fn().mockResolvedValue(false), getConfiguredRepoFromSettings: vi.fn().mockResolvedValue('owner/repo'), parseGitRemotes: vi.fn().mockResolvedValue([]), validateConfiguredRemote: vi.fn().mockResolvedValue(undefined), diff --git a/src/commands/start.ts b/src/commands/start.ts index c7204ca..a35d6db 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -15,7 +15,7 @@ import { findMainWorktreePathWithSettings } from '../utils/git.js' import { loadEnvIntoProcess } from '../utils/env.js' import { extractSettingsOverrides } from '../utils/cli-overrides.js' import { createNeonProviderFromSettings } from '../utils/neon-helpers.js' -import { getConfiguredRepoFromSettings, hasMultipleRemotes } from '../utils/remote.js' +import { getConfiguredRepoFromSettings, hasMultipleRemotes, hasNoRemotes } from '../utils/remote.js' import { capitalizeFirstLetter } from '../utils/text.js' import type { StartOptions, StartResult } from '../types/index.js' import { launchFirstRunSetup, needsFirstRunSetup } from '../utils/first-run-setup.js' @@ -127,6 +127,13 @@ export class StartCommand { } } + // Check for missing remotes and trigger init flow if needed + if (!isJsonMode && (await hasNoRemotes())) { + getLogger().warn('No git remotes detected. iloom requires a GitHub remote to function.') + await launchFirstRunSetup('Help me configure a GitHub remote for this repository. There are no git remotes configured.') + // Continue after setup - remotes should now be configured + } + let repo: string | undefined // Only get repo if we have multiple remotes (prehook already validated config) diff --git a/src/utils/first-run-setup.ts b/src/utils/first-run-setup.ts index 2468b5f..ff9214e 100644 --- a/src/utils/first-run-setup.ts +++ b/src/utils/first-run-setup.ts @@ -78,8 +78,9 @@ async function hasNonEmptySettings(filePath: string): Promise { /** * Launch interactive first-run setup via InitCommand + * @param customMessage Optional custom message to pass to Claude instead of the default */ -export async function launchFirstRunSetup(): Promise { +export async function launchFirstRunSetup(customMessage?: string): Promise { logger.info('First-time project setup detected.') logger.info( 'iloom will now launch an interactive configuration session with Claude.' @@ -89,9 +90,8 @@ export async function launchFirstRunSetup(): Promise { await waitForKeypress('Press any key to start configuration...') const initCommand = new InitCommand() - await initCommand.execute( - 'Help me configure iloom settings for this project. This is my first time using iloom here. Note: Your iloom command will execute once we are done with configuration changes.' - ) + const defaultMessage = 'Help me configure iloom settings for this project. This is my first time using iloom here. Note: Your iloom command will execute once we are done with configuration changes.' + await initCommand.execute(customMessage ?? defaultMessage) // Mark project as configured to prevent wizard from re-triggering // Use the same project root resolution as needsFirstRunSetup() for consistency diff --git a/src/utils/remote.test.ts b/src/utils/remote.test.ts index d24db43..962b706 100644 --- a/src/utils/remote.test.ts +++ b/src/utils/remote.test.ts @@ -4,6 +4,7 @@ import type { ExecaReturnValue } from 'execa' import { parseGitRemotes, hasMultipleRemotes, + hasNoRemotes, getConfiguredRepoFromSettings, validateConfiguredRemote, } from './remote.js' @@ -160,6 +161,36 @@ describe('remote utils', () => { }) }) + describe('hasNoRemotes', () => { + it('should return true when no remotes exist', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: '', + } as Partial as ExecaReturnValue) + + const result = await hasNoRemotes() + + expect(result).toBe(true) + }) + + it('should return false when remotes exist', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'origin\tgit@github.com:user/repo.git (fetch)\norigin\tgit@github.com:user/repo.git (push)', + } as Partial as ExecaReturnValue) + + const result = await hasNoRemotes() + + expect(result).toBe(false) + }) + + it('should return false when git command fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('fatal: not a git repository')) + + const result = await hasNoRemotes() + + expect(result).toBe(false) + }) + }) + describe('getConfiguredRepoFromSettings', () => { it('should return repo string from configured remote', async () => { const settings: IloomSettings = { diff --git a/src/utils/remote.ts b/src/utils/remote.ts index 9f4c2ac..4e354c1 100644 --- a/src/utils/remote.ts +++ b/src/utils/remote.ts @@ -95,6 +95,25 @@ export async function hasMultipleRemotes(cwd?: string): Promise { } } +/** + * Check if repository has no remotes + */ +export async function hasNoRemotes(cwd?: string): Promise { + try { + const remotes = await parseGitRemotes(cwd) + return remotes.length === 0 + } catch (error) { + // Same error handling pattern as hasMultipleRemotes + const errMsg = error instanceof Error ? error.message : String(error) + if (/not a git repository/i.test(errMsg)) { + logger.debug('Skipping git remote check: not a git repository') + } else { + logger.warn(`Unable to check git remotes: ${errMsg}`) + } + return false + } +} + /** * Get configured repository string from settings * Returns "owner/repo" format for use with gh CLI --repo flag diff --git a/templates/prompts/init-prompt.txt b/templates/prompts/init-prompt.txt index c700027..5622243 100644 --- a/templates/prompts/init-prompt.txt +++ b/templates/prompts/init-prompt.txt @@ -311,7 +311,31 @@ REMOTES_INFO {{/IF SINGLE_REMOTE}} {{#IF NO_REMOTES}} -**Warning:** No git remotes detected. The user will need to configure a remote before using iloom's GitHub features. +**CRITICAL: No git remotes detected.** This repository has no Git remotes configured. iloom requires a GitHub remote to function properly. + +**Before proceeding with other configuration, you MUST help the user set up a remote:** + +1. **Ask for the GitHub repository URL:** + - Question: "Please provide your GitHub repository URL (e.g., https://github.com/owner/repo or git@github.com:owner/repo)" + - Validate the URL matches GitHub format (HTTPS or SSH) + +2. **Add the remote using git:** + ```bash + git remote add origin + ``` + +3. **Verify the remote was added:** + ```bash + git remote -v + ``` + +4. **Inform the user:** "Remote 'origin' has been configured. You can now proceed with iloom configuration." + +**Important:** If the user doesn't have a GitHub repository yet, guide them to: +- Create one at https://github.com/new +- Then return to provide the URL + +**After configuring the remote, proceed to Phase 1 (Local Development Settings).** {{/IF NO_REMOTES}} **If Linear was selected:** From 1c8185d20fd5d84fff9fef1066b6baeebeb04bb1 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 18 Dec 2025 00:27:58 -0500 Subject: [PATCH 2/2] Fix type cast in cleanup test to use proper type assertion. - Replace generic `as any` with `Partial` cast - Improve type safety by using explicit partial type assertion Fixes #361 --- src/commands/cleanup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/cleanup.test.ts b/src/commands/cleanup.test.ts index b19a5d1..d84fc13 100644 --- a/src/commands/cleanup.test.ts +++ b/src/commands/cleanup.test.ts @@ -51,7 +51,7 @@ describe('CleanupCommand', () => { getProtectedBranches: vi.fn(), getSpinModel: vi.fn(), getSummaryModel: vi.fn(), - }) as any) + }) as Partial as SettingsManager) mockGitWorktreeManager = new GitWorktreeManager() as vi.Mocked // Mock listWorktrees by default to prevent executeIssueCleanup from failing