// Configuration file management for cool-branch // Manages repo-to-folder mappings stored in /cool-branch.json // Also supports per-repo config in .cool-branch/config.json import * as fs from 'node:fs'; import * as path from 'node:path'; import { getCoolBranchConfigPath, getCoolBranchLocalConfigPath } from './constants'; import { getOriginUrl, getRepoRoot } from './git'; /** * Copy config modes for .cool-branch directory */ export type CopyConfigMode = 'all' | 'none' | 'local'; /** * Local config file structure for .cool-branch/config.json */ export interface LocalConfig { base?: string; dirname?: string; copyConfig?: CopyConfigMode; remote?: string; } /** * Parse a config file and extract known fields * @param configPath Path to the config file * @returns Partial LocalConfig with extracted fields, or empty object if invalid */ function parseLocalConfigFile(configPath: string): LocalConfig { if (!fs.existsSync(configPath)) { return {}; } try { const content = fs.readFileSync(configPath, 'utf-8'); const parsed = JSON.parse(content); // Validate and extract only known fields const result: LocalConfig = {}; if (typeof parsed.base === 'string') { result.base = parsed.base; } if (typeof parsed.dirname === 'string') { result.dirname = parsed.dirname; } if ( parsed.copyConfig === 'all' || parsed.copyConfig === 'none' || parsed.copyConfig === 'local' ) { result.copyConfig = parsed.copyConfig; } if (typeof parsed.remote === 'string') { result.remote = parsed.remote; } return result; } catch { // Invalid JSON or read error - return empty config return {}; } } /** * Result from loading a custom config */ export interface CustomConfigResult { config: LocalConfig; error?: string; } /** * Load a custom config from a specified path * @param configPath Path to a config file or directory containing config.json * @returns Object with config and optional error message */ export function loadCustomConfig(configPath: string): CustomConfigResult { if (!fs.existsSync(configPath)) { return { config: {}, error: `Config path does not exist: ${configPath}`, }; } const stat = fs.statSync(configPath); if (stat.isFile()) { // Direct path to a config file const config = parseLocalConfigFile(configPath); return { config }; } else if (stat.isDirectory()) { // Directory - look for config.json inside const configFilePath = path.join(configPath, 'config.json'); if (!fs.existsSync(configFilePath)) { return { config: {}, error: `Config file not found: ${configFilePath}`, }; } const config = parseLocalConfigFile(configFilePath); return { config }; } return { config: {}, error: `Invalid config path: ${configPath}`, }; } /** * Read the local config from .cool-branch/ directory in the repo root * Reads both config.json and config.local.json, with local values taking precedence * @param cwd Working directory (optional) * @returns Local config object, or empty object if not found or invalid */ export function readLocalConfig(cwd?: string): LocalConfig { const repoRoot = getRepoRoot(cwd); if (!repoRoot) { return {}; } // Read both config files const baseConfig = parseLocalConfigFile(getCoolBranchConfigPath(repoRoot)); const localConfig = parseLocalConfigFile(getCoolBranchLocalConfigPath(repoRoot)); // Merge: local config overrides base config return { ...baseConfig, ...localConfig, }; } /** * Get the path to the config file * @param base Base directory for worktrees * @returns Path to cool-branch.json */ export function getConfigPath(base: string): string { return path.join(base, 'cool-branch.json'); } /** * Read the config file * @param base Base directory for worktrees * @returns Config object mapping repo identifiers to folder names * @throws Error if file exists but cannot be parsed */ export function readConfig(base: string): Record { const configPath = getConfigPath(base); if (!fs.existsSync(configPath)) { return {}; } try { const content = fs.readFileSync(configPath, 'utf-8'); return JSON.parse(content) as Record; } catch (error) { if (error instanceof SyntaxError) { throw new Error(`Failed to parse config file at ${configPath}: ${error.message}`); } throw error; } } /** * Write the config file * @param base Base directory for worktrees * @param config Config object to write */ export function writeConfig(base: string, config: Record): void { // Create base directory if it doesn't exist if (!fs.existsSync(base)) { fs.mkdirSync(base, { recursive: true }); } const configPath = getConfigPath(base); const content = JSON.stringify(config, null, 2); fs.writeFileSync(configPath, content, 'utf-8'); } /** * Get a unique identifier for the current repository * Uses origin URL if available, otherwise uses the absolute repo path * @param cwd Working directory (optional) * @returns Repository identifier string * @throws Error if not in a git repository */ export function getRepoIdentifier(cwd?: string): string { // Try to get origin URL first const originUrl = getOriginUrl(cwd); if (originUrl) { return originUrl; } // Fall back to absolute repo path const repoRoot = getRepoRoot(cwd); if (!repoRoot) { throw new Error('Not in a git repository'); } return repoRoot; } /** * Get the folder name for the current repository * Looks up the mapping in config, or derives from repo basename and saves it * @param base Base directory for worktrees * @param cwd Working directory (optional) * @param options Optional settings * @param options.localDirname If provided, use this dirname (from local config) without saving to global config * @returns Folder name to use for this repo's worktrees */ export function getRepoFolderName( base: string, cwd?: string, options?: { localDirname?: string }, ): string { // If local dirname is provided, use it directly without saving to global config if (options?.localDirname) { return options.localDirname; } const repoId = getRepoIdentifier(cwd); const config = readConfig(base); // If mapping exists, return it if (config[repoId]) { return config[repoId] as string; } // Otherwise, derive from repo root basename and save it const repoRoot = getRepoRoot(cwd); if (!repoRoot) { throw new Error('Not in a git repository'); } const folderName = path.basename(repoRoot); config[repoId] = folderName; writeConfig(base, config); return folderName; } /** * Set the folder name mapping for the current repository * @param base Base directory for worktrees * @param folderName Folder name to use for this repo * @param cwd Working directory (optional) */ export function setRepoFolderName(base: string, folderName: string, cwd?: string): void { const repoId = getRepoIdentifier(cwd); const config = readConfig(base); config[repoId] = folderName; writeConfig(base, config); } /** * Get the base path for worktrees of the current repository * @param base Base directory for worktrees * @param cwd Working directory (optional) * @param options Optional settings * @param options.localDirname If provided, use this dirname (from local config) without saving to global config * @returns Path to / */ export function getWorktreeBasePath( base: string, cwd?: string, options?: { localDirname?: string }, ): string { const folderName = getRepoFolderName(base, cwd, options); const worktreeBase = path.join(base, folderName); // Create directory if it doesn't exist if (!fs.existsSync(worktreeBase)) { fs.mkdirSync(worktreeBase, { recursive: true }); } return worktreeBase; } /** * Get the full path for a specific worktree * @param base Base directory for worktrees * @param branchName Branch name for the worktree * @param cwd Working directory (optional) * @param options Optional settings * @param options.localDirname If provided, use this dirname (from local config) without saving to global config * @returns Path to // */ export function getWorktreePath( base: string, branchName: string, cwd?: string, options?: { localDirname?: string }, ): string { const folderName = getRepoFolderName(base, cwd, options); return path.join(base, folderName, branchName); } /** * Expand ~ to home directory in a path * @param inputPath Path that may contain ~ * @returns Path with ~ expanded to home directory */ export function expandPath(inputPath: string): string { if (inputPath.startsWith('~')) { const home = process.env['HOME'] ?? ''; return path.join(home, inputPath.slice(1)); } return inputPath; }