@shutootaki/gwm
Version:
git worktree manager CLI
717 lines • 27.4 kB
JavaScript
import { execSync } from 'child_process';
import { existsSync, readdirSync, statSync } from 'fs';
import { join, dirname, relative, resolve } from 'path';
import { loadConfig } from '../config.js';
import { escapeShellArg, execAsync } from './shell.js';
import { isVirtualEnv } from './virtualenv.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 {
await execAsync('git rev-parse --git-dir', {
cwd: process.cwd(),
});
}
catch {
throw new Error('Not a git repository. Please run this command from within a git repository.');
}
const { stdout } = await execAsync('git worktree list --porcelain', {
encoding: 'utf8',
cwd: process.cwd(),
});
const worktrees = parseWorktrees(stdout);
return worktrees;
}
catch (err) {
if (err instanceof Error) {
throw err; // 既に適切なメッセージがある場合はそのまま
}
throw new Error(`Failed to get worktrees: ${err}`);
}
}
/**
* git fetch --prune origin を実行(非同期版)
*/
export async function fetchAndPrune() {
try {
// origin を前提として fetch --prune を試みる
await execAsync('git fetch --prune origin', {
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 async function removeWorktree(path, force = false) {
try {
const forceFlag = force ? ' --force' : '';
await execAsync(`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 async function deleteLocalBranch(branch, force = false) {
try {
const flag = force ? '-D' : '-d';
await execAsync(`git branch ${flag} ${escapeShellArg(branch)}`, {
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 async function getRemoteBranchesWithInfo() {
try {
// git for-each-ref でリモートブランチの詳細情報を取得
const { stdout } = await execAsync('git for-each-ref refs/remotes --format="%(refname:short)|%(committerdate:iso8601-strict)|%(committername)|%(subject)"', {
cwd: process.cwd(),
encoding: 'utf8',
});
const branches = stdout
.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,
/**
* 仮想環境ディレクトリをスキップするかどうか。
* true : isVirtualEnv() に一致したディレクトリを走査対象から除外 (既定)
* false : 仮想環境も通常ディレクトリとして扱う
*/
skipVirtualEnvs = true) {
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;
// ===== 追加: ディレクトリ自体が除外パターン / 仮想環境に一致する場合は再帰しない =====
if (excludePatterns &&
excludePatterns.some((p) => matchesPattern(entry, p) || matchesPattern(relativePath, p))) {
// エントリ自体が除外対象
continue;
}
// 仮想環境ディレクトリの除外はフラグに基づいて決定
if (skipVirtualEnvs && isVirtualEnv(relativePath)) {
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 async function copyFiles(sourceDir, targetDir, files) {
// Node 組み込み promises API を使用
const { copyFile, mkdir, lstat, readlink, symlink, realpath } = await import('fs/promises');
const os = await import('os');
const copiedFiles = [];
const skippedVirtualEnvSet = new Set();
const skippedOversize = [];
const { virtual_env_handling } = loadConfig();
const isIsolationEnabled = (() => {
if (!virtual_env_handling)
return false; // デフォルト disabled
if (typeof virtual_env_handling.isolate_virtual_envs === 'boolean') {
return virtual_env_handling.isolate_virtual_envs;
}
// 後方互換: mode
return virtual_env_handling.mode === 'skip';
})();
// サイズ上限 (バイト)
const maxFileSizeBytes = virtual_env_handling?.max_file_size_mb !== undefined
? virtual_env_handling.max_file_size_mb >= 0
? virtual_env_handling.max_file_size_mb * 1024 * 1024
: undefined
: virtual_env_handling?.max_copy_size_mb &&
virtual_env_handling.max_copy_size_mb > 0
? virtual_env_handling.max_copy_size_mb * 1024 * 1024
: undefined;
const maxDirSizeBytes = virtual_env_handling?.max_dir_size_mb !== undefined
? virtual_env_handling.max_dir_size_mb >= 0
? virtual_env_handling.max_dir_size_mb * 1024 * 1024
: undefined
: undefined;
// ディレクトリ単位で累積サイズを追跡
const dirSizeMap = new Map();
// 並列度
const parallelism = virtual_env_handling?.copy_parallelism !== undefined
? virtual_env_handling.copy_parallelism === 0
? os.cpus().length
: virtual_env_handling.copy_parallelism
: 4;
const active = [];
async function processFile(file) {
try {
const sourcePath = join(sourceDir, file);
const targetPath = join(targetDir, file);
if (!existsSync(sourcePath))
return;
if (isIsolationEnabled && isVirtualEnv(file)) {
// 先頭セグメントだけではなく、相対パス全体を保持して詳細を提供する
skippedVirtualEnvSet.add(file);
return;
}
const lst = await lstat(sourcePath);
if (!lst.isSymbolicLink() && maxFileSizeBytes !== undefined) {
if (lst.size > maxFileSizeBytes) {
skippedOversize.push(file);
return;
}
}
if (!lst.isSymbolicLink() && maxDirSizeBytes !== undefined) {
const dirRel = dirname(file) || '.';
let violates = false;
let cursor = dirRel;
while (true) {
const current = dirSizeMap.get(cursor) ?? 0;
if (current + lst.size > maxDirSizeBytes) {
violates = true;
break;
}
if (cursor === '.')
break;
const parent = dirname(cursor);
if (parent === cursor)
break;
cursor = parent;
}
if (violates) {
skippedOversize.push(file);
return;
}
// サイズ加算
cursor = dirRel;
while (true) {
const current = dirSizeMap.get(cursor) ?? 0;
dirSizeMap.set(cursor, current + lst.size);
if (cursor === '.')
break;
const parent = dirname(cursor);
if (parent === cursor)
break;
cursor = parent;
}
}
// ディレクトリ作成
const targetDirName = dirname(targetPath);
if (!existsSync(targetDirName))
await mkdir(targetDirName, { recursive: true });
if (lst.isSymbolicLink()) {
const linkTarget = await readlink(sourcePath);
const absoluteLinkTarget = resolve(dirname(sourcePath), linkTarget);
let rewrittenTarget = absoluteLinkTarget;
if (isIsolationEnabled) {
try {
const realSourceDir = await realpath(sourceDir);
const targetReal = await realpath(absoluteLinkTarget);
if (targetReal.startsWith(realSourceDir)) {
const relFromSource = relative(realSourceDir, targetReal);
rewrittenTarget = join(targetDir, relFromSource);
}
}
catch {
/* ignore */
}
}
let linkSrc = rewrittenTarget;
if (rewrittenTarget !== absoluteLinkTarget) {
linkSrc = relative(dirname(targetPath), rewrittenTarget);
}
await symlink(linkSrc, targetPath);
copiedFiles.push(file);
}
else {
await copyFile(sourcePath, targetPath);
copiedFiles.push(file);
}
}
catch {
/* ignore individual errors */
}
}
for (const file of files) {
const p = processFile(file).then(() => {
const idx = active.indexOf(p);
if (idx >= 0)
active.splice(idx, 1);
});
active.push(p);
if (active.length >= parallelism) {
await Promise.race(active);
}
}
await Promise.all(active);
return {
copied: copiedFiles,
skippedVirtualEnvs: [...skippedVirtualEnvSet],
skippedOversize,
};
}
//# sourceMappingURL=git.js.map