diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 5534c97..b1f54cc 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -34,6 +34,7 @@ import { updateWorktreeSession, getLanguage, loadProjectConfig, + saveProjectConfig, isVerboseMode, resolveConfigValue, invalidateGlobalConfigCache, @@ -501,60 +502,6 @@ describe('analytics config resolution', () => { }); }); -describe('model config resolution', () => { - let testDir: string; - let originalTaktConfigDir: string | undefined; - - beforeEach(() => { - testDir = join(tmpdir(), `takt-test-${randomUUID()}`); - mkdirSync(testDir, { recursive: true }); - originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; - process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt'); - invalidateGlobalConfigCache(); - }); - - afterEach(() => { - if (originalTaktConfigDir === undefined) { - delete process.env.TAKT_CONFIG_DIR; - } else { - process.env.TAKT_CONFIG_DIR = originalTaktConfigDir; - } - invalidateGlobalConfigCache(); - - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - }); - - it('should resolve project model over global model', () => { - const projectConfigDir = getProjectConfigDir(testDir); - mkdirSync(projectConfigDir, { recursive: true }); - writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nmodel: project-model\n'); - - const globalConfigDir = process.env.TAKT_CONFIG_DIR!; - mkdirSync(globalConfigDir, { recursive: true }); - writeFileSync(join(globalConfigDir, 'config.yaml'), 'model: global-model\n'); - - expect(resolveConfigValue(testDir, 'model')).toBe('project-model'); - }); - - it('should fallback to global model when project model is not set', () => { - const projectConfigDir = getProjectConfigDir(testDir); - mkdirSync(projectConfigDir, { recursive: true }); - writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n'); - - const globalConfigDir = process.env.TAKT_CONFIG_DIR!; - mkdirSync(globalConfigDir, { recursive: true }); - writeFileSync(join(globalConfigDir, 'config.yaml'), 'model: global-model\n'); - - expect(resolveConfigValue(testDir, 'model')).toBe('global-model'); - }); - - it('should return undefined when neither project nor global model is set', () => { - expect(resolveConfigValue(testDir, 'model')).toBeUndefined(); - }); -}); - describe('isVerboseMode', () => { let testDir: string; let originalTaktConfigDir: string | undefined; @@ -1209,3 +1156,157 @@ describe('provider-based session management', () => { }); }); }); + +describe('loadProjectConfig snake_case normalization', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should normalize auto_pr → autoPr and remove snake_case key', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'auto_pr: true\n'); + + const config = loadProjectConfig(testDir); + + expect(config.autoPr).toBe(true); + expect((config as Record).auto_pr).toBeUndefined(); + }); + + it('should normalize draft_pr → draftPr and remove snake_case key', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'draft_pr: true\n'); + + const config = loadProjectConfig(testDir); + + expect(config.draftPr).toBe(true); + expect((config as Record).draft_pr).toBeUndefined(); + }); + + it('should normalize base_branch → baseBranch and remove snake_case key', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'base_branch: main\n'); + + const config = loadProjectConfig(testDir); + + expect(config.baseBranch).toBe('main'); + expect((config as Record).base_branch).toBeUndefined(); + }); +}); + +describe('saveProjectConfig snake_case denormalization', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should persist autoPr as auto_pr and reload correctly', () => { + saveProjectConfig(testDir, { piece: 'default', autoPr: true }); + + const saved = loadProjectConfig(testDir); + + expect(saved.autoPr).toBe(true); + expect((saved as Record).auto_pr).toBeUndefined(); + }); + + it('should persist draftPr as draft_pr and reload correctly', () => { + saveProjectConfig(testDir, { piece: 'default', draftPr: true }); + + const saved = loadProjectConfig(testDir); + + expect(saved.draftPr).toBe(true); + expect((saved as Record).draft_pr).toBeUndefined(); + }); + + it('should persist baseBranch as base_branch and reload correctly', () => { + saveProjectConfig(testDir, { piece: 'default', baseBranch: 'main' }); + + const saved = loadProjectConfig(testDir); + + expect(saved.baseBranch).toBe('main'); + expect((saved as Record).base_branch).toBeUndefined(); + }); + + it('should not write camelCase keys to YAML file', () => { + saveProjectConfig(testDir, { piece: 'default', autoPr: true, draftPr: false, baseBranch: 'develop' }); + + const projectConfigDir = getProjectConfigDir(testDir); + const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8'); + + expect(content).toContain('auto_pr:'); + expect(content).toContain('draft_pr:'); + expect(content).toContain('base_branch:'); + expect(content).not.toContain('autoPr:'); + expect(content).not.toContain('draftPr:'); + expect(content).not.toContain('baseBranch:'); + }); +}); + +describe('resolveConfigValue autoPr/draftPr/baseBranch from project config', () => { + let testDir: string; + let originalTaktConfigDir: string | undefined; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; + process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt'); + invalidateGlobalConfigCache(); + }); + + afterEach(() => { + if (originalTaktConfigDir === undefined) { + delete process.env.TAKT_CONFIG_DIR; + } else { + process.env.TAKT_CONFIG_DIR = originalTaktConfigDir; + } + invalidateGlobalConfigCache(); + + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should resolve autoPr from project config written in snake_case YAML', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'auto_pr: true\n'); + + expect(resolveConfigValue(testDir, 'autoPr')).toBe(true); + }); + + it('should resolve draftPr from project config written in snake_case YAML', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'draft_pr: true\n'); + + expect(resolveConfigValue(testDir, 'draftPr')).toBe(true); + }); + + it('should resolve baseBranch from project config written in snake_case YAML', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'base_branch: main\n'); + + expect(resolveConfigValue(testDir, 'baseBranch')).toBe('main'); + }); +}); diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index a1fe8bf..bbdd97d 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -104,11 +104,24 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { applyProjectConfigEnvOverrides(parsedConfig); + const { + auto_pr, + draft_pr, + base_branch, + provider_options, + provider_profiles, + analytics, + ...rest + } = parsedConfig; + return { ...DEFAULT_PROJECT_CONFIG, - ...(parsedConfig as ProjectLocalConfig), - analytics: normalizeAnalytics(parsedConfig.analytics as Record | undefined), - providerOptions: normalizeProviderOptions(parsedConfig.provider_options as { + ...(rest as ProjectLocalConfig), + autoPr: auto_pr as boolean | undefined, + draftPr: draft_pr as boolean | undefined, + baseBranch: base_branch as string | undefined, + analytics: normalizeAnalytics(analytics as Record | undefined), + providerOptions: normalizeProviderOptions(provider_options as { codex?: { network_access?: boolean }; opencode?: { network_access?: boolean }; claude?: { @@ -118,7 +131,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { }; }; } | undefined), - providerProfiles: normalizeProviderProfiles(parsedConfig.provider_profiles as Record }> | undefined), + providerProfiles: normalizeProviderProfiles(provider_profiles as Record }> | undefined), }; } @@ -129,21 +142,21 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig const configDir = getConfigDir(projectDir); const configPath = getConfigPath(projectDir); - // Ensure directory exists if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } - // Copy project resources (only copies files that don't exist) copyProjectResourcesToDir(configDir); const savePayload: Record = { ...config }; + const rawAnalytics = denormalizeAnalytics(config.analytics); if (rawAnalytics) { savePayload.analytics = rawAnalytics; } else { delete savePayload.analytics; } + const rawProfiles = denormalizeProviderProfiles(config.providerProfiles); if (rawProfiles && Object.keys(rawProfiles).length > 0) { savePayload.provider_profiles = rawProfiles; @@ -153,6 +166,13 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig delete savePayload.providerProfiles; delete savePayload.providerOptions; + if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr; + if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr; + if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch; + delete savePayload.autoPr; + delete savePayload.draftPr; + delete savePayload.baseBranch; + const content = stringify(savePayload, { indent: 2 }); writeFileSync(configPath, content, 'utf-8'); invalidateResolvedConfigCache(projectDir); diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts index 86fc104..5125e5c 100644 --- a/src/infra/config/resolveConfigValue.ts +++ b/src/infra/config/resolveConfigValue.ts @@ -113,30 +113,7 @@ function getLocalLayerValue( project: ReturnType, key: K, ): LoadedConfig[K] | undefined { - switch (key) { - case 'piece': - return project.piece as LoadedConfig[K] | undefined; - case 'provider': - return project.provider as LoadedConfig[K] | undefined; - case 'model': - return project.model as LoadedConfig[K] | undefined; - case 'autoPr': - return project.auto_pr as LoadedConfig[K] | undefined; - case 'draftPr': - return project.draft_pr as LoadedConfig[K] | undefined; - case 'verbose': - return project.verbose as LoadedConfig[K] | undefined; - case 'analytics': - return project.analytics as LoadedConfig[K] | undefined; - case 'providerOptions': - return project.providerOptions as LoadedConfig[K] | undefined; - case 'providerProfiles': - return project.providerProfiles as LoadedConfig[K] | undefined; - case 'baseBranch': - return project.base_branch as LoadedConfig[K] | undefined; - default: - return undefined; - } + return project[key as keyof typeof project] as LoadedConfig[K] | undefined; } function getGlobalLayerValue( diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index 44dc5a4..7dbd7f6 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -12,26 +12,20 @@ export interface ProjectLocalConfig { piece?: string; /** Provider selection for agent runtime */ provider?: 'claude' | 'codex' | 'opencode' | 'mock'; - /** Model selection for agent runtime */ - model?: string; /** Auto-create PR after worktree execution */ - auto_pr?: boolean; + autoPr?: boolean; /** Create PR as draft */ - draft_pr?: boolean; + draftPr?: boolean; + /** Base branch to clone from (overrides global baseBranch) */ + baseBranch?: string; /** Verbose output mode */ verbose?: boolean; /** Project-level analytics overrides */ analytics?: AnalyticsConfig; /** Provider-specific options (overrides global, overridden by piece/movement) */ - provider_options?: MovementProviderOptions; - /** Provider-specific options (camelCase alias) */ providerOptions?: MovementProviderOptions; /** Provider-specific permission profiles (project-level override) */ - provider_profiles?: ProviderPermissionProfiles; - /** Provider-specific permission profiles (camelCase alias) */ providerProfiles?: ProviderPermissionProfiles; - /** Custom settings */ - [key: string]: unknown; } /** Persona session data for persistence */