@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,703 @@
1
+ import { execa } from "execa";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import chalk from "chalk";
5
+ import {
6
+ WorktreeConfig,
7
+ WorktreeWithPR,
8
+ CleanupTarget,
9
+ MergedPullRequest,
10
+ CleanupReason,
11
+ } from "./cli/ui/types.js";
12
+ import { getPullRequestByBranch, getMergedPullRequests } from "./github.js";
13
+ import {
14
+ hasUncommittedChanges,
15
+ hasUnpushedCommits,
16
+ hasUnpushedCommitsInRepo,
17
+ getLocalBranches,
18
+ checkRemoteBranchExists,
19
+ branchHasUniqueCommitsComparedToBase,
20
+ getRepositoryRoot,
21
+ getWorktreeRoot,
22
+ ensureGitignoreEntry,
23
+ branchExists,
24
+ getCurrentBranch,
25
+ } from "./git.js";
26
+ import { getConfig } from "./config/index.js";
27
+ import { GIT_CONFIG } from "./config/constants.js";
28
+ import { startSpinner } from "./utils/spinner.js";
29
+
30
+ // Re-export WorktreeConfig for external use
31
+ export type { WorktreeConfig };
32
+
33
+ // 保護対象のブランチ(クリーンアップから除外)
34
+ export const PROTECTED_BRANCHES = ["main", "master", "develop"];
35
+
36
+ export function isProtectedBranchName(branchName: string): boolean {
37
+ const normalized = branchName
38
+ .replace(/^refs\/heads\//, "")
39
+ .replace(/^origin\//, "");
40
+ return PROTECTED_BRANCHES.includes(normalized);
41
+ }
42
+
43
+ export async function switchToProtectedBranch({
44
+ branchName,
45
+ repoRoot,
46
+ remoteRef,
47
+ }: {
48
+ branchName: string;
49
+ repoRoot: string;
50
+ remoteRef?: string | null;
51
+ }): Promise<"none" | "local" | "remote"> {
52
+ const currentBranch = await getCurrentBranch();
53
+ if (currentBranch === branchName) {
54
+ return "none";
55
+ }
56
+
57
+ const runGit = async (args: string[]) => {
58
+ try {
59
+ await execa("git", args, { cwd: repoRoot });
60
+ } catch (error) {
61
+ throw new WorktreeError(
62
+ `Failed to execute git ${args.join(" ")} for protected branch ${branchName}`,
63
+ error,
64
+ );
65
+ }
66
+ };
67
+
68
+ if (await branchExists(branchName)) {
69
+ await runGit(["checkout", branchName]);
70
+ return "local";
71
+ }
72
+
73
+ const targetRemote = remoteRef ?? `origin/${branchName}`;
74
+ const fetchRef = targetRemote.replace(/^origin\//, "");
75
+
76
+ await runGit(["fetch", "origin", fetchRef]);
77
+ await runGit(["checkout", "-b", branchName, targetRemote]);
78
+
79
+ return "remote";
80
+ }
81
+ export class WorktreeError extends Error {
82
+ constructor(
83
+ message: string,
84
+ public cause?: unknown,
85
+ ) {
86
+ super(message);
87
+ this.name = "WorktreeError";
88
+ }
89
+ }
90
+
91
+ export interface WorktreeInfo {
92
+ path: string;
93
+ branch: string;
94
+ head: string;
95
+ isAccessible?: boolean;
96
+ invalidReason?: string;
97
+ }
98
+
99
+ async function listWorktrees(): Promise<WorktreeInfo[]> {
100
+ try {
101
+ const { stdout } = await execa("git", ["worktree", "list", "--porcelain"]);
102
+ const worktrees: WorktreeInfo[] = [];
103
+ const lines = stdout.split("\n");
104
+
105
+ let currentWorktree: Partial<WorktreeInfo> = {};
106
+
107
+ for (const line of lines) {
108
+ if (line.startsWith("worktree ")) {
109
+ if (currentWorktree.path) {
110
+ worktrees.push(currentWorktree as WorktreeInfo);
111
+ }
112
+ currentWorktree = { path: line.substring(9) };
113
+ } else if (line.startsWith("HEAD ")) {
114
+ currentWorktree.head = line.substring(5);
115
+ } else if (line.startsWith("branch ")) {
116
+ currentWorktree.branch = line.substring(7).replace("refs/heads/", "");
117
+ } else if (line === "") {
118
+ if (currentWorktree.path) {
119
+ worktrees.push(currentWorktree as WorktreeInfo);
120
+ currentWorktree = {};
121
+ }
122
+ }
123
+ }
124
+
125
+ if (currentWorktree.path) {
126
+ worktrees.push(currentWorktree as WorktreeInfo);
127
+ }
128
+
129
+ return worktrees;
130
+ } catch (error) {
131
+ throw new WorktreeError("Failed to list worktrees", error);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 追加のworktree(メインworktreeを除く)の一覧を取得
137
+ * @returns {Promise<WorktreeInfo[]>} worktree情報の配列
138
+ * @throws {WorktreeError} worktree一覧の取得に失敗した場合
139
+ */
140
+ export async function listAdditionalWorktrees(): Promise<WorktreeInfo[]> {
141
+ try {
142
+ const [allWorktrees, repoRoot] = await Promise.all([
143
+ listWorktrees(),
144
+ import("./git.js").then((m) => m.getRepositoryRoot()),
145
+ ]);
146
+
147
+ const fs = await import("node:fs");
148
+
149
+ // Filter out the main worktree (repository root) and add accessibility info
150
+ const additionalWorktrees = allWorktrees
151
+ .filter((worktree) => worktree.path !== repoRoot)
152
+ .map((worktree) => {
153
+ // パスの存在を確認
154
+ const isAccessible = fs.existsSync(worktree.path);
155
+
156
+ const result: WorktreeInfo = {
157
+ ...worktree,
158
+ isAccessible,
159
+ };
160
+
161
+ if (!isAccessible) {
162
+ result.invalidReason = "Path not accessible in current environment";
163
+ }
164
+
165
+ return result;
166
+ });
167
+
168
+ return additionalWorktrees;
169
+ } catch (error) {
170
+ throw new WorktreeError("Failed to list additional worktrees", error);
171
+ }
172
+ }
173
+
174
+ export async function worktreeExists(
175
+ branchName: string,
176
+ ): Promise<string | null> {
177
+ const worktrees = await listWorktrees();
178
+ const worktree = worktrees.find((w) => w.branch === branchName);
179
+ return worktree ? worktree.path : null;
180
+ }
181
+
182
+ export async function generateWorktreePath(
183
+ repoRoot: string,
184
+ branchName: string,
185
+ ): Promise<string> {
186
+ const sanitizedBranchName = branchName.replace(/[/\\:*?"<>|]/g, "-");
187
+ const worktreeDir = path.join(repoRoot, ".worktrees");
188
+ return path.join(worktreeDir, sanitizedBranchName);
189
+ }
190
+
191
+ /**
192
+ * 指定されたパスに既存のworktreeが存在するか確認
193
+ * @param {string} targetPath - 確認するパス
194
+ * @returns {Promise<WorktreeInfo | null>} 既存のworktree情報、または存在しない場合はnull
195
+ */
196
+ export async function checkWorktreePathConflict(
197
+ targetPath: string,
198
+ ): Promise<WorktreeInfo | null> {
199
+ const worktrees = await listWorktrees();
200
+ const existingWorktree = worktrees.find((w) => w.path === targetPath);
201
+ return existingWorktree || null;
202
+ }
203
+
204
+ /**
205
+ * 衝突を避けるため、代替のworktreeパスを生成
206
+ * @param {string} basePath - 元のパス
207
+ * @returns {Promise<string>} 利用可能な代替パス
208
+ */
209
+ export async function generateAlternativeWorktreePath(
210
+ basePath: string,
211
+ ): Promise<string> {
212
+ let counter = 2;
213
+ let alternativePath = `${basePath}-${counter}`;
214
+
215
+ // 衝突しないパスが見つかるまで試行
216
+ while (await checkWorktreePathConflict(alternativePath)) {
217
+ counter++;
218
+ alternativePath = `${basePath}-${counter}`;
219
+ }
220
+
221
+ return alternativePath;
222
+ }
223
+
224
+ /**
225
+ * 新しいworktreeを作成
226
+ * @param {WorktreeConfig} config - worktreeの設定
227
+ * @throws {WorktreeError} worktreeの作成に失敗した場合
228
+ */
229
+ export async function createWorktree(config: WorktreeConfig): Promise<void> {
230
+ if (isProtectedBranchName(config.branchName)) {
231
+ throw new WorktreeError(
232
+ `Branch "${config.branchName}" is protected and cannot be used to create a worktree`,
233
+ );
234
+ }
235
+
236
+ try {
237
+ const worktreeParentDir = path.dirname(config.worktreePath);
238
+
239
+ try {
240
+ await fs.mkdir(worktreeParentDir, { recursive: true });
241
+ } catch (error: unknown) {
242
+ const errorWithCode = error as { code?: unknown; message?: unknown };
243
+ const code =
244
+ errorWithCode && typeof errorWithCode.code === "string"
245
+ ? errorWithCode.code
246
+ : undefined;
247
+ const message =
248
+ error instanceof Error
249
+ ? error.message
250
+ : typeof errorWithCode?.message === "string"
251
+ ? errorWithCode.message
252
+ : String(error);
253
+ const reason =
254
+ code === "EEXIST"
255
+ ? `${worktreeParentDir} already exists and is not a directory`
256
+ : message;
257
+
258
+ throw new WorktreeError(
259
+ `Failed to prepare worktree directory for ${config.branchName}: ${reason}`,
260
+ error,
261
+ );
262
+ }
263
+
264
+ const args = ["worktree", "add"];
265
+
266
+ if (config.isNewBranch) {
267
+ args.push("-b", config.branchName);
268
+ }
269
+
270
+ args.push(config.worktreePath);
271
+
272
+ if (config.isNewBranch) {
273
+ args.push(config.baseBranch);
274
+ } else {
275
+ args.push(config.branchName);
276
+ }
277
+
278
+ const spinnerMessage = `Creating worktree for ${config.branchName}`;
279
+ let stopSpinner: (() => void) | undefined;
280
+
281
+ try {
282
+ stopSpinner = startSpinner(spinnerMessage);
283
+ } catch {
284
+ stopSpinner = undefined;
285
+ }
286
+
287
+ const stopActiveSpinner = () => {
288
+ if (stopSpinner) {
289
+ stopSpinner();
290
+ stopSpinner = undefined;
291
+ }
292
+ };
293
+
294
+ const gitProcess = execa("git", args);
295
+
296
+ // パイプでリアルタイムに進捗を表示する
297
+ const childProcess = gitProcess as typeof gitProcess & {
298
+ stdout?: NodeJS.ReadableStream;
299
+ stderr?: NodeJS.ReadableStream;
300
+ };
301
+
302
+ const attachStream = (
303
+ stream: NodeJS.ReadableStream | undefined,
304
+ pipeTarget: NodeJS.WriteStream,
305
+ ) => {
306
+ if (!stream) return;
307
+ if (typeof stream.once === "function") {
308
+ stream.once("data", stopActiveSpinner);
309
+ }
310
+ if (typeof stream.pipe === "function") {
311
+ stream.pipe(pipeTarget);
312
+ }
313
+ };
314
+
315
+ attachStream(childProcess.stdout, process.stdout);
316
+ attachStream(childProcess.stderr, process.stderr);
317
+
318
+ try {
319
+ await gitProcess;
320
+ } finally {
321
+ stopActiveSpinner();
322
+ }
323
+
324
+ // .gitignoreに.worktrees/を追加(エラーは警告として扱う)
325
+ try {
326
+ let gitignoreRoot = config.repoRoot;
327
+ try {
328
+ gitignoreRoot = await getWorktreeRoot();
329
+ } catch (resolveError) {
330
+ if (process.env.DEBUG) {
331
+ const reason =
332
+ resolveError instanceof Error
333
+ ? resolveError.message
334
+ : String(resolveError);
335
+ console.warn(
336
+ `Debug: Failed to resolve current worktree root for .gitignore update. Falling back to ${gitignoreRoot}. Reason: ${reason}`,
337
+ );
338
+ }
339
+ }
340
+
341
+ await ensureGitignoreEntry(gitignoreRoot, ".worktrees/");
342
+ } catch (error: unknown) {
343
+ const message = error instanceof Error ? error.message : String(error);
344
+ // .gitignoreの更新失敗は警告としてログに出すが、worktree作成は成功とする
345
+ console.warn(`Warning: Failed to update .gitignore: ${message}`);
346
+ }
347
+ } catch (error: unknown) {
348
+ // Extract more detailed error information from git command
349
+ const errorOutput = (value: unknown) =>
350
+ typeof value === "string" && value.trim().length > 0 ? value : null;
351
+ const gitError =
352
+ errorOutput((error as { stderr?: unknown })?.stderr) ??
353
+ errorOutput((error as { stdout?: unknown })?.stdout) ??
354
+ (error instanceof Error ? error.message : String(error));
355
+ const errorMessage = `Failed to create worktree for ${config.branchName}\nGit error: ${gitError}`;
356
+ throw new WorktreeError(errorMessage, error);
357
+ }
358
+ }
359
+
360
+ export async function removeWorktree(
361
+ worktreePath: string,
362
+ force = false,
363
+ ): Promise<void> {
364
+ try {
365
+ const args = ["worktree", "remove"];
366
+ if (force) {
367
+ args.push("--force");
368
+ }
369
+ args.push(worktreePath);
370
+
371
+ await execa("git", args);
372
+ } catch (error) {
373
+ throw new WorktreeError(
374
+ `Failed to remove worktree at ${worktreePath}`,
375
+ error,
376
+ );
377
+ }
378
+ }
379
+
380
+ async function getWorktreesWithPRStatus(): Promise<WorktreeWithPR[]> {
381
+ const worktrees = await listAdditionalWorktrees();
382
+ const worktreesWithPR: WorktreeWithPR[] = [];
383
+
384
+ for (const worktree of worktrees) {
385
+ if (worktree.branch) {
386
+ const pullRequest = await getPullRequestByBranch(worktree.branch);
387
+ worktreesWithPR.push({
388
+ worktreePath: worktree.path,
389
+ branch: worktree.branch,
390
+ pullRequest,
391
+ });
392
+ }
393
+ }
394
+
395
+ return worktreesWithPR;
396
+ }
397
+
398
+ /**
399
+ * worktreeに存在しないローカルブランチの中でマージ済みPRに関連するクリーンアップ候補を取得
400
+ * @returns {Promise<CleanupTarget[]>} クリーンアップ候補の配列
401
+ */
402
+ async function getOrphanedLocalBranches({
403
+ mergedPRs,
404
+ baseBranch,
405
+ repoRoot,
406
+ }: {
407
+ mergedPRs: MergedPullRequest[];
408
+ baseBranch: string;
409
+ repoRoot: string;
410
+ }): Promise<CleanupTarget[]> {
411
+ try {
412
+ // 並列実行で高速化
413
+ const [localBranches, worktrees] = await Promise.all([
414
+ getLocalBranches(),
415
+ listAdditionalWorktrees(),
416
+ ]);
417
+
418
+ const cleanupTargets: CleanupTarget[] = [];
419
+ const worktreeBranches = new Set(
420
+ worktrees.map((w) => w.branch).filter(Boolean),
421
+ );
422
+
423
+ if (process.env.DEBUG_CLEANUP) {
424
+ console.log(
425
+ chalk.cyan("Debug: Orphaned branch scan - Available local branches:"),
426
+ );
427
+ localBranches.forEach((b) =>
428
+ console.log(` ${b.name} (type: ${b.type})`),
429
+ );
430
+ console.log(
431
+ chalk.cyan(
432
+ `Debug: Worktree branches: ${Array.from(worktreeBranches).join(", ")}`,
433
+ ),
434
+ );
435
+ }
436
+
437
+ for (const localBranch of localBranches) {
438
+ // 保護対象ブランチはスキップ
439
+ if (isProtectedBranchName(localBranch.name)) {
440
+ if (process.env.DEBUG_CLEANUP) {
441
+ console.log(
442
+ chalk.yellow(
443
+ `Debug: Skipping protected branch ${localBranch.name}`,
444
+ ),
445
+ );
446
+ }
447
+ continue;
448
+ }
449
+
450
+ // worktreeに存在しないローカルブランチのみ対象
451
+ if (!worktreeBranches.has(localBranch.name)) {
452
+ const mergedPR = findMatchingPR(localBranch.name, mergedPRs);
453
+ let hasUnpushed = false;
454
+ try {
455
+ hasUnpushed = await hasUnpushedCommitsInRepo(
456
+ localBranch.name,
457
+ repoRoot,
458
+ );
459
+ } catch {
460
+ hasUnpushed = true;
461
+ }
462
+
463
+ const reasons: CleanupReason[] = [];
464
+
465
+ if (mergedPR) {
466
+ reasons.push("merged-pr");
467
+ }
468
+
469
+ if (!hasUnpushed) {
470
+ const hasUniqueCommits = await branchHasUniqueCommitsComparedToBase(
471
+ localBranch.name,
472
+ baseBranch,
473
+ repoRoot,
474
+ );
475
+
476
+ if (!hasUniqueCommits) {
477
+ reasons.push("no-diff-with-base");
478
+ }
479
+ }
480
+
481
+ if (process.env.DEBUG_CLEANUP) {
482
+ console.log(
483
+ chalk.gray(
484
+ `Debug: Checking orphaned branch ${localBranch.name} -> PR: ${mergedPR ? "MATCH" : "NO MATCH"}, reasons: ${reasons.join(", ")}`,
485
+ ),
486
+ );
487
+ }
488
+
489
+ if (reasons.length > 0) {
490
+ let hasRemoteBranch = false;
491
+ try {
492
+ hasRemoteBranch = await checkRemoteBranchExists(localBranch.name);
493
+ } catch {
494
+ hasRemoteBranch = false;
495
+ }
496
+
497
+ cleanupTargets.push({
498
+ worktreePath: null, // worktreeは存在しない
499
+ branch: localBranch.name,
500
+ pullRequest: mergedPR ?? null,
501
+ hasUncommittedChanges: false, // worktreeが存在しないため常にfalse
502
+ hasUnpushedCommits: hasUnpushed,
503
+ cleanupType: "branch-only",
504
+ hasRemoteBranch,
505
+ reasons,
506
+ });
507
+ }
508
+ }
509
+ }
510
+
511
+ if (process.env.DEBUG_CLEANUP) {
512
+ console.log(
513
+ chalk.cyan(
514
+ `Debug: Found ${cleanupTargets.length} orphaned branch cleanup targets`,
515
+ ),
516
+ );
517
+ }
518
+
519
+ return cleanupTargets;
520
+ } catch (error) {
521
+ console.error(chalk.red("Error: Failed to get orphaned local branches"));
522
+ if (process.env.DEBUG_CLEANUP) {
523
+ console.error(chalk.red("Debug: Full error details:"), error);
524
+ }
525
+ return [];
526
+ }
527
+ }
528
+
529
+ function normalizeBranchName(branchName: string): string {
530
+ return branchName
531
+ .replace(/^origin\//, "")
532
+ .replace(/^refs\/heads\//, "")
533
+ .replace(/^refs\/remotes\/origin\//, "")
534
+ .trim();
535
+ }
536
+
537
+ function findMatchingPR(
538
+ worktreeBranch: string,
539
+ mergedPRs: MergedPullRequest[],
540
+ ): MergedPullRequest | null {
541
+ const normalizedWorktreeBranch = normalizeBranchName(worktreeBranch);
542
+
543
+ for (const pr of mergedPRs) {
544
+ const normalizedPRBranch = normalizeBranchName(pr.branch);
545
+
546
+ if (normalizedWorktreeBranch === normalizedPRBranch) {
547
+ return pr;
548
+ }
549
+ }
550
+
551
+ return null;
552
+ }
553
+
554
+ /**
555
+ * マージ済みPRに関連するworktreeおよびローカルブランチのクリーンアップ候補を取得
556
+ * @returns {Promise<CleanupTarget[]>} クリーンアップ候補の配列
557
+ */
558
+ export async function getMergedPRWorktrees(): Promise<CleanupTarget[]> {
559
+ const [config, repoRoot] = await Promise.all([
560
+ getConfig(),
561
+ getRepositoryRoot(),
562
+ ]);
563
+ const baseBranch = config.defaultBaseBranch || GIT_CONFIG.DEFAULT_BASE_BRANCH;
564
+
565
+ // 並列実行で高速化 - worktreeとマージ済みPRの両方を取得
566
+ const [mergedPRs, worktreesWithPR] = await Promise.all([
567
+ getMergedPullRequests(),
568
+ getWorktreesWithPRStatus(),
569
+ ]);
570
+ const orphanedBranches = await getOrphanedLocalBranches({
571
+ mergedPRs,
572
+ baseBranch,
573
+ repoRoot,
574
+ });
575
+ const cleanupTargets: CleanupTarget[] = [];
576
+
577
+ if (process.env.DEBUG_CLEANUP) {
578
+ console.log(chalk.cyan("Debug: Available worktrees:"));
579
+ worktreesWithPR.forEach((w) =>
580
+ console.log(` ${w.branch} -> ${w.worktreePath}`),
581
+ );
582
+ console.log(chalk.cyan("Debug: Merged PRs:"));
583
+ mergedPRs.forEach((pr) => console.log(` ${pr.branch} (PR #${pr.number})`));
584
+ }
585
+
586
+ for (const worktree of worktreesWithPR) {
587
+ // 保護対象ブランチはスキップ
588
+ if (isProtectedBranchName(worktree.branch)) {
589
+ if (process.env.DEBUG_CLEANUP) {
590
+ console.log(
591
+ chalk.yellow(`Debug: Skipping protected branch ${worktree.branch}`),
592
+ );
593
+ }
594
+ continue;
595
+ }
596
+
597
+ const mergedPR = findMatchingPR(worktree.branch, mergedPRs);
598
+
599
+ if (process.env.DEBUG_CLEANUP) {
600
+ const normalizedWorktree = normalizeBranchName(worktree.branch);
601
+ console.log(
602
+ chalk.gray(
603
+ `Debug: Checking worktree ${worktree.branch} (normalized: ${normalizedWorktree}) -> ${mergedPR ? "MATCH" : "NO MATCH"}`,
604
+ ),
605
+ );
606
+ }
607
+
608
+ const cleanupReasons: CleanupReason[] = [];
609
+
610
+ if (mergedPR) {
611
+ cleanupReasons.push("merged-pr");
612
+ }
613
+
614
+ // worktreeパスの存在を確認
615
+ const fs = await import("node:fs");
616
+ const isAccessible = fs.existsSync(worktree.worktreePath);
617
+
618
+ let hasUncommitted = false;
619
+ let hasUnpushed = false;
620
+
621
+ if (isAccessible) {
622
+ // worktreeが存在する場合のみ状態をチェック
623
+ try {
624
+ [hasUncommitted, hasUnpushed] = await Promise.all([
625
+ hasUncommittedChanges(worktree.worktreePath),
626
+ hasUnpushedCommits(worktree.worktreePath, worktree.branch),
627
+ ]);
628
+ } catch (error) {
629
+ // エラーが発生した場合はデフォルト値を使用
630
+ if (process.env.DEBUG_CLEANUP) {
631
+ console.log(
632
+ chalk.yellow(
633
+ `Debug: Failed to check status for worktree ${worktree.worktreePath}: ${error instanceof Error ? error.message : String(error)}`,
634
+ ),
635
+ );
636
+ }
637
+ }
638
+ }
639
+
640
+ if (!hasUnpushed) {
641
+ const hasUniqueCommits = await branchHasUniqueCommitsComparedToBase(
642
+ worktree.branch,
643
+ baseBranch,
644
+ repoRoot,
645
+ );
646
+
647
+ if (!hasUniqueCommits) {
648
+ cleanupReasons.push("no-diff-with-base");
649
+ }
650
+ }
651
+
652
+ if (process.env.DEBUG_CLEANUP) {
653
+ console.log(
654
+ chalk.gray(
655
+ `Debug: Cleanup reasons for ${worktree.branch}: ${cleanupReasons.length > 0 ? cleanupReasons.join(", ") : "none"}`,
656
+ ),
657
+ );
658
+ }
659
+
660
+ if (cleanupReasons.length === 0) {
661
+ continue;
662
+ }
663
+
664
+ const hasRemoteBranch = await checkRemoteBranchExists(worktree.branch);
665
+
666
+ const target: CleanupTarget = {
667
+ worktreePath: worktree.worktreePath,
668
+ branch: worktree.branch,
669
+ pullRequest: mergedPR ?? null,
670
+ hasUncommittedChanges: hasUncommitted,
671
+ hasUnpushedCommits: hasUnpushed,
672
+ cleanupType: "worktree-and-branch",
673
+ hasRemoteBranch,
674
+ isAccessible,
675
+ reasons: cleanupReasons,
676
+ };
677
+
678
+ if (!isAccessible) {
679
+ target.invalidReason = "Path not accessible in current environment";
680
+ }
681
+
682
+ cleanupTargets.push(target);
683
+ }
684
+
685
+ // orphanedBranches (ローカルブランチのみの削除対象) を追加
686
+ cleanupTargets.push(...orphanedBranches);
687
+
688
+ if (process.env.DEBUG_CLEANUP) {
689
+ const worktreeTargets = cleanupTargets.filter(
690
+ (t) => t.cleanupType === "worktree-and-branch",
691
+ ).length;
692
+ const branchOnlyTargets = cleanupTargets.filter(
693
+ (t) => t.cleanupType === "branch-only",
694
+ ).length;
695
+ console.log(
696
+ chalk.cyan(
697
+ `Debug: Found ${cleanupTargets.length} cleanup targets (${worktreeTargets} worktree+branch, ${branchOnlyTargets} branch-only)`,
698
+ ),
699
+ );
700
+ }
701
+
702
+ return cleanupTargets;
703
+ }