UNPKG

@akiojin/claude-worktree

Version:

Interactive Git worktree manager for Claude Code with graphical branch selection

301 lines 12.5 kB
import { execa } from 'execa'; import path from 'node:path'; import chalk from 'chalk'; import { getPullRequestByBranch, getMergedPullRequests } from './github.js'; import { hasUncommittedChanges, hasUnpushedCommits, getLocalBranches, checkRemoteBranchExists } from './git.js'; // 保護対象のブランチ(クリーンアップから除外) const PROTECTED_BRANCHES = ['main', 'master', 'develop']; export class WorktreeError extends Error { cause; constructor(message, cause) { super(message); this.cause = cause; this.name = 'WorktreeError'; } } async function listWorktrees() { try { const { stdout } = await execa('git', ['worktree', 'list', '--porcelain']); const worktrees = []; const lines = stdout.split('\n'); let currentWorktree = {}; for (const line of lines) { if (line.startsWith('worktree ')) { if (currentWorktree.path) { worktrees.push(currentWorktree); } currentWorktree = { path: line.substring(9) }; } else if (line.startsWith('HEAD ')) { currentWorktree.head = line.substring(5); } else if (line.startsWith('branch ')) { currentWorktree.branch = line.substring(7).replace('refs/heads/', ''); } else if (line === '') { if (currentWorktree.path) { worktrees.push(currentWorktree); currentWorktree = {}; } } } if (currentWorktree.path) { worktrees.push(currentWorktree); } return worktrees; } catch (error) { throw new WorktreeError('Failed to list worktrees', error); } } /** * 追加のworktree(メインworktreeを除く)の一覧を取得 * @returns {Promise<WorktreeInfo[]>} worktree情報の配列 * @throws {WorktreeError} worktree一覧の取得に失敗した場合 */ export async function listAdditionalWorktrees() { try { const [allWorktrees, repoRoot] = await Promise.all([ listWorktrees(), import('./git.js').then(m => m.getRepositoryRoot()) ]); const fs = await import('node:fs'); // Filter out the main worktree (repository root) and add accessibility info const additionalWorktrees = allWorktrees .filter(worktree => worktree.path !== repoRoot) .map(worktree => { // パスの存在を確認 const isAccessible = fs.existsSync(worktree.path); const result = { ...worktree, isAccessible }; if (!isAccessible) { result.invalidReason = 'Path not accessible in current environment'; } return result; }); return additionalWorktrees; } catch (error) { throw new WorktreeError('Failed to list additional worktrees', error); } } export async function worktreeExists(branchName) { const worktrees = await listWorktrees(); const worktree = worktrees.find(w => w.branch === branchName); return worktree ? worktree.path : null; } export async function generateWorktreePath(repoRoot, branchName) { const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|]/g, '-'); const worktreeDir = path.join(repoRoot, '.git', 'worktree'); return path.join(worktreeDir, sanitizedBranchName); } /** * 新しいworktreeを作成 * @param {WorktreeConfig} config - worktreeの設定 * @throws {WorktreeError} worktreeの作成に失敗した場合 */ export async function createWorktree(config) { try { const args = ['worktree', 'add']; if (config.isNewBranch) { args.push('-b', config.branchName); } args.push(config.worktreePath); if (config.isNewBranch) { args.push(config.baseBranch); } else { args.push(config.branchName); } await execa('git', args); } catch (error) { throw new WorktreeError(`Failed to create worktree for ${config.branchName}`, error); } } export async function removeWorktree(worktreePath, force = false) { try { const args = ['worktree', 'remove']; if (force) { args.push('--force'); } args.push(worktreePath); await execa('git', args); } catch (error) { throw new WorktreeError(`Failed to remove worktree at ${worktreePath}`, error); } } async function getWorktreesWithPRStatus() { const worktrees = await listAdditionalWorktrees(); const worktreesWithPR = []; for (const worktree of worktrees) { if (worktree.branch) { const pullRequest = await getPullRequestByBranch(worktree.branch); worktreesWithPR.push({ worktreePath: worktree.path, branch: worktree.branch, pullRequest }); } } return worktreesWithPR; } /** * worktreeに存在しないローカルブランチの中でマージ済みPRに関連するクリーンアップ候補を取得 * @returns {Promise<CleanupTarget[]>} クリーンアップ候補の配列 */ async function getOrphanedLocalBranches() { try { // 並列実行で高速化 const [localBranches, mergedPRs, worktrees] = await Promise.all([ getLocalBranches(), getMergedPullRequests(), listAdditionalWorktrees() ]); const cleanupTargets = []; const worktreeBranches = new Set(worktrees.map(w => w.branch).filter(Boolean)); if (process.env.DEBUG_CLEANUP) { console.log(chalk.cyan('Debug: Orphaned branch scan - Available local branches:')); localBranches.forEach(b => console.log(` ${b.name} (type: ${b.type})`)); console.log(chalk.cyan(`Debug: Worktree branches: ${Array.from(worktreeBranches).join(', ')}`)); } for (const localBranch of localBranches) { // 保護対象ブランチはスキップ if (PROTECTED_BRANCHES.includes(localBranch.name)) { if (process.env.DEBUG_CLEANUP) { console.log(chalk.yellow(`Debug: Skipping protected branch ${localBranch.name}`)); } continue; } // worktreeに存在しないローカルブランチのみ対象 if (!worktreeBranches.has(localBranch.name)) { const mergedPR = findMatchingPR(localBranch.name, mergedPRs); if (process.env.DEBUG_CLEANUP) { console.log(chalk.gray(`Debug: Checking orphaned branch ${localBranch.name} -> ${mergedPR ? 'MATCH' : 'NO MATCH'}`)); } if (mergedPR) { // リモートブランチの存在確認 const hasRemoteBranch = await checkRemoteBranchExists(localBranch.name); cleanupTargets.push({ worktreePath: null, // worktreeは存在しない branch: localBranch.name, pullRequest: mergedPR, hasUncommittedChanges: false, // worktreeが存在しないため常にfalse hasUnpushedCommits: false, // ローカルブランチのみの場合、プッシュ対象なし cleanupType: 'branch-only', hasRemoteBranch }); } } } if (process.env.DEBUG_CLEANUP) { console.log(chalk.cyan(`Debug: Found ${cleanupTargets.length} orphaned branch cleanup targets`)); } return cleanupTargets; } catch (error) { console.error(chalk.red('Error: Failed to get orphaned local branches')); if (process.env.DEBUG_CLEANUP) { console.error(chalk.red('Debug: Full error details:'), error); } return []; } } function normalizeBranchName(branchName) { return branchName .replace(/^origin\//, '') .replace(/^refs\/heads\//, '') .replace(/^refs\/remotes\/origin\//, '') .trim(); } function findMatchingPR(worktreeBranch, mergedPRs) { const normalizedWorktreeBranch = normalizeBranchName(worktreeBranch); for (const pr of mergedPRs) { const normalizedPRBranch = normalizeBranchName(pr.branch); if (normalizedWorktreeBranch === normalizedPRBranch) { return pr; } } return null; } /** * マージ済みPRに関連するworktreeおよびローカルブランチのクリーンアップ候補を取得 * @returns {Promise<CleanupTarget[]>} クリーンアップ候補の配列 */ export async function getMergedPRWorktrees() { // 並列実行で高速化 - worktreeとローカルブランチの両方を取得 const [worktreesWithPR, orphanedBranches, mergedPRs] = await Promise.all([ getWorktreesWithPRStatus(), getOrphanedLocalBranches(), getMergedPullRequests() ]); const cleanupTargets = []; if (process.env.DEBUG_CLEANUP) { console.log(chalk.cyan('Debug: Available worktrees:')); worktreesWithPR.forEach(w => console.log(` ${w.branch} -> ${w.worktreePath}`)); console.log(chalk.cyan('Debug: Merged PRs:')); mergedPRs.forEach(pr => console.log(` ${pr.branch} (PR #${pr.number})`)); } for (const worktree of worktreesWithPR) { // 保護対象ブランチはスキップ if (PROTECTED_BRANCHES.includes(worktree.branch)) { if (process.env.DEBUG_CLEANUP) { console.log(chalk.yellow(`Debug: Skipping protected branch ${worktree.branch}`)); } continue; } const mergedPR = findMatchingPR(worktree.branch, mergedPRs); if (process.env.DEBUG_CLEANUP) { const normalizedWorktree = normalizeBranchName(worktree.branch); console.log(chalk.gray(`Debug: Checking worktree ${worktree.branch} (normalized: ${normalizedWorktree}) -> ${mergedPR ? 'MATCH' : 'NO MATCH'}`)); } if (mergedPR) { // worktreeパスの存在を確認 const fs = await import('node:fs'); const isAccessible = fs.existsSync(worktree.worktreePath); let hasUncommitted = false; let hasUnpushed = false; if (isAccessible) { // worktreeが存在する場合のみ状態をチェック try { [hasUncommitted, hasUnpushed] = await Promise.all([ hasUncommittedChanges(worktree.worktreePath), hasUnpushedCommits(worktree.worktreePath, worktree.branch) ]); } catch (error) { // エラーが発生した場合はデフォルト値を使用 if (process.env.DEBUG_CLEANUP) { console.log(chalk.yellow(`Debug: Failed to check status for worktree ${worktree.worktreePath}: ${error instanceof Error ? error.message : String(error)}`)); } } } const target = { worktreePath: worktree.worktreePath, branch: worktree.branch, pullRequest: mergedPR, hasUncommittedChanges: hasUncommitted, hasUnpushedCommits: hasUnpushed, cleanupType: 'worktree-and-branch', hasRemoteBranch: await checkRemoteBranchExists(worktree.branch), isAccessible }; if (!isAccessible) { target.invalidReason = 'Path not accessible in current environment'; } cleanupTargets.push(target); } } // orphanedBranches (ローカルブランチのみの削除対象) を追加 cleanupTargets.push(...orphanedBranches); if (process.env.DEBUG_CLEANUP) { const worktreeTargets = cleanupTargets.filter(t => t.cleanupType === 'worktree-and-branch').length; const branchOnlyTargets = cleanupTargets.filter(t => t.cleanupType === 'branch-only').length; console.log(chalk.cyan(`Debug: Found ${cleanupTargets.length} cleanup targets (${worktreeTargets} worktree+branch, ${branchOnlyTargets} branch-only)`)); } return cleanupTargets; } //# sourceMappingURL=worktree.js.map