@shutootaki/gwm
Version:
git worktree manager CLI
580 lines • 21.2 kB
JavaScript
import { execSync } from 'child_process';
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join, dirname, relative } from 'path';
import { loadConfig } from '../config.js';
import { escapeShellArg } from './shell.js';
/**
* git worktree list --porcelain の出力をパースする
*/
export function parseWorktrees(output) {
const lines = output.trim().split('\n');
const worktrees = [];
let currentWorktree = {};
for (const line of lines) {
if (line.startsWith('worktree ')) {
if (currentWorktree.path) {
if (!currentWorktree.branch)
currentWorktree.branch = '(detached)';
if (!currentWorktree.head)
currentWorktree.head = 'UNKNOWN';
worktrees.push(currentWorktree);
}
currentWorktree = {
path: line.substring(9),
status: 'OTHER',
isActive: false,
isMain: false,
};
}
else if (line.startsWith('HEAD ')) {
currentWorktree.head = line.substring(5);
}
else if (line.startsWith('branch ')) {
currentWorktree.branch = line.substring(7);
}
else if (line === 'bare') {
currentWorktree.branch = '(bare)';
currentWorktree.isMain = true;
currentWorktree.status = 'MAIN';
}
else if (line === 'detached') {
currentWorktree.branch = '(detached)';
}
else if (line === 'locked') {
// lockedの情報は保持するが、ステータスは変更しない
}
}
if (currentWorktree.path) {
// フィールドのフォールバック値を付与
if (!currentWorktree.branch)
currentWorktree.branch = '(detached)';
if (!currentWorktree.head)
currentWorktree.head = 'UNKNOWN';
worktrees.push(currentWorktree);
}
// 最初のworktreeをメインとしてマーク(通常の場合)
if (worktrees.length > 0 && !worktrees.some((w) => w.isMain)) {
worktrees[0].isMain = true;
worktrees[0].status = 'MAIN';
}
// 現在のディレクトリと一致するworktreeをACTIVEにする
const currentDir = process.cwd();
worktrees.forEach((worktree) => {
if (worktree.path === currentDir) {
worktree.isActive = true;
worktree.status = 'ACTIVE';
}
});
return worktrees;
}
/**
* worktreeのリストを取得し、PRUNABLE状態を判定する
*/
export async function getWorktreesWithStatus() {
try {
// Gitリポジトリかどうかチェック
try {
execSync('git rev-parse --git-dir', {
stdio: 'ignore',
cwd: process.cwd(),
});
}
catch {
throw new Error('Not a git repository. Please run this command from within a git repository.');
}
const output = execSync('git worktree list --porcelain', {
encoding: 'utf8',
cwd: process.cwd(),
});
const worktrees = parseWorktrees(output);
return worktrees;
}
catch (err) {
if (err instanceof Error) {
throw err; // 既に適切なメッセージがある場合はそのまま
}
throw new Error(`Failed to get worktrees: ${err}`);
}
}
/**
* git fetch --prune origin を実行
*/
export function fetchAndPrune() {
try {
// origin を前提として fetch --prune を試みる
execSync('git fetch --prune origin', {
stdio: 'ignore',
cwd: process.cwd(),
});
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
// origin が存在しない場合はユーザーフレンドリなメッセージを返す
if (/No such remote ['"]?origin['"]?/.test(message)) {
throw new Error('No remote named "origin" found. Please configure a remote repository.');
}
// それ以外のエラーは共通メッセージにラップ
throw new Error(`Failed to fetch and prune from remote: ${message}`);
}
}
/**
* worktreeを削除する
*/
export function removeWorktree(path, force = false) {
try {
const forceFlag = force ? ' --force' : '';
execSync(`git worktree remove ${escapeShellArg(path)}${forceFlag}`, {
cwd: process.cwd(),
});
}
catch (err) {
throw new Error(`Failed to remove worktree ${path}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
/**
* Gitリポジトリ名を取得する
* リモートのorigin URLからリポジトリ名を抽出する
* フォールバックとして現在のディレクトリ名を使用する
*/
export function getRepositoryName() {
try {
const remoteUrl = execSync('git remote get-url origin', {
cwd: process.cwd(),
encoding: 'utf8',
}).trim();
// GitHubのURLからリポジトリ名を抽出
// HTTPS: https://github.com/user/repo.git
// SSH: git@github.com:user/repo.git
const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/);
if (match && match[1]) {
return match[1];
}
}
catch {
// リモートが存在しない場合やエラーの場合はフォールバック
console.warn('Could not get repository name from remote, falling back to directory name');
}
// フォールバック: 現在のディレクトリ名を使用
return process.cwd().split('/').pop() || 'unknown';
}
/**
* メインブランチ(複数可)のworktreeでgit pullを実行する
*/
export async function pullMainBranch() {
try {
const config = loadConfig();
const worktrees = await getWorktreesWithStatus();
const results = [];
// メインブランチに該当するworktreeを特定
const mainWorktrees = worktrees.filter((worktree) => config.main_branches.some((mainBranch) => worktree.branch === mainBranch ||
worktree.branch === `refs/heads/${mainBranch}`));
if (mainWorktrees.length === 0) {
throw new Error(`No worktrees found for main branches: ${config.main_branches.join(', ')}`);
}
// 各メインワークツリーでpullを実行
for (const worktree of mainWorktrees) {
try {
const output = execSync('git pull', {
cwd: worktree.path,
encoding: 'utf8',
});
results.push({
branch: worktree.branch,
path: worktree.path,
success: true,
message: output.trim(),
});
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
results.push({
branch: worktree.branch,
path: worktree.path,
success: false,
message: `Failed to pull: ${errorMessage}`,
});
}
}
return results;
}
catch (err) {
throw new Error(`Failed to pull main branches: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
/**
* ローカルブランチが存在するか確認
*/
export function localBranchExists(branch) {
try {
execSync(`git show-ref --verify --quiet refs/heads/${escapeShellArg(branch)}`, {
stdio: 'ignore',
cwd: process.cwd(),
});
return true;
}
catch {
return false;
}
}
/**
* ブランチに未マージコミットがあるかを簡易判定
* origin/<branch> が存在する場合に限り git cherry で差分を確認。
* 取得に失敗した場合は true を返し、安全側で未マージとみなす。
*/
export function hasUnmergedCommits(branch) {
try {
// origin/<branch> が無い場合は fetch を試みない
try {
execSync(`git show-ref --verify --quiet refs/remotes/origin/${escapeShellArg(branch)}`, {
stdio: 'ignore',
cwd: process.cwd(),
});
}
catch {
// upstream がない = 既に削除 or push していない -> 安全のため未マージと判定しない
return false;
}
const output = execSync(`git cherry origin/${escapeShellArg(branch)} ${escapeShellArg(branch)}`, {
encoding: 'utf8',
cwd: process.cwd(),
}).trim();
return output.length > 0;
}
catch {
// 何らかのエラー時は安全側で未マージと見なす
return true;
}
}
/**
* ローカルブランチを削除する (未マージコミットがある場合は -D を要求)
*/
export function deleteLocalBranch(branch, force = false) {
try {
const flag = force ? '-D' : '-d';
execSync(`git branch ${flag} ${escapeShellArg(branch)}`, {
stdio: 'ignore',
cwd: process.cwd(),
});
}
catch (err) {
throw new Error(`Failed to delete branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* リモートブランチの状態を確認
* - isDeleted: origin/<branch> が存在しない
* - isMerged : ブランチがいずれかの mainBranch にマージ済み
*/
export function checkRemoteBranchStatus(branch, mainBranches) {
// sanitize branch (refs/heads/... を取り除く)
const branchName = branch.replace(/^refs\/heads\//, '');
let isDeleted = false;
let isMerged = false;
let mergedIntoBranch;
// リモートブランチの存在は fetch/prune 済みのローカル追跡リファレンスを確認する
// ネットワークアクセスを伴う `git ls-remote` はコストが高いため使用しない。
try {
execSync(`git show-ref --verify --quiet refs/remotes/origin/${escapeShellArg(branchName)}`, {
cwd: process.cwd(),
stdio: 'ignore',
});
// 参照が見つかった = ブランチは存在
}
catch {
// 参照が無い = origin にブランチが存在しない(削除済み)
isDeleted = true;
}
// マージ判定 (origin にブランチがある場合のみチェック)
if (!isDeleted) {
for (const mainBr of mainBranches) {
try {
execSync(`git merge-base --is-ancestor origin/${escapeShellArg(branchName)} origin/${escapeShellArg(mainBr)}`, { cwd: process.cwd(), stdio: 'ignore' });
// exit code 0 なら ancestor
isMerged = true;
mergedIntoBranch = mainBr;
break;
}
catch {
// exit code 1 -> not ancestor, その他 -> 無視
continue;
}
}
}
return { isDeleted, isMerged, mergedIntoBranch };
}
/**
* ワークツリーパスでローカル変更を確認
*/
export function checkLocalChanges(worktreePath) {
let statusLines = [];
try {
const output = execSync('git status --porcelain', {
cwd: worktreePath,
encoding: 'utf8',
});
statusLines = output.split('\n').filter((l) => l.trim() !== '');
}
catch {
// ignore, treat as no changes
}
let hasUnstagedChanges = false;
let hasUntrackedFiles = false;
let hasStagedChanges = false;
statusLines.forEach((line) => {
if (line.startsWith('??'))
hasUntrackedFiles = true;
else {
const [x, y] = line.split('');
if (x && x !== ' ')
hasStagedChanges = true;
if (y && y !== ' ')
hasUnstagedChanges = true;
}
});
// 未プッシュコミット
let hasLocalCommits = false;
try {
// upstream が無い場合エラーになる
execSync('git rev-parse --abbrev-ref --symbolic-full-name @{u}', {
cwd: worktreePath,
stdio: 'ignore',
});
const cherry = execSync('git cherry -v', {
cwd: worktreePath,
encoding: 'utf8',
}).trim();
if (cherry.length > 0)
hasLocalCommits = true;
}
catch {
// upstreamが無い -> リモートに存在しない or 削除済み。未プッシュコミットは無視
hasLocalCommits = false;
}
return {
hasUnstagedChanges,
hasUntrackedFiles,
hasStagedChanges,
hasLocalCommits,
};
}
/**
* 削除可能なワークツリーを取得
*/
export async function getCleanableWorktrees() {
const config = loadConfig();
const worktrees = await getWorktreesWithStatus();
const results = [];
for (const wt of worktrees) {
// MAIN / ACTIVE を除外
if (wt.isMain || wt.isActive)
continue;
const { isDeleted, isMerged, mergedIntoBranch } = checkRemoteBranchStatus(wt.branch, config.main_branches);
// 1. リモート削除 または マージ済み
if (!isDeleted && !isMerged)
continue;
// 2. ローカル変更なし
const local = checkLocalChanges(wt.path);
const hasChanges = local.hasLocalCommits ||
local.hasStagedChanges ||
local.hasUnstagedChanges ||
local.hasUntrackedFiles;
if (hasChanges)
continue;
results.push({
worktree: wt,
reason: isDeleted ? 'remote_deleted' : 'merged',
mergedIntoBranch,
});
}
return results;
}
export function getRepoRoot() {
try {
return execSync('git rev-parse --show-toplevel', {
cwd: process.cwd(),
encoding: 'utf8',
}).trim();
}
catch {
// 取得できない場合はカレントディレクトリを返す
return process.cwd();
}
}
/**
* リモートブランチの詳細情報を取得する
*/
export function getRemoteBranchesWithInfo() {
try {
// git for-each-ref でリモートブランチの詳細情報を取得
const output = execSync('git for-each-ref refs/remotes --format="%(refname:short)|%(committerdate:iso8601-strict)|%(committername)|%(subject)"', {
cwd: process.cwd(),
encoding: 'utf8',
});
const branches = output
.split('\n')
.filter((line) => line.trim() && !line.includes('HEAD'))
.map((line) => {
const [fullName, date, committer, subject] = line.split('|');
const name = fullName.replace('origin/', '');
return {
name,
fullName,
lastCommitDate: date || '',
lastCommitterName: committer || '',
lastCommitMessage: subject || '',
};
});
return branches;
}
catch (err) {
throw new Error(`Failed to get remote branches: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
/**
* メインワークツリーのパスを取得する
* 通常、最初のワークツリーがメインとなる
*/
export function getMainWorktreePath() {
try {
const worktrees = parseWorktrees(execSync('git worktree list --porcelain', {
encoding: 'utf8',
cwd: process.cwd(),
}));
// isMainフラグが設定されているワークツリーを探す
const mainWorktree = worktrees.find((wt) => wt.isMain);
if (mainWorktree) {
return mainWorktree.path;
}
// フォールバック: 最初のワークツリーをメインとして扱う
if (worktrees.length > 0) {
return worktrees[0].path;
}
return null;
}
catch {
return null;
}
}
/**
* gitignoreされたファイルのリストを取得
* @param workdir 検索対象のディレクトリ
* @param patterns 検索パターン(ワイルドカード対応)
* @param excludePatterns 除外パターン
*/
export function getIgnoredFiles(workdir, patterns, excludePatterns) {
const matchedFiles = [];
// パターンに基づいてファイルを直接検索
function scanDirectory(dir, baseDir = workdir) {
try {
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const relativePath = relative(baseDir, fullPath);
// .gitディレクトリはスキップ
if (entry === '.git')
continue;
try {
const stat = statSync(fullPath);
if (stat.isDirectory()) {
// ディレクトリの場合は再帰的に検索
scanDirectory(fullPath, baseDir);
}
else if (stat.isFile()) {
// ファイルの場合はパターンマッチング
let shouldInclude = false;
// 除外パターンのチェック
if (excludePatterns) {
let isExcluded = false;
for (const excludePattern of excludePatterns) {
if (matchesPattern(entry, excludePattern) ||
matchesPattern(relativePath, excludePattern)) {
isExcluded = true;
break;
}
}
if (isExcluded)
continue;
}
// 含めるパターンのチェック
for (const pattern of patterns) {
if (matchesPattern(entry, pattern) ||
matchesPattern(relativePath, pattern)) {
shouldInclude = true;
break;
}
}
if (shouldInclude) {
// gitで追跡されていないファイルのみを対象とする
try {
execSync(`git ls-files --error-unmatch ${escapeShellArg(relativePath)}`, {
cwd: baseDir,
stdio: 'ignore',
});
// ファイルが追跡されている場合はスキップ
}
catch {
// ファイルが追跡されていない場合は含める
matchedFiles.push(relativePath);
}
}
}
}
catch {
// ファイルアクセスエラーは無視
}
}
}
catch {
// ディレクトリスキャンエラーは無視
}
}
scanDirectory(workdir);
return matchedFiles;
}
/**
* ファイル名がパターンにマッチするかチェック
* 簡易的なワイルドカードマッチング
*/
function matchesPattern(file, pattern) {
// 簡易的なワイルドカードマッチング
// * を任意の文字列に変換
const regexPattern = pattern
.split('*')
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
const regex = new RegExp(`^${regexPattern}$`);
return regex.test(file);
}
/**
* ファイルを別のディレクトリにコピー
* @param sourceDir コピー元ディレクトリ
* @param targetDir コピー先ディレクトリ
* @param files コピーするファイルのリスト(相対パス)
* @returns コピーしたファイルのリスト
*/
export function copyFiles(sourceDir, targetDir, files) {
const copiedFiles = [];
for (const file of files) {
try {
const sourcePath = join(sourceDir, file);
const targetPath = join(targetDir, file);
// ソースファイルが存在しない場合はスキップ
if (!existsSync(sourcePath)) {
continue;
}
// ターゲットディレクトリを作成
const targetFileDir = dirname(targetPath);
if (!existsSync(targetFileDir)) {
mkdirSync(targetFileDir, { recursive: true });
}
// ファイルをコピー
copyFileSync(sourcePath, targetPath);
copiedFiles.push(file);
}
catch {
// エラーは無視して次のファイルに進む
}
}
return copiedFiles;
}
//# sourceMappingURL=git.js.map