Created
February 22, 2026 16:20
-
-
Save jrejaud/5d8c0faa22ecc861d98a07a469f27664 to your computer and use it in GitHub Desktop.
Claude Code worktree workflow - Linear integration + MCP intelligence layer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env zx | |
| import {chalk, fs, $, argv, path} from "zx" | |
| import {checkbox} from "@inquirer/prompts" | |
| /** | |
| * Worktree Cleanup Script | |
| * | |
| * This script safely removes a git worktree after work is complete. | |
| * It performs these steps: | |
| * 1. Verifies the worktree exists | |
| * 2. Checks if the branch has been merged (prevents accidental data loss) | |
| * 3. Syncs any new Claude Code permissions back to parent repo | |
| * 4. Removes the worktree directory | |
| * 5. Prunes git worktree references | |
| * 6. Cleans up ~/.claude.json trust entry | |
| * 7. Deletes the git branch | |
| * | |
| * Safety features: | |
| * - Won't delete unmerged branches without confirmation or --force | |
| * - Interactive permission sync (choose which to keep) | |
| * - Cleans up all traces of the worktree | |
| */ | |
| // Parse arguments | |
| const branchName = argv._[0] | |
| const force = argv.force || false | |
| if (!branchName) { | |
| console.error(chalk.red("Error: Branch name is required")) | |
| console.log(chalk.yellow("Usage: npm run worktree-cleanup <branch-name> [--force]")) | |
| console.log(chalk.yellow(" --force: Force delete even if branch is not merged")) | |
| process.exit(1) | |
| } | |
| const projectRoot = process.cwd() | |
| const worktreePath = path.join(projectRoot, ".trees", branchName) | |
| // Determine master branch from package.json | |
| const masterBranch = (() => { | |
| try { | |
| const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")) | |
| const masterBranch = packageJson.masterBranch | |
| if (!masterBranch) { | |
| console.error(chalk.red("Error: masterBranch not defined in package.json")) | |
| process.exit(1) | |
| } | |
| return masterBranch | |
| } catch (error) { | |
| console.error(chalk.red(`Error: Could not read package.json: ${error.message}`)) | |
| process.exit(1) | |
| } | |
| })() | |
| console.log(chalk.blue(`Cleaning up worktree for branch: ${branchName}`)) | |
| console.log(chalk.gray(`Worktree path: ${worktreePath}`)) | |
| // Step 1: Check if worktree exists | |
| console.log(chalk.blue("\n1. Checking worktree exists...")) | |
| if (!fs.existsSync(worktreePath)) { | |
| console.error(chalk.red(` ✗ Worktree not found at ${worktreePath}`)) | |
| process.exit(1) | |
| } | |
| console.log(chalk.green(" ✓ Worktree exists")) | |
| // Step 2: Check if branch is merged into master | |
| console.log(chalk.blue("\n2. Checking if branch is merged...")) | |
| try { | |
| await $`git fetch origin ${masterBranch}` | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ Failed to fetch origin/${masterBranch}: ${error.message}`)) | |
| process.exit(1) | |
| } | |
| let isMerged = false | |
| try { | |
| const mergedBranches = await $`git branch --merged origin/${masterBranch}` | |
| isMerged = mergedBranches.stdout.includes(branchName) | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Could not check merge status: ${error.message}`)) | |
| } | |
| if (!isMerged) { | |
| if (!force) { | |
| console.log(chalk.yellow(` ⚠ Branch '${branchName}' is NOT merged into ${masterBranch}`)) | |
| // Check if running in interactive mode | |
| if (process.stdin.isTTY) { | |
| const readline = await import("readline") | |
| const rl = readline.createInterface({ | |
| input: process.stdin, | |
| output: process.stdout | |
| }) | |
| const answer = await new Promise((resolve, reject) => { | |
| try { | |
| rl.question(chalk.yellow(" Continue anyway? (y/N) "), resolve) | |
| } catch (error) { | |
| reject(error) | |
| } | |
| }) | |
| rl.close() | |
| if (!answer.toLowerCase().startsWith("y")) { | |
| console.log(chalk.gray(" Aborted.")) | |
| process.exit(1) | |
| } | |
| } else { | |
| console.error(chalk.red(" ✗ Branch not merged and not in interactive mode. Use --force to override.")) | |
| process.exit(1) | |
| } | |
| } else { | |
| console.log(chalk.yellow(" ⚠ Branch not merged, but --force specified. Continuing...")) | |
| } | |
| } else { | |
| console.log(chalk.green(` ✓ Branch is merged into ${masterBranch}`)) | |
| } | |
| // Step 3: Sync permissions back to parent | |
| // During development in the worktree, you may have approved new permissions for Claude. | |
| // This step copies those new permissions back to the parent repo so they're available | |
| // in future worktrees and regular development sessions. | |
| console.log(chalk.blue("\n3. Syncing permissions back to parent...")) | |
| const parentSettings = path.join(projectRoot, ".claude", "settings.local.json") | |
| const worktreeSettings = path.join(worktreePath, ".claude", "settings.local.json") | |
| if (fs.existsSync(worktreeSettings) && fs.existsSync(parentSettings)) { | |
| try { | |
| const parent = JSON.parse(fs.readFileSync(parentSettings, "utf8")) | |
| const worktree = JSON.parse(fs.readFileSync(worktreeSettings, "utf8")) | |
| const parentAllow = new Set(parent?.permissions?.allow || []) | |
| const worktreeAllow = worktree?.permissions?.allow || [] | |
| // Find new permissions that worktree has but parent doesn't | |
| const newPermissions = worktreeAllow.filter(p => !parentAllow.has(p)) | |
| if (newPermissions.length === 0) { | |
| console.log(chalk.gray(" No new permissions to sync")) | |
| } else if (!process.stdin.isTTY) { | |
| console.log(chalk.yellow(` ⚠ Non-interactive session — skipping permission sync (${newPermissions.length} new permissions not synced)`)) | |
| } else { | |
| // Interactive: let user pick which permissions to sync | |
| console.log(chalk.gray(` Found ${newPermissions.length} new permission(s) in worktree\n`)) | |
| const permissionsToSync = await checkbox({ | |
| message: "Select permissions to sync to parent:", | |
| choices: newPermissions.map(p => ({name: p, value: p})) | |
| }) | |
| if (permissionsToSync.length > 0) { | |
| parent.permissions = parent.permissions || {} | |
| parent.permissions.allow = [...parentAllow, ...permissionsToSync] | |
| fs.writeFileSync(parentSettings, JSON.stringify(parent, null, 2)) | |
| console.log(chalk.green(` ✓ Added ${permissionsToSync.length} new permissions to parent:`)) | |
| permissionsToSync.forEach(p => console.log(chalk.gray(` - ${p}`))) | |
| } else { | |
| console.log(chalk.gray(" No permissions selected — skipping sync")) | |
| } | |
| } | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to sync permissions: ${error.message}`)) | |
| } | |
| } else { | |
| console.log(chalk.gray(" No worktree settings to sync")) | |
| } | |
| // Step 4: Remove worktree directory | |
| console.log(chalk.blue("\n4. Removing worktree directory...")) | |
| try { | |
| await $`rm -rf ${worktreePath}` | |
| console.log(chalk.green(" ✓ Worktree directory removed")) | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ Failed to remove worktree directory: ${error.message}`)) | |
| process.exit(1) | |
| } | |
| // Step 5: Prune git worktree references | |
| console.log(chalk.blue("\n5. Pruning git worktree references...")) | |
| try { | |
| await $`git worktree prune` | |
| console.log(chalk.green(" ✓ Git worktree references pruned")) | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to prune worktrees: ${error.message}`)) | |
| } | |
| // Step 6: Clean up ~/.claude.json trust entry | |
| console.log(chalk.blue("\n6. Cleaning up ~/.claude.json trust entry...")) | |
| const claudeJsonPath = path.join(process.env.HOME, ".claude.json") | |
| if (fs.existsSync(claudeJsonPath)) { | |
| try { | |
| const config = JSON.parse(fs.readFileSync(claudeJsonPath, "utf8")) | |
| if (config.projects && config.projects[worktreePath]) { | |
| delete config.projects[worktreePath] | |
| fs.writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2)) | |
| console.log(chalk.green(" ✓ Removed trust entry for worktree")) | |
| } else { | |
| console.log(chalk.gray(" No trust entry to remove")) | |
| } | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to clean up trust entry: ${error.message}`)) | |
| } | |
| } else { | |
| console.log(chalk.gray(" No ~/.claude.json found")) | |
| } | |
| // Step 7: Delete the branch | |
| console.log(chalk.blue("\n7. Deleting branch...")) | |
| try { | |
| if (force) { | |
| await $`git branch -D ${branchName}` | |
| } else { | |
| await $`git branch -d ${branchName}` | |
| } | |
| console.log(chalk.green(` ✓ Branch '${branchName}' deleted`)) | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ Failed to delete branch: ${error.message}`)) | |
| console.log(chalk.yellow(" Manual fix: git branch -D " + branchName)) | |
| process.exit(1) | |
| } | |
| // Done! | |
| console.log(chalk.green.bold("\n✓ Worktree cleanup complete!")) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env zx | |
| import {chalk, fs, $, argv, path} from "zx" | |
| /** | |
| * Worktree Setup Script for Claude Code Development | |
| * | |
| * This script automates the setup of git worktrees for parallel feature development. | |
| * It creates an isolated git worktree and configures it with: | |
| * - Project dependencies (node_modules) | |
| * - Environment files | |
| * - Claude Code permissions (auto-allow edits) | |
| * - MCP server configs (Serena targets this worktree, not parent) | |
| * - Build caches (shared via symlinks for speed) | |
| * | |
| * Why worktrees? Work on multiple features simultaneously without branch-switching conflicts. | |
| */ | |
| // Parse arguments | |
| const branchName = argv._[0] | |
| const installOnly = argv.install || false | |
| const copyOnly = argv.copy || false | |
| const force = argv.force || false | |
| const baseBranchOverride = argv["base-branch"] || null | |
| if (!branchName) { | |
| console.error(chalk.red("Error: Branch name is required")) | |
| console.log(chalk.yellow("Usage: npm run worktree-setup <branch-name> [--install] [--copy] [--force] [--base-branch=<branch>]")) | |
| console.log(chalk.yellow(" --install: Run npm install only (skip copy)")) | |
| console.log(chalk.yellow(" --copy: Copy node_modules only (skip npm install)")) | |
| console.log(chalk.yellow(" --force: Force reinstall/copy node_modules even if they already exist")) | |
| console.log(chalk.yellow(" --base-branch: Base branch to create new branch from (default: masterBranch from package.json)")) | |
| process.exit(1) | |
| } | |
| const projectRoot = process.cwd() | |
| const worktreePath = path.join(projectRoot, ".trees", branchName) | |
| // Determine base branch from package.json or --base-branch flag | |
| const baseBranch = (() => { | |
| if (baseBranchOverride) return baseBranchOverride | |
| try { | |
| const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")) | |
| const masterBranch = packageJson.masterBranch | |
| if (!masterBranch) { | |
| console.error(chalk.red("Error: masterBranch not defined in package.json")) | |
| console.log(chalk.yellow("Please add 'masterBranch' field to package.json or use --base-branch flag")) | |
| process.exit(1) | |
| } | |
| return masterBranch | |
| } catch (error) { | |
| console.error(chalk.red(`Error: Could not read package.json: ${error.message}`)) | |
| process.exit(1) | |
| } | |
| })() | |
| console.log(chalk.blue(`Setting up worktree for branch: ${branchName}`)) | |
| console.log(chalk.gray(`Worktree path: ${worktreePath}`)) | |
| console.log(chalk.gray(`Base branch: ${baseBranch}`)) | |
| // Step 1: Verify worktree exists, create if needed | |
| console.log(chalk.blue("\n1. Checking worktree...")) | |
| if (!fs.existsSync(worktreePath)) { | |
| console.log(chalk.yellow(" Worktree does not exist, creating it...")) | |
| try { | |
| // Ensure .trees directory exists | |
| const treesDir = path.join(projectRoot, ".trees") | |
| if (!fs.existsSync(treesDir)) { | |
| fs.mkdirSync(treesDir, {recursive: true}) | |
| } | |
| // Check if branch exists | |
| let branchExists = false | |
| try { | |
| await $`git show-ref --verify --quiet refs/heads/${branchName}` | |
| branchExists = true | |
| } catch { | |
| // Branch doesn't exist, will create it | |
| } | |
| // Create branch if it doesn't exist | |
| if (!branchExists) { | |
| console.log(chalk.yellow(` Branch '${branchName}' does not exist, creating it from ${baseBranch}...`)) | |
| try { | |
| await $`git branch ${branchName} ${baseBranch}` | |
| console.log(chalk.green(` ✓ Created branch: ${branchName} from ${baseBranch}`)) | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ Failed to create branch: ${error.message}`)) | |
| process.exit(1) | |
| } | |
| } | |
| // Create the worktree | |
| await $`git worktree add ${worktreePath} ${branchName}` | |
| console.log(chalk.green(` ✓ Created worktree for branch: ${branchName}`)) | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ Failed to create worktree: ${error.message}`)) | |
| console.log(chalk.yellow(` Manual fix: git worktree add .trees/${branchName} ${branchName}`)) | |
| process.exit(1) | |
| } | |
| } else { | |
| console.log(chalk.green("✓ Worktree exists")) | |
| } | |
| // Step 2: Handle .env files (symlink most, copy .env.vault) | |
| // Why symlinks? Changes to .env in main repo auto-propagate to all worktrees. | |
| // Why copy .env.vault? Git sees symlinks as file type changes, which creates noise in diffs. | |
| console.log(chalk.blue("\n2. Setting up .env files...")) | |
| const envDirs = [".", "server", "client"] | |
| for (const dir of envDirs) { | |
| const sourceDir = path.join(projectRoot, dir) | |
| const targetDir = path.join(worktreePath, dir) | |
| if (!fs.existsSync(sourceDir)) { | |
| console.log(chalk.yellow(` ⚠ Directory not found: ${dir}/`)) | |
| continue | |
| } | |
| const envFiles = fs.readdirSync(sourceDir).filter(f => f.startsWith(".env")) | |
| if (envFiles.length === 0) { | |
| console.log(chalk.gray(` No .env files in ${dir}/`)) | |
| continue | |
| } | |
| for (const envFile of envFiles) { | |
| const sourcePath = path.join(sourceDir, envFile) | |
| const targetPath = path.join(targetDir, envFile) | |
| try { | |
| // Remove existing file/symlink if it exists | |
| if (fs.existsSync(targetPath)) { | |
| fs.unlinkSync(targetPath) | |
| } | |
| // Copy .env.vault files to avoid git seeing them as type changes | |
| // Symlink all other .env files | |
| if (envFile === ".env.vault") { | |
| fs.copyFileSync(sourcePath, targetPath) | |
| console.log(chalk.green(` ✓ Copied ${dir}/${envFile}`)) | |
| } else { | |
| fs.symlinkSync(sourcePath, targetPath) | |
| console.log(chalk.green(` ✓ Symlinked ${dir}/${envFile}`)) | |
| } | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ Failed to setup ${dir}/${envFile}: ${error.message}`)) | |
| process.exit(1) | |
| } | |
| } | |
| } | |
| // Step 3: Copy node_modules (unless --install flag is set) | |
| // Why copy? Faster than npm install (~30s vs ~5min for large projects). | |
| // The copy gets you 95% there, then Step 4's npm install fixes version mismatches. | |
| if (!installOnly) { | |
| // Check if node_modules already exists in worktree | |
| const rootNodeModules = path.join(worktreePath, "node_modules") | |
| const nodeModulesExists = fs.existsSync(rootNodeModules) | |
| if (nodeModulesExists && !force) { | |
| console.log(chalk.blue("\n3. Skipping node_modules copy (already exists, use --force to override)")) | |
| } else { | |
| console.log(chalk.blue("\n3. Copying node_modules directories...")) | |
| const moduleDirs = [".", "server", "client", "shared"] | |
| for (const dir of moduleDirs) { | |
| const sourceModules = path.join(projectRoot, dir, "node_modules") | |
| const targetModules = path.join(worktreePath, dir, "node_modules") | |
| if (!fs.existsSync(sourceModules)) { | |
| console.log(chalk.gray(` No node_modules in ${dir}/`)) | |
| continue | |
| } | |
| console.log(chalk.gray(` Copying ${dir}/node_modules...`)) | |
| try { | |
| // Remove target if it exists | |
| if (fs.existsSync(targetModules)) { | |
| await $`rm -rf ${targetModules}` | |
| } | |
| // Copy with rsync for efficiency | |
| await $`rsync -aq ${sourceModules}/ ${targetModules}/` | |
| console.log(chalk.green(` ✓ Copied ${dir}/node_modules`)) | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to copy ${dir}/node_modules: ${error.message}`)) | |
| } | |
| } | |
| } | |
| } else { | |
| console.log(chalk.gray("\n3. Skipping node_modules copy (--install flag set)")) | |
| } | |
| // Step 4: Run npm install (unless --copy flag is set) | |
| if (!copyOnly) { | |
| // Check if node_modules already exists in worktree | |
| const rootNodeModules = path.join(worktreePath, "node_modules") | |
| const nodeModulesExists = fs.existsSync(rootNodeModules) | |
| if (nodeModulesExists && !force) { | |
| console.log(chalk.blue("\n4. Skipping npm install (node_modules already exists, use --force to override)")) | |
| } else { | |
| console.log(chalk.blue("\n4. Running npm install to fix version mismatches...")) | |
| try { | |
| // Change to worktree directory | |
| process.chdir(worktreePath) | |
| // Run npm install in root | |
| console.log(chalk.gray(" Installing root dependencies...")) | |
| await $`npm install` | |
| console.log(chalk.green(" ✓ Root dependencies installed")) | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ npm install failed: ${error.message}`)) | |
| console.log(chalk.yellow(" Manual fix: cd to worktree and run 'npm install'")) | |
| process.exit(1) | |
| } finally { | |
| // Change back to project root | |
| process.chdir(projectRoot) | |
| } | |
| } | |
| } else { | |
| console.log(chalk.gray("\n4. Skipping npm install (--copy flag set)")) | |
| } | |
| // Step 5: Copy .claude/settings.local.json from parent and ensure Edit/Write permissions | |
| // Git-tracked .claude/ files (commands, hooks, skills) already exist from git worktree add. | |
| // Only settings.local.json is gitignored and needs to be copied. | |
| // | |
| // Auto-add Edit:** and Write:** permissions so Claude can modify files without prompting. | |
| // Worktrees are for active development, so we want permissive defaults. | |
| console.log(chalk.blue("\n5. Setting up .claude/settings.local.json...")) | |
| const sourceSettings = path.join(projectRoot, ".claude", "settings.local.json") | |
| const targetSettings = path.join(worktreePath, ".claude", "settings.local.json") | |
| // Start with parent settings or empty object | |
| let settings = {} | |
| if (fs.existsSync(sourceSettings)) { | |
| try { | |
| fs.copyFileSync(sourceSettings, targetSettings) | |
| settings = JSON.parse(fs.readFileSync(targetSettings, "utf8")) | |
| const permCount = settings?.permissions?.allow?.length || 0 | |
| console.log(chalk.green(` ✓ Copied settings.local.json (${permCount} permissions)`)) | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to copy settings.local.json: ${error.message}`)) | |
| } | |
| } else { | |
| console.log(chalk.yellow(" ⚠ No settings.local.json found in parent, creating new one")) | |
| } | |
| // Ensure Edit:** and Write:** permissions exist (auto-allow edits in worktrees) | |
| const requiredPerms = ["Edit:**", "Write:**"] | |
| if (!settings.permissions) settings.permissions = {} | |
| if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [] | |
| const added = [] | |
| for (const perm of requiredPerms) { | |
| if (!settings.permissions.allow.includes(perm)) { | |
| settings.permissions.allow.unshift(perm) | |
| added.push(perm) | |
| } | |
| } | |
| if (added.length > 0) { | |
| try { | |
| fs.writeFileSync(targetSettings, JSON.stringify(settings, null, 2)) | |
| console.log(chalk.green(` ✓ Added permissions: ${added.join(", ")}`)) | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to write permissions: ${error.message}`)) | |
| } | |
| } else { | |
| console.log(chalk.gray(" Edit/Write permissions already present")) | |
| } | |
| // Step 6: Install Husky and copy internal files | |
| console.log(chalk.blue("\n6. Installing Husky git hooks...")) | |
| try { | |
| // Copy .husky/_/ directory which contains husky.sh (gitignored) | |
| const sourceHuskyInternal = path.join(projectRoot, ".husky", "_") | |
| const targetHuskyInternal = path.join(worktreePath, ".husky", "_") | |
| if (fs.existsSync(sourceHuskyInternal)) { | |
| if (!fs.existsSync(targetHuskyInternal)) { | |
| fs.mkdirSync(targetHuskyInternal, {recursive: true}) | |
| } | |
| // Copy husky.sh and other internal files | |
| await $`cp -r ${sourceHuskyInternal}/* ${targetHuskyInternal}/` | |
| console.log(chalk.green(" ✓ Copied Husky internal files")) | |
| } | |
| process.chdir(worktreePath) | |
| await $`npx husky install` | |
| console.log(chalk.green(" ✓ Husky hooks installed")) | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ Husky install failed: ${error.message}`)) | |
| console.log(chalk.yellow(" Manual fix: cd to worktree and run 'npx husky install'")) | |
| process.exit(1) | |
| } finally { | |
| process.chdir(projectRoot) | |
| } | |
| // Step 7: Symlink .turbo/ cache directory | |
| // Why symlink? Turbo is the monorepo build tool. Sharing the cache across worktrees | |
| // means builds stay fast even when switching between worktrees. | |
| console.log(chalk.blue("\n7. Symlinking .turbo/ cache directory...")) | |
| const sourceTurboDir = path.join(projectRoot, ".turbo") | |
| const targetTurboDir = path.join(worktreePath, ".turbo") | |
| if (!fs.existsSync(sourceTurboDir)) { | |
| console.log(chalk.yellow(" ⚠ .turbo/ directory not found (will be created on first build)")) | |
| } else { | |
| try { | |
| // Remove existing directory/symlink if it exists | |
| if (fs.existsSync(targetTurboDir)) { | |
| if (fs.lstatSync(targetTurboDir).isSymbolicLink()) { | |
| fs.unlinkSync(targetTurboDir) | |
| } else { | |
| await $`rm -rf ${targetTurboDir}` | |
| } | |
| } | |
| // Create symlink | |
| fs.symlinkSync(sourceTurboDir, targetTurboDir) | |
| console.log(chalk.green(" ✓ Symlinked .turbo/ directory")) | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to symlink .turbo/: ${error.message}`)) | |
| } | |
| } | |
| // Step 8: Setup MCP configuration (inherit from parent + add worktree-specific Serena) | |
| // | |
| // THE KEY INTELLIGENCE LAYER: | |
| // - Inherits all MCP servers from parent (Postgres, Linear, etc.) | |
| // - Adds worktree-specific Serena config that targets THIS worktree's path | |
| // - This ensures Serena's code intelligence reads/indexes the worktree code, not the parent repo | |
| // | |
| // Without this, Serena would analyze the wrong codebase! | |
| console.log(chalk.blue("\n8. Setting up MCP configuration...")) | |
| // Create .mcp.json by inheriting from parent project and adding worktree-specific config | |
| const mcpConfigPath = path.join(worktreePath, ".mcp.json") | |
| // Start with worktree-specific Serena config | |
| const worktreeSerenaConfig = { | |
| "serena-worktree": { | |
| type: "stdio", | |
| command: "uv", | |
| args: [ | |
| "run", | |
| "--directory", | |
| path.join(process.env.HOME, "Development/serena"), | |
| "serena", | |
| "start-mcp-server", | |
| "--context", | |
| "ide-assistant", | |
| "--project", | |
| worktreePath | |
| ], | |
| env: {} | |
| } | |
| } | |
| // Read parent's .mcp.json if it exists | |
| let parentMcpJsonServers = {} | |
| const parentMcpJsonPath = path.join(projectRoot, ".mcp.json") | |
| if (fs.existsSync(parentMcpJsonPath)) { | |
| try { | |
| const parentMcpJson = JSON.parse(fs.readFileSync(parentMcpJsonPath, "utf8")) | |
| if (parentMcpJson.mcpServers) { | |
| parentMcpJsonServers = parentMcpJson.mcpServers | |
| console.log(chalk.green(` ✓ Found ${Object.keys(parentMcpJsonServers).length} MCP server(s) in parent .mcp.json`)) | |
| } | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to read parent .mcp.json: ${error.message}`)) | |
| } | |
| } else { | |
| console.log(chalk.gray(" No parent .mcp.json found")) | |
| } | |
| // Read parent project's MCP servers from ~/.claude.json | |
| let parentClaudeConfigServers = {} | |
| const userClaudeConfigPath = path.join(process.env.HOME, ".claude.json") | |
| if (fs.existsSync(userClaudeConfigPath)) { | |
| try { | |
| const userClaudeConfig = JSON.parse(fs.readFileSync(userClaudeConfigPath, "utf8")) | |
| if (userClaudeConfig.projects && userClaudeConfig.projects[projectRoot]) { | |
| const projectConfig = userClaudeConfig.projects[projectRoot] | |
| if (projectConfig.mcpServers) { | |
| parentClaudeConfigServers = projectConfig.mcpServers | |
| console.log(chalk.green(` ✓ Found ${Object.keys(parentClaudeConfigServers).length} MCP server(s) in ~/.claude.json for parent project`)) | |
| } | |
| } | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to read ~/.claude.json: ${error.message}`)) | |
| } | |
| } else { | |
| console.log(chalk.gray(" No ~/.claude.json found")) | |
| } | |
| // Merge all MCP servers: parent .mcp.json + parent ~/.claude.json + worktree serena | |
| // Order matters: later entries override earlier ones, so worktree serena takes precedence | |
| const mcpConfig = { | |
| mcpServers: { | |
| ...parentMcpJsonServers, // from parent's .mcp.json | |
| ...parentClaudeConfigServers, // from ~/.claude.json project entry | |
| ...worktreeSerenaConfig // worktree-specific serena (overrides if exists) | |
| } | |
| } | |
| const totalServers = Object.keys(mcpConfig.mcpServers).length | |
| try { | |
| fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)) | |
| console.log(chalk.green(` ✓ Created .mcp.json with ${totalServers} MCP server(s)`)) | |
| // List inherited servers for visibility | |
| const inheritedServers = Object.keys(mcpConfig.mcpServers).filter(s => s !== "serena-worktree") | |
| if (inheritedServers.length > 0) { | |
| console.log(chalk.gray(` Inherited: ${inheritedServers.join(", ")}`)) | |
| } | |
| } catch (error) { | |
| console.error(chalk.red(` ✗ Failed to create .mcp.json: ${error.message}`)) | |
| console.log(chalk.yellow(" Warning: Serena will target main repo instead of worktree")) | |
| } | |
| // Copy .serena/cache directory from main repo (as recommended by Serena docs) | |
| // Why? Serena indexes the entire codebase on first run (~2-5 minutes). | |
| // Copying the cache skips re-indexing since the code is mostly identical. | |
| const sourceSerenaCache = path.join(projectRoot, ".serena", "cache") | |
| const targetSerenaDir = path.join(worktreePath, ".serena") | |
| const targetSerenaCache = path.join(targetSerenaDir, "cache") | |
| if (!fs.existsSync(sourceSerenaCache)) { | |
| console.log(chalk.yellow(" ⚠ .serena/cache not found in main repo (will be created on first use)")) | |
| } else { | |
| try { | |
| // Create .serena directory if it doesn't exist | |
| if (!fs.existsSync(targetSerenaDir)) { | |
| fs.mkdirSync(targetSerenaDir, {recursive: true}) | |
| } | |
| // Remove existing cache directory if it exists | |
| if (fs.existsSync(targetSerenaCache)) { | |
| await $`rm -rf ${targetSerenaCache}` | |
| } | |
| // Copy cache using rsync for efficiency | |
| console.log(chalk.gray(" Copying .serena/cache (this may take a moment)...")) | |
| await $`rsync -aq ${sourceSerenaCache}/ ${targetSerenaCache}/` | |
| console.log(chalk.green(" ✓ Copied .serena/cache directory (avoids re-indexing)")) | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to copy .serena/cache: ${error.message}`)) | |
| } | |
| } | |
| // Step 9: Pre-approve Claude Code trust for worktree | |
| // Claude Code asks "Do you trust this directory?" on first launch. | |
| // Pre-approving it here eliminates that prompt since we know this is our own code. | |
| console.log(chalk.blue("\n9. Pre-approving Claude Code trust for worktree...")) | |
| const claudeConfigPath = path.join(process.env.HOME, ".claude.json") | |
| try { | |
| let claudeConfig = {} | |
| if (fs.existsSync(claudeConfigPath)) { | |
| claudeConfig = JSON.parse(fs.readFileSync(claudeConfigPath, "utf8")) | |
| } | |
| // Ensure projects object exists | |
| if (!claudeConfig.projects) { | |
| claudeConfig.projects = {} | |
| } | |
| // Add or update worktree entry with trust pre-approved | |
| if (!claudeConfig.projects[worktreePath]) { | |
| claudeConfig.projects[worktreePath] = {} | |
| } | |
| claudeConfig.projects[worktreePath].hasTrustDialogAccepted = true | |
| // Write back | |
| fs.writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2)) | |
| console.log(chalk.green(" ✓ Pre-approved Claude Code trust for worktree")) | |
| } catch (error) { | |
| console.log(chalk.yellow(` ⚠ Warning: Failed to pre-approve trust: ${error.message}`)) | |
| console.log(chalk.yellow(" You may see a trust dialog when starting Claude in this worktree")) | |
| } | |
| // Done! | |
| console.log(chalk.green.bold("\n✓ Worktree setup complete!")) | |
| console.log(chalk.blue("\nTo switch to the worktree, run:")) | |
| console.log(chalk.gray(` cd ${worktreePath}`)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -e | |
| ## | |
| ## Worktree Start Script - Entry point for worktree-based development | |
| ## | |
| ## This script: | |
| ## 1. Calls worktree-setup.mjs to create and configure the worktree | |
| ## 2. Extracts the Linear issue ID from the branch name (e.g., sc-2535-feature -> SC-2535) | |
| ## 3. Creates .context/ directory with task tracking files that survive context compaction | |
| ## 4. Launches Claude with a prompt to implement the Linear issue | |
| ## | |
| ## Usage: ./scripts/worktree-start.sh sc-2535-add-feature [base-branch] | |
| ## | |
| BRANCH="" | |
| BASE_BRANCH="" | |
| # Parse arguments | |
| for arg in "$@"; do | |
| if [[ -z "$BRANCH" ]]; then | |
| BRANCH="$arg" | |
| elif [[ -z "$BASE_BRANCH" ]]; then | |
| BASE_BRANCH="$arg" | |
| fi | |
| done | |
| if [[ -z "$BRANCH" ]]; then | |
| echo "Usage: ./scripts/worktree-start.sh <branch-name> [base-branch]" | |
| echo " base-branch: Optional. Branch to create from (default: masterBranch from package.json)" | |
| exit 1 | |
| fi | |
| if [[ -n "$BASE_BRANCH" ]]; then | |
| npm run worktree-setup "$BRANCH" -- --base-branch="$BASE_BRANCH" | |
| else | |
| npm run worktree-setup "$BRANCH" | |
| fi | |
| cd ".trees/$BRANCH" | |
| # Extract Linear issue ID from branch name (e.g., sc-2535-some-description -> SC-2535) | |
| ISSUE_ID=$(echo "$BRANCH" | grep -oE '^[sS][cC]-[0-9]+' | tr '[:lower:]' '[:upper:]') | |
| # Initialize .context/ for task tracking (survives context compaction) | |
| # These files persist across Claude Code sessions and context compaction, providing continuity. | |
| # - context.md: Links to Linear, tracks key files, decisions, discoveries | |
| # - tasks.md: Checklist of pending/in-progress/completed tasks | |
| # - progress.log: Session timestamps and major milestones | |
| mkdir -p .context | |
| cat > .context/context.md << EOF | |
| # Task: $ISSUE_ID | |
| **Branch:** $BRANCH | |
| **Linear:** https://linear.app/studio-corsair/issue/$ISSUE_ID | |
| **Last Updated:** $(date '+%Y-%m-%d %H:%M') | |
| ## Key Files | |
| <!-- Files critical to this task --> | |
| ## Decisions Made | |
| <!-- Decisions and rationale --> | |
| ## Discoveries | |
| <!-- Things learned during implementation --> | |
| EOF | |
| cat > .context/tasks.md << EOF | |
| # Tasks: $ISSUE_ID | |
| **Last Updated:** $(date '+%Y-%m-%d %H:%M') | |
| ## In Progress | |
| ## Pending | |
| - [ ] Fetch Linear issue and understand requirements | |
| - [ ] Implement solution | |
| - [ ] Write tests | |
| - [ ] Create PR | |
| ## Completed | |
| ## Next Steps | |
| 1. Read Linear issue $ISSUE_ID | |
| EOF | |
| echo "[$(date '+%Y-%m-%d %H:%M')] SESSION START - $ISSUE_ID" > .context/progress.log | |
| # Select CLI tool based on --mobile flag | |
| if [[ "$USE_MOBILE" == "true" ]]; then | |
| CLI_TOOL="happy" | |
| else | |
| CLI_TOOL="claude" | |
| fi | |
| # Build the Claude command with initial prompt | |
| # The prompt is loaded from ~/.claude/commands/linear-implement.md and customized with the issue ID. | |
| # This tells Claude to read the Linear issue and implement it autonomously. | |
| CLAUDE_CMD="$CLI_TOOL --permission-mode acceptEdits" | |
| if [[ -z "$ISSUE_ID" ]]; then | |
| echo "Warning: Could not extract Linear issue ID from branch name" | |
| else | |
| # Read template from linear-implement.md and substitute issue_id | |
| TEMPLATE_FILE="$HOME/.claude/commands/linear-implement.md" | |
| if [[ -f "$TEMPLATE_FILE" ]]; then | |
| # Extract content after the YAML frontmatter (after second ---) | |
| PROMPT=$(sed -n '/^---$/,/^---$/d; p' "$TEMPLATE_FILE" | sed "s/{{issue_id}}/${ISSUE_ID}/g") | |
| else | |
| PROMPT="Please implement the Linear issue ${ISSUE_ID}." | |
| fi | |
| CLAUDE_CMD="$CLAUDE_CMD \"$PROMPT\"" | |
| fi | |
| # Run the CLI tool | |
| eval "$CLAUDE_CMD" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment