@shutootaki/gwm
Version:
git worktree manager CLI
271 lines • 12 kB
JavaScript
import { jsx as _jsx } from "react/jsx-runtime";
import { useState, useEffect, useMemo, useCallback, useRef, } from 'react';
import { SelectList } from './SelectList.js';
import { TextInput, validateBranchName, generateWorktreePreview, } from './TextInput.js';
import { formatErrorForDisplay } from '../utils/index.js';
import { useWorktree } from '../hooks/useWorktree.js';
import { LoadingSpinner } from './ui/LoadingSpinner.js';
import { Notice } from './ui/Notice.js';
import { HookConfirmation } from './ui/HookConfirmation.js';
import { getRemoteBranchesWithInfo } from '../utils/git.js';
import { escapeShellArg, execAsync } from '../utils/shell.js';
import { loadConfigWithSource } from '../config/loader.js';
import { verifyTrust, trustRepository, } from '../trust/index.js';
export const WorktreeAdd = ({ branchName, isRemote = false, fromBranch, openCode = false, openCursor = false, outputPath = false, skipHooks = false, }) => {
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
const [remoteBranches, setRemoteBranches] = useState([]);
const [isRemoteBranchesLoaded, setIsRemoteBranchesLoaded] = useState(false);
const [pendingConfirmation, setPendingConfirmation] = useState(null);
// 初期表示モードは引数有無に応じて決定する
// * branchName 指定あり → すぐに worktree 作成するので "loading" 表示のみ
// * -r 指定でリモート一覧を取りに行く場合も "loading"
// * それ以外は新規ブランチ入力モード
const initialViewMode = branchName
? 'loading'
: isRemote
? 'loading'
: 'input';
const [viewMode, setViewMode] = useState(initialViewMode);
// ローディング時のラベル(状況に応じて変更)
const [loadingLabel, setLoadingLabel] = useState(branchName ? 'Creating worktree...' : 'Fetching remote branches...');
// onSuccess / onError の参照が毎レンダー変わらないように useCallback でメモ化
const handleSuccess = useCallback((data) => {
setSuccess(JSON.stringify(data));
}, []);
const handleError = useCallback((message) => {
setError(message);
}, []);
const handleHooksStart = useCallback(() => {
setViewMode('hooks');
}, []);
const { createWorktree } = useWorktree({
fromBranch,
openCode,
openCursor,
outputPath,
skipHooks,
onSuccess: handleSuccess,
onError: handleError,
onHooksStart: handleHooksStart,
});
// createWorktree の最新参照を保持(useEffect の依存配列から除外するため)
const createWorktreeRef = useRef(createWorktree);
useEffect(() => {
createWorktreeRef.current = createWorktree;
}, [createWorktree]);
const fetchRemoteBranches = async () => {
try {
// リモート一覧を取得して fetch
const { stdout } = await execAsync('git remote', {
cwd: process.cwd(),
encoding: 'utf8',
});
const remotes = stdout
.split('\n')
.map((r) => r.trim())
.filter(Boolean);
const targetRemotes = remotes.includes('origin') ? ['origin'] : remotes;
for (const remote of targetRemotes) {
await execAsync(`git fetch --prune ${escapeShellArg(remote)}`, {
cwd: process.cwd(),
});
}
// リモートブランチの詳細情報を取得
const branches = await getRemoteBranchesWithInfo();
setRemoteBranches(branches);
setIsRemoteBranchesLoaded(true);
setViewMode('select');
}
catch (err) {
setError(formatErrorForDisplay(err));
}
};
// remoteBranches の SelectList 用変換をメモ化
const selectItems = useMemo(() => remoteBranches.map((branch) => ({
label: branch.name,
value: branch.name,
description: branch.lastCommitMessage,
metadata: {
lastCommitDate: branch.lastCommitDate,
lastCommitterName: branch.lastCommitterName,
lastCommitMessage: branch.lastCommitMessage,
},
})), [remoteBranches]);
/**
* worktree作成前に信頼確認を行い、必要に応じて確認UIを表示
*/
const checkTrustAndCreate = useCallback((branch, isRemoteBranch) => {
// --skip-hooks が指定されている場合は確認不要
if (skipHooks) {
createWorktree(branch, isRemoteBranch);
return;
}
// 設定をソース情報付きで読み込む
const { config, hasProjectHooks, repoRoot } = loadConfigWithSource();
// リポジトリ外の場合は確認不要
if (!repoRoot) {
createWorktree(branch, isRemoteBranch);
return;
}
// 信頼状態を確認
const trustStatus = verifyTrust(repoRoot, config, hasProjectHooks);
if (trustStatus.status === 'needs-confirmation') {
// 確認が必要 → 確認UIを表示
setPendingConfirmation({
trustStatus,
branch,
isRemote: isRemoteBranch,
});
setViewMode('confirm');
}
else {
// 信頼済み or hooks なし → そのまま作成
createWorktree(branch, isRemoteBranch);
}
}, [skipHooks, createWorktree]);
/**
* 確認UIでの選択を処理
*/
const handleConfirmChoice = useCallback((choice) => {
if (!pendingConfirmation)
return;
const { trustStatus, branch, isRemote: isRemoteBranch, } = pendingConfirmation;
if (choice === 'cancel') {
// キャンセル → 中止
setError('Operation cancelled.');
setPendingConfirmation(null);
return;
}
if (choice === 'trust') {
// 信頼してキャッシュに保存
const { repoRoot } = loadConfigWithSource();
if (repoRoot) {
trustRepository(repoRoot, trustStatus.configPath, trustStatus.configHash, trustStatus.commands);
}
}
// trust または once → worktree作成を続行
setPendingConfirmation(null);
setViewMode('loading');
setLoadingLabel('Creating worktree...');
createWorktree(branch, isRemoteBranch);
}, [pendingConfirmation, createWorktree]);
const handleBranchSelect = (item) => {
checkTrustAndCreate(item.value, true);
};
const handleBranchInput = (branchName) => {
checkTrustAndCreate(branchName, false);
};
const handleCancel = () => {
setError('Operation cancelled');
};
const handleModeSwitch = () => {
if (viewMode === 'input') {
// 新規ブランチ入力からリモートブランチ選択に切り替え
if (!isRemoteBranchesLoaded) {
setLoadingLabel('Fetching remote branches...');
setViewMode('loading');
fetchRemoteBranches();
}
else {
setViewMode('select');
}
}
else if (viewMode === 'select') {
// リモートブランチ選択から新規ブランチ入力に切り替え
setViewMode('input');
}
};
// checkTrustAndCreate の最新参照を保持(useEffect から参照するため)
const checkTrustAndCreateRef = useRef(checkTrustAndCreate);
useEffect(() => {
checkTrustAndCreateRef.current = checkTrustAndCreate;
}, [checkTrustAndCreate]);
// 初回マウント時の処理
useEffect(() => {
if (branchName) {
// 引数でブランチ名が指定された場合、信頼確認してから作成
checkTrustAndCreateRef.current(branchName, isRemote);
}
else if (isRemote) {
// -r フラグが指定された場合、リモートブランチ選択モードに
setLoadingLabel('Fetching remote branches...');
setViewMode('loading');
fetchRemoteBranches();
}
else {
// 引数なしの場合、デフォルトで新規ブランチ入力モード
setViewMode('input');
}
}, [branchName, isRemote]);
// 成功してエディタを開いた場合は 1 秒後に自動で CLI を終了する
useEffect(() => {
if (success && (openCode || openCursor)) {
const timer = setTimeout(() => process.exit(0), 1000);
return () => clearTimeout(timer);
}
}, [success, openCode, openCursor]);
if (success) {
// エディタを開くオプションが指定されている場合は簡易表示
if (openCode || openCursor) {
let worktreePath;
let actions = [];
try {
const parsed = JSON.parse(success);
worktreePath = parsed.path;
actions = parsed.actions || [];
}
catch {
worktreePath = success;
}
const editorName = openCode ? 'VS Code' : 'Cursor';
// hooks実行結果をフィルタリング(Executedまたはfailedを含むもの)
const hookMessages = actions.filter((a) => a.includes('hook') || a.includes('Hook'));
const messages = [
`✓ ${worktreePath}`,
...hookMessages.map((h) => `• ${h}`),
];
return (_jsx(Notice, { variant: "success", title: `Worktree created and opened in ${editorName}`, messages: messages }));
}
let worktreePath;
let actions = [];
try {
const parsed = JSON.parse(success);
worktreePath = parsed.path;
actions = parsed.actions || [];
}
catch {
// 後方互換性のため、文字列の場合はそのままパスとして扱う
worktreePath = success;
}
// worktreePathからブランチ名を抽出(最後のディレクトリ名)
const branchName = worktreePath.split('/').pop() || '';
return (_jsx(Notice, { variant: "success", title: "Worktree created successfully!", messages: [
`Location: ${worktreePath}`,
...actions.map((a) => `• ${a}`),
'To navigate to the created worktree, run:',
` $ gwm go ${branchName}`,
` $ cd "${worktreePath}"`,
] }));
}
if (error) {
return (_jsx(Notice, { variant: "error", title: "Failed to create worktree", messages: [error, 'Branch may already exist or permission issue'] }));
}
if (viewMode === 'input') {
return (_jsx(TextInput, { title: "Create new worktree", placeholder: "Enter new branch name...", onSubmit: handleBranchInput, onCancel: handleCancel, onModeSwitch: handleModeSwitch, validate: validateBranchName, preview: generateWorktreePreview }));
}
if (viewMode === 'select') {
return (_jsx(SelectList, { items: selectItems, onSelect: handleBranchSelect, onCancel: handleCancel, title: "Create worktree from remote branch", placeholder: "Search remote branches..." }));
}
// viewMode === 'confirm'(確認UI表示)
if (viewMode === 'confirm' && pendingConfirmation) {
return (_jsx(HookConfirmation, { reason: pendingConfirmation.trustStatus.reason, commands: pendingConfirmation.trustStatus.commands, onChoice: handleConfirmChoice }));
}
// viewMode === 'hooks'(フック実行中はスピナーを非表示)
if (viewMode === 'hooks') {
return null;
}
// viewMode === 'loading'
return _jsx(LoadingSpinner, { label: loadingLabel });
};
//# sourceMappingURL=WorktreeAdd.js.map