takt: refactor-project-config-case (#358)

This commit is contained in:
nrs 2026-02-22 21:33:42 +09:00 committed by GitHub
parent c066db46c7
commit 9e68f086d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 186 additions and 94 deletions

View File

@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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');
});
});

View File

@ -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<string, unknown> | 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<string, unknown> | 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<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | 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<string, unknown> = { ...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);

View File

@ -113,30 +113,7 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
project: ReturnType<typeof loadProjectConfigCached>,
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<K extends ConfigParameterKey>(

View File

@ -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 */