@akiojin/claude-worktree
Version:
Interactive Git worktree manager for Claude Code with graphical branch selection
472 lines • 16.9 kB
JavaScript
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