@akiojin/gwt 2.10.0 → 2.11.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 (96) hide show
  1. package/dist/cli/ui/components/App.d.ts.map +1 -1
  2. package/dist/cli/ui/components/App.js +5 -47
  3. package/dist/cli/ui/components/App.js.map +1 -1
  4. package/dist/cli/ui/components/common/Input.d.ts +1 -1
  5. package/dist/cli/ui/components/screens/BranchListScreen.d.ts +1 -2
  6. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  7. package/dist/cli/ui/components/screens/BranchListScreen.js +19 -14
  8. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  9. package/dist/cli/ui/components/screens/ModelSelectorScreen.d.ts.map +1 -1
  10. package/dist/cli/ui/components/screens/ModelSelectorScreen.js +6 -6
  11. package/dist/cli/ui/components/screens/ModelSelectorScreen.js.map +1 -1
  12. package/dist/cli/ui/components/screens/PRCleanupScreen.d.ts.map +1 -1
  13. package/dist/cli/ui/components/screens/PRCleanupScreen.js +0 -3
  14. package/dist/cli/ui/components/screens/PRCleanupScreen.js.map +1 -1
  15. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  16. package/dist/cli/ui/hooks/useGitData.js +43 -2
  17. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  18. package/dist/cli/ui/types.d.ts +16 -4
  19. package/dist/cli/ui/types.d.ts.map +1 -1
  20. package/dist/cli/ui/utils/branchFormatter.d.ts.map +1 -1
  21. package/dist/cli/ui/utils/branchFormatter.js +124 -15
  22. package/dist/cli/ui/utils/branchFormatter.js.map +1 -1
  23. package/dist/cli/ui/utils/modelOptions.d.ts.map +1 -1
  24. package/dist/cli/ui/utils/modelOptions.js.map +1 -1
  25. package/dist/client/assets/{index-CNWntAlF.js → index-Dl798X5w.js} +1 -1
  26. package/dist/client/index.html +1 -1
  27. package/dist/config/index.d.ts.map +1 -1
  28. package/dist/config/index.js.map +1 -1
  29. package/dist/git.d.ts +6 -0
  30. package/dist/git.d.ts.map +1 -1
  31. package/dist/git.js +40 -5
  32. package/dist/git.js.map +1 -1
  33. package/dist/web/client/src/components/BranchGraph.js +1 -1
  34. package/dist/web/client/src/components/BranchGraph.js.map +1 -1
  35. package/dist/web/client/src/pages/BranchDetailPage.d.ts.map +1 -1
  36. package/dist/web/client/src/pages/BranchDetailPage.js +8 -3
  37. package/dist/web/client/src/pages/BranchDetailPage.js.map +1 -1
  38. package/dist/web/server/routes/sessions.d.ts.map +1 -1
  39. package/dist/web/server/routes/sessions.js +4 -2
  40. package/dist/web/server/routes/sessions.js.map +1 -1
  41. package/dist/worktree.d.ts.map +1 -1
  42. package/dist/worktree.js +31 -44
  43. package/dist/worktree.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +9 -17
  46. package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +14 -20
  47. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +14 -44
  48. package/src/cli/ui/__tests__/components/App.test.tsx +8 -15
  49. package/src/cli/ui/__tests__/components/ModelSelectorScreen.initial.test.tsx +12 -5
  50. package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +3 -2
  51. package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +3 -2
  52. package/src/cli/ui/__tests__/components/common/Input.test.tsx +3 -2
  53. package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +15 -14
  54. package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +3 -3
  55. package/src/cli/ui/__tests__/components/common/Select.test.tsx +1 -4
  56. package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +3 -2
  57. package/src/cli/ui/__tests__/components/parts/Header.test.tsx +3 -2
  58. package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +3 -2
  59. package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +3 -2
  60. package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +3 -2
  61. package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +3 -2
  62. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +31 -41
  63. package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +3 -2
  64. package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +3 -2
  65. package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +3 -2
  66. package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +18 -17
  67. package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +3 -2
  68. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +5 -12
  69. package/src/cli/ui/__tests__/integration/navigation.test.tsx +15 -10
  70. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +3 -2
  71. package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +0 -4
  72. package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +3 -5
  73. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +24 -22
  74. package/src/cli/ui/components/App.tsx +3 -74
  75. package/src/cli/ui/components/common/Input.tsx +1 -1
  76. package/src/cli/ui/components/screens/BranchListScreen.tsx +32 -22
  77. package/src/cli/ui/components/screens/ModelSelectorScreen.tsx +46 -49
  78. package/src/cli/ui/components/screens/PRCleanupScreen.tsx +0 -3
  79. package/src/cli/ui/hooks/useGitData.ts +59 -1
  80. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +3 -2
  81. package/src/cli/ui/types.ts +24 -5
  82. package/src/cli/ui/utils/branchFormatter.ts +123 -15
  83. package/src/cli/ui/utils/modelOptions.test.ts +4 -6
  84. package/src/cli/ui/utils/modelOptions.ts +1 -2
  85. package/src/config/index.ts +2 -1
  86. package/src/git.ts +56 -16
  87. package/src/web/client/src/components/BranchGraph.tsx +1 -1
  88. package/src/web/client/src/pages/BranchDetailPage.tsx +8 -3
  89. package/src/web/server/routes/sessions.ts +12 -5
  90. package/src/worktree.ts +31 -59
  91. package/dist/cli/ui/components/screens/WorktreeManagerScreen.d.ts +0 -20
  92. package/dist/cli/ui/components/screens/WorktreeManagerScreen.d.ts.map +0 -1
  93. package/dist/cli/ui/components/screens/WorktreeManagerScreen.js +0 -65
  94. package/dist/cli/ui/components/screens/WorktreeManagerScreen.js.map +0 -1
  95. package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +0 -151
  96. package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +0 -117
@@ -1,3 +1,6 @@
1
+ import type { LastToolUsage } from "../../types/api.js";
2
+ export type { LastToolUsage } from "../../types/api.js";
3
+
1
4
  export interface WorktreeInfo {
2
5
  path: string;
3
6
  locked: boolean;
@@ -5,11 +8,8 @@ export interface WorktreeInfo {
5
8
  isAccessible?: boolean;
6
9
  }
7
10
 
8
- import type { LastToolUsage } from "../../types/api.js";
9
-
10
11
  export type AITool = string;
11
12
  export type InferenceLevel = "low" | "medium" | "high" | "xhigh";
12
- export type { LastToolUsage } from "../../types/api.js";
13
13
 
14
14
  export interface ModelOption {
15
15
  id: string;
@@ -20,6 +20,12 @@ export interface ModelOption {
20
20
  isDefault?: boolean;
21
21
  }
22
22
 
23
+ export interface BranchDivergence {
24
+ ahead: number;
25
+ behind: number;
26
+ upToDate: boolean;
27
+ }
28
+
23
29
  export interface BranchInfo {
24
30
  name: string;
25
31
  type: "local" | "remote";
@@ -39,6 +45,9 @@ export interface BranchInfo {
39
45
  mergedPR?: { number: number; mergedAt: string };
40
46
  latestCommitTimestamp?: number;
41
47
  lastToolUsage?: LastToolUsage | null;
48
+ upstream?: string | null;
49
+ divergence?: BranchDivergence | null;
50
+ hasRemoteCounterpart?: boolean;
42
51
  }
43
52
 
44
53
  export interface BranchChoice {
@@ -132,7 +141,7 @@ export interface WorktreeWithPR {
132
141
  pullRequest: PullRequest | null;
133
142
  }
134
143
 
135
- export type CleanupReason = "merged-pr" | "no-diff-with-base" | "remote-synced";
144
+ export type CleanupReason = "no-diff-with-base" | "remote-synced";
136
145
 
137
146
  export interface CleanupTarget {
138
147
  worktreePath: string | null; // null for local branch only cleanup
@@ -173,7 +182,6 @@ export interface GitHubPRResponse {
173
182
  */
174
183
  export type ScreenType =
175
184
  | "branch-list"
176
- | "worktree-manager"
177
185
  | "branch-creator"
178
186
  | "branch-action-selector"
179
187
  | "ai-tool-selector"
@@ -201,6 +209,14 @@ export interface Screen {
201
209
  */
202
210
  export type WorktreeStatus = "active" | "inaccessible" | undefined;
203
211
 
212
+ export type SyncStatus =
213
+ | "up-to-date"
214
+ | "ahead"
215
+ | "behind"
216
+ | "diverged"
217
+ | "no-upstream"
218
+ | "remote-only";
219
+
204
220
  export interface BranchItem extends BranchInfo {
205
221
  // Display properties
206
222
  icons: string[];
@@ -209,6 +225,9 @@ export interface BranchItem extends BranchInfo {
209
225
  label: string;
210
226
  value: string;
211
227
  lastToolUsageLabel?: string | null;
228
+ syncStatus?: SyncStatus;
229
+ syncInfo?: string | undefined;
230
+ remoteName?: string | undefined;
212
231
  }
213
232
 
214
233
  /**
@@ -25,23 +25,38 @@ const worktreeIcons: Record<Exclude<WorktreeStatus, undefined>, string> = {
25
25
  };
26
26
 
27
27
  const changeIcons = {
28
- current: "",
29
- hasChanges: "✏️",
30
- unpushed: "⬆️",
31
- openPR: "🔀",
28
+ current: "👉",
29
+ hasChanges: "💾",
30
+ unpushed: "📤",
31
+ openPR: "🔃",
32
32
  mergedPR: "✅",
33
33
  warning: "⚠️",
34
34
  };
35
35
 
36
36
  const remoteIcon = "☁";
37
37
 
38
+ // Sync status icons
39
+ const syncIcons = {
40
+ upToDate: "✓",
41
+ ahead: "↑",
42
+ behind: "↓",
43
+ diverged: "↕",
44
+ none: "-",
45
+ remoteOnly: "☁",
46
+ };
47
+
48
+ // Remote column markers
49
+ const remoteMarkers = {
50
+ tracked: "🔗", // ローカル+同名リモートあり
51
+ localOnly: "💻", // ローカルのみ(リモートなし)
52
+ remoteOnly: "☁️", // リモートのみ(ローカルなし)
53
+ };
54
+
38
55
  // Emoji width varies by terminal. Provide explicit minimum widths so we never
39
56
  // underestimate and accidentally push the row past the terminal columns.
40
57
  const iconWidthOverrides: Record<string, number> = {
41
58
  // Remote icon
42
59
  [remoteIcon]: 1,
43
- // Unpushed icon
44
- "⬆": 1,
45
60
  // Branch type icons
46
61
  "⚡": 1,
47
62
  "✨": 1,
@@ -53,11 +68,16 @@ const iconWidthOverrides: Record<string, number> = {
53
68
  "🟢": 1,
54
69
  "🟠": 1,
55
70
  // Change status icons
56
- "": 1,
57
- "✏️": 1,
58
- "🔀": 1,
71
+ "👉": 1,
72
+ "💾": 1,
73
+ "📤": 1,
74
+ "🔃": 1,
59
75
  "✅": 1,
60
76
  "⚠️": 1,
77
+ // Remote markers
78
+ "🔗": 1,
79
+ "💻": 1,
80
+ "☁️": 1,
61
81
  };
62
82
 
63
83
  const getIconWidth = (icon: string): number => {
@@ -168,17 +188,81 @@ export function formatBranchItem(
168
188
  changesIcon = " ".repeat(COLUMN_WIDTH);
169
189
  }
170
190
 
171
- // Column 4: Remote icon
172
- let remoteIconStr: string;
191
+ // Column 4: Remote status (✓ for tracked, L for local-only, R for remote-only)
192
+ let remoteStatusStr: string;
193
+ if (branch.type === "remote") {
194
+ // リモートのみのブランチ
195
+ remoteStatusStr = padIcon(remoteMarkers.remoteOnly);
196
+ } else if (branch.hasRemoteCounterpart) {
197
+ // ローカルブランチで同名リモートあり
198
+ remoteStatusStr = padIcon(remoteMarkers.tracked);
199
+ } else {
200
+ // ローカルブランチでリモートなし
201
+ remoteStatusStr = padIcon(remoteMarkers.localOnly);
202
+ }
203
+
204
+ // Column 5: Sync status (=, ↑N, ↓N, ↕, -)
205
+ // 5文字固定幅(アイコン1文字 + 数字最大4桁)、9999超は「9999+」表示
206
+ const SYNC_COLUMN_WIDTH = 6; // アイコン1 + 数字4 + スペース1
207
+
208
+ // 数字を4桁以内にフォーマット(9999超は9999+)
209
+ const formatSyncNumber = (n: number): string => {
210
+ if (n > 9999) return "9999+";
211
+ return String(n);
212
+ };
213
+
214
+ // Sync列を固定幅でパディング
215
+ const padSyncColumn = (content: string): string => {
216
+ const width = stringWidth(content);
217
+ const padding = Math.max(0, SYNC_COLUMN_WIDTH - width);
218
+ return content + " ".repeat(padding);
219
+ };
220
+
221
+ let syncStatusStr: string;
222
+ if (branch.type === "remote") {
223
+ // リモートのみ → 比較不可
224
+ syncStatusStr = padSyncColumn(syncIcons.none);
225
+ } else if (branch.divergence) {
226
+ const { ahead, behind, upToDate } = branch.divergence;
227
+ if (upToDate) {
228
+ syncStatusStr = padSyncColumn(syncIcons.upToDate);
229
+ } else if (ahead > 0 && behind > 0) {
230
+ // diverged: ↕ のみ表示(詳細は別途表示可能)
231
+ syncStatusStr = padSyncColumn(syncIcons.diverged);
232
+ } else if (ahead > 0) {
233
+ // ahead: ↑N の形式
234
+ syncStatusStr = padSyncColumn(
235
+ `${syncIcons.ahead}${formatSyncNumber(ahead)}`,
236
+ );
237
+ } else {
238
+ // behind: ↓N の形式
239
+ syncStatusStr = padSyncColumn(
240
+ `${syncIcons.behind}${formatSyncNumber(behind)}`,
241
+ );
242
+ }
243
+ } else {
244
+ // divergence情報なし
245
+ syncStatusStr = padSyncColumn(syncIcons.none);
246
+ }
247
+
248
+ // Build Local/Remote name for display
249
+ // ローカルブランチ: ブランチ名を表示
250
+ // リモートのみ: origin/xxxをフル表示
251
+ let displayName: string;
252
+ let remoteName: string | null = null;
253
+
173
254
  if (branch.type === "remote") {
174
- remoteIconStr = padIcon(remoteIcon);
255
+ // リモートのみのブランチ: フルのリモートブランチ名を表示
256
+ displayName = branch.name; // origin/xxx
257
+ remoteName = branch.name;
175
258
  } else {
176
- remoteIconStr = " ".repeat(COLUMN_WIDTH);
259
+ // ローカルブランチ: ブランチ名を表示
260
+ displayName = branch.name;
177
261
  }
178
262
 
179
263
  // Build label with fixed-width columns
180
- // Format: [Type][Worktree][Changes][Remote] BranchName
181
- const label = `${branchTypeIcon}${worktreeIcon}${changesIcon}${remoteIconStr}${branch.name}`;
264
+ // Format: [Type][Worktree][Changes][Remote][Sync] DisplayName
265
+ const label = `${branchTypeIcon}${worktreeIcon}${changesIcon}${remoteStatusStr}${syncStatusStr}${displayName}`;
182
266
 
183
267
  // Collect icons for compatibility
184
268
  const icons: string[] = [];
@@ -208,6 +292,27 @@ export function formatBranchItem(
208
292
  icons.push(remoteIcon);
209
293
  }
210
294
 
295
+ // Determine sync status for BranchItem
296
+ let syncStatus: BranchItem["syncStatus"];
297
+ if (branch.type === "remote") {
298
+ syncStatus = "remote-only";
299
+ } else if (!branch.hasRemoteCounterpart) {
300
+ syncStatus = "no-upstream";
301
+ } else if (branch.divergence) {
302
+ const { ahead, behind, upToDate } = branch.divergence;
303
+ if (upToDate) {
304
+ syncStatus = "up-to-date";
305
+ } else if (ahead > 0 && behind > 0) {
306
+ syncStatus = "diverged";
307
+ } else if (ahead > 0) {
308
+ syncStatus = "ahead";
309
+ } else {
310
+ syncStatus = "behind";
311
+ }
312
+ } else {
313
+ syncStatus = "no-upstream";
314
+ }
315
+
211
316
  return {
212
317
  // Copy all properties from BranchInfo
213
318
  ...branch,
@@ -218,6 +323,9 @@ export function formatBranchItem(
218
323
  label,
219
324
  value: branch.name,
220
325
  lastToolUsageLabel: buildLastToolUsageLabel(branch.lastToolUsage),
326
+ syncStatus,
327
+ syncInfo: undefined,
328
+ remoteName: remoteName ?? undefined,
221
329
  };
222
330
  }
223
331
 
@@ -11,11 +11,7 @@ describe("modelOptions", () => {
11
11
  it("lists Claude official aliases and sets Opus 4.5 as default", () => {
12
12
  const options = getModelOptions("claude-code");
13
13
  const ids = options.map((m) => m.id);
14
- expect(ids).toEqual([
15
- "opus",
16
- "sonnet",
17
- "haiku",
18
- ]);
14
+ expect(ids).toEqual(["opus", "sonnet", "haiku"]);
19
15
  const defaultModel = getDefaultModelOption("claude-code");
20
16
  expect(defaultModel?.id).toBe("opus");
21
17
  expect(defaultModel?.label).toBe("Opus 4.5");
@@ -34,7 +30,9 @@ describe("modelOptions", () => {
34
30
  });
35
31
 
36
32
  it("uses medium as default reasoning for codex-max", () => {
37
- const codexMax = getModelOptions("codex-cli").find((m) => m.id === "gpt-5.1-codex-max");
33
+ const codexMax = getModelOptions("codex-cli").find(
34
+ (m) => m.id === "gpt-5.1-codex-max",
35
+ );
38
36
  expect(getDefaultInferenceForModel(codexMax)).toBe("medium");
39
37
  });
40
38
 
@@ -15,8 +15,7 @@ const MODEL_OPTIONS: Record<string, ModelOption[]> = {
15
15
  {
16
16
  id: "sonnet",
17
17
  label: "Sonnet 4.5",
18
- description:
19
- "Official Sonnet alias for Claude Code.",
18
+ description: "Official Sonnet alias for Claude Code.",
20
19
  },
21
20
  {
22
21
  id: "haiku",
@@ -136,7 +136,8 @@ export async function saveSession(sessionData: SessionData): Promise<void> {
136
136
  branch: sessionData.lastBranch,
137
137
  worktreePath: sessionData.lastWorktreePath,
138
138
  toolId: sessionData.lastUsedTool ?? "unknown",
139
- toolLabel: sessionData.toolLabel ?? sessionData.lastUsedTool ?? "Custom",
139
+ toolLabel:
140
+ sessionData.toolLabel ?? sessionData.lastUsedTool ?? "Custom",
140
141
  mode: sessionData.mode ?? null,
141
142
  model: sessionData.model ?? null,
142
143
  timestamp: sessionData.timestamp,
package/src/git.ts CHANGED
@@ -174,11 +174,9 @@ export async function getWorktreeRoot(): Promise<string> {
174
174
  export async function getCurrentBranch(): Promise<string | null> {
175
175
  try {
176
176
  const repoRoot = await getRepositoryRoot();
177
- const { stdout } = await execa(
178
- "git",
179
- ["branch", "--show-current"],
180
- { cwd: repoRoot },
181
- );
177
+ const { stdout } = await execa("git", ["branch", "--show-current"], {
178
+ cwd: repoRoot,
179
+ });
182
180
  return stdout.trim() || null;
183
181
  } catch {
184
182
  try {
@@ -195,11 +193,15 @@ async function getBranchCommitTimestamps(
195
193
  cwd?: string,
196
194
  ): Promise<Map<string, number>> {
197
195
  try {
198
- const { stdout = "" } = (await execa("git", [
199
- "for-each-ref",
200
- "--format=%(refname:short)%00%(committerdate:unix)",
201
- ...refs,
202
- ], cwd ? { cwd } : undefined)) ?? { stdout: "" };
196
+ const { stdout = "" } = (await execa(
197
+ "git",
198
+ [
199
+ "for-each-ref",
200
+ "--format=%(refname:short)%00%(committerdate:unix)",
201
+ ...refs,
202
+ ],
203
+ cwd ? { cwd } : undefined,
204
+ )) ?? { stdout: "" };
203
205
 
204
206
  const map = new Map<string, number>();
205
207
 
@@ -222,9 +224,10 @@ async function getBranchCommitTimestamps(
222
224
  export async function getLocalBranches(): Promise<BranchInfo[]> {
223
225
  try {
224
226
  const commitMap = await getBranchCommitTimestamps(["refs/heads"]);
225
- const { stdout = "" } =
226
- (await execa("git", ["branch", "--format=%(refname:short)"])) ??
227
- { stdout: "" };
227
+ const { stdout = "" } = (await execa("git", [
228
+ "branch",
229
+ "--format=%(refname:short)",
230
+ ])) ?? { stdout: "" };
228
231
  return stdout
229
232
  .split("\n")
230
233
  .filter((line) => line.trim())
@@ -250,9 +253,11 @@ export async function getLocalBranches(): Promise<BranchInfo[]> {
250
253
  export async function getRemoteBranches(): Promise<BranchInfo[]> {
251
254
  try {
252
255
  const commitMap = await getBranchCommitTimestamps(["refs/remotes"]);
253
- const { stdout = "" } =
254
- (await execa("git", ["branch", "-r", "--format=%(refname:short)"])) ??
255
- { stdout: "" };
256
+ const { stdout = "" } = (await execa("git", [
257
+ "branch",
258
+ "-r",
259
+ "--format=%(refname:short)",
260
+ ])) ?? { stdout: "" };
256
261
  return stdout
257
262
  .split("\n")
258
263
  .filter((line) => line.trim() && !line.includes("HEAD"))
@@ -299,6 +304,41 @@ export async function getAllBranches(): Promise<BranchInfo[]> {
299
304
  return [...localBranches, ...remoteBranches];
300
305
  }
301
306
 
307
+ /**
308
+ * ローカルブランチのupstream(追跡ブランチ)情報を取得
309
+ * @param cwd - 作業ディレクトリ(省略時はリポジトリルート)
310
+ * @returns Map<ローカルブランチ名, upstreamブランチ名>
311
+ */
312
+ export async function collectUpstreamMap(
313
+ cwd?: string,
314
+ ): Promise<Map<string, string>> {
315
+ const workDir = cwd ?? (await getRepositoryRoot());
316
+ try {
317
+ const { stdout } = await execa(
318
+ "git",
319
+ [
320
+ "for-each-ref",
321
+ "--format=%(refname:short)|%(upstream:short)",
322
+ "refs/heads",
323
+ ],
324
+ { cwd: workDir },
325
+ );
326
+
327
+ return stdout
328
+ .split("\n")
329
+ .filter((line) => line.includes("|"))
330
+ .reduce((map, line) => {
331
+ const [branch, upstream] = line.split("|");
332
+ if (branch?.trim() && upstream?.trim()) {
333
+ map.set(branch.trim(), upstream.trim());
334
+ }
335
+ return map;
336
+ }, new Map<string, string>());
337
+ } catch {
338
+ return new Map();
339
+ }
340
+ }
341
+
302
342
  export async function createBranch(
303
343
  branchName: string,
304
344
  baseBranch = "main",
@@ -71,7 +71,7 @@ export function BranchGraph({ branches }: BranchGraphProps) {
71
71
  });
72
72
  }
73
73
 
74
- laneMap.get(base)!.nodes.push(branch);
74
+ laneMap.get(base)?.nodes.push(branch);
75
75
  });
76
76
 
77
77
  return Array.from(laneMap.values()).sort((a, b) => {
@@ -425,17 +425,22 @@ export function BranchDetailPage() {
425
425
  worktreePath: branch.worktreePath ?? null,
426
426
  toolId:
427
427
  first.toolType === "custom"
428
- ? first.toolName ?? "custom"
428
+ ? (first.toolName ?? "custom")
429
429
  : (first.toolType as LastToolUsage["toolId"]),
430
430
  toolLabel:
431
431
  first.toolType === "custom"
432
- ? first.toolName ?? "Custom"
432
+ ? (first.toolName ?? "Custom")
433
433
  : toolLabel(first.toolType),
434
434
  mode: first.mode ?? "normal",
435
435
  model: null,
436
436
  timestamp: first.startedAt ? Date.parse(first.startedAt) : Date.now(),
437
437
  };
438
- }, [branch?.lastToolUsage, branch?.name, branch?.worktreePath, branchSessions]);
438
+ }, [
439
+ branch?.lastToolUsage,
440
+ branch?.name,
441
+ branch?.worktreePath,
442
+ branchSessions,
443
+ ]);
439
444
 
440
445
  const handleSessionExit = (code: number) => {
441
446
  setActiveSessionId(null);
@@ -57,9 +57,13 @@ export async function registerSessionRoutes(
57
57
 
58
58
  // 履歴を永続化(best-effort)
59
59
  try {
60
- const { stdout: repoRoot } = await execa("git", ["rev-parse", "--show-toplevel"], {
61
- cwd: worktreePath,
62
- });
60
+ const { stdout: repoRoot } = await execa(
61
+ "git",
62
+ ["rev-parse", "--show-toplevel"],
63
+ {
64
+ cwd: worktreePath,
65
+ },
66
+ );
63
67
  let branchName: string | null = null;
64
68
  try {
65
69
  const { stdout: branchStdout } = await execa(
@@ -75,9 +79,12 @@ export async function registerSessionRoutes(
75
79
  await saveSession({
76
80
  lastWorktreePath: worktreePath,
77
81
  lastBranch: branchName,
78
- lastUsedTool: toolType === "custom" ? toolName ?? "custom" : toolType,
82
+ lastUsedTool:
83
+ toolType === "custom" ? (toolName ?? "custom") : toolType,
79
84
  toolLabel:
80
- toolType === "custom" ? toolName ?? "Custom" : toolLabelFromType(toolType),
85
+ toolType === "custom"
86
+ ? (toolName ?? "Custom")
87
+ : toolLabelFromType(toolType),
81
88
  mode,
82
89
  timestamp: Date.now(),
83
90
  repositoryRoot: repoRoot.trim(),
package/src/worktree.ts CHANGED
@@ -6,10 +6,9 @@ import {
6
6
  WorktreeConfig,
7
7
  WorktreeWithPR,
8
8
  CleanupTarget,
9
- MergedPullRequest,
10
9
  CleanupReason,
11
10
  } from "./cli/ui/types.js";
12
- import { getPullRequestByBranch, getMergedPullRequests } from "./github.js";
11
+ import { getPullRequestByBranch } from "./github.js";
13
12
  import {
14
13
  hasUncommittedChanges,
15
14
  hasUnpushedCommits,
@@ -27,6 +26,23 @@ import { getConfig } from "./config/index.js";
27
26
  import { GIT_CONFIG } from "./config/constants.js";
28
27
  import { startSpinner } from "./utils/spinner.js";
29
28
 
29
+ async function getUpstreamBranch(branch: string): Promise<string | null> {
30
+ try {
31
+ const result = await execa("git", [
32
+ "rev-parse",
33
+ "--abbrev-ref",
34
+ `${branch}@{upstream}`,
35
+ ]);
36
+ const stdout =
37
+ typeof (result as { stdout?: unknown })?.stdout === "string"
38
+ ? (result as { stdout: string }).stdout.trim()
39
+ : "";
40
+ return stdout.length ? stdout : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
30
46
  // Re-export WorktreeConfig for external use
31
47
  export type { WorktreeConfig };
32
48
 
@@ -414,15 +430,13 @@ async function getWorktreesWithPRStatus(): Promise<WorktreeWithPR[]> {
414
430
  }
415
431
 
416
432
  /**
417
- * worktreeに存在しないローカルブランチの中でマージ済みPRに関連するクリーンアップ候補を取得
433
+ * worktreeに存在しないローカルブランチのクリーンアップ候補を取得
418
434
  * @returns {Promise<CleanupTarget[]>} クリーンアップ候補の配列
419
435
  */
420
436
  async function getOrphanedLocalBranches({
421
- mergedPRs,
422
437
  baseBranch,
423
438
  repoRoot,
424
439
  }: {
425
- mergedPRs: MergedPullRequest[];
426
440
  baseBranch: string;
427
441
  repoRoot: string;
428
442
  }): Promise<CleanupTarget[]> {
@@ -467,7 +481,6 @@ async function getOrphanedLocalBranches({
467
481
 
468
482
  // worktreeに存在しないローカルブランチのみ対象
469
483
  if (!worktreeBranches.has(localBranch.name)) {
470
- const mergedPR = findMatchingPR(localBranch.name, mergedPRs);
471
484
  let hasUnpushed = false;
472
485
  try {
473
486
  hasUnpushed = await hasUnpushedCommitsInRepo(
@@ -480,13 +493,12 @@ async function getOrphanedLocalBranches({
480
493
 
481
494
  const reasons: CleanupReason[] = [];
482
495
 
483
- if (mergedPR) {
484
- reasons.push("merged-pr");
485
- }
496
+ const upstreamBranch = await getUpstreamBranch(localBranch.name);
497
+ const comparisonBase = upstreamBranch ?? baseBranch;
486
498
 
487
499
  const hasUniqueCommits = await branchHasUniqueCommitsComparedToBase(
488
500
  localBranch.name,
489
- baseBranch,
501
+ comparisonBase,
490
502
  repoRoot,
491
503
  );
492
504
 
@@ -497,7 +509,7 @@ async function getOrphanedLocalBranches({
497
509
  if (process.env.DEBUG_CLEANUP) {
498
510
  console.log(
499
511
  chalk.gray(
500
- `Debug: Checking orphaned branch ${localBranch.name} -> PR: ${mergedPR ? "MATCH" : "NO MATCH"}, reasons: ${reasons.join(", ")}`,
512
+ `Debug: Checking orphaned branch ${localBranch.name} -> reasons: ${reasons.join(", ")}`,
501
513
  ),
502
514
  );
503
515
  }
@@ -517,7 +529,7 @@ async function getOrphanedLocalBranches({
517
529
  cleanupTargets.push({
518
530
  worktreePath: null, // worktreeは存在しない
519
531
  branch: localBranch.name,
520
- pullRequest: mergedPR ?? null,
532
+ pullRequest: null,
521
533
  hasUncommittedChanges: false, // worktreeが存在しないため常にfalse
522
534
  hasUnpushedCommits: hasUnpushed,
523
535
  cleanupType: "branch-only",
@@ -546,49 +558,19 @@ async function getOrphanedLocalBranches({
546
558
  }
547
559
  }
548
560
 
549
- function normalizeBranchName(branchName: string): string {
550
- return branchName
551
- .replace(/^origin\//, "")
552
- .replace(/^refs\/heads\//, "")
553
- .replace(/^refs\/remotes\/origin\//, "")
554
- .trim();
555
- }
556
-
557
- function findMatchingPR(
558
- worktreeBranch: string,
559
- mergedPRs: MergedPullRequest[],
560
- ): MergedPullRequest | null {
561
- const normalizedWorktreeBranch = normalizeBranchName(worktreeBranch);
562
-
563
- for (const pr of mergedPRs) {
564
- const normalizedPRBranch = normalizeBranchName(pr.branch);
565
-
566
- if (normalizedWorktreeBranch === normalizedPRBranch) {
567
- return pr;
568
- }
569
- }
570
-
571
- return null;
572
- }
573
-
574
561
  /**
575
562
  * マージ済みPRに関連するworktreeおよびローカルブランチのクリーンアップ候補を取得
576
563
  * @returns {Promise<CleanupTarget[]>} クリーンアップ候補の配列
577
564
  */
578
565
  export async function getMergedPRWorktrees(): Promise<CleanupTarget[]> {
579
- const [config, repoRoot] = await Promise.all([
566
+ const [config, repoRoot, worktreesWithPR] = await Promise.all([
580
567
  getConfig(),
581
568
  getRepositoryRoot(),
569
+ getWorktreesWithPRStatus(),
582
570
  ]);
583
571
  const baseBranch = config.defaultBaseBranch || GIT_CONFIG.DEFAULT_BASE_BRANCH;
584
572
 
585
- // 並列実行で高速化 - worktreeとマージ済みPRの両方を取得
586
- const [mergedPRs, worktreesWithPR] = await Promise.all([
587
- getMergedPullRequests(),
588
- getWorktreesWithPRStatus(),
589
- ]);
590
573
  const orphanedBranches = await getOrphanedLocalBranches({
591
- mergedPRs,
592
574
  baseBranch,
593
575
  repoRoot,
594
576
  });
@@ -599,8 +581,6 @@ export async function getMergedPRWorktrees(): Promise<CleanupTarget[]> {
599
581
  worktreesWithPR.forEach((w) =>
600
582
  console.log(` ${w.branch} -> ${w.worktreePath}`),
601
583
  );
602
- console.log(chalk.cyan("Debug: Merged PRs:"));
603
- mergedPRs.forEach((pr) => console.log(` ${pr.branch} (PR #${pr.number})`));
604
584
  }
605
585
 
606
586
  for (const worktree of worktreesWithPR) {
@@ -614,15 +594,8 @@ export async function getMergedPRWorktrees(): Promise<CleanupTarget[]> {
614
594
  continue;
615
595
  }
616
596
 
617
- const mergedPR = findMatchingPR(worktree.branch, mergedPRs);
618
-
619
597
  if (process.env.DEBUG_CLEANUP) {
620
- const normalizedWorktree = normalizeBranchName(worktree.branch);
621
- console.log(
622
- chalk.gray(
623
- `Debug: Checking worktree ${worktree.branch} (normalized: ${normalizedWorktree}) -> ${mergedPR ? "MATCH" : "NO MATCH"}`,
624
- ),
625
- );
598
+ console.log(chalk.gray(`Debug: Checking worktree ${worktree.branch}`));
626
599
  }
627
600
 
628
601
  const cleanupReasons: CleanupReason[] = [];
@@ -670,13 +643,12 @@ export async function getMergedPRWorktrees(): Promise<CleanupTarget[]> {
670
643
  hasRemoteBranch = false;
671
644
  }
672
645
 
673
- if (mergedPR) {
674
- cleanupReasons.push("merged-pr");
675
- }
646
+ const upstreamBranch = await getUpstreamBranch(worktree.branch);
647
+ const comparisonBase = upstreamBranch ?? baseBranch;
676
648
 
677
649
  const hasUniqueCommits = await branchHasUniqueCommitsComparedToBase(
678
650
  worktree.branch,
679
- baseBranch,
651
+ comparisonBase,
680
652
  repoRoot,
681
653
  );
682
654
 
@@ -703,7 +675,7 @@ export async function getMergedPRWorktrees(): Promise<CleanupTarget[]> {
703
675
  const target: CleanupTarget = {
704
676
  worktreePath: worktree.worktreePath,
705
677
  branch: worktree.branch,
706
- pullRequest: mergedPR ?? null,
678
+ pullRequest: null,
707
679
  hasUncommittedChanges: hasUncommitted,
708
680
  hasUnpushedCommits: hasUnpushed,
709
681
  cleanupType: "worktree-and-branch",