@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.
- package/README.ja.md +323 -0
- package/README.md +347 -0
- package/bin/gwt.js +5 -0
- package/package.json +125 -0
- package/src/claude-history.ts +717 -0
- package/src/claude.ts +292 -0
- package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
- package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
- package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
- package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
- package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
- package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
- package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
- package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
- package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
- package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
- package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
- package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
- package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
- package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
- package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
- package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
- package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
- package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
- package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
- package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
- package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
- package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
- package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
- package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
- package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
- package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
- package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
- package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
- package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
- package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
- package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
- package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
- package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
- package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
- package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
- package/src/cli/ui/components/App.tsx +793 -0
- package/src/cli/ui/components/common/Confirm.tsx +40 -0
- package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
- package/src/cli/ui/components/common/Input.tsx +36 -0
- package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
- package/src/cli/ui/components/common/Select.tsx +216 -0
- package/src/cli/ui/components/parts/Footer.tsx +41 -0
- package/src/cli/ui/components/parts/Header.test.tsx +85 -0
- package/src/cli/ui/components/parts/Header.tsx +63 -0
- package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
- package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
- package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
- package/src/cli/ui/components/parts/Stats.tsx +67 -0
- package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
- package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
- package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
- package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
- package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
- package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
- package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
- package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
- package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
- package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
- package/src/cli/ui/hooks/useGitData.ts +157 -0
- package/src/cli/ui/hooks/useScreenState.ts +44 -0
- package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
- package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
- package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
- package/src/cli/ui/types.ts +295 -0
- package/src/cli/ui/utils/baseBranch.ts +34 -0
- package/src/cli/ui/utils/branchFormatter.ts +222 -0
- package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
- package/src/codex.ts +139 -0
- package/src/config/builtin-tools.ts +44 -0
- package/src/config/constants.ts +100 -0
- package/src/config/env-history.ts +45 -0
- package/src/config/index.ts +204 -0
- package/src/config/tools.ts +293 -0
- package/src/git.ts +1102 -0
- package/src/github.ts +158 -0
- package/src/index.test.ts +87 -0
- package/src/index.ts +684 -0
- package/src/index.ts.backup +1543 -0
- package/src/launcher.ts +142 -0
- package/src/repositories/git.repository.ts +129 -0
- package/src/repositories/github.repository.ts +83 -0
- package/src/repositories/worktree.repository.ts +69 -0
- package/src/services/BatchMergeService.ts +251 -0
- package/src/services/WorktreeOrchestrator.ts +115 -0
- package/src/services/__tests__/BatchMergeService.test.ts +518 -0
- package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
- package/src/services/dependency-installer.ts +199 -0
- package/src/services/git.service.ts +113 -0
- package/src/services/github.service.ts +61 -0
- package/src/services/worktree.service.ts +66 -0
- package/src/types/api.ts +241 -0
- package/src/types/tools.ts +235 -0
- package/src/utils/spinner.ts +54 -0
- package/src/utils/terminal.ts +272 -0
- package/src/utils.test.ts +43 -0
- package/src/utils.ts +60 -0
- package/src/web/client/index.html +12 -0
- package/src/web/client/src/components/BranchGraph.tsx +231 -0
- package/src/web/client/src/components/EnvEditor.tsx +145 -0
- package/src/web/client/src/components/Terminal.tsx +137 -0
- package/src/web/client/src/hooks/useBranches.ts +41 -0
- package/src/web/client/src/hooks/useConfig.ts +31 -0
- package/src/web/client/src/hooks/useSessions.ts +59 -0
- package/src/web/client/src/hooks/useWorktrees.ts +47 -0
- package/src/web/client/src/index.css +834 -0
- package/src/web/client/src/lib/api.ts +184 -0
- package/src/web/client/src/lib/websocket.ts +174 -0
- package/src/web/client/src/main.tsx +29 -0
- package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
- package/src/web/client/src/pages/BranchListPage.tsx +264 -0
- package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
- package/src/web/client/src/router.tsx +27 -0
- package/src/web/client/vite.config.ts +21 -0
- package/src/web/server/env/importer.ts +54 -0
- package/src/web/server/index.ts +74 -0
- package/src/web/server/pty/manager.ts +189 -0
- package/src/web/server/routes/branches.ts +126 -0
- package/src/web/server/routes/config.ts +220 -0
- package/src/web/server/routes/index.ts +37 -0
- package/src/web/server/routes/sessions.ts +130 -0
- package/src/web/server/routes/worktrees.ts +108 -0
- package/src/web/server/services/branches.ts +368 -0
- package/src/web/server/services/worktrees.ts +85 -0
- package/src/web/server/websocket/handler.ts +180 -0
- package/src/worktree.ts +703 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
WorktreeOrchestrator,
|
|
4
|
+
type WorktreeService,
|
|
5
|
+
} from "../WorktreeOrchestrator.js";
|
|
6
|
+
import * as git from "../../git.js";
|
|
7
|
+
|
|
8
|
+
// Mock git.getCurrentBranch
|
|
9
|
+
vi.mock("../../git.js", () => ({
|
|
10
|
+
getCurrentBranch: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("WorktreeOrchestrator", () => {
|
|
14
|
+
let orchestrator: WorktreeOrchestrator;
|
|
15
|
+
let mockWorktreeService: WorktreeService;
|
|
16
|
+
const mockRepoRoot = "/mock/repo";
|
|
17
|
+
const mockBranch = "feature-test";
|
|
18
|
+
const mockWorktreePath = "/mock/repo/.git/worktree/feature-test";
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
// Create mock service without vi.mock()
|
|
22
|
+
mockWorktreeService = {
|
|
23
|
+
worktreeExists: vi.fn(),
|
|
24
|
+
generateWorktreePath: vi.fn(),
|
|
25
|
+
createWorktree: vi.fn(),
|
|
26
|
+
};
|
|
27
|
+
orchestrator = new WorktreeOrchestrator(mockWorktreeService);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("ensureWorktree", () => {
|
|
31
|
+
it("should return existing worktree path if worktree exists", async () => {
|
|
32
|
+
// Arrange
|
|
33
|
+
(mockWorktreeService.worktreeExists as any).mockResolvedValue(
|
|
34
|
+
mockWorktreePath,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
const result = await orchestrator.ensureWorktree(
|
|
39
|
+
mockBranch,
|
|
40
|
+
mockRepoRoot,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
expect(result).toBe(mockWorktreePath);
|
|
45
|
+
expect(mockWorktreeService.worktreeExists).toHaveBeenCalledWith(
|
|
46
|
+
mockBranch,
|
|
47
|
+
);
|
|
48
|
+
expect(mockWorktreeService.generateWorktreePath).not.toHaveBeenCalled();
|
|
49
|
+
expect(mockWorktreeService.createWorktree).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should create new worktree if it does not exist", async () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
(mockWorktreeService.worktreeExists as any).mockResolvedValue(null);
|
|
55
|
+
(mockWorktreeService.generateWorktreePath as any).mockResolvedValue(
|
|
56
|
+
mockWorktreePath,
|
|
57
|
+
);
|
|
58
|
+
(mockWorktreeService.createWorktree as any).mockResolvedValue(undefined);
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
const result = await orchestrator.ensureWorktree(
|
|
62
|
+
mockBranch,
|
|
63
|
+
mockRepoRoot,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(result).toBe(mockWorktreePath);
|
|
68
|
+
expect(mockWorktreeService.worktreeExists).toHaveBeenCalledWith(
|
|
69
|
+
mockBranch,
|
|
70
|
+
);
|
|
71
|
+
expect(mockWorktreeService.generateWorktreePath).toHaveBeenCalledWith(
|
|
72
|
+
mockRepoRoot,
|
|
73
|
+
mockBranch,
|
|
74
|
+
);
|
|
75
|
+
expect(mockWorktreeService.createWorktree).toHaveBeenCalledWith({
|
|
76
|
+
branchName: mockBranch,
|
|
77
|
+
worktreePath: mockWorktreePath,
|
|
78
|
+
repoRoot: mockRepoRoot,
|
|
79
|
+
isNewBranch: false,
|
|
80
|
+
baseBranch: "main",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should use custom base branch when provided", async () => {
|
|
85
|
+
// Arrange
|
|
86
|
+
(mockWorktreeService.worktreeExists as any).mockResolvedValue(null);
|
|
87
|
+
(mockWorktreeService.generateWorktreePath as any).mockResolvedValue(
|
|
88
|
+
mockWorktreePath,
|
|
89
|
+
);
|
|
90
|
+
(mockWorktreeService.createWorktree as any).mockResolvedValue(undefined);
|
|
91
|
+
const customBaseBranch = "develop";
|
|
92
|
+
|
|
93
|
+
// Act
|
|
94
|
+
const result = await orchestrator.ensureWorktree(
|
|
95
|
+
mockBranch,
|
|
96
|
+
mockRepoRoot,
|
|
97
|
+
{
|
|
98
|
+
baseBranch: customBaseBranch,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
expect(result).toBe(mockWorktreePath);
|
|
104
|
+
expect(mockWorktreeService.createWorktree).toHaveBeenCalledWith({
|
|
105
|
+
branchName: mockBranch,
|
|
106
|
+
worktreePath: mockWorktreePath,
|
|
107
|
+
repoRoot: mockRepoRoot,
|
|
108
|
+
isNewBranch: false,
|
|
109
|
+
baseBranch: customBaseBranch,
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should mark worktree creation as new branch when requested", async () => {
|
|
114
|
+
(mockWorktreeService.worktreeExists as any).mockResolvedValue(null);
|
|
115
|
+
(mockWorktreeService.generateWorktreePath as any).mockResolvedValue(
|
|
116
|
+
mockWorktreePath,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
(mockWorktreeService.createWorktree as any).mockResolvedValue(undefined);
|
|
120
|
+
|
|
121
|
+
const result = await orchestrator.ensureWorktree(
|
|
122
|
+
mockBranch,
|
|
123
|
+
mockRepoRoot,
|
|
124
|
+
{
|
|
125
|
+
baseBranch: "origin/feature-test",
|
|
126
|
+
isNewBranch: true,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(result).toBe(mockWorktreePath);
|
|
131
|
+
expect(mockWorktreeService.createWorktree).toHaveBeenCalledWith({
|
|
132
|
+
branchName: mockBranch,
|
|
133
|
+
worktreePath: mockWorktreePath,
|
|
134
|
+
repoRoot: mockRepoRoot,
|
|
135
|
+
isNewBranch: true,
|
|
136
|
+
baseBranch: "origin/feature-test",
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should throw error if worktree creation fails", async () => {
|
|
141
|
+
// Arrange
|
|
142
|
+
(mockWorktreeService.worktreeExists as any).mockResolvedValue(null);
|
|
143
|
+
(mockWorktreeService.generateWorktreePath as any).mockResolvedValue(
|
|
144
|
+
mockWorktreePath,
|
|
145
|
+
);
|
|
146
|
+
const mockError = new Error("Failed to create worktree");
|
|
147
|
+
(mockWorktreeService.createWorktree as any).mockRejectedValue(mockError);
|
|
148
|
+
|
|
149
|
+
// Act & Assert
|
|
150
|
+
await expect(
|
|
151
|
+
orchestrator.ensureWorktree(mockBranch, mockRepoRoot),
|
|
152
|
+
).rejects.toThrow("Failed to create worktree");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should reuse existing worktree if creation reports branch already checked out", async () => {
|
|
156
|
+
const existingPath = "/mock/repo/.git/worktree/feature-test-existing";
|
|
157
|
+
|
|
158
|
+
(mockWorktreeService.worktreeExists as any)
|
|
159
|
+
.mockResolvedValueOnce(null)
|
|
160
|
+
.mockResolvedValueOnce(existingPath);
|
|
161
|
+
|
|
162
|
+
(mockWorktreeService.generateWorktreePath as any).mockResolvedValue(
|
|
163
|
+
mockWorktreePath,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const alreadyCheckedOutError = new Error(
|
|
167
|
+
"fatal: 'feature-test' is already checked out at '/mock/repo/.git/worktree/feature-test-existing'",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
(mockWorktreeService.createWorktree as any).mockRejectedValue(
|
|
171
|
+
alreadyCheckedOutError,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const result = await orchestrator.ensureWorktree(
|
|
175
|
+
mockBranch,
|
|
176
|
+
mockRepoRoot,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(result).toBe(existingPath);
|
|
180
|
+
expect(mockWorktreeService.worktreeExists).toHaveBeenCalledTimes(2);
|
|
181
|
+
expect(mockWorktreeService.createWorktree).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("current branch handling", () => {
|
|
185
|
+
it("should return repository root when current branch is selected", async () => {
|
|
186
|
+
// Arrange
|
|
187
|
+
const currentBranch = "main";
|
|
188
|
+
(git.getCurrentBranch as any).mockResolvedValue(currentBranch);
|
|
189
|
+
|
|
190
|
+
// Act
|
|
191
|
+
const result = await orchestrator.ensureWorktree(
|
|
192
|
+
currentBranch,
|
|
193
|
+
mockRepoRoot,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Assert
|
|
197
|
+
expect(result).toBe(mockRepoRoot);
|
|
198
|
+
expect(git.getCurrentBranch).toHaveBeenCalled();
|
|
199
|
+
expect(mockWorktreeService.worktreeExists).not.toHaveBeenCalled();
|
|
200
|
+
expect(mockWorktreeService.createWorktree).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should create worktree when non-current branch is selected", async () => {
|
|
204
|
+
// Arrange
|
|
205
|
+
const currentBranch = "main";
|
|
206
|
+
const otherBranch = "feature-test";
|
|
207
|
+
(git.getCurrentBranch as any).mockResolvedValue(currentBranch);
|
|
208
|
+
(mockWorktreeService.worktreeExists as any).mockResolvedValue(null);
|
|
209
|
+
(mockWorktreeService.generateWorktreePath as any).mockResolvedValue(
|
|
210
|
+
mockWorktreePath,
|
|
211
|
+
);
|
|
212
|
+
(mockWorktreeService.createWorktree as any).mockResolvedValue(
|
|
213
|
+
undefined,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Act
|
|
217
|
+
const result = await orchestrator.ensureWorktree(
|
|
218
|
+
otherBranch,
|
|
219
|
+
mockRepoRoot,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Assert
|
|
223
|
+
expect(result).toBe(mockWorktreePath);
|
|
224
|
+
expect(git.getCurrentBranch).toHaveBeenCalled();
|
|
225
|
+
expect(mockWorktreeService.worktreeExists).toHaveBeenCalledWith(
|
|
226
|
+
otherBranch,
|
|
227
|
+
);
|
|
228
|
+
expect(mockWorktreeService.createWorktree).toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("should fallback to normal flow when getCurrentBranch returns null", async () => {
|
|
232
|
+
// Arrange
|
|
233
|
+
(git.getCurrentBranch as any).mockResolvedValue(null);
|
|
234
|
+
(mockWorktreeService.worktreeExists as any).mockResolvedValue(null);
|
|
235
|
+
(mockWorktreeService.generateWorktreePath as any).mockResolvedValue(
|
|
236
|
+
mockWorktreePath,
|
|
237
|
+
);
|
|
238
|
+
(mockWorktreeService.createWorktree as any).mockResolvedValue(
|
|
239
|
+
undefined,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Act
|
|
243
|
+
const result = await orchestrator.ensureWorktree(
|
|
244
|
+
mockBranch,
|
|
245
|
+
mockRepoRoot,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Assert
|
|
249
|
+
expect(result).toBe(mockWorktreePath);
|
|
250
|
+
expect(git.getCurrentBranch).toHaveBeenCalled();
|
|
251
|
+
expect(mockWorktreeService.worktreeExists).toHaveBeenCalledWith(
|
|
252
|
+
mockBranch,
|
|
253
|
+
);
|
|
254
|
+
expect(mockWorktreeService.createWorktree).toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { execa } from "execa";
|
|
4
|
+
import { startSpinner } from "../utils/spinner.js";
|
|
5
|
+
|
|
6
|
+
export type PackageManager = "bun" | "pnpm" | "npm";
|
|
7
|
+
|
|
8
|
+
interface PackageManagerCandidate {
|
|
9
|
+
manager: PackageManager;
|
|
10
|
+
lockfile: string;
|
|
11
|
+
command: [string, ...string[]];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DetectedPackageManager {
|
|
15
|
+
manager: PackageManager;
|
|
16
|
+
lockfile: string;
|
|
17
|
+
command: [string, ...string[]];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DependencySkipReason =
|
|
21
|
+
| "missing-lockfile"
|
|
22
|
+
| "missing-binary"
|
|
23
|
+
| "install-failed"
|
|
24
|
+
| "lockfile-access-error"
|
|
25
|
+
| "unknown-error";
|
|
26
|
+
|
|
27
|
+
export type DependencyInstallResult =
|
|
28
|
+
| {
|
|
29
|
+
skipped: false;
|
|
30
|
+
manager: PackageManager;
|
|
31
|
+
lockfile: string;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
skipped: true;
|
|
35
|
+
manager: PackageManager | null;
|
|
36
|
+
lockfile: string | null;
|
|
37
|
+
reason: DependencySkipReason;
|
|
38
|
+
message?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class DependencyInstallError extends Error {
|
|
42
|
+
constructor(
|
|
43
|
+
message: string,
|
|
44
|
+
public cause?: unknown,
|
|
45
|
+
) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = "DependencyInstallError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const INSTALL_CANDIDATES: PackageManagerCandidate[] = [
|
|
52
|
+
{
|
|
53
|
+
manager: "bun",
|
|
54
|
+
lockfile: "bun.lock",
|
|
55
|
+
command: ["bun", "install", "--frozen-lockfile"],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
manager: "pnpm",
|
|
59
|
+
lockfile: "pnpm-lock.yaml",
|
|
60
|
+
command: ["pnpm", "install", "--frozen-lockfile"],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
manager: "npm",
|
|
64
|
+
lockfile: "package-lock.json",
|
|
65
|
+
command: ["npm", "ci"],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
manager: "npm",
|
|
69
|
+
lockfile: "package.json",
|
|
70
|
+
command: ["npm", "install"],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
async function fileExists(targetPath: string): Promise<boolean> {
|
|
75
|
+
try {
|
|
76
|
+
await fs.access(targetPath);
|
|
77
|
+
return true;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
80
|
+
if (code === "ENOENT") {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
throw new DependencyInstallError(
|
|
84
|
+
`Lockfile access failed: ${targetPath} (${code ?? "unknown"})`,
|
|
85
|
+
error,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function detectPackageManager(
|
|
91
|
+
worktreePath: string,
|
|
92
|
+
): Promise<DetectedPackageManager | null> {
|
|
93
|
+
const normalized = path.resolve(worktreePath);
|
|
94
|
+
|
|
95
|
+
for (const candidate of INSTALL_CANDIDATES) {
|
|
96
|
+
const fullPath = path.join(normalized, candidate.lockfile);
|
|
97
|
+
if (await fileExists(fullPath)) {
|
|
98
|
+
return {
|
|
99
|
+
manager: candidate.manager,
|
|
100
|
+
lockfile: fullPath,
|
|
101
|
+
command: candidate.command,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function installDependenciesForWorktree(
|
|
110
|
+
worktreePath: string,
|
|
111
|
+
): Promise<DependencyInstallResult> {
|
|
112
|
+
let detection: DetectedPackageManager | null = null;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
detection = await detectPackageManager(worktreePath);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof DependencyInstallError) {
|
|
118
|
+
return {
|
|
119
|
+
skipped: true,
|
|
120
|
+
manager: null,
|
|
121
|
+
lockfile: null,
|
|
122
|
+
reason: "lockfile-access-error",
|
|
123
|
+
message: error.message,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
skipped: true,
|
|
129
|
+
manager: null,
|
|
130
|
+
lockfile: null,
|
|
131
|
+
reason: "unknown-error",
|
|
132
|
+
message: error instanceof Error ? error.message : String(error),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!detection) {
|
|
137
|
+
return {
|
|
138
|
+
skipped: true,
|
|
139
|
+
manager: null,
|
|
140
|
+
lockfile: null,
|
|
141
|
+
reason: "missing-lockfile",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const [binary, ...args] = detection.command;
|
|
146
|
+
|
|
147
|
+
const spinner = startSpinner(
|
|
148
|
+
`Installing dependencies via ${detection.manager} (${path.basename(detection.lockfile)})`,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await execa(binary, args, {
|
|
153
|
+
cwd: worktreePath,
|
|
154
|
+
stdout: "inherit",
|
|
155
|
+
stderr: "inherit",
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
159
|
+
|
|
160
|
+
if (code === "ENOENT") {
|
|
161
|
+
console.warn(
|
|
162
|
+
`Package manager '${binary}' was not found; skipping automatic install.`,
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
skipped: true,
|
|
166
|
+
manager: detection.manager,
|
|
167
|
+
lockfile: detection.lockfile,
|
|
168
|
+
reason: "missing-binary",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const messageParts = [
|
|
173
|
+
`Dependency installation failed (${detection.manager}).`,
|
|
174
|
+
`Command: ${binary} ${args.join(" ")}`,
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
const stderr = (error as { stderr?: string })?.stderr;
|
|
178
|
+
if (stderr) {
|
|
179
|
+
messageParts.push(stderr.trim());
|
|
180
|
+
}
|
|
181
|
+
const failureMessage = messageParts.join("\n");
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
skipped: true,
|
|
185
|
+
manager: detection.manager,
|
|
186
|
+
lockfile: detection.lockfile,
|
|
187
|
+
reason: "install-failed",
|
|
188
|
+
message: failureMessage,
|
|
189
|
+
};
|
|
190
|
+
} finally {
|
|
191
|
+
spinner();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
skipped: false,
|
|
196
|
+
manager: detection.manager,
|
|
197
|
+
lockfile: detection.lockfile,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { GitRepository } from "../repositories/git.repository.js";
|
|
2
|
+
import { BranchInfo } from "../cli/ui/types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Git操作のビジネスロジックを管理するService
|
|
6
|
+
*/
|
|
7
|
+
export class GitService {
|
|
8
|
+
constructor(private readonly repository: GitRepository) {}
|
|
9
|
+
|
|
10
|
+
async isValidRepository(): Promise<boolean> {
|
|
11
|
+
return await this.repository.isRepository();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async getAllBranches(): Promise<BranchInfo[]> {
|
|
15
|
+
const [localBranches, remoteBranches, currentBranch] = await Promise.all([
|
|
16
|
+
this.getLocalBranchesInfo(),
|
|
17
|
+
this.getRemoteBranchesInfo(),
|
|
18
|
+
this.repository.getCurrentBranch(),
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// 現在のブランチ情報を設定
|
|
22
|
+
if (currentBranch) {
|
|
23
|
+
localBranches.forEach((branch) => {
|
|
24
|
+
if (branch.name === currentBranch) {
|
|
25
|
+
branch.isCurrent = true;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return [...localBranches, ...remoteBranches];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private async getLocalBranchesInfo(): Promise<BranchInfo[]> {
|
|
34
|
+
const branches = await this.repository.getBranches({ remote: false });
|
|
35
|
+
return branches.map((name) => ({
|
|
36
|
+
name,
|
|
37
|
+
type: "local" as const,
|
|
38
|
+
branchType: this.getBranchType(name),
|
|
39
|
+
isCurrent: false,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async getRemoteBranchesInfo(): Promise<BranchInfo[]> {
|
|
44
|
+
const branches = await this.repository.getBranches({ remote: true });
|
|
45
|
+
return branches.map((name) => ({
|
|
46
|
+
name,
|
|
47
|
+
type: "remote" as const,
|
|
48
|
+
branchType: this.getBranchType(name.replace(/^origin\//, "")),
|
|
49
|
+
isCurrent: false,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private getBranchType(branchName: string): BranchInfo["branchType"] {
|
|
54
|
+
if (branchName.startsWith("feature/")) return "feature";
|
|
55
|
+
if (branchName.startsWith("hotfix/")) return "hotfix";
|
|
56
|
+
if (branchName.startsWith("release/")) return "release";
|
|
57
|
+
if (branchName === "main" || branchName === "master") return "main";
|
|
58
|
+
if (branchName === "develop" || branchName === "dev") return "develop";
|
|
59
|
+
return "other";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async createFeatureBranch(
|
|
63
|
+
taskName: string,
|
|
64
|
+
baseBranch: string,
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
const branchName = `feature/${taskName}`;
|
|
67
|
+
await this.repository.createBranch(branchName, baseBranch);
|
|
68
|
+
return branchName;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async deleteBranch(
|
|
72
|
+
branchName: string,
|
|
73
|
+
options?: {
|
|
74
|
+
force?: boolean;
|
|
75
|
+
remote?: boolean;
|
|
76
|
+
},
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
if (options?.remote) {
|
|
79
|
+
await this.repository.deleteRemoteBranch(branchName);
|
|
80
|
+
} else {
|
|
81
|
+
await this.repository.deleteBranch(branchName, options?.force);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async hasUncommittedChanges(workdir?: string): Promise<boolean> {
|
|
86
|
+
return await this.repository.hasChanges(workdir);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getChangedFilesCount(workdir?: string): Promise<number> {
|
|
90
|
+
return await this.repository.getChangedFilesCount(workdir);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async commitAllChanges(message: string): Promise<void> {
|
|
94
|
+
await this.repository.add(".");
|
|
95
|
+
await this.repository.commit(message, { all: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async pushChanges(branchName: string): Promise<void> {
|
|
99
|
+
await this.repository.push({ upstream: true, branch: branchName });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async stashChanges(message?: string): Promise<void> {
|
|
103
|
+
await this.repository.stash(message);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async discardAllChanges(): Promise<void> {
|
|
107
|
+
await this.repository.checkout(".");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async fetchRemoteUpdates(): Promise<void> {
|
|
111
|
+
await this.repository.fetch({ all: true, prune: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { GitHubRepository } from "../repositories/github.repository.js";
|
|
2
|
+
import type { PullRequest, MergedPullRequest } from "../cli/ui/types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GitHub操作のビジネスロジックを管理するService
|
|
6
|
+
*/
|
|
7
|
+
export class GitHubService {
|
|
8
|
+
constructor(private readonly repository: GitHubRepository) {}
|
|
9
|
+
|
|
10
|
+
async isAvailable(): Promise<boolean> {
|
|
11
|
+
return await this.repository.isAvailable();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async checkAuthentication(): Promise<boolean> {
|
|
15
|
+
return await this.repository.isAuthenticated();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getMergedPullRequests(): Promise<MergedPullRequest[]> {
|
|
19
|
+
// リモート情報を更新
|
|
20
|
+
await this.repository.fetchRemoteUpdates();
|
|
21
|
+
|
|
22
|
+
const prs = await this.repository.fetchPullRequests({
|
|
23
|
+
state: "merged",
|
|
24
|
+
limit: 100,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return prs
|
|
28
|
+
.filter((pr) => pr.mergedAt !== null)
|
|
29
|
+
.map((pr) => ({
|
|
30
|
+
number: pr.number,
|
|
31
|
+
title: pr.title,
|
|
32
|
+
branch: pr.headRefName,
|
|
33
|
+
mergedAt: pr.mergedAt as string, // filterで null を除外済み
|
|
34
|
+
author: pr.author?.login || "unknown",
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async getPullRequestByBranch(
|
|
39
|
+
branchName: string,
|
|
40
|
+
): Promise<PullRequest | null> {
|
|
41
|
+
const prs = await this.repository.fetchPullRequests({
|
|
42
|
+
head: branchName,
|
|
43
|
+
state: "all",
|
|
44
|
+
limit: 1,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (prs.length === 0 || !prs[0]) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const pr = prs[0];
|
|
52
|
+
return {
|
|
53
|
+
number: pr.number,
|
|
54
|
+
title: pr.title,
|
|
55
|
+
state: pr.state as "OPEN" | "CLOSED" | "MERGED",
|
|
56
|
+
branch: pr.headRefName,
|
|
57
|
+
mergedAt: pr.mergedAt,
|
|
58
|
+
author: pr.author?.login || "unknown",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { WorktreeRepository } from "../repositories/worktree.repository.js";
|
|
3
|
+
import { GitRepository } from "../repositories/git.repository.js";
|
|
4
|
+
import { WorktreeInfo } from "../worktree.js";
|
|
5
|
+
import { WorktreeConfig } from "../cli/ui/types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Worktree操作のビジネスロジックを管理するService
|
|
9
|
+
*/
|
|
10
|
+
export class WorktreeService {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly worktreeRepository: WorktreeRepository,
|
|
13
|
+
private readonly gitRepository: GitRepository,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async listAdditionalWorktrees(): Promise<WorktreeInfo[]> {
|
|
17
|
+
const [allWorktrees, repoRoot] = await Promise.all([
|
|
18
|
+
this.listAllWorktrees(),
|
|
19
|
+
this.gitRepository.getRepositoryRoot(),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// メインworktree(リポジトリルート)を除外
|
|
23
|
+
return allWorktrees.filter((w) => w.path !== repoRoot);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async listAllWorktrees(): Promise<WorktreeInfo[]> {
|
|
27
|
+
const worktrees = await this.worktreeRepository.list();
|
|
28
|
+
return worktrees.map((w) => ({
|
|
29
|
+
path: w.path,
|
|
30
|
+
branch: w.branch,
|
|
31
|
+
head: w.head,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async createWorktree(config: WorktreeConfig): Promise<void> {
|
|
36
|
+
await this.worktreeRepository.add(config.worktreePath, config.branchName);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async removeWorktree(worktreePath: string, force = false): Promise<void> {
|
|
40
|
+
await this.worktreeRepository.remove(worktreePath, force);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getWorktreeByBranch(
|
|
44
|
+
branchName: string,
|
|
45
|
+
): Promise<WorktreeInfo | undefined> {
|
|
46
|
+
const worktrees = await this.listAllWorktrees();
|
|
47
|
+
return worktrees.find((w) => w.branch === branchName);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getRecommendedWorktreePath(branchName: string): Promise<string> {
|
|
51
|
+
const repoRoot = await this.gitRepository.getRepositoryRoot();
|
|
52
|
+
const repoName = path.basename(repoRoot);
|
|
53
|
+
const parentDir = path.dirname(repoRoot);
|
|
54
|
+
|
|
55
|
+
// ブランチ名からworktreeパスを生成
|
|
56
|
+
const safeBranchName = branchName
|
|
57
|
+
.replace(/\//g, "-")
|
|
58
|
+
.replace(/[^a-zA-Z0-9-_]/g, "");
|
|
59
|
+
|
|
60
|
+
return path.join(parentDir, `${repoName}-${safeBranchName}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async prune(): Promise<void> {
|
|
64
|
+
await this.worktreeRepository.prune();
|
|
65
|
+
}
|
|
66
|
+
}
|