@akiojin/gwt 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +323 -0
- package/README.md +347 -0
- package/bin/gwt.js +5 -0
- package/package.json +125 -0
- package/src/claude-history.ts +717 -0
- package/src/claude.ts +292 -0
- package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
- package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
- package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
- package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
- package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
- package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
- package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
- package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
- package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
- package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
- package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
- package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
- package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
- package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
- package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
- package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
- package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
- package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
- package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
- package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
- package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
- package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
- package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
- package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
- package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
- package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
- package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
- package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
- package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
- package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
- package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
- package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
- package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
- package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
- package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
- package/src/cli/ui/components/App.tsx +793 -0
- package/src/cli/ui/components/common/Confirm.tsx +40 -0
- package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
- package/src/cli/ui/components/common/Input.tsx +36 -0
- package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
- package/src/cli/ui/components/common/Select.tsx +216 -0
- package/src/cli/ui/components/parts/Footer.tsx +41 -0
- package/src/cli/ui/components/parts/Header.test.tsx +85 -0
- package/src/cli/ui/components/parts/Header.tsx +63 -0
- package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
- package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
- package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
- package/src/cli/ui/components/parts/Stats.tsx +67 -0
- package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
- package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
- package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
- package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
- package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
- package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
- package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
- package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
- package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
- package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
- package/src/cli/ui/hooks/useGitData.ts +157 -0
- package/src/cli/ui/hooks/useScreenState.ts +44 -0
- package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
- package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
- package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
- package/src/cli/ui/types.ts +295 -0
- package/src/cli/ui/utils/baseBranch.ts +34 -0
- package/src/cli/ui/utils/branchFormatter.ts +222 -0
- package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
- package/src/codex.ts +139 -0
- package/src/config/builtin-tools.ts +44 -0
- package/src/config/constants.ts +100 -0
- package/src/config/env-history.ts +45 -0
- package/src/config/index.ts +204 -0
- package/src/config/tools.ts +293 -0
- package/src/git.ts +1102 -0
- package/src/github.ts +158 -0
- package/src/index.test.ts +87 -0
- package/src/index.ts +684 -0
- package/src/index.ts.backup +1543 -0
- package/src/launcher.ts +142 -0
- package/src/repositories/git.repository.ts +129 -0
- package/src/repositories/github.repository.ts +83 -0
- package/src/repositories/worktree.repository.ts +69 -0
- package/src/services/BatchMergeService.ts +251 -0
- package/src/services/WorktreeOrchestrator.ts +115 -0
- package/src/services/__tests__/BatchMergeService.test.ts +518 -0
- package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
- package/src/services/dependency-installer.ts +199 -0
- package/src/services/git.service.ts +113 -0
- package/src/services/github.service.ts +61 -0
- package/src/services/worktree.service.ts +66 -0
- package/src/types/api.ts +241 -0
- package/src/types/tools.ts +235 -0
- package/src/utils/spinner.ts +54 -0
- package/src/utils/terminal.ts +272 -0
- package/src/utils.test.ts +43 -0
- package/src/utils.ts +60 -0
- package/src/web/client/index.html +12 -0
- package/src/web/client/src/components/BranchGraph.tsx +231 -0
- package/src/web/client/src/components/EnvEditor.tsx +145 -0
- package/src/web/client/src/components/Terminal.tsx +137 -0
- package/src/web/client/src/hooks/useBranches.ts +41 -0
- package/src/web/client/src/hooks/useConfig.ts +31 -0
- package/src/web/client/src/hooks/useSessions.ts +59 -0
- package/src/web/client/src/hooks/useWorktrees.ts +47 -0
- package/src/web/client/src/index.css +834 -0
- package/src/web/client/src/lib/api.ts +184 -0
- package/src/web/client/src/lib/websocket.ts +174 -0
- package/src/web/client/src/main.tsx +29 -0
- package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
- package/src/web/client/src/pages/BranchListPage.tsx +264 -0
- package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
- package/src/web/client/src/router.tsx +27 -0
- package/src/web/client/vite.config.ts +21 -0
- package/src/web/server/env/importer.ts +54 -0
- package/src/web/server/index.ts +74 -0
- package/src/web/server/pty/manager.ts +189 -0
- package/src/web/server/routes/branches.ts +126 -0
- package/src/web/server/routes/config.ts +220 -0
- package/src/web/server/routes/index.ts +37 -0
- package/src/web/server/routes/sessions.ts +130 -0
- package/src/web/server/routes/worktrees.ts +108 -0
- package/src/web/server/services/branches.ts +368 -0
- package/src/web/server/services/worktrees.ts +85 -0
- package/src/web/server/websocket/handler.ts +180 -0
- package/src/worktree.ts +703 -0
package/src/launcher.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* カスタムツール起動機能
|
|
3
|
+
*
|
|
4
|
+
* カスタムAIツールの起動処理を管理します。
|
|
5
|
+
* 3つの実行タイプ(path, bunx, command)をサポートします。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execa } from "execa";
|
|
9
|
+
import type { CustomAITool, LaunchOptions } from "./types/tools.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* コマンド名をPATH環境変数から解決
|
|
13
|
+
*
|
|
14
|
+
* Unix/Linuxではwhichコマンド、Windowsではwhereコマンドを使用して、
|
|
15
|
+
* コマンド名を絶対パスに解決します。
|
|
16
|
+
*
|
|
17
|
+
* @param commandName - 解決するコマンド名
|
|
18
|
+
* @returns コマンドの絶対パス
|
|
19
|
+
* @throws コマンドが見つからない場合
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveCommand(commandName: string): Promise<string> {
|
|
22
|
+
const whichCommand = process.platform === "win32" ? "where" : "which";
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const result = await execa(whichCommand, [commandName]);
|
|
26
|
+
|
|
27
|
+
// where(Windows)は複数行返す可能性があるため、最初の行のみ取得
|
|
28
|
+
const resolvedPath = (result.stdout.split("\n")[0] ?? "").trim();
|
|
29
|
+
|
|
30
|
+
if (!resolvedPath) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Command "${commandName}" not found in PATH.\n` +
|
|
33
|
+
`Please ensure the command is installed and available in your PATH environment variable.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return resolvedPath;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// which/whereコマンド自体が失敗した場合
|
|
40
|
+
if (error instanceof Error) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Failed to resolve command "${commandName}".\n` +
|
|
43
|
+
`Error: ${error.message}\n` +
|
|
44
|
+
`Please ensure the command is installed and available in your PATH environment variable.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 引数配列を構築
|
|
53
|
+
*
|
|
54
|
+
* defaultArgs + modeArgs[mode] + extraArgs の順で引数を結合します。
|
|
55
|
+
* 未定義のフィールドは空配列として扱います。
|
|
56
|
+
*
|
|
57
|
+
* @param tool - カスタムツール定義
|
|
58
|
+
* @param options - 起動オプション
|
|
59
|
+
* @returns 結合された引数配列
|
|
60
|
+
*/
|
|
61
|
+
function buildArgs(tool: CustomAITool, options: LaunchOptions): string[] {
|
|
62
|
+
const args: string[] = [];
|
|
63
|
+
|
|
64
|
+
// 1. defaultArgs
|
|
65
|
+
if (tool.defaultArgs) {
|
|
66
|
+
args.push(...tool.defaultArgs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. modeArgs[mode]
|
|
70
|
+
const mode = options.mode || "normal";
|
|
71
|
+
const modeArgs = tool.modeArgs[mode];
|
|
72
|
+
if (modeArgs) {
|
|
73
|
+
args.push(...modeArgs);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 3. extraArgs
|
|
77
|
+
if (options.extraArgs) {
|
|
78
|
+
args.push(...options.extraArgs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* カスタムAIツールを起動
|
|
86
|
+
*
|
|
87
|
+
* ツールの実行タイプ(path/bunx/command)に応じて適切な方法で起動します。
|
|
88
|
+
* stdio: "inherit" で起動するため、ツールの入出力は親プロセスに継承されます。
|
|
89
|
+
*
|
|
90
|
+
* @param tool - カスタムツール定義
|
|
91
|
+
* @param options - 起動オプション
|
|
92
|
+
* @throws 起動に失敗した場合
|
|
93
|
+
*/
|
|
94
|
+
export async function launchCustomAITool(
|
|
95
|
+
tool: CustomAITool,
|
|
96
|
+
options: LaunchOptions = {},
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const args = buildArgs(tool, options);
|
|
99
|
+
|
|
100
|
+
const env = {
|
|
101
|
+
...process.env,
|
|
102
|
+
...(options.sharedEnv ?? {}),
|
|
103
|
+
...(tool.env ?? {}),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// execa共通オプション(cwdがundefinedの場合は含めない)
|
|
107
|
+
const execaOptions = {
|
|
108
|
+
stdio: "inherit" as const,
|
|
109
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
110
|
+
env,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
switch (tool.type) {
|
|
114
|
+
case "path": {
|
|
115
|
+
// 絶対パスで直接実行
|
|
116
|
+
await execa(tool.command, args, execaOptions);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "bunx": {
|
|
121
|
+
// bunx経由でパッケージ実行
|
|
122
|
+
// bunx [package] [args...]
|
|
123
|
+
await execa("bunx", [tool.command, ...args], execaOptions);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "command": {
|
|
128
|
+
// PATH解決 → 実行
|
|
129
|
+
const resolvedPath = await resolveCommand(tool.command);
|
|
130
|
+
await execa(resolvedPath, args, execaOptions);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
default: {
|
|
135
|
+
// TypeScriptの型チェックで到達不可能だが、実行時の安全性のため
|
|
136
|
+
const exhaustiveCheck: never = tool.type;
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Unknown tool execution type: ${exhaustiveCheck as string}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import { GitError } from "../git.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Git操作のための低レベルRepository
|
|
6
|
+
* execaの直接呼び出しをカプセル化
|
|
7
|
+
*/
|
|
8
|
+
export class GitRepository {
|
|
9
|
+
async execute(args: string[], options?: { cwd?: string }): Promise<string> {
|
|
10
|
+
try {
|
|
11
|
+
const { stdout } = await execa("git", args, options);
|
|
12
|
+
return stdout;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
throw new GitError(`Git command failed: git ${args.join(" ")}`, error);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async isRepository(): Promise<boolean> {
|
|
19
|
+
try {
|
|
20
|
+
await this.execute(["rev-parse", "--git-dir"]);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async getRepositoryRoot(): Promise<string> {
|
|
28
|
+
const stdout = await this.execute(["rev-parse", "--show-toplevel"]);
|
|
29
|
+
return stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async getCurrentBranch(): Promise<string | null> {
|
|
33
|
+
try {
|
|
34
|
+
const stdout = await this.execute(["branch", "--show-current"]);
|
|
35
|
+
return stdout.trim() || null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async getBranches(options: { remote?: boolean }): Promise<string[]> {
|
|
42
|
+
const args = ["branch"];
|
|
43
|
+
if (options.remote) {
|
|
44
|
+
args.push("-r");
|
|
45
|
+
}
|
|
46
|
+
args.push("--format=%(refname:short)");
|
|
47
|
+
|
|
48
|
+
const stdout = await this.execute(args);
|
|
49
|
+
return stdout
|
|
50
|
+
.split("\n")
|
|
51
|
+
.filter((line) => line.trim())
|
|
52
|
+
.filter((line) => !line.includes("HEAD"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async createBranch(branchName: string, baseBranch?: string): Promise<void> {
|
|
56
|
+
const args = ["checkout", "-b", branchName];
|
|
57
|
+
if (baseBranch) {
|
|
58
|
+
args.push(baseBranch);
|
|
59
|
+
}
|
|
60
|
+
await this.execute(args);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async deleteBranch(branchName: string, force = false): Promise<void> {
|
|
64
|
+
const args = ["branch", force ? "-D" : "-d", branchName];
|
|
65
|
+
await this.execute(args);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async deleteRemoteBranch(branchName: string): Promise<void> {
|
|
69
|
+
await this.execute(["push", "origin", "--delete", branchName]);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getStatus(options?: { cwd?: string }): Promise<string> {
|
|
73
|
+
return await this.execute(
|
|
74
|
+
["status", "--porcelain"],
|
|
75
|
+
options?.cwd ? options : undefined,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async hasChanges(workdir?: string): Promise<boolean> {
|
|
80
|
+
const status = await this.getStatus(workdir ? { cwd: workdir } : undefined);
|
|
81
|
+
return status.trim().length > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async fetch(options?: { all?: boolean; prune?: boolean }): Promise<void> {
|
|
85
|
+
const args = ["fetch"];
|
|
86
|
+
if (options?.all) args.push("--all");
|
|
87
|
+
if (options?.prune) args.push("--prune");
|
|
88
|
+
await this.execute(args);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async push(options?: { upstream?: boolean; branch?: string }): Promise<void> {
|
|
92
|
+
const args = ["push"];
|
|
93
|
+
if (options?.upstream && options?.branch) {
|
|
94
|
+
args.push("--set-upstream", "origin", options.branch);
|
|
95
|
+
}
|
|
96
|
+
await this.execute(args);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async commit(message: string, options?: { all?: boolean }): Promise<void> {
|
|
100
|
+
const args = ["commit", "-m", message];
|
|
101
|
+
if (options?.all) args.push("-a");
|
|
102
|
+
await this.execute(args);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async add(files: string[] | "."): Promise<void> {
|
|
106
|
+
const args = ["add"];
|
|
107
|
+
if (Array.isArray(files)) {
|
|
108
|
+
args.push(...files);
|
|
109
|
+
} else {
|
|
110
|
+
args.push(files);
|
|
111
|
+
}
|
|
112
|
+
await this.execute(args);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async stash(message?: string): Promise<void> {
|
|
116
|
+
const args = ["stash", "push"];
|
|
117
|
+
if (message) args.push("-m", message);
|
|
118
|
+
await this.execute(args);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async checkout(target: string): Promise<void> {
|
|
122
|
+
await this.execute(["checkout", target]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getChangedFilesCount(workdir?: string): Promise<number> {
|
|
126
|
+
const status = await this.getStatus(workdir ? { cwd: workdir } : undefined);
|
|
127
|
+
return status.split("\n").filter((line) => line.trim()).length;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import type { GitHubPRResponse } from "../cli/ui/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GitHub CLI操作のための低レベルRepository
|
|
7
|
+
*/
|
|
8
|
+
export class GitHubRepository {
|
|
9
|
+
async execute(args: string[]): Promise<string> {
|
|
10
|
+
try {
|
|
11
|
+
const { stdout } = await execa("gh", args);
|
|
12
|
+
return stdout;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
throw new Error(
|
|
16
|
+
`GitHub CLI command failed: gh ${args.join(" ")}\n${message}`,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async isAvailable(): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
await this.execute(["--version"]);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async isAuthenticated(): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
await this.execute(["api", "user"]);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async fetchPullRequests(options: {
|
|
40
|
+
state?: "all" | "open" | "closed" | "merged";
|
|
41
|
+
limit?: number;
|
|
42
|
+
head?: string;
|
|
43
|
+
}): Promise<GitHubPRResponse[]> {
|
|
44
|
+
const args = ["pr", "list"];
|
|
45
|
+
|
|
46
|
+
if (options.state) {
|
|
47
|
+
args.push("--state", options.state);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (options.head) {
|
|
51
|
+
args.push("--head", options.head);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
args.push(
|
|
55
|
+
"--json",
|
|
56
|
+
"number,title,state,headRefName,mergedAt,author",
|
|
57
|
+
"--limit",
|
|
58
|
+
String(options.limit || 100),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const stdout = await this.execute(args);
|
|
62
|
+
|
|
63
|
+
if (!stdout || stdout.trim() === "") {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return JSON.parse(stdout);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async fetchRemoteUpdates(): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
await execa("git", ["fetch", "--all", "--prune"]);
|
|
73
|
+
} catch {
|
|
74
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
75
|
+
console.log(
|
|
76
|
+
chalk.yellow(
|
|
77
|
+
"Debug: Failed to fetch remote updates, continuing anyway",
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import { WorktreeError } from "../worktree.js";
|
|
3
|
+
|
|
4
|
+
export interface WorktreeData {
|
|
5
|
+
path: string;
|
|
6
|
+
head: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Git Worktree操作のための低レベルRepository
|
|
12
|
+
*/
|
|
13
|
+
export class WorktreeRepository {
|
|
14
|
+
async execute(args: string[], options?: { cwd?: string }): Promise<string> {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await execa("git", ["worktree", ...args], options);
|
|
17
|
+
return stdout;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new WorktreeError(
|
|
20
|
+
`Worktree command failed: git worktree ${args.join(" ")}`,
|
|
21
|
+
error,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async list(): Promise<WorktreeData[]> {
|
|
27
|
+
const stdout = await this.execute(["list", "--porcelain"]);
|
|
28
|
+
const worktrees: WorktreeData[] = [];
|
|
29
|
+
const lines = stdout.split("\n");
|
|
30
|
+
|
|
31
|
+
let currentWorktree: Partial<WorktreeData> = {};
|
|
32
|
+
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
if (line.startsWith("worktree ")) {
|
|
35
|
+
currentWorktree.path = line.substring(9);
|
|
36
|
+
} else if (line.startsWith("HEAD ")) {
|
|
37
|
+
currentWorktree.head = line.substring(5);
|
|
38
|
+
} else if (line.startsWith("branch ")) {
|
|
39
|
+
currentWorktree.branch = line.substring(7).replace("refs/heads/", "");
|
|
40
|
+
} else if (line === "") {
|
|
41
|
+
if (currentWorktree.path) {
|
|
42
|
+
worktrees.push(currentWorktree as WorktreeData);
|
|
43
|
+
currentWorktree = {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (currentWorktree.path) {
|
|
49
|
+
worktrees.push(currentWorktree as WorktreeData);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return worktrees;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async add(worktreePath: string, branchName: string): Promise<void> {
|
|
56
|
+
await this.execute(["add", worktreePath, branchName]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async remove(worktreePath: string, force = false): Promise<void> {
|
|
60
|
+
const args = ["remove"];
|
|
61
|
+
if (force) args.push("--force");
|
|
62
|
+
args.push(worktreePath);
|
|
63
|
+
await this.execute(args);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async prune(): Promise<void> {
|
|
67
|
+
await this.execute(["prune"]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BatchMergeConfig,
|
|
3
|
+
BatchMergeProgress,
|
|
4
|
+
BatchMergeResult,
|
|
5
|
+
BranchMergeStatus,
|
|
6
|
+
BatchMergeSummary,
|
|
7
|
+
} from "../cli/ui/types";
|
|
8
|
+
import * as git from "../git";
|
|
9
|
+
import * as worktree from "../worktree";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* BatchMergeService - Orchestrates batch merge operations
|
|
13
|
+
* @see specs/SPEC-ee33ca26/plan.md - Service layer architecture
|
|
14
|
+
*/
|
|
15
|
+
export class BatchMergeService {
|
|
16
|
+
/**
|
|
17
|
+
* Determine source branch for merge (main > develop > master)
|
|
18
|
+
* @returns Source branch name
|
|
19
|
+
* @throws Error if no source branch found
|
|
20
|
+
* @see specs/SPEC-ee33ca26/spec.md - FR-004
|
|
21
|
+
*/
|
|
22
|
+
async determineSourceBranch(): Promise<string> {
|
|
23
|
+
const branches = await git.getLocalBranches();
|
|
24
|
+
const branchNames = branches.map((b) => b.name);
|
|
25
|
+
|
|
26
|
+
// Priority: main > develop > master
|
|
27
|
+
if (branchNames.includes("main")) {
|
|
28
|
+
return "main";
|
|
29
|
+
}
|
|
30
|
+
if (branchNames.includes("develop")) {
|
|
31
|
+
return "develop";
|
|
32
|
+
}
|
|
33
|
+
if (branchNames.includes("master")) {
|
|
34
|
+
return "master";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
throw new Error("Unable to determine source branch");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get target branches for merge (exclude main, develop, master)
|
|
42
|
+
* @returns Array of target branch names
|
|
43
|
+
* @see specs/SPEC-ee33ca26/spec.md - FR-003
|
|
44
|
+
*/
|
|
45
|
+
async getTargetBranches(): Promise<string[]> {
|
|
46
|
+
const branches = await git.getLocalBranches();
|
|
47
|
+
const excludedBranches = ["main", "develop", "master"];
|
|
48
|
+
|
|
49
|
+
return branches
|
|
50
|
+
.map((b) => b.name)
|
|
51
|
+
.filter((name) => !excludedBranches.includes(name));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ensure worktree exists for target branch
|
|
56
|
+
* @param branchName - Target branch name
|
|
57
|
+
* @returns Worktree path
|
|
58
|
+
* @see specs/SPEC-ee33ca26/spec.md - FR-006
|
|
59
|
+
*/
|
|
60
|
+
async ensureWorktree(branchName: string): Promise<string> {
|
|
61
|
+
const worktrees = await worktree.listAdditionalWorktrees();
|
|
62
|
+
const existingWorktree = worktrees.find((w: { path: string }) =>
|
|
63
|
+
w.path.includes(branchName.replace(/\//g, "-")),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (existingWorktree) {
|
|
67
|
+
return existingWorktree.path;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Create new worktree
|
|
71
|
+
const repoRoot = await git.getRepositoryRoot();
|
|
72
|
+
const worktreePath = await worktree.generateWorktreePath(
|
|
73
|
+
repoRoot,
|
|
74
|
+
branchName,
|
|
75
|
+
);
|
|
76
|
+
await worktree.createWorktree({
|
|
77
|
+
branchName,
|
|
78
|
+
worktreePath,
|
|
79
|
+
repoRoot,
|
|
80
|
+
isNewBranch: false,
|
|
81
|
+
baseBranch: "",
|
|
82
|
+
});
|
|
83
|
+
return worktreePath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Merge source branch into target branch
|
|
88
|
+
* @param branchName - Target branch name
|
|
89
|
+
* @param sourceBranch - Source branch name
|
|
90
|
+
* @param config - Batch merge configuration
|
|
91
|
+
* @returns Merge status for the branch
|
|
92
|
+
* @see specs/SPEC-ee33ca26/spec.md - FR-007, FR-008
|
|
93
|
+
*/
|
|
94
|
+
async mergeBranch(
|
|
95
|
+
branchName: string,
|
|
96
|
+
sourceBranch: string,
|
|
97
|
+
config: BatchMergeConfig,
|
|
98
|
+
): Promise<BranchMergeStatus> {
|
|
99
|
+
const startTime = Date.now();
|
|
100
|
+
let worktreeCreated = false;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// Ensure worktree exists
|
|
104
|
+
const worktrees = await worktree.listAdditionalWorktrees();
|
|
105
|
+
const worktreePath = await this.ensureWorktree(branchName);
|
|
106
|
+
worktreeCreated = !worktrees.some(
|
|
107
|
+
(w: { path: string }) => w.path === worktreePath,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Execute merge
|
|
111
|
+
await git.mergeFromBranch(worktreePath, sourceBranch, config.dryRun);
|
|
112
|
+
|
|
113
|
+
// Check for conflicts
|
|
114
|
+
const hasConflict = await git.hasMergeConflict(worktreePath);
|
|
115
|
+
|
|
116
|
+
if (hasConflict) {
|
|
117
|
+
await git.abortMerge(worktreePath);
|
|
118
|
+
return {
|
|
119
|
+
branchName,
|
|
120
|
+
status: "skipped",
|
|
121
|
+
worktreeCreated,
|
|
122
|
+
durationSeconds: (Date.now() - startTime) / 1000,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Rollback dry-run merge
|
|
127
|
+
if (config.dryRun) {
|
|
128
|
+
await git.resetToHead(worktreePath);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Auto-push after successful merge
|
|
132
|
+
let pushStatus: "success" | "failed" | "not_executed" = "not_executed";
|
|
133
|
+
if (config.autoPush && !config.dryRun) {
|
|
134
|
+
try {
|
|
135
|
+
const currentBranch = await git.getCurrentBranchName(worktreePath);
|
|
136
|
+
await git.pushBranchToRemote(
|
|
137
|
+
worktreePath,
|
|
138
|
+
currentBranch,
|
|
139
|
+
config.remote || "origin",
|
|
140
|
+
);
|
|
141
|
+
pushStatus = "success";
|
|
142
|
+
} catch {
|
|
143
|
+
// Push failure should not fail the merge
|
|
144
|
+
pushStatus = "failed";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
branchName,
|
|
150
|
+
status: "success",
|
|
151
|
+
pushStatus,
|
|
152
|
+
worktreeCreated,
|
|
153
|
+
durationSeconds: (Date.now() - startTime) / 1000,
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// Check if it's a merge conflict
|
|
157
|
+
const hasConflict = await git.hasMergeConflict(
|
|
158
|
+
await this.ensureWorktree(branchName),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (hasConflict) {
|
|
162
|
+
try {
|
|
163
|
+
await git.abortMerge(await this.ensureWorktree(branchName));
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore abort errors
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
branchName,
|
|
170
|
+
status: "skipped",
|
|
171
|
+
worktreeCreated,
|
|
172
|
+
durationSeconds: (Date.now() - startTime) / 1000,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
branchName,
|
|
178
|
+
status: "failed",
|
|
179
|
+
error: error instanceof Error ? error.message : String(error),
|
|
180
|
+
worktreeCreated,
|
|
181
|
+
durationSeconds: (Date.now() - startTime) / 1000,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Execute batch merge for all target branches
|
|
188
|
+
* @param config - Batch merge configuration
|
|
189
|
+
* @param onProgress - Progress callback function
|
|
190
|
+
* @returns Batch merge result
|
|
191
|
+
* @see specs/SPEC-ee33ca26/spec.md - FR-001 to FR-015
|
|
192
|
+
*/
|
|
193
|
+
async executeBatchMerge(
|
|
194
|
+
config: BatchMergeConfig,
|
|
195
|
+
onProgress?: (progress: BatchMergeProgress) => void,
|
|
196
|
+
): Promise<BatchMergeResult> {
|
|
197
|
+
const startTime = Date.now();
|
|
198
|
+
const statuses: BranchMergeStatus[] = [];
|
|
199
|
+
|
|
200
|
+
// Fetch latest from remote
|
|
201
|
+
await git.fetchAllRemotes();
|
|
202
|
+
|
|
203
|
+
const totalBranches = config.targetBranches.length;
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < totalBranches; i++) {
|
|
206
|
+
const branchName = config.targetBranches[i] || "";
|
|
207
|
+
const elapsedSeconds = (Date.now() - startTime) / 1000;
|
|
208
|
+
|
|
209
|
+
// Report progress
|
|
210
|
+
if (onProgress) {
|
|
211
|
+
const progress: BatchMergeProgress = {
|
|
212
|
+
currentBranch: branchName,
|
|
213
|
+
currentIndex: i,
|
|
214
|
+
totalBranches,
|
|
215
|
+
percentage: Math.floor((i / totalBranches) * 100),
|
|
216
|
+
elapsedSeconds,
|
|
217
|
+
currentPhase: "merge",
|
|
218
|
+
};
|
|
219
|
+
onProgress(progress);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Merge branch
|
|
223
|
+
if (branchName) {
|
|
224
|
+
const status = await this.mergeBranch(
|
|
225
|
+
branchName,
|
|
226
|
+
config.sourceBranch,
|
|
227
|
+
config,
|
|
228
|
+
);
|
|
229
|
+
statuses.push(status);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Calculate summary
|
|
234
|
+
const summary: BatchMergeSummary = {
|
|
235
|
+
totalCount: statuses.length,
|
|
236
|
+
successCount: statuses.filter((s) => s.status === "success").length,
|
|
237
|
+
skippedCount: statuses.filter((s) => s.status === "skipped").length,
|
|
238
|
+
failedCount: statuses.filter((s) => s.status === "failed").length,
|
|
239
|
+
pushedCount: 0,
|
|
240
|
+
pushFailedCount: 0,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
statuses,
|
|
245
|
+
summary,
|
|
246
|
+
totalDurationSeconds: (Date.now() - startTime) / 1000,
|
|
247
|
+
cancelled: false,
|
|
248
|
+
config,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|