@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,115 @@
|
|
|
1
|
+
import type { WorktreeConfig } from "../worktree.js";
|
|
2
|
+
import {
|
|
3
|
+
worktreeExists,
|
|
4
|
+
generateWorktreePath,
|
|
5
|
+
createWorktree,
|
|
6
|
+
} from "../worktree.js";
|
|
7
|
+
import { getCurrentBranch } from "../git.js";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* WorktreeService interface for dependency injection
|
|
12
|
+
*/
|
|
13
|
+
export interface WorktreeService {
|
|
14
|
+
worktreeExists: (branch: string) => Promise<string | null>;
|
|
15
|
+
generateWorktreePath: (repoRoot: string, branch: string) => Promise<string>;
|
|
16
|
+
createWorktree: (config: WorktreeConfig) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EnsureWorktreeOptions {
|
|
20
|
+
baseBranch?: string;
|
|
21
|
+
isNewBranch?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* WorktreeOrchestrator - Manages worktree existence checks and creation
|
|
26
|
+
*
|
|
27
|
+
* Responsibility:
|
|
28
|
+
* - Check if worktree exists for a given branch
|
|
29
|
+
* - Create worktree if it doesn't exist
|
|
30
|
+
* - Return worktree path
|
|
31
|
+
*/
|
|
32
|
+
export class WorktreeOrchestrator {
|
|
33
|
+
private worktreeService: WorktreeService;
|
|
34
|
+
|
|
35
|
+
constructor(worktreeService?: WorktreeService) {
|
|
36
|
+
this.worktreeService = worktreeService || {
|
|
37
|
+
worktreeExists,
|
|
38
|
+
generateWorktreePath,
|
|
39
|
+
createWorktree,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Ensure worktree exists for the given branch
|
|
45
|
+
* If worktree exists, return its path
|
|
46
|
+
* If worktree doesn't exist, create it and return the path
|
|
47
|
+
*
|
|
48
|
+
* @param branch - Branch name
|
|
49
|
+
* @param repoRoot - Repository root path
|
|
50
|
+
* @param options - Creation options (base branch, new branch flag)
|
|
51
|
+
* @returns Worktree path
|
|
52
|
+
*/
|
|
53
|
+
async ensureWorktree(
|
|
54
|
+
branch: string,
|
|
55
|
+
repoRoot: string,
|
|
56
|
+
options: EnsureWorktreeOptions = {},
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
const baseBranch = options.baseBranch ?? "main";
|
|
59
|
+
const isNewBranch = options.isNewBranch ?? false;
|
|
60
|
+
|
|
61
|
+
// Check if selected branch is current branch
|
|
62
|
+
const currentBranch = await getCurrentBranch();
|
|
63
|
+
if (currentBranch === branch) {
|
|
64
|
+
// Current branch selected: use repository root
|
|
65
|
+
console.log(
|
|
66
|
+
chalk.gray(
|
|
67
|
+
` ℹ️ Current branch '${branch}' selected - using repository root`,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
return repoRoot;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check if worktree already exists
|
|
74
|
+
const existingPath = await this.worktreeService.worktreeExists(branch);
|
|
75
|
+
|
|
76
|
+
if (existingPath) {
|
|
77
|
+
return existingPath;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Generate worktree path
|
|
81
|
+
const worktreePath = await this.worktreeService.generateWorktreePath(
|
|
82
|
+
repoRoot,
|
|
83
|
+
branch,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
// Create worktree (or branch)
|
|
88
|
+
await this.worktreeService.createWorktree({
|
|
89
|
+
branchName: branch,
|
|
90
|
+
worktreePath,
|
|
91
|
+
repoRoot,
|
|
92
|
+
isNewBranch,
|
|
93
|
+
baseBranch,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return worktreePath;
|
|
97
|
+
} catch (error: unknown) {
|
|
98
|
+
const message =
|
|
99
|
+
error instanceof Error ? error.message : String(error ?? "");
|
|
100
|
+
const normalized = message.toLowerCase();
|
|
101
|
+
const alreadyExists =
|
|
102
|
+
normalized.includes("already checked out") ||
|
|
103
|
+
normalized.includes("already exists");
|
|
104
|
+
|
|
105
|
+
if (alreadyExists) {
|
|
106
|
+
const fallbackPath = await this.worktreeService.worktreeExists(branch);
|
|
107
|
+
if (fallbackPath) {
|
|
108
|
+
return fallbackPath;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { BatchMergeService } from "../BatchMergeService";
|
|
3
|
+
import type { BatchMergeConfig } from "../../ui/types";
|
|
4
|
+
|
|
5
|
+
// Mock git module
|
|
6
|
+
vi.mock("../../git", () => ({
|
|
7
|
+
fetchAllRemotes: vi.fn(),
|
|
8
|
+
getLocalBranches: vi.fn(),
|
|
9
|
+
mergeFromBranch: vi.fn(),
|
|
10
|
+
hasMergeConflict: vi.fn(),
|
|
11
|
+
abortMerge: vi.fn(),
|
|
12
|
+
getMergeStatus: vi.fn(),
|
|
13
|
+
getRepositoryRoot: vi.fn(),
|
|
14
|
+
resetToHead: vi.fn(),
|
|
15
|
+
getCurrentBranchName: vi.fn(),
|
|
16
|
+
pushBranchToRemote: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// Mock worktree module
|
|
20
|
+
vi.mock("../../worktree", () => ({
|
|
21
|
+
listAdditionalWorktrees: vi.fn(),
|
|
22
|
+
generateWorktreePath: vi.fn(),
|
|
23
|
+
createWorktree: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import * as git from "../../git";
|
|
27
|
+
import * as worktree from "../../worktree";
|
|
28
|
+
|
|
29
|
+
// ========================================
|
|
30
|
+
// BatchMergeService Tests (SPEC-ee33ca26)
|
|
31
|
+
// ========================================
|
|
32
|
+
|
|
33
|
+
describe("BatchMergeService", () => {
|
|
34
|
+
let service: BatchMergeService;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
service = new BatchMergeService();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("Initialization (T201)", () => {
|
|
46
|
+
it("should create BatchMergeService instance", () => {
|
|
47
|
+
expect(service).toBeInstanceOf(BatchMergeService);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should have required methods", () => {
|
|
51
|
+
expect(typeof service.determineSourceBranch).toBe("function");
|
|
52
|
+
expect(typeof service.getTargetBranches).toBe("function");
|
|
53
|
+
expect(typeof service.ensureWorktree).toBe("function");
|
|
54
|
+
expect(typeof service.mergeBranch).toBe("function");
|
|
55
|
+
expect(typeof service.executeBatchMerge).toBe("function");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("determineSourceBranch (T203)", () => {
|
|
60
|
+
it("should return 'main' when main branch exists", async () => {
|
|
61
|
+
(git.getLocalBranches as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
62
|
+
{
|
|
63
|
+
name: "main",
|
|
64
|
+
type: "local",
|
|
65
|
+
branchType: "main",
|
|
66
|
+
isCurrent: false,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "develop",
|
|
70
|
+
type: "local",
|
|
71
|
+
branchType: "develop",
|
|
72
|
+
isCurrent: false,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "feature/test",
|
|
76
|
+
type: "local",
|
|
77
|
+
branchType: "feature",
|
|
78
|
+
isCurrent: true,
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const sourceBranch = await service.determineSourceBranch();
|
|
83
|
+
|
|
84
|
+
expect(sourceBranch).toBe("main");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should return 'develop' when main does not exist but develop exists", async () => {
|
|
88
|
+
(git.getLocalBranches as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
89
|
+
{
|
|
90
|
+
name: "develop",
|
|
91
|
+
type: "local",
|
|
92
|
+
branchType: "develop",
|
|
93
|
+
isCurrent: false,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "feature/test",
|
|
97
|
+
type: "local",
|
|
98
|
+
branchType: "feature",
|
|
99
|
+
isCurrent: true,
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
const sourceBranch = await service.determineSourceBranch();
|
|
104
|
+
|
|
105
|
+
expect(sourceBranch).toBe("develop");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should return 'master' when main and develop do not exist", async () => {
|
|
109
|
+
(git.getLocalBranches as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
110
|
+
{
|
|
111
|
+
name: "master",
|
|
112
|
+
type: "local",
|
|
113
|
+
branchType: "main",
|
|
114
|
+
isCurrent: false,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "feature/test",
|
|
118
|
+
type: "local",
|
|
119
|
+
branchType: "feature",
|
|
120
|
+
isCurrent: true,
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const sourceBranch = await service.determineSourceBranch();
|
|
125
|
+
|
|
126
|
+
expect(sourceBranch).toBe("master");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should throw error when no source branch found", async () => {
|
|
130
|
+
(git.getLocalBranches as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
131
|
+
{
|
|
132
|
+
name: "feature/test",
|
|
133
|
+
type: "local",
|
|
134
|
+
branchType: "feature",
|
|
135
|
+
isCurrent: true,
|
|
136
|
+
},
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
await expect(service.determineSourceBranch()).rejects.toThrow(
|
|
140
|
+
"Unable to determine source branch",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("getTargetBranches (T205)", () => {
|
|
146
|
+
it("should return all local branches excluding main, develop, master", async () => {
|
|
147
|
+
(git.getLocalBranches as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
148
|
+
{
|
|
149
|
+
name: "main",
|
|
150
|
+
type: "local",
|
|
151
|
+
branchType: "main",
|
|
152
|
+
isCurrent: false,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "develop",
|
|
156
|
+
type: "local",
|
|
157
|
+
branchType: "develop",
|
|
158
|
+
isCurrent: false,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "feature/a",
|
|
162
|
+
type: "local",
|
|
163
|
+
branchType: "feature",
|
|
164
|
+
isCurrent: false,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "feature/b",
|
|
168
|
+
type: "local",
|
|
169
|
+
branchType: "feature",
|
|
170
|
+
isCurrent: true,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "hotfix/c",
|
|
174
|
+
type: "local",
|
|
175
|
+
branchType: "hotfix",
|
|
176
|
+
isCurrent: false,
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
const targetBranches = await service.getTargetBranches();
|
|
181
|
+
|
|
182
|
+
expect(targetBranches).toEqual(["feature/a", "feature/b", "hotfix/c"]);
|
|
183
|
+
expect(targetBranches).not.toContain("main");
|
|
184
|
+
expect(targetBranches).not.toContain("develop");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should return empty array when only main/develop/master exist", async () => {
|
|
188
|
+
(git.getLocalBranches as ReturnType<typeof vi.fn>).mockResolvedValue([
|
|
189
|
+
{
|
|
190
|
+
name: "main",
|
|
191
|
+
type: "local",
|
|
192
|
+
branchType: "main",
|
|
193
|
+
isCurrent: true,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "develop",
|
|
197
|
+
type: "local",
|
|
198
|
+
branchType: "develop",
|
|
199
|
+
isCurrent: false,
|
|
200
|
+
},
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
const targetBranches = await service.getTargetBranches();
|
|
204
|
+
|
|
205
|
+
expect(targetBranches).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("ensureWorktree (T207)", () => {
|
|
210
|
+
it("should return existing worktree path if worktree exists", async () => {
|
|
211
|
+
(
|
|
212
|
+
worktree.listAdditionalWorktrees as ReturnType<typeof vi.fn>
|
|
213
|
+
).mockResolvedValue([
|
|
214
|
+
{
|
|
215
|
+
path: "/repo/.worktrees/feature-a",
|
|
216
|
+
locked: false,
|
|
217
|
+
prunable: false,
|
|
218
|
+
isAccessible: true,
|
|
219
|
+
},
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
const worktreePath = await service.ensureWorktree("feature/a");
|
|
223
|
+
|
|
224
|
+
expect(worktreePath).toBe("/repo/.worktrees/feature-a");
|
|
225
|
+
expect(worktree.createWorktree).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("should create worktree if it does not exist", async () => {
|
|
229
|
+
(
|
|
230
|
+
worktree.listAdditionalWorktrees as ReturnType<typeof vi.fn>
|
|
231
|
+
).mockResolvedValue([]);
|
|
232
|
+
(git.getRepositoryRoot as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
233
|
+
"/repo",
|
|
234
|
+
);
|
|
235
|
+
(
|
|
236
|
+
worktree.generateWorktreePath as ReturnType<typeof vi.fn>
|
|
237
|
+
).mockResolvedValue("/repo/.worktrees/feature-b");
|
|
238
|
+
(worktree.createWorktree as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
239
|
+
|
|
240
|
+
const worktreePath = await service.ensureWorktree("feature/b");
|
|
241
|
+
|
|
242
|
+
expect(worktreePath).toBe("/repo/.worktrees/feature-b");
|
|
243
|
+
expect(worktree.createWorktree).toHaveBeenCalledWith({
|
|
244
|
+
branchName: "feature/b",
|
|
245
|
+
worktreePath: "/repo/.worktrees/feature-b",
|
|
246
|
+
repoRoot: "/repo",
|
|
247
|
+
isNewBranch: false,
|
|
248
|
+
baseBranch: "",
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("mergeBranch (T209-T212)", () => {
|
|
254
|
+
const config: BatchMergeConfig = {
|
|
255
|
+
sourceBranch: "main",
|
|
256
|
+
targetBranches: ["feature/a"],
|
|
257
|
+
dryRun: false,
|
|
258
|
+
autoPush: false,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
(
|
|
263
|
+
worktree.listAdditionalWorktrees as ReturnType<typeof vi.fn>
|
|
264
|
+
).mockResolvedValue([
|
|
265
|
+
{
|
|
266
|
+
path: "/repo/.worktrees/feature-a",
|
|
267
|
+
locked: false,
|
|
268
|
+
prunable: false,
|
|
269
|
+
},
|
|
270
|
+
]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("should successfully merge without conflicts", async () => {
|
|
274
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
275
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
276
|
+
false,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const status = await service.mergeBranch("feature/a", "main", config);
|
|
280
|
+
|
|
281
|
+
expect(status.branchName).toBe("feature/a");
|
|
282
|
+
expect(status.status).toBe("success");
|
|
283
|
+
expect(status.worktreeCreated).toBe(false);
|
|
284
|
+
expect(git.mergeFromBranch).toHaveBeenCalledWith(
|
|
285
|
+
"/repo/.worktrees/feature-a",
|
|
286
|
+
"main",
|
|
287
|
+
false,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should skip branch on merge conflict", async () => {
|
|
292
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
293
|
+
new Error("Merge conflict"),
|
|
294
|
+
);
|
|
295
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
296
|
+
true,
|
|
297
|
+
);
|
|
298
|
+
(git.abortMerge as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
299
|
+
|
|
300
|
+
const status = await service.mergeBranch("feature/a", "main", config);
|
|
301
|
+
|
|
302
|
+
expect(status.branchName).toBe("feature/a");
|
|
303
|
+
expect(status.status).toBe("skipped");
|
|
304
|
+
expect(git.abortMerge).toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should handle other errors as failed", async () => {
|
|
308
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
309
|
+
new Error("Network error"),
|
|
310
|
+
);
|
|
311
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
312
|
+
false,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const status = await service.mergeBranch("feature/a", "main", config);
|
|
316
|
+
|
|
317
|
+
expect(status.branchName).toBe("feature/a");
|
|
318
|
+
expect(status.status).toBe("failed");
|
|
319
|
+
expect(status.error).toContain("Network error");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("mergeBranch - Dry-run mode (T303-T304)", () => {
|
|
324
|
+
const dryRunConfig: BatchMergeConfig = {
|
|
325
|
+
sourceBranch: "main",
|
|
326
|
+
targetBranches: ["feature/a"],
|
|
327
|
+
dryRun: true,
|
|
328
|
+
autoPush: false,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
(
|
|
333
|
+
worktree.listAdditionalWorktrees as ReturnType<typeof vi.fn>
|
|
334
|
+
).mockResolvedValue([
|
|
335
|
+
{
|
|
336
|
+
path: "/repo/.worktrees/feature-a",
|
|
337
|
+
locked: false,
|
|
338
|
+
prunable: false,
|
|
339
|
+
},
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("should rollback with resetToHead after successful dry-run merge", async () => {
|
|
344
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
345
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
346
|
+
false,
|
|
347
|
+
);
|
|
348
|
+
(git.resetToHead as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
349
|
+
|
|
350
|
+
const status = await service.mergeBranch(
|
|
351
|
+
"feature/a",
|
|
352
|
+
"main",
|
|
353
|
+
dryRunConfig,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
expect(status.branchName).toBe("feature/a");
|
|
357
|
+
expect(status.status).toBe("success");
|
|
358
|
+
expect(git.mergeFromBranch).toHaveBeenCalledWith(
|
|
359
|
+
"/repo/.worktrees/feature-a",
|
|
360
|
+
"main",
|
|
361
|
+
true,
|
|
362
|
+
);
|
|
363
|
+
expect(git.resetToHead).toHaveBeenCalledWith(
|
|
364
|
+
"/repo/.worktrees/feature-a",
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("should rollback with abortMerge after dry-run merge conflict", async () => {
|
|
369
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
370
|
+
new Error("CONFLICT (content)"),
|
|
371
|
+
);
|
|
372
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
373
|
+
true,
|
|
374
|
+
);
|
|
375
|
+
(git.abortMerge as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
376
|
+
|
|
377
|
+
const status = await service.mergeBranch(
|
|
378
|
+
"feature/a",
|
|
379
|
+
"main",
|
|
380
|
+
dryRunConfig,
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
expect(status.branchName).toBe("feature/a");
|
|
384
|
+
expect(status.status).toBe("skipped");
|
|
385
|
+
expect(git.abortMerge).toHaveBeenCalledWith("/repo/.worktrees/feature-a");
|
|
386
|
+
expect(git.resetToHead).not.toHaveBeenCalled();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("mergeBranch - Auto-push mode (T401-T404)", () => {
|
|
391
|
+
const autoPushConfig: BatchMergeConfig = {
|
|
392
|
+
sourceBranch: "main",
|
|
393
|
+
targetBranches: ["feature/a"],
|
|
394
|
+
dryRun: false,
|
|
395
|
+
autoPush: true,
|
|
396
|
+
remote: "origin",
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
beforeEach(() => {
|
|
400
|
+
(
|
|
401
|
+
worktree.listAdditionalWorktrees as ReturnType<typeof vi.fn>
|
|
402
|
+
).mockResolvedValue([
|
|
403
|
+
{
|
|
404
|
+
path: "/repo/.worktrees/feature-a",
|
|
405
|
+
locked: false,
|
|
406
|
+
prunable: false,
|
|
407
|
+
},
|
|
408
|
+
]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("should push successfully after merge when autoPush is enabled", async () => {
|
|
412
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
413
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
414
|
+
false,
|
|
415
|
+
);
|
|
416
|
+
(git.getCurrentBranchName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
417
|
+
"feature/a",
|
|
418
|
+
);
|
|
419
|
+
(git.pushBranchToRemote as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
420
|
+
|
|
421
|
+
const status = await service.mergeBranch(
|
|
422
|
+
"feature/a",
|
|
423
|
+
"main",
|
|
424
|
+
autoPushConfig,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(status.branchName).toBe("feature/a");
|
|
428
|
+
expect(status.status).toBe("success");
|
|
429
|
+
expect(status.pushStatus).toBe("success");
|
|
430
|
+
expect(git.pushBranchToRemote).toHaveBeenCalledWith(
|
|
431
|
+
"/repo/.worktrees/feature-a",
|
|
432
|
+
"feature/a",
|
|
433
|
+
"origin",
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("should handle push failure without failing merge", async () => {
|
|
438
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
439
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
440
|
+
false,
|
|
441
|
+
);
|
|
442
|
+
(git.getCurrentBranchName as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
443
|
+
"feature/a",
|
|
444
|
+
);
|
|
445
|
+
(git.pushBranchToRemote as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
446
|
+
new Error("Push failed: permission denied"),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const status = await service.mergeBranch(
|
|
450
|
+
"feature/a",
|
|
451
|
+
"main",
|
|
452
|
+
autoPushConfig,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
expect(status.branchName).toBe("feature/a");
|
|
456
|
+
expect(status.status).toBe("success");
|
|
457
|
+
expect(status.pushStatus).toBe("failed");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("should not push when autoPush is false", async () => {
|
|
461
|
+
const noPushConfig = { ...autoPushConfig, autoPush: false };
|
|
462
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
463
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
464
|
+
false,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const status = await service.mergeBranch(
|
|
468
|
+
"feature/a",
|
|
469
|
+
"main",
|
|
470
|
+
noPushConfig,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
expect(status.branchName).toBe("feature/a");
|
|
474
|
+
expect(status.status).toBe("success");
|
|
475
|
+
expect(status.pushStatus).toBe("not_executed");
|
|
476
|
+
expect(git.pushBranchToRemote).not.toHaveBeenCalled();
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("executeBatchMerge (T213)", () => {
|
|
481
|
+
it("should execute batch merge for all target branches", async () => {
|
|
482
|
+
const config: BatchMergeConfig = {
|
|
483
|
+
sourceBranch: "main",
|
|
484
|
+
targetBranches: ["feature/a", "feature/b"],
|
|
485
|
+
dryRun: false,
|
|
486
|
+
autoPush: false,
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
(git.fetchAllRemotes as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
490
|
+
(git.getRepositoryRoot as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
491
|
+
"/repo",
|
|
492
|
+
);
|
|
493
|
+
(
|
|
494
|
+
worktree.listAdditionalWorktrees as ReturnType<typeof vi.fn>
|
|
495
|
+
).mockResolvedValue([]);
|
|
496
|
+
(worktree.generateWorktreePath as ReturnType<typeof vi.fn>)
|
|
497
|
+
.mockResolvedValueOnce("/repo/.worktrees/feature-a")
|
|
498
|
+
.mockResolvedValueOnce("/repo/.worktrees/feature-b");
|
|
499
|
+
(worktree.createWorktree as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
500
|
+
(git.mergeFromBranch as ReturnType<typeof vi.fn>).mockResolvedValue();
|
|
501
|
+
(git.hasMergeConflict as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
502
|
+
false,
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const progressUpdates: BatchMergeProgress[] = [];
|
|
506
|
+
const result = await service.executeBatchMerge(config, (progress) => {
|
|
507
|
+
progressUpdates.push(progress);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
expect(result.statuses).toHaveLength(2);
|
|
511
|
+
expect(result.summary.totalCount).toBe(2);
|
|
512
|
+
expect(result.summary.successCount).toBeGreaterThanOrEqual(0);
|
|
513
|
+
expect(result.cancelled).toBe(false);
|
|
514
|
+
expect(progressUpdates.length).toBeGreaterThan(0);
|
|
515
|
+
expect(git.fetchAllRemotes).toHaveBeenCalled();
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
});
|