// Add command - creates a new worktree for a branch import { execSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { getWorktreeBasePath, getWorktreePath, readLocalConfig } from '../config'; import { getCoolBranchDir } from '../constants'; import { addWorktree, branchExists, getRepoRoot, listBranches, listWorktrees, removeWorktree, runGit, } from '../git'; import { isInteractive, promptConfirm, promptSelect, type SelectResult } from '../prompt'; /** * Copy config modes for .cool-branch directory */ export type CopyConfigMode = 'all' | 'none' | 'local'; /** * Options for the add command */ export interface AddOptions { base: string; localDirname?: string | undefined; branchName: string; remote: string; force: boolean; setup: string | undefined; noSetup: boolean; copyConfig?: CopyConfigMode | undefined; } /** * Run a setup script in the worktree directory * Streams stdout/stderr to console in real-time. * The script runs from inside the worktree directory. * @param scriptPath Absolute path to the script * @param worktreePath Path to the worktree directory (used as cwd) * @param originalDir Path to the original repo directory (passed as $1) * @returns true if script succeeded, false otherwise */ function runSetupScript(scriptPath: string, worktreePath: string, originalDir: string): boolean { try { execSync(`"${scriptPath}" "${originalDir}"`, { cwd: worktreePath, stdio: 'inherit', }); return true; } catch { return false; } } /** * Find a local setup script in the .cool-branch directory * Looks for files named "setup.local" with or without extension (e.g., setup.local, setup.local.sh) * @param coolBranchDir Path to .cool-branch directory * @returns Path to the script if found, null otherwise */ function findLocalSetupScriptInCoolBranchDir(coolBranchDir: string): string | null { try { const entries = fs.readdirSync(coolBranchDir); for (const entry of entries) { // Match setup.local or setup.local.* (with any extension) if (entry === 'setup.local' || entry.startsWith('setup.local.')) { return path.join(coolBranchDir, entry); } } } catch { // Directory doesn't exist or can't be read } return null; } /** * Find a setup script in the .cool-branch directory * First checks for local variants (setup.local, setup.local.sh), then falls back to * regular setup scripts (setup, setup.sh) * @param coolBranchDir Path to .cool-branch directory * @returns Path to the script if found, null otherwise */ function findSetupScriptInCoolBranchDir(coolBranchDir: string): string | null { // First try to find a local setup script const localScript = findLocalSetupScriptInCoolBranchDir(coolBranchDir); if (localScript) { return localScript; } // Fall back to regular setup script try { const entries = fs.readdirSync(coolBranchDir); for (const entry of entries) { // Match setup or setup.* (with any extension), but NOT setup.local* if ( (entry === 'setup' || (entry.startsWith('setup.') && entry !== 'setup.')) && !entry.startsWith('setup.local') ) { return path.join(coolBranchDir, entry); } } } catch { // Directory doesn't exist or can't be read } return null; } /** * Find a setup script in the repo * Checks .cool-branch/setup or .cool-branch/setup.* * @param repoRoot Path to the repo root * @returns Path to the script if found, null otherwise */ function findSetupScript(repoRoot: string): string | null { return findSetupScriptInCoolBranchDir(getCoolBranchDir(repoRoot)); } /** * Recursively remove a directory and all its contents */ function removeDirectoryRecursive(dirPath: string): void { if (!fs.existsSync(dirPath)) { return; } const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { removeDirectoryRecursive(fullPath); } else { fs.unlinkSync(fullPath); } } fs.rmdirSync(dirPath); } /** * Check if directory exists and is non-empty */ function isNonEmptyDirectory(dirPath: string): boolean { if (!fs.existsSync(dirPath)) { return false; } const stat = fs.statSync(dirPath); if (!stat.isDirectory()) { return false; } const entries = fs.readdirSync(dirPath); return entries.length > 0; } /** * Check if a filename matches the local pattern * Matches: *.local (e.g., setup.local) or *.local.* (e.g., setup.local.sh, config.local.json) * @param filename Name of the file * @returns true if the filename matches local pattern */ function isLocalFile(filename: string): boolean { // Match *.local (no extension after .local) if (filename.endsWith('.local')) { return true; } // Match *.local.* (has extension after .local) return filename.includes('.local.'); } /** * Copy files from the source .cool-branch directory to the target * @param sourceCoolBranchDir Path to source .cool-branch directory * @param targetCoolBranchDir Path to target .cool-branch directory * @param mode Copy mode: 'all', 'none', or 'local' */ function copyCoolBranchDir( sourceCoolBranchDir: string, targetCoolBranchDir: string, mode: CopyConfigMode, ): void { if (mode === 'none') { return; } if (!fs.existsSync(sourceCoolBranchDir)) { return; } try { const entries = fs.readdirSync(sourceCoolBranchDir, { withFileTypes: true }); for (const entry of entries) { const sourcePath = path.join(sourceCoolBranchDir, entry.name); const targetPath = path.join(targetCoolBranchDir, entry.name); // Skip directories (don't copy subdirectories) if (entry.isDirectory()) { continue; } // In 'local' mode, only copy files matching local patterns if (mode === 'local' && !isLocalFile(entry.name)) { continue; } // Ensure target directory exists if (!fs.existsSync(targetCoolBranchDir)) { fs.mkdirSync(targetCoolBranchDir, { recursive: true }); } // Copy the file, preserving mode const sourceStats = fs.statSync(sourcePath); fs.copyFileSync(sourcePath, targetPath); fs.chmodSync(targetPath, sourceStats.mode); } } catch { // Ignore errors during copy - non-critical operation } } /** * Add a new worktree for the specified branch * @param options Command options */ export function addCommand(options: AddOptions): void { // Validate context - ensure we're in a git repository const repoRoot = getRepoRoot(); if (repoRoot === null) { console.error('Error: Not in a git repository'); process.exit(1); } // Prepare config options for path functions const configOpts = options.localDirname ? { localDirname: options.localDirname } : undefined; // Get the base path for worktrees of this repo (creates it if needed) getWorktreeBasePath(options.base, undefined, configOpts); // Get target path for the worktree const targetPath = getWorktreePath(options.base, options.branchName, undefined, configOpts); // Check for existing directory if (isNonEmptyDirectory(targetPath)) { if (!options.force) { console.error(`Error: Directory already exists and is non-empty: ${targetPath}`); console.error('Use -f to force overwrite.'); process.exit(1); } // Force mode: properly remove the existing worktree (directory + git registration) try { removeWorktree(targetPath, true); } catch { // If removeWorktree fails (e.g., directory already partially removed), // clean up stale registrations and remove any remaining directory runGit(['worktree', 'prune']); removeDirectoryRecursive(targetPath); } } // Clean up any stale worktree registrations before adding runGit(['worktree', 'prune']); // Fetch from remote (ignore errors if no remote exists) runGit(['fetch', options.remote]); // Check if branch exists locally const localExists = branchExists(options.branchName); // Check if branch exists on remote const remoteResult = runGit([ 'show-ref', '--verify', '--quiet', `refs/remotes/${options.remote}/${options.branchName}`, ]); const remoteExists = remoteResult.exitCode === 0; // Create worktree with appropriate options try { if (localExists) { // Branch exists locally - use it directly addWorktree(targetPath, options.branchName, false); } else if (remoteExists) { // Branch exists on remote - git will automatically track it addWorktree(targetPath, options.branchName, false); } else { // Branch doesn't exist - create a new one addWorktree(targetPath, options.branchName, true); } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: Failed to create worktree: ${message}`); process.exit(1); } console.log(`Worktree created at: ${targetPath}`); // Copy .cool-branch directory to worktree // Determine copy mode: CLI flag > config file > default (local) const localConfig = readLocalConfig(); const copyMode = options.copyConfig ?? localConfig.copyConfig ?? 'local'; const sourceCoolBranchDir = getCoolBranchDir(repoRoot); const targetCoolBranchDir = getCoolBranchDir(targetPath); copyCoolBranchDir(sourceCoolBranchDir, targetCoolBranchDir, copyMode); // Run setup script if applicable if (!options.noSetup) { let scriptPath: string | null = null; let isExplicitScript = false; if (options.setup) { // Custom script specified via --setup (relative to main repo or absolute) scriptPath = path.resolve(repoRoot, options.setup); isExplicitScript = true; } else { // Look for setup script in the WORKTREE (not the main repo) // This allows branch-specific setup scripts and uses copied local scripts scriptPath = findSetupScript(targetPath); } if (scriptPath) { if (!fs.existsSync(scriptPath)) { // Script specified via --setup but doesn't exist if (isExplicitScript) { console.log(`Warning: Setup script not found: ${scriptPath}`); } // For default script, skip silently (already checked above) } else { console.log('Running setup script...'); const success = runSetupScript(scriptPath, targetPath, repoRoot); if (success) { console.log('Setup complete.'); } else { console.error('Error: Setup script failed.'); process.exit(1); } } } } } /** * Options for interactive add */ export interface InteractiveAddOptions { base: string; localDirname?: string | undefined; remote: string; setup: string | undefined; noSetup: boolean; copyConfig?: CopyConfigMode | undefined; } /** * Interactive add - prompt user to select or enter a branch name * @param options Command options */ export async function interactiveAddCommand(options: InteractiveAddOptions): Promise { // Validate context - ensure we're in a git repository const repoRoot = getRepoRoot(); if (repoRoot === null) { console.error('Error: Not in a git repository'); process.exit(1); } // Ensure we're in an interactive terminal if (!isInteractive()) { console.error('Error: Branch name is required in non-interactive mode'); console.error('Usage: cool-branch add '); process.exit(1); } // Prepare config options for path functions const configOpts = options.localDirname ? { localDirname: options.localDirname } : undefined; // Get all branches and worktrees const branches = listBranches(); const worktrees = listWorktrees(); // Build display list with worktree indicators const worktreeBranches = new Set(worktrees.map((w) => w.branch)); const displayOptions = branches.map((branch) => { if (worktreeBranches.has(branch)) { return `${branch} [worktree]`; } return branch; }); // Prompt for selection const result: SelectResult = await promptSelect( 'Select existing branch or enter new name:', displayOptions, ); let branchName: string; let force = false; if (result.type === 'index') { // User selected an existing branch by number branchName = branches[result.value] as string; // Check if it already has a worktree if (worktreeBranches.has(branchName)) { const worktreePath = getWorktreePath(options.base, branchName, undefined, configOpts); const confirmed = await promptConfirm( `Worktree exists at ${worktreePath}. Overwrite?`, true, ); if (!confirmed) { console.log('Aborted.'); process.exit(0); } force = true; } } else { // User entered text - use as new branch name branchName = result.value; if (!branchName) { console.error('Error: Branch name cannot be empty'); process.exit(1); } // Check if this branch already has a worktree if (worktreeBranches.has(branchName)) { const worktreePath = getWorktreePath(options.base, branchName, undefined, configOpts); const confirmed = await promptConfirm( `Worktree exists at ${worktreePath}. Overwrite?`, true, ); if (!confirmed) { console.log('Aborted.'); process.exit(0); } force = true; } } // Proceed with add command addCommand({ base: options.base, localDirname: options.localDirname, branchName, remote: options.remote, force, setup: options.setup, noSetup: options.noSetup, copyConfig: options.copyConfig, }); }