/** * GitFlow Config — Read/write .claude/gitflow/config.json v2.1.0. * Self-contained: imports only from local lib + node built-ins. */ import { readFile, writeFile, mkdir, copyFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join, dirname, resolve } from 'path'; import type { GitFlowConfig, WorktreeMode } from './types.js'; import { normalizeForPlatform, normalizeForStorage } from './paths.js'; import { detectPlatform } from './platform.js'; import { execGit, getGitCommonDir } from './git.js'; /** Legacy location (≤5.4 stored the config INSIDE the develop worktree). */ const CONFIG_RELATIVE = '.claude/gitflow/config.json'; /** * The canonical config path = `/.gitflow/config.json`, where the repo * root is the PARENT of the git common dir. The common dir is shared by every * worktree, so this resolves to the SAME file from main, develop or any * feature/* worktree — the config is repo-global LOCAL state, not branch state, * and living outside every working tree it can never be committed by accident. * Pure: only path math (the git call lives in resolveConfigPath). */ export function repoRootConfigFromCommonDir(commonDir: string, cwd: string): string { const root = dirname(resolve(cwd, commonDir)); return join(root, '.gitflow', 'config.json'); } /** * Parse `git worktree list --porcelain` and return the path of the worktree * checked out on `develop` (where ≤5.4 stored the config), or null. Pure. */ export function developWorktreePath(worktreeListPorcelain: string): string | null { let currentPath = ''; for (const line of worktreeListPorcelain.split('\n')) { if (line.startsWith('worktree ')) currentPath = line.slice(9); if (line.includes('branch refs/heads/develop') && currentPath) return currentPath; } return null; } export type ConfigResolution = | { action: 'root'; path: string } | { action: 'migrate'; from: string; to: string } | { action: 'legacy'; path: string } | { action: 'none' }; /** * Decide where to read the config from, given whether the canonical root config * already exists and whether a legacy (in-worktree) config was found. When only * a legacy config exists, it is migrated to the root. Pure (no I/O). */ export function decideConfigResolution(opts: { rootPath: string | null; rootExists: boolean; legacyPath: string | null; }): ConfigResolution { const { rootPath, rootExists, legacyPath } = opts; if (rootPath && rootExists) return { action: 'root', path: rootPath }; if (!legacyPath) return { action: 'none' }; if (rootPath) return { action: 'migrate', from: legacyPath, to: rootPath }; return { action: 'legacy', path: legacyPath }; } export async function resolveConfigPath(startDir?: string): Promise { const cwd = startDir || process.cwd(); const commonDir = await getGitCommonDir(cwd); const rootPath = commonDir ? repoRootConfigFromCommonDir(commonDir, cwd) : null; const rootExists = !!(rootPath && existsSync(rootPath)); // Only hunt legacy locations when the canonical one isn't there yet. const legacyPath = rootExists ? null : await findLegacyConfigPath(cwd); const decision = decideConfigResolution({ rootPath, rootExists, legacyPath }); switch (decision.action) { case 'root': case 'legacy': return decision.path; case 'none': return null; case 'migrate': // One-time, best-effort, idempotent migration legacy → root. On any // failure (perms, racing run) fall back to the legacy path so reads never break. try { await mkdir(dirname(decision.to), { recursive: true }); await copyFile(decision.from, decision.to); return decision.to; } catch { return decision.from; } } } /** Locate a legacy (≤5.4) in-worktree config: cwd, the develop worktree, then the toplevel. */ async function findLegacyConfigPath(cwd: string): Promise { const local = join(cwd, CONFIG_RELATIVE); if (existsSync(local)) return local; const wtResult = await execGit(['worktree', 'list', '--porcelain'], cwd); if (wtResult.exitCode === 0) { const devPath = developWorktreePath(wtResult.stdout); if (devPath) { const devConfig = join(devPath, CONFIG_RELATIVE); if (existsSync(devConfig)) return devConfig; } } const rootResult = await execGit(['rev-parse', '--show-toplevel'], cwd); if (rootResult.exitCode === 0) { const topConfig = join(rootResult.stdout, CONFIG_RELATIVE); if (existsSync(topConfig)) return topConfig; } return null; } export async function readConfig(configPath?: string): Promise { const path = configPath || await resolveConfigPath(); if (!path) { throw new Error('GitFlow config not found. Run gitflow init first.'); } const content = await readFile(path, 'utf-8'); const raw = JSON.parse(content); return applyDefaults(raw); } export async function writeConfig(config: GitFlowConfig, configPath: string): Promise { const stored = structuredClone(config); stored.repository.rootFolder = normalizeForStorage(stored.repository.rootFolder); if (stored.worktrees.structure) { const s = stored.worktrees.structure; s.main = normalizeForStorage(s.main); s.develop = normalizeForStorage(s.develop); s.features = normalizeForStorage(s.features); s.releases = normalizeForStorage(s.releases); s.hotfixes = normalizeForStorage(s.hotfixes); } if (stored.workspace?.path) { stored.workspace.path = normalizeForStorage(stored.workspace.path); } await mkdir(dirname(configPath), { recursive: true }); await writeFile(configPath, JSON.stringify(stored, null, 2) + '\n'); } /** * Patch ONLY `versioning.current` in the on-disk config, preserving everything * else verbatim (the 4.x `sed` equivalent — no applyDefaults, no path * normalization). Returns the config path, or null when no config is found. * Used by finish to record the next development version. */ export async function updateCurrentVersion(newVersion: string, startDir?: string): Promise { const path = await resolveConfigPath(startDir); if (!path) return null; const raw = JSON.parse(await readFile(path, 'utf-8')) as Record; const versioning = (raw.versioning && typeof raw.versioning === 'object' ? raw.versioning : {}) as Record; versioning.current = newVersion; raw.versioning = versioning; await writeFile(path, JSON.stringify(raw, null, 2) + '\n'); return path; } export async function readConfigForPlatform(configPath?: string): Promise { const config = await readConfig(configPath); const { platform } = detectPlatform(); config.repository.rootFolder = normalizeForPlatform(config.repository.rootFolder, platform); if (config.worktrees.structure) { const s = config.worktrees.structure; s.main = normalizeForPlatform(s.main, platform); s.develop = normalizeForPlatform(s.develop, platform); s.features = normalizeForPlatform(s.features, platform); s.releases = normalizeForPlatform(s.releases, platform); s.hotfixes = normalizeForPlatform(s.hotfixes, platform); } if (config.workspace?.path) { config.workspace.path = normalizeForPlatform(config.workspace.path, platform); } return config; } export function createDefaultConfig(overrides?: Partial): GitFlowConfig { const { platform, shell } = detectPlatform(); const base: GitFlowConfig = { version: '2.1.0', platform: { detected: platform, shell, detectedAt: new Date().toISOString() }, workspace: { path: '', name: '' }, repository: { name: '', rootFolder: '', nameVariants: { pascalCaseDot: '', pascalCase: '', kebabCase: '', snakeCase: '', displayName: '' }, defaultBranch: 'main', remoteUrl: '', }, git: { provider: 'unknown', branches: { main: 'main', develop: 'develop' }, prefixes: { feature: 'feature/', release: 'release/', hotfix: 'hotfix/' }, }, worktrees: { enabled: true, mode: 'organized' as WorktreeMode, structure: { main: '', develop: '', features: '', releases: '', hotfixes: '' }, }, versioning: { strategy: 'semver', current: '0.0.0', tagPrefix: 'v', sources: ['csproj', 'package.json', 'VERSION'] }, efcore: { enabled: true, validateOnCommit: true, blockDestructive: true, migrationNaming: '{context}_v{version}_{sequence}_{Description}', migrationNamingSquash: '{context}_v{version}', squashBeforePR: true, localHosts: [], }, workflow: { push: { afterCommit: 'worktree' }, pr: { autoLabels: true, requireReview: true } }, language: { code: 'en' }, }; if (overrides) { return deepMerge(base as unknown as Record, overrides as unknown as Record) as unknown as GitFlowConfig; } return base; } function applyDefaults(raw: Record): GitFlowConfig { const defaults = createDefaultConfig(); return deepMerge(defaults as unknown as Record, raw) as unknown as GitFlowConfig; } function deepMerge(target: Record, source: Record): Record { const result = { ...target }; for (const key of Object.keys(source)) { const sVal = source[key]; const tVal = target[key]; if (sVal && typeof sVal === 'object' && !Array.isArray(sVal) && tVal && typeof tVal === 'object' && !Array.isArray(tVal)) { result[key] = deepMerge(tVal as Record, sVal as Record); } else if (sVal !== undefined) { result[key] = sVal; } } return result; }