UNPKG

@akiojin/claude-worktree

Version:

Interactive Git worktree manager for Claude Code with graphical branch selection

472 lines 16.9 kB
import { execa } from 'execa'; import path from 'node:path'; export class GitError extends Error { cause; constructor(message, cause) { super(message); this.cause = cause; this.name = 'GitError'; } } /** * 現在のディレクトリがGitリポジトリかどうかを確認 * @returns {Promise<boolean>} Gitリポジトリの場合true */ export async function isGitRepository() { try { await execa('git', ['rev-parse', '--git-dir']); return true; } catch { return false; } } /** * Gitリポジトリのルートディレクトリを取得 * @returns {Promise<string>} リポジトリのルートパス * @throws {GitError} リポジトリルートの取得に失敗した場合 */ export async function getRepositoryRoot() { try { // git rev-parse --git-common-dirを使用してメインリポジトリの.gitディレクトリを取得 const { stdout: gitCommonDir } = await execa('git', ['rev-parse', '--git-common-dir']); const gitDir = gitCommonDir.trim(); // .gitディレクトリの親ディレクトリがリポジトリルート const path = await import('node:path'); const repoRoot = path.dirname(gitDir); // 相対パスが返された場合(.gitなど)は、現在のディレクトリからの相対パスとして解決 if (!path.isAbsolute(repoRoot)) { return path.resolve(repoRoot); } return repoRoot; } catch (error) { throw new GitError('Failed to get repository root', error); } } export async function getRemoteBranches() { try { const { stdout } = await execa('git', ['branch', '-r', '--format=%(refname:short)']); return stdout .split('\n') .filter(line => line.trim() && !line.includes('HEAD')) .map(line => { const name = line.trim(); const branchName = name.replace(/^origin\//, ''); return { name, type: 'remote', branchType: getBranchType(branchName), isCurrent: false }; }); } catch (error) { throw new GitError('Failed to get remote branches', error); } } async function getCurrentBranch() { try { const { stdout } = await execa('git', ['branch', '--show-current']); return stdout.trim() || null; } catch { return null; } } export async function getLocalBranches() { try { const { stdout } = await execa('git', ['branch', '--format=%(refname:short)']); return stdout .split('\n') .filter(line => line.trim()) .map(name => ({ name: name.trim(), type: 'local', branchType: getBranchType(name.trim()), isCurrent: false })); } catch (error) { throw new GitError('Failed to get local branches', error); } } /** * ローカルとリモートのすべてのブランチ情報を取得 * @returns {Promise<BranchInfo[]>} ブランチ情報の配列 */ export async function getAllBranches() { const [localBranches, remoteBranches, currentBranch] = await Promise.all([ getLocalBranches(), getRemoteBranches(), getCurrentBranch() ]); // 現在のブランチ情報を設定 if (currentBranch) { localBranches.forEach(branch => { if (branch.name === currentBranch) { branch.isCurrent = true; } }); } return [...localBranches, ...remoteBranches]; } export async function createBranch(branchName, baseBranch = 'main') { try { await execa('git', ['checkout', '-b', branchName, baseBranch]); } catch (error) { throw new GitError(`Failed to create branch ${branchName}`, error); } } export async function branchExists(branchName) { try { await execa('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]); return true; } catch { return false; } } export async function deleteBranch(branchName, force = false) { try { const args = ['branch', force ? '-D' : '-d', branchName]; await execa('git', args); } catch (error) { throw new GitError(`Failed to delete branch ${branchName}`, error); } } async function getWorkdirStatus(worktreePath) { try { // ファイルシステムの存在確認のためにfs.existsSyncを使用 const fs = await import('node:fs'); if (!fs.existsSync(worktreePath)) { // worktreeパスが存在しない場合はデフォルト値を返す return { hasChanges: false, changedFilesCount: 0 }; } const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: worktreePath }); const lines = stdout.split('\n').filter(line => line.trim()); return { hasChanges: lines.length > 0, changedFilesCount: lines.length }; } catch (error) { throw new GitError(`Failed to get worktree status for path: ${worktreePath}`, error); } } export async function hasUncommittedChanges(worktreePath) { const status = await getWorkdirStatus(worktreePath); return status.hasChanges; } export async function getChangedFilesCount(worktreePath) { const status = await getWorkdirStatus(worktreePath); return status.changedFilesCount; } export async function showStatus(worktreePath) { try { const { stdout } = await execa('git', ['status'], { cwd: worktreePath }); return stdout; } catch (error) { throw new GitError('Failed to show status', error); } } export async function stashChanges(worktreePath, message) { try { const args = message ? ['stash', 'push', '-m', message] : ['stash']; await execa('git', args, { cwd: worktreePath }); } catch (error) { throw new GitError('Failed to stash changes', error); } } export async function discardAllChanges(worktreePath) { try { // Reset tracked files await execa('git', ['reset', '--hard'], { cwd: worktreePath }); // Clean untracked files await execa('git', ['clean', '-fd'], { cwd: worktreePath }); } catch (error) { throw new GitError('Failed to discard changes', error); } } export async function commitChanges(worktreePath, message) { try { // Add all changes await execa('git', ['add', '-A'], { cwd: worktreePath }); // Commit await execa('git', ['commit', '-m', message], { cwd: worktreePath }); } catch (error) { throw new GitError('Failed to commit changes', error); } } function getBranchType(branchName) { if (branchName === 'main' || branchName === 'master') return 'main'; if (branchName === 'develop' || branchName === 'dev') return 'develop'; if (branchName.startsWith('feature/')) return 'feature'; if (branchName.startsWith('hotfix/')) return 'hotfix'; if (branchName.startsWith('release/')) return 'release'; return 'other'; } export async function hasUnpushedCommits(worktreePath, branch) { try { const { stdout } = await execa('git', ['log', `origin/${branch}..${branch}`, '--oneline'], { cwd: worktreePath }); return stdout.trim().length > 0; } catch { // If the branch doesn't exist on remote, consider it has unpushed commits return true; } } /** * Get the latest commit message for a specific branch in a worktree */ export async function getLatestCommitMessage(worktreePath, branch) { try { const { stdout } = await execa('git', ['log', '-1', '--pretty=format:%s', branch], { cwd: worktreePath }); return stdout.trim() || null; } catch { return null; } } /** * Get the count of unpushed commits */ export async function getUnpushedCommitsCount(worktreePath, branch) { try { const { stdout } = await execa('git', ['rev-list', '--count', `origin/${branch}..${branch}`], { cwd: worktreePath }); return parseInt(stdout.trim()) || 0; } catch { return 0; } } /** * Get the count of uncommitted changes (staged + unstaged) */ export async function getUncommittedChangesCount(worktreePath) { try { const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: worktreePath }); return stdout.trim().split('\n').filter(line => line.trim()).length; } catch { return 0; } } /** * Get enhanced session information for display */ export async function getEnhancedSessionInfo(worktreePath, branch) { try { const [hasUncommitted, uncommittedCount, hasUnpushed, unpushedCount, latestCommit] = await Promise.all([ hasUncommittedChanges(worktreePath), getUncommittedChangesCount(worktreePath), hasUnpushedCommits(worktreePath, branch), getUnpushedCommitsCount(worktreePath, branch), getLatestCommitMessage(worktreePath, branch) ]); // Determine branch type based on branch name let branchType = 'other'; const lowerBranch = branch.toLowerCase(); if (lowerBranch.startsWith('feature/') || lowerBranch.startsWith('feat/')) { branchType = 'feature'; } else if (lowerBranch.startsWith('bugfix/') || lowerBranch.startsWith('bug/') || lowerBranch.startsWith('fix/')) { branchType = 'bugfix'; } else if (lowerBranch.startsWith('hotfix/')) { branchType = 'hotfix'; } else if (lowerBranch === 'develop' || lowerBranch === 'development') { branchType = 'develop'; } else if (lowerBranch === 'main') { branchType = 'main'; } else if (lowerBranch === 'master') { branchType = 'master'; } return { hasUncommittedChanges: hasUncommitted, uncommittedChangesCount: uncommittedCount, hasUnpushedCommits: hasUnpushed, unpushedCommitsCount: unpushedCount, latestCommitMessage: latestCommit, branchType }; } catch (error) { // Return safe defaults if any operation fails return { hasUncommittedChanges: false, uncommittedChangesCount: 0, hasUnpushedCommits: false, unpushedCommitsCount: 0, latestCommitMessage: null, branchType: 'other' }; } } export async function fetchAllRemotes() { try { await execa('git', ['fetch', '--all', '--prune']); } catch (error) { throw new GitError('Failed to fetch remote branches', error); } } export async function getCurrentVersion(repoRoot) { try { const packageJsonPath = path.join(repoRoot, 'package.json'); const fs = await import('node:fs'); const packageJson = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf-8')); return packageJson.version || '0.0.0'; } catch (error) { // package.jsonが存在しない場合はデフォルトバージョンを返す return '0.0.0'; } } export function calculateNewVersion(currentVersion, versionBump) { const versionParts = currentVersion.split('.'); const major = parseInt(versionParts[0] || '0'); const minor = parseInt(versionParts[1] || '0'); const patch = parseInt(versionParts[2] || '0'); switch (versionBump) { case 'major': return `${major + 1}.0.0`; case 'minor': return `${major}.${minor + 1}.0`; case 'patch': return `${major}.${minor}.${patch + 1}`; } } export async function executeNpmVersionInWorktree(worktreePath, newVersion) { try { // まずpackage.jsonが存在するか確認 const fs = await import('node:fs'); const packageJsonPath = path.join(worktreePath, 'package.json'); if (!fs.existsSync(packageJsonPath)) { // package.jsonが存在しない場合は作成 const packageJson = { name: path.basename(worktreePath), version: newVersion, description: '', main: 'index.js', scripts: {}, keywords: [], author: '', license: 'ISC' }; await fs.promises.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); // 新規作成したpackage.jsonをコミット await execa('git', ['add', 'package.json'], { cwd: worktreePath }); await execa('git', ['commit', '-m', `chore: create package.json with version ${newVersion}`], { cwd: worktreePath }); } else { // worktree内でnpm versionコマンドを実行(既に計算済みのバージョンを使用) await execa('npm', ['version', newVersion, '--no-git-tag-version'], { cwd: worktreePath }); // package.jsonの変更をコミット(package-lock.jsonは存在する場合のみ) const filesToAdd = ['package.json']; if (fs.existsSync(path.join(worktreePath, 'package-lock.json'))) { filesToAdd.push('package-lock.json'); } await execa('git', ['add', ...filesToAdd], { cwd: worktreePath }); await execa('git', ['commit', '-m', `chore: bump version to ${newVersion}`], { cwd: worktreePath }); } } catch (error) { // エラーの詳細情報を含める const errorMessage = error instanceof Error ? error.message : String(error); const errorDetails = error?.stderr ? ` (stderr: ${error.stderr})` : ''; const errorStdout = error?.stdout ? ` (stdout: ${error.stdout})` : ''; throw new GitError(`Failed to execute npm version ${newVersion} in worktree: ${errorMessage}${errorDetails}${errorStdout}`, error); } } export async function deleteRemoteBranch(branchName, remote = 'origin') { try { await execa('git', ['push', remote, '--delete', branchName]); } catch (error) { throw new GitError(`Failed to delete remote branch ${remote}/${branchName}`, error); } } export async function getCurrentBranchName(worktreePath) { try { const { stdout } = await execa('git', ['branch', '--show-current'], { cwd: worktreePath }); return stdout.trim(); } catch (error) { throw new GitError('Failed to get current branch name', error); } } export async function pushBranchToRemote(worktreePath, branchName, remote = 'origin') { try { // Check if the remote branch exists const remoteBranchExists = await checkRemoteBranchExists(branchName, remote); if (remoteBranchExists) { // Push to existing remote branch await execa('git', ['push', remote, branchName], { cwd: worktreePath }); } else { // Push and set upstream for new remote branch await execa('git', ['push', '--set-upstream', remote, branchName], { cwd: worktreePath }); } } catch (error) { throw new GitError(`Failed to push branch ${branchName} to ${remote}`, error); } } export async function checkRemoteBranchExists(branchName, remote = 'origin') { try { await execa('git', ['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${branchName}`]); return true; } catch { return false; } } /** * 現在のディレクトリがworktreeディレクトリかどうかを確認 * @returns {Promise<boolean>} worktreeディレクトリの場合true */ export async function isInWorktree() { try { // git rev-parse --show-toplevelとgit rev-parse --git-common-dirの結果を比較 const [toplevelResult, gitCommonDirResult] = await Promise.all([ execa('git', ['rev-parse', '--show-toplevel']), execa('git', ['rev-parse', '--git-common-dir']) ]); const toplevel = toplevelResult.stdout.trim(); const gitCommonDir = gitCommonDirResult.stdout.trim(); // gitCommonDirが絶対パスで、toplevelと異なる親ディレクトリを持つ場合はworktree const path = await import('node:path'); if (path.isAbsolute(gitCommonDir)) { const mainRepoRoot = path.dirname(gitCommonDir); return toplevel !== mainRepoRoot; } // gitCommonDirが相対パス(.git)の場合はメインリポジトリ return false; } catch { return false; } } //# sourceMappingURL=git.js.map