@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
package/src/git.ts ADDED
@@ -0,0 +1,1102 @@
1
+ import { execa } from "execa";
2
+ import path from "node:path";
3
+ import { BranchInfo } from "./cli/ui/types.js";
4
+
5
+ export class GitError extends Error {
6
+ constructor(
7
+ message: string,
8
+ public cause?: unknown,
9
+ ) {
10
+ super(message);
11
+ this.name = "GitError";
12
+ }
13
+ }
14
+
15
+ /**
16
+ * 現在のディレクトリがGitリポジトリかどうかを確認
17
+ * Worktree環境でも動作するように、.gitファイルの存在も確認します
18
+ * @returns {Promise<boolean>} Gitリポジトリの場合true
19
+ */
20
+ export async function isGitRepository(): Promise<boolean> {
21
+ try {
22
+ // まず.gitの存在を確認(ディレクトリまたはファイル)
23
+ const fs = await import("node:fs");
24
+ const gitPath = path.join(process.cwd(), ".git");
25
+
26
+ if (fs.existsSync(gitPath)) {
27
+ // .gitが存在する場合、Git環境として認識
28
+ if (process.env.DEBUG) {
29
+ const stats = fs.statSync(gitPath);
30
+ console.error(
31
+ `[DEBUG] .git exists: ${gitPath} (${stats.isDirectory() ? "directory" : "file"})`,
32
+ );
33
+ }
34
+ return true;
35
+ }
36
+
37
+ // .gitが存在しない場合、git rev-parseで確認
38
+ const result = await execa("git", ["rev-parse", "--git-dir"]);
39
+ if (process.env.DEBUG) {
40
+ console.error(`[DEBUG] git rev-parse --git-dir: ${result.stdout}`);
41
+ }
42
+ return true;
43
+ } catch (error: any) {
44
+ // Debug: log the error for troubleshooting
45
+ if (process.env.DEBUG) {
46
+ console.error(`[DEBUG] git rev-parse --git-dir failed:`, error.message);
47
+ if (error.stderr) {
48
+ console.error(`[DEBUG] stderr:`, error.stderr);
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Gitリポジトリのルートディレクトリを取得
57
+ * @returns {Promise<string>} リポジトリのルートパス
58
+ * @throws {GitError} リポジトリルートの取得に失敗した場合
59
+ */
60
+ export async function getRepositoryRoot(): Promise<string> {
61
+ try {
62
+ // git rev-parse --git-common-dirを使用してメインリポジトリの.gitディレクトリを取得
63
+ const { stdout: gitCommonDir } = await execa("git", [
64
+ "rev-parse",
65
+ "--git-common-dir",
66
+ ]);
67
+ const gitDir = gitCommonDir.trim();
68
+
69
+ // .gitディレクトリの親ディレクトリがリポジトリルート
70
+ const path = await import("node:path");
71
+ const repoRoot = path.dirname(gitDir);
72
+
73
+ // 相対パスが返された場合(.gitなど)は、現在のディレクトリからの相対パスとして解決
74
+ if (!path.isAbsolute(repoRoot)) {
75
+ return path.resolve(repoRoot);
76
+ }
77
+
78
+ return repoRoot;
79
+ } catch (error) {
80
+ throw new GitError("Failed to get repository root", error);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 現在の作業ツリー(root branch か worktree かを問わず)のルートディレクトリを取得
86
+ * @returns {Promise<string>} カレントWorktreeのルートパス
87
+ * @throws {GitError} 取得に失敗した場合
88
+ */
89
+ export async function getWorktreeRoot(): Promise<string> {
90
+ try {
91
+ const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
92
+ return stdout.trim();
93
+ } catch (error) {
94
+ throw new GitError("Failed to get worktree root", error);
95
+ }
96
+ }
97
+
98
+ export async function getCurrentBranch(): Promise<string | null> {
99
+ try {
100
+ const { stdout } = await execa("git", ["branch", "--show-current"]);
101
+ return stdout.trim() || null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ async function getBranchCommitTimestamps(
108
+ refs: string[],
109
+ ): Promise<Map<string, number>> {
110
+ try {
111
+ const { stdout } = await execa("git", [
112
+ "for-each-ref",
113
+ "--format=%(refname:short)%00%(committerdate:unix)",
114
+ ...refs,
115
+ ]);
116
+
117
+ const map = new Map<string, number>();
118
+
119
+ for (const line of stdout.split("\n")) {
120
+ if (!line) continue;
121
+ const [ref, timestamp] = line.split("\0");
122
+ if (!ref || !timestamp) continue;
123
+ if (ref.endsWith("/HEAD")) continue;
124
+ const parsed = Number.parseInt(timestamp, 10);
125
+ if (Number.isNaN(parsed)) continue;
126
+ map.set(ref, parsed);
127
+ }
128
+
129
+ return map;
130
+ } catch (error) {
131
+ throw new GitError("Failed to get branch commit timestamps", error);
132
+ }
133
+ }
134
+
135
+ export async function getLocalBranches(): Promise<BranchInfo[]> {
136
+ try {
137
+ const commitMap = await getBranchCommitTimestamps(["refs/heads"]);
138
+ const { stdout } = await execa("git", [
139
+ "branch",
140
+ "--format=%(refname:short)",
141
+ ]);
142
+ return stdout
143
+ .split("\n")
144
+ .filter((line) => line.trim())
145
+ .map((name) => {
146
+ const trimmed = name.trim();
147
+ const timestamp = commitMap.get(trimmed);
148
+
149
+ return {
150
+ name: trimmed,
151
+ type: "local" as const,
152
+ branchType: getBranchType(trimmed),
153
+ isCurrent: false,
154
+ ...(timestamp !== undefined
155
+ ? { latestCommitTimestamp: timestamp }
156
+ : {}),
157
+ } satisfies BranchInfo;
158
+ });
159
+ } catch (error) {
160
+ throw new GitError("Failed to get local branches", error);
161
+ }
162
+ }
163
+
164
+ export async function getRemoteBranches(): Promise<BranchInfo[]> {
165
+ try {
166
+ const commitMap = await getBranchCommitTimestamps(["refs/remotes"]);
167
+ const { stdout } = await execa("git", [
168
+ "branch",
169
+ "-r",
170
+ "--format=%(refname:short)",
171
+ ]);
172
+ return stdout
173
+ .split("\n")
174
+ .filter((line) => line.trim() && !line.includes("HEAD"))
175
+ .map((line) => {
176
+ const name = line.trim();
177
+ const branchName = name.replace(/^origin\//, "");
178
+ const timestamp = commitMap.get(name);
179
+
180
+ return {
181
+ name,
182
+ type: "remote" as const,
183
+ branchType: getBranchType(branchName),
184
+ isCurrent: false,
185
+ ...(timestamp !== undefined
186
+ ? { latestCommitTimestamp: timestamp }
187
+ : {}),
188
+ } satisfies BranchInfo;
189
+ });
190
+ } catch (error) {
191
+ throw new GitError("Failed to get remote branches", error);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * ローカルとリモートのすべてのブランチ情報を取得
197
+ * @returns {Promise<BranchInfo[]>} ブランチ情報の配列
198
+ */
199
+ export async function getAllBranches(): Promise<BranchInfo[]> {
200
+ const [localBranches, remoteBranches, currentBranch] = await Promise.all([
201
+ getLocalBranches(),
202
+ getRemoteBranches(),
203
+ getCurrentBranch(),
204
+ ]);
205
+
206
+ // 現在のブランチ情報を設定
207
+ if (currentBranch) {
208
+ localBranches.forEach((branch) => {
209
+ if (branch.name === currentBranch) {
210
+ branch.isCurrent = true;
211
+ }
212
+ });
213
+ }
214
+
215
+ return [...localBranches, ...remoteBranches];
216
+ }
217
+
218
+ export async function createBranch(
219
+ branchName: string,
220
+ baseBranch = "main",
221
+ ): Promise<void> {
222
+ try {
223
+ await execa("git", ["checkout", "-b", branchName, baseBranch]);
224
+ } catch (error) {
225
+ throw new GitError(`Failed to create branch ${branchName}`, error);
226
+ }
227
+ }
228
+
229
+ export async function branchExists(branchName: string): Promise<boolean> {
230
+ try {
231
+ await execa("git", [
232
+ "show-ref",
233
+ "--verify",
234
+ "--quiet",
235
+ `refs/heads/${branchName}`,
236
+ ]);
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ export async function deleteBranch(
244
+ branchName: string,
245
+ force = false,
246
+ ): Promise<void> {
247
+ try {
248
+ const args = ["branch", force ? "-D" : "-d", branchName];
249
+ await execa("git", args);
250
+ } catch (error) {
251
+ throw new GitError(`Failed to delete branch ${branchName}`, error);
252
+ }
253
+ }
254
+
255
+ interface WorktreeStatusResult {
256
+ hasChanges: boolean;
257
+ changedFilesCount: number;
258
+ }
259
+
260
+ async function getWorkdirStatus(
261
+ worktreePath: string,
262
+ ): Promise<WorktreeStatusResult> {
263
+ try {
264
+ // ファイルシステムの存在確認のためにfs.existsSyncを使用
265
+ const fs = await import("node:fs");
266
+ if (!fs.existsSync(worktreePath)) {
267
+ // worktreeパスが存在しない場合はデフォルト値を返す
268
+ return {
269
+ hasChanges: false,
270
+ changedFilesCount: 0,
271
+ };
272
+ }
273
+
274
+ const { stdout } = await execa("git", ["status", "--porcelain"], {
275
+ cwd: worktreePath,
276
+ });
277
+ const lines = stdout.split("\n").filter((line) => line.trim());
278
+ return {
279
+ hasChanges: lines.length > 0,
280
+ changedFilesCount: lines.length,
281
+ };
282
+ } catch (error) {
283
+ throw new GitError(
284
+ `Failed to get worktree status for path: ${worktreePath}`,
285
+ error,
286
+ );
287
+ }
288
+ }
289
+
290
+ export async function hasUncommittedChanges(
291
+ worktreePath: string,
292
+ ): Promise<boolean> {
293
+ const status = await getWorkdirStatus(worktreePath);
294
+ return status.hasChanges;
295
+ }
296
+
297
+ export async function getChangedFilesCount(
298
+ worktreePath: string,
299
+ ): Promise<number> {
300
+ const status = await getWorkdirStatus(worktreePath);
301
+ return status.changedFilesCount;
302
+ }
303
+
304
+ export async function showStatus(worktreePath: string): Promise<string> {
305
+ try {
306
+ const { stdout } = await execa("git", ["status"], { cwd: worktreePath });
307
+ return stdout;
308
+ } catch (error) {
309
+ throw new GitError("Failed to show status", error);
310
+ }
311
+ }
312
+
313
+ export async function stashChanges(
314
+ worktreePath: string,
315
+ message?: string,
316
+ ): Promise<void> {
317
+ try {
318
+ const args = message ? ["stash", "push", "-m", message] : ["stash"];
319
+ await execa("git", args, { cwd: worktreePath });
320
+ } catch (error) {
321
+ throw new GitError("Failed to stash changes", error);
322
+ }
323
+ }
324
+
325
+ export async function discardAllChanges(worktreePath: string): Promise<void> {
326
+ try {
327
+ // Reset tracked files
328
+ await execa("git", ["reset", "--hard"], { cwd: worktreePath });
329
+ // Clean untracked files
330
+ await execa("git", ["clean", "-fd"], { cwd: worktreePath });
331
+ } catch (error) {
332
+ throw new GitError("Failed to discard changes", error);
333
+ }
334
+ }
335
+
336
+ export async function commitChanges(
337
+ worktreePath: string,
338
+ message: string,
339
+ ): Promise<void> {
340
+ try {
341
+ // Add all changes
342
+ await execa("git", ["add", "-A"], { cwd: worktreePath });
343
+ // Commit
344
+ await execa("git", ["commit", "-m", message], { cwd: worktreePath });
345
+ } catch (error) {
346
+ throw new GitError("Failed to commit changes", error);
347
+ }
348
+ }
349
+
350
+ function getBranchType(branchName: string): BranchInfo["branchType"] {
351
+ if (branchName === "main" || branchName === "master") return "main";
352
+ if (branchName === "develop" || branchName === "dev") return "develop";
353
+ if (branchName.startsWith("feature/")) return "feature";
354
+ if (branchName.startsWith("hotfix/")) return "hotfix";
355
+ if (branchName.startsWith("release/")) return "release";
356
+ return "other";
357
+ }
358
+
359
+ async function hasUnpushedCommitsInternal(
360
+ branch: string,
361
+ options: { cwd?: string } = {},
362
+ ): Promise<boolean> {
363
+ const { cwd } = options;
364
+ const execOptions = cwd ? { cwd } : undefined;
365
+ try {
366
+ const { stdout } = await execa(
367
+ "git",
368
+ ["log", `origin/${branch}..${branch}`, "--oneline"],
369
+ execOptions,
370
+ );
371
+ return stdout.trim().length > 0;
372
+ } catch {
373
+ const candidates = [
374
+ `origin/${branch}`,
375
+ "origin/main",
376
+ "origin/master",
377
+ "origin/develop",
378
+ "origin/dev",
379
+ branch,
380
+ "main",
381
+ "master",
382
+ "develop",
383
+ "dev",
384
+ ];
385
+
386
+ for (const candidate of candidates) {
387
+ try {
388
+ await execa("git", ["rev-parse", "--verify", candidate], execOptions);
389
+
390
+ // If we are checking the same branch again, we already know the remote ref is missing.
391
+ if (candidate === `origin/${branch}` || candidate === branch) {
392
+ continue;
393
+ }
394
+
395
+ try {
396
+ await execa(
397
+ "git",
398
+ ["merge-base", "--is-ancestor", branch, candidate],
399
+ execOptions,
400
+ );
401
+ return false;
402
+ } catch {
403
+ // Not merged into this candidate, try next one.
404
+ }
405
+ } catch {
406
+ // Candidate ref does not exist. Try the next candidate.
407
+ }
408
+ }
409
+
410
+ // Could not prove that the branch is merged anywhere safe, treat as unpushed commits.
411
+ return true;
412
+ }
413
+ }
414
+
415
+ export async function hasUnpushedCommits(
416
+ worktreePath: string,
417
+ branch: string,
418
+ ): Promise<boolean> {
419
+ return hasUnpushedCommitsInternal(branch, { cwd: worktreePath });
420
+ }
421
+
422
+ export async function hasUnpushedCommitsInRepo(
423
+ branch: string,
424
+ repoRoot?: string,
425
+ ): Promise<boolean> {
426
+ return hasUnpushedCommitsInternal(branch, repoRoot ? { cwd: repoRoot } : {});
427
+ }
428
+
429
+ export async function branchHasUniqueCommitsComparedToBase(
430
+ branch: string,
431
+ baseBranch: string,
432
+ repoRoot?: string,
433
+ ): Promise<boolean> {
434
+ const execOptions = repoRoot ? { cwd: repoRoot } : undefined;
435
+ try {
436
+ await execa("git", ["rev-parse", "--verify", branch], execOptions);
437
+ } catch {
438
+ return true;
439
+ }
440
+
441
+ const normalizedBase = baseBranch.trim();
442
+ if (!normalizedBase) {
443
+ return true;
444
+ }
445
+
446
+ const candidates = new Set<string>();
447
+ candidates.add(normalizedBase);
448
+
449
+ if (!normalizedBase.startsWith("origin/")) {
450
+ candidates.add(`origin/${normalizedBase}`);
451
+ } else {
452
+ const localEquivalent = normalizedBase.replace(/^origin\//, "");
453
+ if (localEquivalent) {
454
+ candidates.add(localEquivalent);
455
+ }
456
+ }
457
+
458
+ for (const candidate of candidates) {
459
+ try {
460
+ await execa("git", ["rev-parse", "--verify", candidate], execOptions);
461
+ } catch {
462
+ continue;
463
+ }
464
+
465
+ try {
466
+ const { stdout } = await execa(
467
+ "git",
468
+ ["log", `${candidate}..${branch}`, "--oneline"],
469
+ execOptions,
470
+ );
471
+
472
+ if (stdout.trim().length > 0) {
473
+ return true;
474
+ }
475
+
476
+ return false;
477
+ } catch {
478
+ // Comparison failed for this candidate, try next one.
479
+ }
480
+ }
481
+
482
+ // If no valid base candidate was found, treat the branch as having unique commits.
483
+ return true;
484
+ }
485
+
486
+ /**
487
+ * Get the latest commit message for a specific branch in a worktree
488
+ */
489
+ export async function getLatestCommitMessage(
490
+ worktreePath: string,
491
+ branch: string,
492
+ ): Promise<string | null> {
493
+ try {
494
+ const { stdout } = await execa(
495
+ "git",
496
+ ["log", "-1", "--pretty=format:%s", branch],
497
+ { cwd: worktreePath },
498
+ );
499
+ return stdout.trim() || null;
500
+ } catch {
501
+ return null;
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Get the count of unpushed commits
507
+ */
508
+ export async function getUnpushedCommitsCount(
509
+ worktreePath: string,
510
+ branch: string,
511
+ ): Promise<number> {
512
+ try {
513
+ const { stdout } = await execa(
514
+ "git",
515
+ ["rev-list", "--count", `origin/${branch}..${branch}`],
516
+ { cwd: worktreePath },
517
+ );
518
+ return parseInt(stdout.trim(), 10) || 0;
519
+ } catch {
520
+ return 0;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Get the count of uncommitted changes (staged + unstaged)
526
+ */
527
+ export async function getUncommittedChangesCount(
528
+ worktreePath: string,
529
+ ): Promise<number> {
530
+ try {
531
+ const { stdout } = await execa("git", ["status", "--porcelain"], {
532
+ cwd: worktreePath,
533
+ });
534
+ return stdout
535
+ .trim()
536
+ .split("\n")
537
+ .filter((line) => line.trim()).length;
538
+ } catch {
539
+ return 0;
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Enhanced session information for better display
545
+ */
546
+ export interface EnhancedSessionInfo {
547
+ hasUncommittedChanges: boolean;
548
+ uncommittedChangesCount: number;
549
+ hasUnpushedCommits: boolean;
550
+ unpushedCommitsCount: number;
551
+ latestCommitMessage: string | null;
552
+ branchType:
553
+ | "feature"
554
+ | "bugfix"
555
+ | "hotfix"
556
+ | "develop"
557
+ | "main"
558
+ | "master"
559
+ | "other";
560
+ }
561
+
562
+ /**
563
+ * Get enhanced session information for display
564
+ */
565
+ export async function getEnhancedSessionInfo(
566
+ worktreePath: string,
567
+ branch: string,
568
+ ): Promise<EnhancedSessionInfo> {
569
+ try {
570
+ const [
571
+ hasUncommitted,
572
+ uncommittedCount,
573
+ hasUnpushed,
574
+ unpushedCount,
575
+ latestCommit,
576
+ ] = await Promise.all([
577
+ hasUncommittedChanges(worktreePath),
578
+ getUncommittedChangesCount(worktreePath),
579
+ hasUnpushedCommits(worktreePath, branch),
580
+ getUnpushedCommitsCount(worktreePath, branch),
581
+ getLatestCommitMessage(worktreePath, branch),
582
+ ]);
583
+
584
+ // Determine branch type based on branch name
585
+ let branchType: EnhancedSessionInfo["branchType"] = "other";
586
+ const lowerBranch = branch.toLowerCase();
587
+
588
+ if (lowerBranch.startsWith("feature/") || lowerBranch.startsWith("feat/")) {
589
+ branchType = "feature";
590
+ } else if (
591
+ lowerBranch.startsWith("bugfix/") ||
592
+ lowerBranch.startsWith("bug/") ||
593
+ lowerBranch.startsWith("fix/")
594
+ ) {
595
+ branchType = "bugfix";
596
+ } else if (lowerBranch.startsWith("hotfix/")) {
597
+ branchType = "hotfix";
598
+ } else if (lowerBranch === "develop" || lowerBranch === "development") {
599
+ branchType = "develop";
600
+ } else if (lowerBranch === "main") {
601
+ branchType = "main";
602
+ } else if (lowerBranch === "master") {
603
+ branchType = "master";
604
+ }
605
+
606
+ return {
607
+ hasUncommittedChanges: hasUncommitted,
608
+ uncommittedChangesCount: uncommittedCount,
609
+ hasUnpushedCommits: hasUnpushed,
610
+ unpushedCommitsCount: unpushedCount,
611
+ latestCommitMessage: latestCommit,
612
+ branchType,
613
+ };
614
+ } catch {
615
+ // Return safe defaults if any operation fails
616
+ return {
617
+ hasUncommittedChanges: false,
618
+ uncommittedChangesCount: 0,
619
+ hasUnpushedCommits: false,
620
+ unpushedCommitsCount: 0,
621
+ latestCommitMessage: null,
622
+ branchType: "other",
623
+ };
624
+ }
625
+ }
626
+
627
+ export async function fetchAllRemotes(options?: {
628
+ cwd?: string;
629
+ }): Promise<void> {
630
+ try {
631
+ const execOptions = options?.cwd ? { cwd: options.cwd } : undefined;
632
+ const args = ["fetch", "--all", "--prune"];
633
+ if (execOptions) {
634
+ await execa("git", args, execOptions);
635
+ } else {
636
+ await execa("git", args);
637
+ }
638
+ } catch (error) {
639
+ throw new GitError("Failed to fetch remote branches", error);
640
+ }
641
+ }
642
+
643
+ export async function getCurrentVersion(repoRoot: string): Promise<string> {
644
+ try {
645
+ const packageJsonPath = path.join(repoRoot, "package.json");
646
+ const fs = await import("node:fs");
647
+ const packageJson = JSON.parse(
648
+ await fs.promises.readFile(packageJsonPath, "utf-8"),
649
+ );
650
+ return packageJson.version || "0.0.0";
651
+ } catch {
652
+ // package.jsonが存在しない場合はデフォルトバージョンを返す
653
+ return "0.0.0";
654
+ }
655
+ }
656
+
657
+ export function calculateNewVersion(
658
+ currentVersion: string,
659
+ versionBump: "patch" | "minor" | "major",
660
+ ): string {
661
+ const versionParts = currentVersion.split(".");
662
+ const major = parseInt(versionParts[0] || "0", 10);
663
+ const minor = parseInt(versionParts[1] || "0", 10);
664
+ const patch = parseInt(versionParts[2] || "0", 10);
665
+
666
+ switch (versionBump) {
667
+ case "major":
668
+ return `${major + 1}.0.0`;
669
+ case "minor":
670
+ return `${major}.${minor + 1}.0`;
671
+ case "patch":
672
+ return `${major}.${minor}.${patch + 1}`;
673
+ }
674
+ }
675
+
676
+ export async function executeNpmVersionInWorktree(
677
+ worktreePath: string,
678
+ newVersion: string,
679
+ ): Promise<void> {
680
+ try {
681
+ // まずpackage.jsonが存在するか確認
682
+ const fs = await import("node:fs");
683
+ const packageJsonPath = path.join(worktreePath, "package.json");
684
+
685
+ if (!fs.existsSync(packageJsonPath)) {
686
+ // package.jsonが存在しない場合は作成
687
+ const packageJson = {
688
+ name: path.basename(worktreePath),
689
+ version: newVersion,
690
+ description: "",
691
+ main: "index.js",
692
+ scripts: {},
693
+ keywords: [],
694
+ author: "",
695
+ license: "ISC",
696
+ };
697
+ await fs.promises.writeFile(
698
+ packageJsonPath,
699
+ JSON.stringify(packageJson, null, 2) + "\n",
700
+ );
701
+
702
+ // 新規作成したpackage.jsonをコミット
703
+ await execa("git", ["add", "package.json"], { cwd: worktreePath });
704
+ await execa(
705
+ "git",
706
+ [
707
+ "commit",
708
+ "-m",
709
+ `chore: create package.json with version ${newVersion}`,
710
+ ],
711
+ { cwd: worktreePath },
712
+ );
713
+ } else {
714
+ // package.json の version を直接書き換え(外部PMに依存しない)
715
+ const content = await fs.promises.readFile(packageJsonPath, "utf-8");
716
+ const json = JSON.parse(content);
717
+ json.version = newVersion;
718
+ await fs.promises.writeFile(
719
+ packageJsonPath,
720
+ JSON.stringify(json, null, 2) + "\n",
721
+ );
722
+
723
+ // 変更をコミット
724
+ await execa("git", ["add", "package.json"], { cwd: worktreePath });
725
+ await execa(
726
+ "git",
727
+ ["commit", "-m", `chore: bump version to ${newVersion}`],
728
+ { cwd: worktreePath },
729
+ );
730
+ }
731
+ } catch (error: any) {
732
+ // エラーの詳細情報を含める
733
+ const errorMessage = error instanceof Error ? error.message : String(error);
734
+ const errorDetails = error?.stderr ? ` (stderr: ${error.stderr})` : "";
735
+ const errorStdout = error?.stdout ? ` (stdout: ${error.stdout})` : "";
736
+ throw new GitError(
737
+ `Failed to update version to ${newVersion} in worktree: ${errorMessage}${errorDetails}${errorStdout}`,
738
+ error,
739
+ );
740
+ }
741
+ }
742
+
743
+ export async function deleteRemoteBranch(
744
+ branchName: string,
745
+ remote = "origin",
746
+ ): Promise<void> {
747
+ try {
748
+ await execa("git", ["push", remote, "--delete", branchName]);
749
+ } catch (error) {
750
+ throw new GitError(
751
+ `Failed to delete remote branch ${remote}/${branchName}`,
752
+ error,
753
+ );
754
+ }
755
+ }
756
+
757
+ export async function getCurrentBranchName(
758
+ worktreePath: string,
759
+ ): Promise<string> {
760
+ try {
761
+ const { stdout } = await execa("git", ["branch", "--show-current"], {
762
+ cwd: worktreePath,
763
+ });
764
+ return stdout.trim();
765
+ } catch (error) {
766
+ throw new GitError("Failed to get current branch name", error);
767
+ }
768
+ }
769
+
770
+ export async function pushBranchToRemote(
771
+ worktreePath: string,
772
+ branchName: string,
773
+ remote = "origin",
774
+ ): Promise<void> {
775
+ try {
776
+ // Check if the remote branch exists
777
+ const remoteBranchExists = await checkRemoteBranchExists(
778
+ branchName,
779
+ remote,
780
+ { cwd: worktreePath },
781
+ );
782
+
783
+ if (remoteBranchExists) {
784
+ // Push to existing remote branch
785
+ await execa("git", ["push", remote, branchName], { cwd: worktreePath });
786
+ } else {
787
+ // Push and set upstream for new remote branch
788
+ await execa("git", ["push", "--set-upstream", remote, branchName], {
789
+ cwd: worktreePath,
790
+ });
791
+ }
792
+ } catch (error) {
793
+ throw new GitError(
794
+ `Failed to push branch ${branchName} to ${remote}`,
795
+ error,
796
+ );
797
+ }
798
+ }
799
+
800
+ export async function checkRemoteBranchExists(
801
+ branchName: string,
802
+ remote = "origin",
803
+ options?: { cwd?: string },
804
+ ): Promise<boolean> {
805
+ try {
806
+ const execOptions = options?.cwd ? { cwd: options.cwd } : undefined;
807
+ const args = [
808
+ "show-ref",
809
+ "--verify",
810
+ "--quiet",
811
+ `refs/remotes/${remote}/${branchName}`,
812
+ ];
813
+ if (execOptions) {
814
+ await execa("git", args, execOptions);
815
+ } else {
816
+ await execa("git", args);
817
+ }
818
+ return true;
819
+ } catch {
820
+ return false;
821
+ }
822
+ }
823
+
824
+ /**
825
+ * 現在のディレクトリがworktreeディレクトリかどうかを確認
826
+ * @returns {Promise<boolean>} worktreeディレクトリの場合true
827
+ */
828
+ export async function isInWorktree(): Promise<boolean> {
829
+ try {
830
+ // git rev-parse --show-toplevelとgit rev-parse --git-common-dirの結果を比較
831
+ const [toplevelResult, gitCommonDirResult] = await Promise.all([
832
+ execa("git", ["rev-parse", "--show-toplevel"]),
833
+ execa("git", ["rev-parse", "--git-common-dir"]),
834
+ ]);
835
+
836
+ const toplevel = toplevelResult.stdout.trim();
837
+ const gitCommonDir = gitCommonDirResult.stdout.trim();
838
+
839
+ // gitCommonDirが絶対パスで、toplevelと異なる親ディレクトリを持つ場合はworktree
840
+ const path = await import("node:path");
841
+ if (path.isAbsolute(gitCommonDir)) {
842
+ const mainRepoRoot = path.dirname(gitCommonDir);
843
+ return toplevel !== mainRepoRoot;
844
+ }
845
+
846
+ // gitCommonDirが相対パス(.git)の場合はメインリポジトリ
847
+ return false;
848
+ } catch {
849
+ return false;
850
+ }
851
+ }
852
+
853
+ /**
854
+ * .gitignoreファイルに指定されたエントリーが存在することを保証します
855
+ * エントリーが既に存在する場合は何もしません
856
+ * @param {string} repoRoot - リポジトリのルートディレクトリ
857
+ * @param {string} entry - 追加するエントリー(例: ".worktrees/")
858
+ * @throws {GitError} ファイルの読み書きに失敗した場合
859
+ */
860
+ // ========================================
861
+ // Batch Merge Operations (SPEC-ee33ca26)
862
+ // ========================================
863
+
864
+ /**
865
+ * Merge from source branch to current branch in worktree
866
+ * @param worktreePath - Path to worktree directory
867
+ * @param sourceBranch - Source branch to merge from
868
+ * @param dryRun - If true, use --no-commit flag for dry-run mode
869
+ * @see specs/SPEC-ee33ca26/research.md - Decision 3: Dry-run implementation
870
+ */
871
+ export async function mergeFromBranch(
872
+ worktreePath: string,
873
+ sourceBranch: string,
874
+ dryRun = false,
875
+ ): Promise<void> {
876
+ try {
877
+ const args = ["merge"];
878
+ if (dryRun) {
879
+ args.push("--no-commit");
880
+ }
881
+ args.push(sourceBranch);
882
+
883
+ await execa("git", args, { cwd: worktreePath });
884
+ } catch (error) {
885
+ throw new GitError(
886
+ `Failed to merge from ${sourceBranch} in ${worktreePath}`,
887
+ error,
888
+ );
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Check if there is a merge in progress in worktree
894
+ * @param worktreePath - Path to worktree directory
895
+ * @returns true if MERGE_HEAD exists (merge in progress)
896
+ * @see specs/SPEC-ee33ca26/research.md - Best practices: Git state confirmation
897
+ */
898
+ export async function hasMergeConflict(worktreePath: string): Promise<boolean> {
899
+ try {
900
+ await execa("git", ["rev-parse", "--git-path", "MERGE_HEAD"], {
901
+ cwd: worktreePath,
902
+ });
903
+ return true;
904
+ } catch {
905
+ return false;
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Abort current merge operation in worktree
911
+ * @param worktreePath - Path to worktree directory
912
+ * @see specs/SPEC-ee33ca26/research.md - Decision 3: Dry-run rollback
913
+ */
914
+ export async function abortMerge(worktreePath: string): Promise<void> {
915
+ try {
916
+ await execa("git", ["merge", "--abort"], { cwd: worktreePath });
917
+ } catch (error) {
918
+ throw new GitError(`Failed to abort merge in ${worktreePath}`, error);
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Get current merge status in worktree
924
+ * @param worktreePath - Path to worktree directory
925
+ * @returns Object with inProgress and hasConflict flags
926
+ * @see specs/SPEC-ee33ca26/research.md - Best practices: Git state confirmation
927
+ */
928
+ export async function getMergeStatus(worktreePath: string): Promise<{
929
+ inProgress: boolean;
930
+ hasConflict: boolean;
931
+ }> {
932
+ // Check if merge is in progress (MERGE_HEAD exists)
933
+ const inProgress = await hasMergeConflict(worktreePath);
934
+
935
+ // Check if there are conflicts (git status --porcelain shows UU)
936
+ let hasConflict = false;
937
+ if (inProgress) {
938
+ try {
939
+ const { stdout } = await execa("git", ["status", "--porcelain"], {
940
+ cwd: worktreePath,
941
+ });
942
+ // UU indicates unmerged paths (conflicts)
943
+ hasConflict = stdout.includes("UU ");
944
+ } catch {
945
+ hasConflict = false;
946
+ }
947
+ }
948
+
949
+ return {
950
+ inProgress,
951
+ hasConflict,
952
+ };
953
+ }
954
+
955
+ /**
956
+ * Reset worktree to HEAD (rollback all changes)
957
+ * Used for dry-run cleanup after git merge --no-commit
958
+ * @param worktreePath - Path to worktree directory
959
+ * @see specs/SPEC-ee33ca26/research.md - Dry-run implementation: --no-commit + rollback
960
+ */
961
+ export async function resetToHead(worktreePath: string): Promise<void> {
962
+ try {
963
+ await execa("git", ["reset", "--hard", "HEAD"], {
964
+ cwd: worktreePath,
965
+ });
966
+ } catch (error) {
967
+ throw new GitError(
968
+ `Failed to reset worktree to HEAD in ${worktreePath}`,
969
+ error,
970
+ );
971
+ }
972
+ }
973
+
974
+ export interface BranchDivergenceStatus {
975
+ branch: string;
976
+ remoteAhead: number;
977
+ localAhead: number;
978
+ }
979
+
980
+ export async function getBranchDivergenceStatuses(options?: {
981
+ cwd?: string;
982
+ remote?: string;
983
+ branches?: string[];
984
+ }): Promise<BranchDivergenceStatus[]> {
985
+ const cwd = options?.cwd;
986
+ const remote = options?.remote ?? "origin";
987
+ const execOptions = cwd ? { cwd } : undefined;
988
+ const branchFilter = options?.branches?.filter(
989
+ (name) => name.trim().length > 0,
990
+ );
991
+ const filterSet =
992
+ branchFilter && branchFilter.length > 0 ? new Set(branchFilter) : null;
993
+
994
+ const branchArgs = ["branch", "--format=%(refname:short)"];
995
+ const { stdout: localBranchOutput } = execOptions
996
+ ? await execa("git", branchArgs, execOptions)
997
+ : await execa("git", branchArgs);
998
+
999
+ const branchNames = localBranchOutput
1000
+ .split("\n")
1001
+ .map((name) => name.trim())
1002
+ .filter(Boolean)
1003
+ .filter((name) => !filterSet || filterSet.has(name));
1004
+
1005
+ if (filterSet && branchNames.length === 0) {
1006
+ return [];
1007
+ }
1008
+
1009
+ const results: BranchDivergenceStatus[] = [];
1010
+
1011
+ for (const branchName of branchNames) {
1012
+ const remoteExists = await checkRemoteBranchExists(
1013
+ branchName,
1014
+ remote,
1015
+ cwd ? { cwd } : undefined,
1016
+ );
1017
+
1018
+ if (!remoteExists) {
1019
+ continue;
1020
+ }
1021
+
1022
+ try {
1023
+ const revListArgs = [
1024
+ "rev-list",
1025
+ "--left-right",
1026
+ "--count",
1027
+ `${remote}/${branchName}...${branchName}`,
1028
+ ];
1029
+ const { stdout } = execOptions
1030
+ ? await execa("git", revListArgs, execOptions)
1031
+ : await execa("git", revListArgs);
1032
+
1033
+ const [remoteAheadRaw, localAheadRaw] = stdout.trim().split(/\s+/);
1034
+ const remoteAhead = Number.parseInt(remoteAheadRaw || "0", 10) || 0;
1035
+ const localAhead = Number.parseInt(localAheadRaw || "0", 10) || 0;
1036
+
1037
+ results.push({ branch: branchName, remoteAhead, localAhead });
1038
+ } catch (error) {
1039
+ throw new GitError(
1040
+ `Failed to inspect divergence for ${branchName}`,
1041
+ error,
1042
+ );
1043
+ }
1044
+ }
1045
+
1046
+ return results;
1047
+ }
1048
+
1049
+ export async function pullFastForward(
1050
+ worktreePath: string,
1051
+ remote = "origin",
1052
+ ): Promise<void> {
1053
+ try {
1054
+ await execa("git", ["pull", "--ff-only", remote], {
1055
+ cwd: worktreePath,
1056
+ });
1057
+ } catch (error) {
1058
+ throw new GitError(`Failed to fast-forward pull in ${worktreePath}`, error);
1059
+ }
1060
+ }
1061
+
1062
+ export async function ensureGitignoreEntry(
1063
+ repoRoot: string,
1064
+ entry: string,
1065
+ ): Promise<void> {
1066
+ const fs = await import("node:fs/promises");
1067
+ const gitignorePath = path.join(repoRoot, ".gitignore");
1068
+
1069
+ try {
1070
+ // .gitignoreファイルを読み込む(存在しない場合は空文字列)
1071
+ let content = "";
1072
+ let eol = "\n";
1073
+ try {
1074
+ content = await fs.readFile(gitignorePath, "utf-8");
1075
+ if (content.includes("\r\n")) {
1076
+ eol = "\r\n";
1077
+ }
1078
+ } catch (error: any) {
1079
+ // ENOENTエラー(ファイルが存在しない)は無視
1080
+ if (error.code !== "ENOENT") {
1081
+ throw error;
1082
+ }
1083
+ }
1084
+
1085
+ const normalizedEntry = entry.trim();
1086
+ const normalizedLines = content.split(/\r?\n/).map((line) => line.trim());
1087
+
1088
+ if (normalizedLines.includes(normalizedEntry)) {
1089
+ // 既に存在する場合は何もしない
1090
+ return;
1091
+ }
1092
+
1093
+ const needsSeparator =
1094
+ content.length > 0 && !content.endsWith("\n") && !content.endsWith("\r");
1095
+ const separator = needsSeparator ? eol : "";
1096
+
1097
+ const newContent = `${content}${separator}${entry}${eol}`;
1098
+ await fs.writeFile(gitignorePath, newContent, "utf-8");
1099
+ } catch (error: any) {
1100
+ throw new GitError(`Failed to update .gitignore: ${error.message}`, error);
1101
+ }
1102
+ }