@akiojin/gwt 4.1.0 → 4.2.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 (42) hide show
  1. package/README.md +28 -3
  2. package/dist/claude.d.ts +2 -0
  3. package/dist/claude.d.ts.map +1 -1
  4. package/dist/claude.js +2 -0
  5. package/dist/claude.js.map +1 -1
  6. package/dist/cli/ui/components/App.d.ts.map +1 -1
  7. package/dist/cli/ui/components/App.js +8 -5
  8. package/dist/cli/ui/components/App.js.map +1 -1
  9. package/dist/cli/ui/components/common/Select.d.ts +3 -1
  10. package/dist/cli/ui/components/common/Select.d.ts.map +1 -1
  11. package/dist/cli/ui/components/common/Select.js +13 -2
  12. package/dist/cli/ui/components/common/Select.js.map +1 -1
  13. package/dist/cli/ui/utils/modelOptions.d.ts +4 -0
  14. package/dist/cli/ui/utils/modelOptions.d.ts.map +1 -1
  15. package/dist/cli/ui/utils/modelOptions.js +19 -0
  16. package/dist/cli/ui/utils/modelOptions.js.map +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +54 -15
  19. package/dist/index.js.map +1 -1
  20. package/dist/utils/prompt.d.ts +12 -0
  21. package/dist/utils/prompt.d.ts.map +1 -1
  22. package/dist/utils/prompt.js +60 -10
  23. package/dist/utils/prompt.js.map +1 -1
  24. package/dist/worktree.d.ts +14 -0
  25. package/dist/worktree.d.ts.map +1 -1
  26. package/dist/worktree.js +33 -2
  27. package/dist/worktree.js.map +1 -1
  28. package/package.json +2 -2
  29. package/src/claude.ts +2 -0
  30. package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +2 -1
  31. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +38 -8
  32. package/src/cli/ui/__tests__/components/App.test.tsx +4 -3
  33. package/src/cli/ui/__tests__/components/ModelSelectorScreen.initial.test.tsx +1 -0
  34. package/src/cli/ui/__tests__/components/common/Select.test.tsx +45 -0
  35. package/src/cli/ui/components/App.tsx +15 -4
  36. package/src/cli/ui/components/common/Select.tsx +14 -1
  37. package/src/cli/ui/utils/modelOptions.test.ts +12 -0
  38. package/src/cli/ui/utils/modelOptions.ts +19 -0
  39. package/src/index.ts +70 -14
  40. package/src/utils/__tests__/prompt.test.ts +72 -35
  41. package/src/utils/prompt.ts +79 -10
  42. package/src/worktree.ts +48 -1
@@ -35,10 +35,10 @@ vi.mock("../../hooks/useScreenState.js", () => ({
35
35
  useScreenState: (...args: unknown[]) => useScreenStateMock(...args),
36
36
  }));
37
37
 
38
- vi.mock("../../../../worktree.js", async () => {
38
+ vi.mock("../../../../worktree.ts", async () => {
39
39
  const actual = await vi.importActual<
40
- typeof import("../../../../worktree.js")
41
- >("../../../../worktree.js");
40
+ typeof import("../../../../worktree.ts")
41
+ >("../../../../worktree.ts");
42
42
  return {
43
43
  ...actual,
44
44
  getMergedPRWorktrees: getMergedPRWorktreesMock,
@@ -48,10 +48,10 @@ vi.mock("../../../../worktree.js", async () => {
48
48
  };
49
49
  });
50
50
 
51
- vi.mock("../../../../git.js", async () => {
51
+ vi.mock("../../../../git.ts", async () => {
52
52
  const actual =
53
- await vi.importActual<typeof import("../../../../git.js")>(
54
- "../../../../git.js",
53
+ await vi.importActual<typeof import("../../../../git.ts")>(
54
+ "../../../../git.ts",
55
55
  );
56
56
  return {
57
57
  ...actual,
@@ -92,7 +92,20 @@ describe("App shortcuts integration", () => {
92
92
  goBackMock.mockClear();
93
93
  resetMock.mockClear();
94
94
  useGitDataMock.mockReturnValue({
95
- branches: [],
95
+ branches: [
96
+ {
97
+ name: "feature/add-new-feature",
98
+ type: "local",
99
+ branchType: "feature",
100
+ isCurrent: false,
101
+ },
102
+ {
103
+ name: "hotfix/urgent-fix",
104
+ type: "local",
105
+ branchType: "hotfix",
106
+ isCurrent: false,
107
+ },
108
+ ],
96
109
  worktrees: [
97
110
  {
98
111
  branch: "feature/existing",
@@ -197,6 +210,8 @@ describe("App shortcuts integration", () => {
197
210
 
198
211
  it("displays per-branch cleanup indicators and waits before clearing results", async () => {
199
212
  vi.useFakeTimers();
213
+ const originalNodeEnv = process.env.NODE_ENV;
214
+ process.env.NODE_ENV = "production";
200
215
 
201
216
  try {
202
217
  const onExit = vi.fn();
@@ -234,8 +249,22 @@ describe("App shortcuts integration", () => {
234
249
  throw new Error("BranchListScreen props missing");
235
250
  }
236
251
 
252
+ await act(async () => {
253
+ initialProps.onToggleSelect?.("feature/add-new-feature");
254
+ initialProps.onToggleSelect?.("hotfix/urgent-fix");
255
+ });
256
+
257
+ await act(async () => {
258
+ await Promise.resolve();
259
+ });
260
+
261
+ const selectedProps = branchListProps.at(-1);
262
+ expect(selectedProps?.selectedBranches).toEqual([
263
+ "feature/add-new-feature",
264
+ "hotfix/urgent-fix",
265
+ ]);
237
266
  act(() => {
238
- initialProps.onCleanupCommand?.();
267
+ selectedProps?.onCleanupCommand?.();
239
268
  });
240
269
 
241
270
  await act(async () => {
@@ -296,6 +325,7 @@ describe("App shortcuts integration", () => {
296
325
  ),
297
326
  ).toBe(false);
298
327
  } finally {
328
+ process.env.NODE_ENV = originalNodeEnv;
299
329
  vi.useRealTimers();
300
330
  }
301
331
  });
@@ -251,6 +251,7 @@ describe("App", () => {
251
251
  });
252
252
 
253
253
  describe("BranchActionSelectorScreen integration", () => {
254
+ // TODO: replace placeholder assertions with real integration checks.
254
255
  it("should show BranchActionSelectorScreen after branch selection", () => {
255
256
  useGitDataMock.mockImplementation(() => ({
256
257
  branches: mockBranches,
@@ -263,7 +264,7 @@ describe("App", () => {
263
264
  const onExit = vi.fn();
264
265
  const { container } = render(<App onExit={onExit} />);
265
266
 
266
- // After implementation, should verify BranchActionSelectorScreen appears
267
+ // TODO: verify BranchActionSelectorScreen appears
267
268
  expect(container).toBeDefined();
268
269
  });
269
270
 
@@ -279,7 +280,7 @@ describe("App", () => {
279
280
  const onExit = vi.fn();
280
281
  const { container } = render(<App onExit={onExit} />);
281
282
 
282
- // After implementation, should verify navigation to AIToolSelectorScreen
283
+ // TODO: verify navigation to AIToolSelectorScreen
283
284
  expect(container).toBeDefined();
284
285
  });
285
286
 
@@ -295,7 +296,7 @@ describe("App", () => {
295
296
  const onExit = vi.fn();
296
297
  const { container } = render(<App onExit={onExit} />);
297
298
 
298
- // After implementation, should verify navigation to BranchCreatorScreen
299
+ // TODO: verify navigation to BranchCreatorScreen
299
300
  expect(container).toBeDefined();
300
301
  });
301
302
  });
@@ -54,6 +54,7 @@ describe("ModelSelectorScreen initial selection", () => {
54
54
 
55
55
  await waitFor(() => expect(selectMocks.length).toBeGreaterThan(0));
56
56
  const modelSelect = selectMocks.at(-1);
57
+ expect(modelSelect).toBeDefined();
57
58
  const index = modelSelect.initialIndex as number;
58
59
  // codex-cli models: ["", gpt-5.2-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5.2]
59
60
  // (index 0 = Default/Auto, index 2 = gpt-5.1-codex-max)
@@ -12,6 +12,9 @@ interface TestItem {
12
12
  value: string;
13
13
  }
14
14
 
15
+ const delay = (ms = 0): Promise<void> =>
16
+ new Promise((resolve) => setTimeout(resolve, ms));
17
+
15
18
  describe("Select", () => {
16
19
  const mockItems: TestItem[] = [
17
20
  { label: "Option 1", value: "opt1" },
@@ -281,4 +284,46 @@ describe("Select", () => {
281
284
  expect(onSelect).not.toHaveBeenCalled();
282
285
  });
283
286
  });
287
+
288
+ describe('Space/Escape handlers', () => {
289
+ it('should call onSpace with the currently highlighted item', async () => {
290
+ const onSelect = vi.fn();
291
+ const onSpace = vi.fn();
292
+ const { stdin } = render(
293
+ <Select items={mockItems} onSelect={onSelect} onSpace={onSpace} />
294
+ );
295
+
296
+ stdin.write(' ');
297
+ await delay(10);
298
+
299
+ expect(onSpace).toHaveBeenCalledTimes(1);
300
+ expect(onSpace).toHaveBeenCalledWith(mockItems[0]);
301
+ });
302
+
303
+ it('should not trigger onSpace when disabled', async () => {
304
+ const onSelect = vi.fn();
305
+ const onSpace = vi.fn();
306
+ const { stdin } = render(
307
+ <Select items={mockItems} onSelect={onSelect} onSpace={onSpace} disabled />
308
+ );
309
+
310
+ stdin.write(' ');
311
+ await delay(10);
312
+
313
+ expect(onSpace).not.toHaveBeenCalled();
314
+ });
315
+
316
+ it('should call onEscape when escape key is pressed', async () => {
317
+ const onSelect = vi.fn();
318
+ const onEscape = vi.fn();
319
+ const { stdin } = render(
320
+ <Select items={mockItems} onSelect={onSelect} onEscape={onEscape} />
321
+ );
322
+
323
+ stdin.write('\u001B');
324
+ await delay(30);
325
+
326
+ expect(onEscape).toHaveBeenCalledTimes(1);
327
+ });
328
+ });
284
329
  });
@@ -53,6 +53,7 @@ import {
53
53
  import {
54
54
  getDefaultInferenceForModel,
55
55
  getDefaultModelOption,
56
+ normalizeModelId,
56
57
  } from "../utils/modelOptions.js";
57
58
  import {
58
59
  resolveContinueSessionId,
@@ -386,10 +387,15 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
386
387
  }
387
388
  }
388
389
 
390
+ const normalizedModel = normalizeModelId(
391
+ entry.toolId as AITool,
392
+ entry.model ?? null,
393
+ );
394
+
389
395
  return {
390
396
  toolId: entry.toolId as AITool,
391
397
  toolLabel: entry.toolLabel,
392
- model: entry.model ?? null,
398
+ model: normalizedModel ?? null,
393
399
  inferenceLevel: (entry.reasoningLevel ??
394
400
  sessionData?.reasoningLevel ??
395
401
  null) as InferenceLevel | null,
@@ -1069,6 +1075,7 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
1069
1075
  if (selectedBranch && selectedTool) {
1070
1076
  const defaultModel = getDefaultModelOption(selectedTool);
1071
1077
  const resolvedModel = selectedModel?.model ?? defaultModel?.id ?? null;
1078
+ const normalizedModel = normalizeModelId(selectedTool, resolvedModel);
1072
1079
  const resolvedInference =
1073
1080
  selectedModel?.inferenceLevel ??
1074
1081
  getDefaultInferenceForModel(defaultModel ?? undefined);
@@ -1080,7 +1087,7 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
1080
1087
  tool: selectedTool,
1081
1088
  mode: executionMode,
1082
1089
  skipPermissions: skip,
1083
- ...(resolvedModel !== undefined ? { model: resolvedModel } : {}),
1090
+ ...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
1084
1091
  ...(resolvedInference !== undefined
1085
1092
  ? { inferenceLevel: resolvedInference }
1086
1093
  : {}),
@@ -1122,10 +1129,14 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
1122
1129
 
1123
1130
  setSelectedTool(selected.toolId);
1124
1131
  setPreferredToolId(selected.toolId);
1132
+ const normalizedQuickStartModel = normalizeModelId(
1133
+ selected.toolId as AITool,
1134
+ selected.model ?? null,
1135
+ );
1125
1136
  setSelectedModel(
1126
- selected.model
1137
+ normalizedQuickStartModel
1127
1138
  ? ({
1128
- model: selected.model,
1139
+ model: normalizedQuickStartModel,
1129
1140
  inferenceLevel: selected.inferenceLevel ?? undefined,
1130
1141
  } as ModelSelectionResult)
1131
1142
  : null,
@@ -28,6 +28,8 @@ export interface SelectProps<T extends SelectItem = SelectItem> {
28
28
  // Optional controlled component props for cursor position
29
29
  selectedIndex?: number;
30
30
  onSelectedIndexChange?: (index: number) => void;
31
+ onSpace?: (item: T) => void;
32
+ onEscape?: () => void;
31
33
  }
32
34
 
33
35
  /**
@@ -47,7 +49,9 @@ function arePropsEqual<T extends SelectItem = SelectItem>(
47
49
  prevProps.onSelect !== nextProps.onSelect ||
48
50
  prevProps.onSelectedIndexChange !== nextProps.onSelectedIndexChange ||
49
51
  prevProps.renderIndicator !== nextProps.renderIndicator ||
50
- prevProps.renderItem !== nextProps.renderItem
52
+ prevProps.renderItem !== nextProps.renderItem ||
53
+ prevProps.onSpace !== nextProps.onSpace ||
54
+ prevProps.onEscape !== nextProps.onEscape
51
55
  ) {
52
56
  return false;
53
57
  }
@@ -93,6 +97,8 @@ const SelectComponent = <T extends SelectItem = SelectItem>({
93
97
  renderItem,
94
98
  selectedIndex: externalSelectedIndex,
95
99
  onSelectedIndexChange,
100
+ onSpace,
101
+ onEscape,
96
102
  }: SelectProps<T>) => {
97
103
  // Support both controlled and uncontrolled modes
98
104
  const [internalSelectedIndex, setInternalSelectedIndex] =
@@ -177,6 +183,13 @@ const SelectComponent = <T extends SelectItem = SelectItem>({
177
183
  if (selectedItem && !disabled) {
178
184
  onSelect(selectedItem);
179
185
  }
186
+ } else if (input === ' ' && onSpace) {
187
+ const selectedItem = items[selectedIndex];
188
+ if (selectedItem && !disabled) {
189
+ onSpace(selectedItem);
190
+ }
191
+ } else if (key.escape && onEscape) {
192
+ onEscape();
180
193
  }
181
194
  // All other keys are ignored and will propagate to parent components
182
195
  });
@@ -3,6 +3,7 @@ import {
3
3
  getModelOptions,
4
4
  getDefaultInferenceForModel,
5
5
  getDefaultModelOption,
6
+ normalizeModelId,
6
7
  } from "./modelOptions.js";
7
8
 
8
9
  const byId = (tool: string) => getModelOptions(tool).map((m) => m.id);
@@ -74,6 +75,17 @@ describe("modelOptions", () => {
74
75
  ]);
75
76
  });
76
77
 
78
+ it("normalizes known Claude model typos and casing", () => {
79
+ expect(normalizeModelId("claude-code", "opuss")).toBe("opus");
80
+ expect(normalizeModelId("claude-code", "Opus")).toBe("opus");
81
+ expect(normalizeModelId("claude-code", "sonnet")).toBe("sonnet");
82
+ expect(normalizeModelId("claude-code", null)).toBeNull();
83
+ expect(normalizeModelId("claude-code", undefined)).toBeNull();
84
+ expect(normalizeModelId("claude-code", "")).toBeNull();
85
+ expect(normalizeModelId("claude-code", " ")).toBeNull();
86
+ expect(normalizeModelId("claude-code", " opus ")).toBe("opus");
87
+ });
88
+
77
89
  it("returns no models for unsupported tools", () => {
78
90
  expect(byId("unknown-tool")).toEqual([]);
79
91
  });
@@ -2,6 +2,7 @@ import type { AITool, InferenceLevel, ModelOption } from "../types.js";
2
2
 
3
3
  const CODEX_BASE_LEVELS: InferenceLevel[] = ["high", "medium", "low"];
4
4
  const CODEX_MAX_LEVELS: InferenceLevel[] = ["xhigh", "high", "medium", "low"];
5
+ const CLAUDE_MODEL_ALIASES = new Set(["opus", "sonnet", "haiku"]);
5
6
 
6
7
  const MODEL_OPTIONS: Record<string, ModelOption[]> = {
7
8
  "claude-code": [
@@ -130,3 +131,21 @@ export function getDefaultInferenceForModel(
130
131
  const levels = getInferenceLevelsForModel(model);
131
132
  return levels[0];
132
133
  }
134
+
135
+ /**
136
+ * Normalize a model identifier for consistent display and persistence.
137
+ */
138
+ export function normalizeModelId(
139
+ tool: AITool,
140
+ model?: string | null,
141
+ ): string | null {
142
+ if (model === null || model === undefined) return model ?? null;
143
+ const trimmed = model.trim();
144
+ if (!trimmed) return null;
145
+ if (tool === "claude-code") {
146
+ const lower = trimmed.toLowerCase();
147
+ if (lower === "opuss") return "opus";
148
+ if (CLAUDE_MODEL_ALIASES.has(lower)) return lower;
149
+ }
150
+ return trimmed;
151
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,11 @@ import {
7
7
  fetchAllRemotes,
8
8
  pullFastForward,
9
9
  getBranchDivergenceStatuses,
10
+ hasUncommittedChanges,
11
+ hasUnpushedCommits,
12
+ getUncommittedChangesCount,
13
+ getUnpushedCommitsCount,
14
+ pushBranchToRemote,
10
15
  GitError,
11
16
  } from "./git.js";
12
17
  import { launchClaudeCode } from "./claude.js";
@@ -23,10 +28,10 @@ import {
23
28
  import chalk from "chalk";
24
29
  import type { SelectionResult } from "./cli/ui/components/App.js";
25
30
  import {
26
- worktreeExists,
27
31
  isProtectedBranchName,
28
32
  switchToProtectedBranch,
29
33
  WorktreeError,
34
+ resolveWorktreePathForBranch,
30
35
  } from "./worktree.js";
31
36
  import {
32
37
  getTerminalStreams,
@@ -44,16 +49,18 @@ import {
44
49
  import { getPackageVersion } from "./utils.js";
45
50
  import { findLatestClaudeSessionId } from "./utils/session.js";
46
51
  import { resolveContinueSessionId } from "./cli/ui/utils/continueSession.js";
52
+ import { normalizeModelId } from "./cli/ui/utils/modelOptions.js";
47
53
  import {
48
54
  installDependenciesForWorktree,
49
55
  DependencyInstallError,
50
56
  type DependencyInstallResult,
51
57
  } from "./services/dependency-installer.js";
52
- import { waitForEnter } from "./utils/prompt.js";
58
+ import { confirmYesNo, waitForEnter } from "./utils/prompt.js";
53
59
 
54
60
  const ERROR_PROMPT = chalk.yellow(
55
61
  "Review the error details, then press Enter to continue.",
56
62
  );
63
+ const POST_SESSION_DELAY_MS = 3000;
57
64
 
58
65
  // Category: cli
59
66
  const appLogger = createLogger({ category: "cli" });
@@ -299,9 +306,10 @@ export async function handleAIToolWorkflow(
299
306
  } = selectionResult;
300
307
 
301
308
  const branchLabel = displayName ?? branch;
309
+ const normalizedModel = normalizeModelId(tool, model ?? null);
302
310
  const modelInfo =
303
- model || inferenceLevel
304
- ? `, model=${model ?? "default"}${inferenceLevel ? `/${inferenceLevel}` : ""}`
311
+ normalizedModel || inferenceLevel
312
+ ? `, model=${normalizedModel ?? "default"}${inferenceLevel ? `/${inferenceLevel}` : ""}`
305
313
  : "";
306
314
  printInfo(
307
315
  `Selected: ${branchLabel} with ${tool} (${mode} mode${modelInfo}, skipPermissions: ${skipPermissions})`,
@@ -328,7 +336,16 @@ export async function handleAIToolWorkflow(
328
336
  ensureOptions.isNewBranch = !localExists;
329
337
  }
330
338
 
331
- const existingWorktree = await worktreeExists(branch);
339
+ const existingWorktreeResolution =
340
+ await resolveWorktreePathForBranch(branch);
341
+ const existingWorktree = existingWorktreeResolution.path;
342
+ if (!existingWorktree && existingWorktreeResolution.mismatch) {
343
+ const actualBranch =
344
+ existingWorktreeResolution.mismatch.actualBranch ?? "unknown";
345
+ printWarning(
346
+ `Worktree mismatch detected: ${existingWorktreeResolution.mismatch.path} is checked out to '${actualBranch}'. Creating or reusing the correct worktree for '${branch}'.`,
347
+ );
348
+ }
332
349
 
333
350
  const isProtectedBranch =
334
351
  isProtectedBranchName(branch) ||
@@ -543,7 +560,7 @@ export async function handleAIToolWorkflow(
543
560
  lastUsedTool: tool,
544
561
  toolLabel: toolConfig.displayName ?? tool,
545
562
  mode,
546
- model: model ?? null,
563
+ model: normalizedModel ?? null,
547
564
  reasoningLevel: inferenceLevel ?? null,
548
565
  skipPermissions: skipPermissions ?? null,
549
566
  timestamp: Date.now(),
@@ -602,8 +619,8 @@ export async function handleAIToolWorkflow(
602
619
  envOverrides: sharedEnv,
603
620
  sessionId: resumeSessionId,
604
621
  };
605
- if (model) {
606
- launchOptions.model = model;
622
+ if (normalizedModel) {
623
+ launchOptions.model = normalizedModel;
607
624
  }
608
625
  launchResult = await launchClaudeCode(worktreePath, launchOptions);
609
626
  } else if (tool === "codex-cli") {
@@ -625,8 +642,8 @@ export async function handleAIToolWorkflow(
625
642
  envOverrides: sharedEnv,
626
643
  sessionId: resumeSessionId,
627
644
  };
628
- if (model) {
629
- launchOptions.model = model;
645
+ if (normalizedModel) {
646
+ launchOptions.model = normalizedModel;
630
647
  }
631
648
  if (inferenceLevel) {
632
649
  launchOptions.reasoningEffort = inferenceLevel as CodexReasoningEffort;
@@ -650,8 +667,8 @@ export async function handleAIToolWorkflow(
650
667
  envOverrides: sharedEnv,
651
668
  sessionId: resumeSessionId,
652
669
  };
653
- if (model) {
654
- launchOptions.model = model;
670
+ if (normalizedModel) {
671
+ launchOptions.model = normalizedModel;
655
672
  }
656
673
  launchResult = await launchGeminiCLI(worktreePath, launchOptions);
657
674
  } else {
@@ -738,7 +755,7 @@ export async function handleAIToolWorkflow(
738
755
  lastUsedTool: tool,
739
756
  toolLabel: toolConfig.displayName ?? tool,
740
757
  mode,
741
- model: model ?? null,
758
+ model: normalizedModel ?? null,
742
759
  reasoningLevel: inferenceLevel ?? null,
743
760
  skipPermissions: skipPermissions ?? null,
744
761
  timestamp: Date.now(),
@@ -746,8 +763,47 @@ export async function handleAIToolWorkflow(
746
763
  lastSessionId: finalSessionId,
747
764
  });
748
765
 
766
+ try {
767
+ const [hasUncommitted, hasUnpushed] = await Promise.all([
768
+ hasUncommittedChanges(worktreePath),
769
+ hasUnpushedCommits(worktreePath, branch),
770
+ ]);
771
+
772
+ if (hasUncommitted) {
773
+ const uncommittedCount = await getUncommittedChangesCount(worktreePath);
774
+ const countLabel =
775
+ uncommittedCount > 0 ? ` (${uncommittedCount}件)` : "";
776
+ printWarning(`未コミットの変更があります${countLabel}。`);
777
+ }
778
+
779
+ if (hasUnpushed) {
780
+ const unpushedCount = await getUnpushedCommitsCount(
781
+ worktreePath,
782
+ branch,
783
+ );
784
+ const countLabel = unpushedCount > 0 ? ` (${unpushedCount}件)` : "";
785
+ const shouldPush = await confirmYesNo(
786
+ `未プッシュのコミットがあります${countLabel}。プッシュしますか?`,
787
+ { defaultValue: false },
788
+ );
789
+ if (shouldPush) {
790
+ printInfo(`Pushing origin/${branch}...`);
791
+ try {
792
+ await pushBranchToRemote(worktreePath, branch);
793
+ printInfo(`Push completed for ${branch}.`);
794
+ } catch (error) {
795
+ const details =
796
+ error instanceof Error ? error.message : String(error);
797
+ printWarning(`Push failed for ${branch}: ${details}`);
798
+ }
799
+ }
800
+ }
801
+ } catch (error) {
802
+ const details = error instanceof Error ? error.message : String(error);
803
+ printWarning(`Failed to check git status after session: ${details}`);
804
+ }
749
805
  // Small buffer before returning to branch list to avoid abrupt screen swap
750
- await new Promise((resolve) => setTimeout(resolve, 3000));
806
+ await new Promise((resolve) => setTimeout(resolve, POST_SESSION_DELAY_MS));
751
807
  printInfo("Session completed successfully. Returning to main menu...");
752
808
  return;
753
809
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { PassThrough } from "node:stream";
2
- import { describe, expect, it, vi } from "vitest";
2
+ import { describe, expect, it, vi, beforeEach } from "vitest";
3
3
 
4
4
  // Shared mock target to avoid hoisting issues
5
5
  const terminalStreams: Record<string, unknown> = {};
@@ -16,16 +16,32 @@ const withTimeout = <T>(promise: Promise<T>, ms = 500): Promise<T> =>
16
16
  ),
17
17
  ]);
18
18
 
19
+ const resetTerminalStreams = () => {
20
+ vi.resetModules();
21
+ for (const key of Object.keys(terminalStreams)) {
22
+ delete terminalStreams[key];
23
+ }
24
+ };
25
+
26
+ const setupTerminalStreams = (isTTY: boolean) => {
27
+ const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
28
+ const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
29
+ Object.defineProperty(stdin, "isTTY", { value: isTTY, configurable: true });
30
+ const exitRawMode = vi.fn();
31
+ Object.assign(terminalStreams, {
32
+ stdin,
33
+ stdout,
34
+ stderr: stdout,
35
+ usingFallback: false,
36
+ exitRawMode,
37
+ });
38
+ return { stdin, stdout, exitRawMode };
39
+ };
40
+
19
41
  describe("waitForEnter", () => {
20
42
  it("uses terminal stdin/stdout and resolves after newline on TTY", async () => {
21
- vi.resetModules();
22
- for (const key of Object.keys(terminalStreams)) {
23
- delete terminalStreams[key];
24
- }
25
-
26
- const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
27
- const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
28
- Object.defineProperty(stdin, "isTTY", { value: true });
43
+ resetTerminalStreams();
44
+ const { stdin, exitRawMode } = setupTerminalStreams(true);
29
45
 
30
46
  let resumed = false;
31
47
  let paused = false;
@@ -41,16 +57,6 @@ describe("waitForEnter", () => {
41
57
  return originalPause();
42
58
  }) as typeof stdin.pause;
43
59
 
44
- const exitRawMode = vi.fn();
45
-
46
- Object.assign(terminalStreams, {
47
- stdin,
48
- stdout,
49
- stderr: stdout,
50
- usingFallback: false,
51
- exitRawMode,
52
- });
53
-
54
60
  const { waitForEnter } = await import("../prompt.js");
55
61
 
56
62
  const waiting = withTimeout(waitForEnter("prompt"), 200);
@@ -63,22 +69,8 @@ describe("waitForEnter", () => {
63
69
  });
64
70
 
65
71
  it("returns immediately on non-TTY stdin", async () => {
66
- vi.resetModules();
67
- for (const key of Object.keys(terminalStreams)) {
68
- delete terminalStreams[key];
69
- }
70
-
71
- const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
72
- const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
73
- Object.defineProperty(stdin, "isTTY", { value: false });
74
-
75
- Object.assign(terminalStreams, {
76
- stdin,
77
- stdout,
78
- stderr: stdout,
79
- usingFallback: false,
80
- exitRawMode: vi.fn(),
81
- });
72
+ resetTerminalStreams();
73
+ setupTerminalStreams(false);
82
74
 
83
75
  const { waitForEnter } = await import("../prompt.js");
84
76
 
@@ -87,3 +79,48 @@ describe("waitForEnter", () => {
87
79
  expect(Date.now() - start).toBeLessThan(50);
88
80
  });
89
81
  });
82
+
83
+ describe("confirmYesNo", () => {
84
+ let stdin: NodeJS.ReadStream;
85
+ let exitRawMode: ReturnType<typeof vi.fn>;
86
+
87
+ beforeEach(() => {
88
+ resetTerminalStreams();
89
+ const setup = setupTerminalStreams(true);
90
+ stdin = setup.stdin;
91
+ exitRawMode = setup.exitRawMode;
92
+ });
93
+
94
+ it("resolves true when user inputs y on TTY", async () => {
95
+ const { confirmYesNo } = await import("../prompt.js");
96
+
97
+ const waiting = withTimeout(confirmYesNo("push?"), 200);
98
+ stdin.write("y\n");
99
+
100
+ await expect(waiting).resolves.toBe(true);
101
+ expect(exitRawMode).toHaveBeenCalled();
102
+ });
103
+
104
+ it("uses default value when input is empty on TTY", async () => {
105
+ const { confirmYesNo } = await import("../prompt.js");
106
+
107
+ const waiting = withTimeout(
108
+ confirmYesNo("push?", { defaultValue: true }),
109
+ 200,
110
+ );
111
+ stdin.write("\n");
112
+
113
+ await expect(waiting).resolves.toBe(true);
114
+ });
115
+
116
+ it("returns default immediately on non-TTY stdin", async () => {
117
+ Object.defineProperty(stdin, "isTTY", { value: false });
118
+
119
+ const { confirmYesNo } = await import("../prompt.js");
120
+
121
+ const start = Date.now();
122
+ const result = await confirmYesNo("push?", { defaultValue: false });
123
+ expect(result).toBe(false);
124
+ expect(Date.now() - start).toBeLessThan(50);
125
+ });
126
+ });