takt: refactor-project-config-case (#358)
This commit is contained in:
parent
c066db46c7
commit
9e68f086d4
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user