@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.
Files changed (132) hide show
  1. package/README.ja.md +323 -0
  2. package/README.md +347 -0
  3. package/bin/gwt.js +5 -0
  4. package/package.json +125 -0
  5. package/src/claude-history.ts +717 -0
  6. package/src/claude.ts +292 -0
  7. package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
  8. package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
  9. package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
  10. package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
  11. package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
  12. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
  13. package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
  14. package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
  15. package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
  16. package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
  17. package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
  18. package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
  19. package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
  20. package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
  21. package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
  22. package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
  23. package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
  24. package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
  25. package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
  26. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
  27. package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
  28. package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
  29. package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
  30. package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
  31. package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
  32. package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
  33. package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
  34. package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
  35. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
  36. package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
  37. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
  38. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
  39. package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
  40. package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
  41. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
  42. package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
  43. package/src/cli/ui/components/App.tsx +793 -0
  44. package/src/cli/ui/components/common/Confirm.tsx +40 -0
  45. package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
  46. package/src/cli/ui/components/common/Input.tsx +36 -0
  47. package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
  48. package/src/cli/ui/components/common/Select.tsx +216 -0
  49. package/src/cli/ui/components/parts/Footer.tsx +41 -0
  50. package/src/cli/ui/components/parts/Header.test.tsx +85 -0
  51. package/src/cli/ui/components/parts/Header.tsx +63 -0
  52. package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
  53. package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
  54. package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
  55. package/src/cli/ui/components/parts/Stats.tsx +67 -0
  56. package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
  57. package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
  58. package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
  59. package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
  61. package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
  62. package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
  63. package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
  64. package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
  65. package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
  66. package/src/cli/ui/hooks/useGitData.ts +157 -0
  67. package/src/cli/ui/hooks/useScreenState.ts +44 -0
  68. package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
  69. package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
  70. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
  71. package/src/cli/ui/types.ts +295 -0
  72. package/src/cli/ui/utils/baseBranch.ts +34 -0
  73. package/src/cli/ui/utils/branchFormatter.ts +222 -0
  74. package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
  75. package/src/codex.ts +139 -0
  76. package/src/config/builtin-tools.ts +44 -0
  77. package/src/config/constants.ts +100 -0
  78. package/src/config/env-history.ts +45 -0
  79. package/src/config/index.ts +204 -0
  80. package/src/config/tools.ts +293 -0
  81. package/src/git.ts +1102 -0
  82. package/src/github.ts +158 -0
  83. package/src/index.test.ts +87 -0
  84. package/src/index.ts +684 -0
  85. package/src/index.ts.backup +1543 -0
  86. package/src/launcher.ts +142 -0
  87. package/src/repositories/git.repository.ts +129 -0
  88. package/src/repositories/github.repository.ts +83 -0
  89. package/src/repositories/worktree.repository.ts +69 -0
  90. package/src/services/BatchMergeService.ts +251 -0
  91. package/src/services/WorktreeOrchestrator.ts +115 -0
  92. package/src/services/__tests__/BatchMergeService.test.ts +518 -0
  93. package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
  94. package/src/services/dependency-installer.ts +199 -0
  95. package/src/services/git.service.ts +113 -0
  96. package/src/services/github.service.ts +61 -0
  97. package/src/services/worktree.service.ts +66 -0
  98. package/src/types/api.ts +241 -0
  99. package/src/types/tools.ts +235 -0
  100. package/src/utils/spinner.ts +54 -0
  101. package/src/utils/terminal.ts +272 -0
  102. package/src/utils.test.ts +43 -0
  103. package/src/utils.ts +60 -0
  104. package/src/web/client/index.html +12 -0
  105. package/src/web/client/src/components/BranchGraph.tsx +231 -0
  106. package/src/web/client/src/components/EnvEditor.tsx +145 -0
  107. package/src/web/client/src/components/Terminal.tsx +137 -0
  108. package/src/web/client/src/hooks/useBranches.ts +41 -0
  109. package/src/web/client/src/hooks/useConfig.ts +31 -0
  110. package/src/web/client/src/hooks/useSessions.ts +59 -0
  111. package/src/web/client/src/hooks/useWorktrees.ts +47 -0
  112. package/src/web/client/src/index.css +834 -0
  113. package/src/web/client/src/lib/api.ts +184 -0
  114. package/src/web/client/src/lib/websocket.ts +174 -0
  115. package/src/web/client/src/main.tsx +29 -0
  116. package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
  117. package/src/web/client/src/pages/BranchListPage.tsx +264 -0
  118. package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
  119. package/src/web/client/src/router.tsx +27 -0
  120. package/src/web/client/vite.config.ts +21 -0
  121. package/src/web/server/env/importer.ts +54 -0
  122. package/src/web/server/index.ts +74 -0
  123. package/src/web/server/pty/manager.ts +189 -0
  124. package/src/web/server/routes/branches.ts +126 -0
  125. package/src/web/server/routes/config.ts +220 -0
  126. package/src/web/server/routes/index.ts +37 -0
  127. package/src/web/server/routes/sessions.ts +130 -0
  128. package/src/web/server/routes/worktrees.ts +108 -0
  129. package/src/web/server/services/branches.ts +368 -0
  130. package/src/web/server/services/worktrees.ts +85 -0
  131. package/src/web/server/websocket/handler.ts +180 -0
  132. package/src/worktree.ts +703 -0
@@ -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
+ }