@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
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import {
|
|
6
|
+
WorktreeConfig,
|
|
7
|
+
WorktreeWithPR,
|
|
8
|
+
CleanupTarget,
|
|
9
|
+
MergedPullRequest,
|
|
10
|
+
CleanupReason,
|
|
11
|
+
} from "./cli/ui/types.js";
|
|
12
|
+
import { getPullRequestByBranch, getMergedPullRequests } from "./github.js";
|
|
13
|
+
import {
|
|
14
|
+
hasUncommittedChanges,
|
|
15
|
+
hasUnpushedCommits,
|
|
16
|
+
hasUnpushedCommitsInRepo,
|
|
17
|
+
getLocalBranches,
|
|
18
|
+
checkRemoteBranchExists,
|
|
19
|
+
branchHasUniqueCommitsComparedToBase,
|
|
20
|
+
getRepositoryRoot,
|
|
21
|
+
getWorktreeRoot,
|
|
22
|
+
ensureGitignoreEntry,
|
|
23
|
+
branchExists,
|
|
24
|
+
getCurrentBranch,
|
|
25
|
+
} from "./git.js";
|
|
26
|
+
import { getConfig } from "./config/index.js";
|
|
27
|
+
import { GIT_CONFIG } from "./config/constants.js";
|
|
28
|
+
import { startSpinner } from "./utils/spinner.js";
|
|
29
|
+
|
|
30
|
+
// Re-export WorktreeConfig for external use
|
|
31
|
+
export type { WorktreeConfig };
|
|
32
|
+
|
|
33
|
+
// 保護対象のブランチ(クリーンアップから除外)
|
|
34
|
+
export const PROTECTED_BRANCHES = ["main", "master", "develop"];
|
|
35
|
+
|
|
36
|
+
export function isProtectedBranchName(branchName: string): boolean {
|
|
37
|
+
const normalized = branchName
|
|
38
|
+
.replace(/^refs\/heads\//, "")
|
|
39
|
+
.replace(/^origin\//, "");
|
|
40
|
+
return PROTECTED_BRANCHES.includes(normalized);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function switchToProtectedBranch({
|
|
44
|
+
branchName,
|
|
45
|
+
repoRoot,
|
|
46
|
+
remoteRef,
|
|
47
|
+
}: {
|
|
48
|
+
branchName: string;
|
|
49
|
+
repoRoot: string;
|
|
50
|
+
remoteRef?: string | null;
|
|
51
|
+
}): Promise<"none" | "local" | "remote"> {
|
|
52
|
+
const currentBranch = await getCurrentBranch();
|
|
53
|
+
if (currentBranch === branchName) {
|
|
54
|
+
return "none";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const runGit = async (args: string[]) => {
|
|
58
|
+
try {
|
|
59
|
+
await execa("git", args, { cwd: repoRoot });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new WorktreeError(
|
|
62
|
+
`Failed to execute git ${args.join(" ")} for protected branch ${branchName}`,
|
|
63
|
+
error,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (await branchExists(branchName)) {
|
|
69
|
+
await runGit(["checkout", branchName]);
|
|
70
|
+
return "local";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const targetRemote = remoteRef ?? `origin/${branchName}`;
|
|
74
|
+
const fetchRef = targetRemote.replace(/^origin\//, "");
|
|
75
|
+
|
|
76
|
+
await runGit(["fetch", "origin", fetchRef]);
|
|
77
|
+
await runGit(["checkout", "-b", branchName, targetRemote]);
|
|
78
|
+
|
|
79
|
+
return "remote";
|
|
80
|
+
}
|
|
81
|
+
export class WorktreeError extends Error {
|
|
82
|
+
constructor(
|
|
83
|
+
message: string,
|
|
84
|
+
public cause?: unknown,
|
|
85
|
+
) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = "WorktreeError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface WorktreeInfo {
|
|
92
|
+
path: string;
|
|
93
|
+
branch: string;
|
|
94
|
+
head: string;
|
|
95
|
+
isAccessible?: boolean;
|
|
96
|
+
invalidReason?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function listWorktrees(): Promise<WorktreeInfo[]> {
|
|
100
|
+
try {
|
|
101
|
+
const { stdout } = await execa("git", ["worktree", "list", "--porcelain"]);
|
|
102
|
+
const worktrees: WorktreeInfo[] = [];
|
|
103
|
+
const lines = stdout.split("\n");
|
|
104
|
+
|
|
105
|
+
let currentWorktree: Partial<WorktreeInfo> = {};
|
|
106
|
+
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (line.startsWith("worktree ")) {
|
|
109
|
+
if (currentWorktree.path) {
|
|
110
|
+
worktrees.push(currentWorktree as WorktreeInfo);
|
|
111
|
+
}
|
|
112
|
+
currentWorktree = { path: line.substring(9) };
|
|
113
|
+
} else if (line.startsWith("HEAD ")) {
|
|
114
|
+
currentWorktree.head = line.substring(5);
|
|
115
|
+
} else if (line.startsWith("branch ")) {
|
|
116
|
+
currentWorktree.branch = line.substring(7).replace("refs/heads/", "");
|
|
117
|
+
} else if (line === "") {
|
|
118
|
+
if (currentWorktree.path) {
|
|
119
|
+
worktrees.push(currentWorktree as WorktreeInfo);
|
|
120
|
+
currentWorktree = {};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (currentWorktree.path) {
|
|
126
|
+
worktrees.push(currentWorktree as WorktreeInfo);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return worktrees;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new WorktreeError("Failed to list worktrees", error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 追加のworktree(メインworktreeを除く)の一覧を取得
|
|
137
|
+
* @returns {Promise<WorktreeInfo[]>} worktree情報の配列
|
|
138
|
+
* @throws {WorktreeError} worktree一覧の取得に失敗した場合
|
|
139
|
+
*/
|
|
140
|
+
export async function listAdditionalWorktrees(): Promise<WorktreeInfo[]> {
|
|
141
|
+
try {
|
|
142
|
+
const [allWorktrees, repoRoot] = await Promise.all([
|
|
143
|
+
listWorktrees(),
|
|
144
|
+
import("./git.js").then((m) => m.getRepositoryRoot()),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const fs = await import("node:fs");
|
|
148
|
+
|
|
149
|
+
// Filter out the main worktree (repository root) and add accessibility info
|
|
150
|
+
const additionalWorktrees = allWorktrees
|
|
151
|
+
.filter((worktree) => worktree.path !== repoRoot)
|
|
152
|
+
.map((worktree) => {
|
|
153
|
+
// パスの存在を確認
|
|
154
|
+
const isAccessible = fs.existsSync(worktree.path);
|
|
155
|
+
|
|
156
|
+
const result: WorktreeInfo = {
|
|
157
|
+
...worktree,
|
|
158
|
+
isAccessible,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (!isAccessible) {
|
|
162
|
+
result.invalidReason = "Path not accessible in current environment";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return result;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return additionalWorktrees;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new WorktreeError("Failed to list additional worktrees", error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function worktreeExists(
|
|
175
|
+
branchName: string,
|
|
176
|
+
): Promise<string | null> {
|
|
177
|
+
const worktrees = await listWorktrees();
|
|
178
|
+
const worktree = worktrees.find((w) => w.branch === branchName);
|
|
179
|
+
return worktree ? worktree.path : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function generateWorktreePath(
|
|
183
|
+
repoRoot: string,
|
|
184
|
+
branchName: string,
|
|
185
|
+
): Promise<string> {
|
|
186
|
+
const sanitizedBranchName = branchName.replace(/[/\\:*?"<>|]/g, "-");
|
|
187
|
+
const worktreeDir = path.join(repoRoot, ".worktrees");
|
|
188
|
+
return path.join(worktreeDir, sanitizedBranchName);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 指定されたパスに既存のworktreeが存在するか確認
|
|
193
|
+
* @param {string} targetPath - 確認するパス
|
|
194
|
+
* @returns {Promise<WorktreeInfo | null>} 既存のworktree情報、または存在しない場合はnull
|
|
195
|
+
*/
|
|
196
|
+
export async function checkWorktreePathConflict(
|
|
197
|
+
targetPath: string,
|
|
198
|
+
): Promise<WorktreeInfo | null> {
|
|
199
|
+
const worktrees = await listWorktrees();
|
|
200
|
+
const existingWorktree = worktrees.find((w) => w.path === targetPath);
|
|
201
|
+
return existingWorktree || null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 衝突を避けるため、代替のworktreeパスを生成
|
|
206
|
+
* @param {string} basePath - 元のパス
|
|
207
|
+
* @returns {Promise<string>} 利用可能な代替パス
|
|
208
|
+
*/
|
|
209
|
+
export async function generateAlternativeWorktreePath(
|
|
210
|
+
basePath: string,
|
|
211
|
+
): Promise<string> {
|
|
212
|
+
let counter = 2;
|
|
213
|
+
let alternativePath = `${basePath}-${counter}`;
|
|
214
|
+
|
|
215
|
+
// 衝突しないパスが見つかるまで試行
|
|
216
|
+
while (await checkWorktreePathConflict(alternativePath)) {
|
|
217
|
+
counter++;
|
|
218
|
+
alternativePath = `${basePath}-${counter}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return alternativePath;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 新しいworktreeを作成
|
|
226
|
+
* @param {WorktreeConfig} config - worktreeの設定
|
|
227
|
+
* @throws {WorktreeError} worktreeの作成に失敗した場合
|
|
228
|
+
*/
|
|
229
|
+
export async function createWorktree(config: WorktreeConfig): Promise<void> {
|
|
230
|
+
if (isProtectedBranchName(config.branchName)) {
|
|
231
|
+
throw new WorktreeError(
|
|
232
|
+
`Branch "${config.branchName}" is protected and cannot be used to create a worktree`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const worktreeParentDir = path.dirname(config.worktreePath);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await fs.mkdir(worktreeParentDir, { recursive: true });
|
|
241
|
+
} catch (error: unknown) {
|
|
242
|
+
const errorWithCode = error as { code?: unknown; message?: unknown };
|
|
243
|
+
const code =
|
|
244
|
+
errorWithCode && typeof errorWithCode.code === "string"
|
|
245
|
+
? errorWithCode.code
|
|
246
|
+
: undefined;
|
|
247
|
+
const message =
|
|
248
|
+
error instanceof Error
|
|
249
|
+
? error.message
|
|
250
|
+
: typeof errorWithCode?.message === "string"
|
|
251
|
+
? errorWithCode.message
|
|
252
|
+
: String(error);
|
|
253
|
+
const reason =
|
|
254
|
+
code === "EEXIST"
|
|
255
|
+
? `${worktreeParentDir} already exists and is not a directory`
|
|
256
|
+
: message;
|
|
257
|
+
|
|
258
|
+
throw new WorktreeError(
|
|
259
|
+
`Failed to prepare worktree directory for ${config.branchName}: ${reason}`,
|
|
260
|
+
error,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const args = ["worktree", "add"];
|
|
265
|
+
|
|
266
|
+
if (config.isNewBranch) {
|
|
267
|
+
args.push("-b", config.branchName);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
args.push(config.worktreePath);
|
|
271
|
+
|
|
272
|
+
if (config.isNewBranch) {
|
|
273
|
+
args.push(config.baseBranch);
|
|
274
|
+
} else {
|
|
275
|
+
args.push(config.branchName);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const spinnerMessage = `Creating worktree for ${config.branchName}`;
|
|
279
|
+
let stopSpinner: (() => void) | undefined;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
stopSpinner = startSpinner(spinnerMessage);
|
|
283
|
+
} catch {
|
|
284
|
+
stopSpinner = undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const stopActiveSpinner = () => {
|
|
288
|
+
if (stopSpinner) {
|
|
289
|
+
stopSpinner();
|
|
290
|
+
stopSpinner = undefined;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const gitProcess = execa("git", args);
|
|
295
|
+
|
|
296
|
+
// パイプでリアルタイムに進捗を表示する
|
|
297
|
+
const childProcess = gitProcess as typeof gitProcess & {
|
|
298
|
+
stdout?: NodeJS.ReadableStream;
|
|
299
|
+
stderr?: NodeJS.ReadableStream;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const attachStream = (
|
|
303
|
+
stream: NodeJS.ReadableStream | undefined,
|
|
304
|
+
pipeTarget: NodeJS.WriteStream,
|
|
305
|
+
) => {
|
|
306
|
+
if (!stream) return;
|
|
307
|
+
if (typeof stream.once === "function") {
|
|
308
|
+
stream.once("data", stopActiveSpinner);
|
|
309
|
+
}
|
|
310
|
+
if (typeof stream.pipe === "function") {
|
|
311
|
+
stream.pipe(pipeTarget);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
attachStream(childProcess.stdout, process.stdout);
|
|
316
|
+
attachStream(childProcess.stderr, process.stderr);
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
await gitProcess;
|
|
320
|
+
} finally {
|
|
321
|
+
stopActiveSpinner();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// .gitignoreに.worktrees/を追加(エラーは警告として扱う)
|
|
325
|
+
try {
|
|
326
|
+
let gitignoreRoot = config.repoRoot;
|
|
327
|
+
try {
|
|
328
|
+
gitignoreRoot = await getWorktreeRoot();
|
|
329
|
+
} catch (resolveError) {
|
|
330
|
+
if (process.env.DEBUG) {
|
|
331
|
+
const reason =
|
|
332
|
+
resolveError instanceof Error
|
|
333
|
+
? resolveError.message
|
|
334
|
+
: String(resolveError);
|
|
335
|
+
console.warn(
|
|
336
|
+
`Debug: Failed to resolve current worktree root for .gitignore update. Falling back to ${gitignoreRoot}. Reason: ${reason}`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
await ensureGitignoreEntry(gitignoreRoot, ".worktrees/");
|
|
342
|
+
} catch (error: unknown) {
|
|
343
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
344
|
+
// .gitignoreの更新失敗は警告としてログに出すが、worktree作成は成功とする
|
|
345
|
+
console.warn(`Warning: Failed to update .gitignore: ${message}`);
|
|
346
|
+
}
|
|
347
|
+
} catch (error: unknown) {
|
|
348
|
+
// Extract more detailed error information from git command
|
|
349
|
+
const errorOutput = (value: unknown) =>
|
|
350
|
+
typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
351
|
+
const gitError =
|
|
352
|
+
errorOutput((error as { stderr?: unknown })?.stderr) ??
|
|
353
|
+
errorOutput((error as { stdout?: unknown })?.stdout) ??
|
|
354
|
+
(error instanceof Error ? error.message : String(error));
|
|
355
|
+
const errorMessage = `Failed to create worktree for ${config.branchName}\nGit error: ${gitError}`;
|
|
356
|
+
throw new WorktreeError(errorMessage, error);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function removeWorktree(
|
|
361
|
+
worktreePath: string,
|
|
362
|
+
force = false,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
try {
|
|
365
|
+
const args = ["worktree", "remove"];
|
|
366
|
+
if (force) {
|
|
367
|
+
args.push("--force");
|
|
368
|
+
}
|
|
369
|
+
args.push(worktreePath);
|
|
370
|
+
|
|
371
|
+
await execa("git", args);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
throw new WorktreeError(
|
|
374
|
+
`Failed to remove worktree at ${worktreePath}`,
|
|
375
|
+
error,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function getWorktreesWithPRStatus(): Promise<WorktreeWithPR[]> {
|
|
381
|
+
const worktrees = await listAdditionalWorktrees();
|
|
382
|
+
const worktreesWithPR: WorktreeWithPR[] = [];
|
|
383
|
+
|
|
384
|
+
for (const worktree of worktrees) {
|
|
385
|
+
if (worktree.branch) {
|
|
386
|
+
const pullRequest = await getPullRequestByBranch(worktree.branch);
|
|
387
|
+
worktreesWithPR.push({
|
|
388
|
+
worktreePath: worktree.path,
|
|
389
|
+
branch: worktree.branch,
|
|
390
|
+
pullRequest,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return worktreesWithPR;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* worktreeに存在しないローカルブランチの中でマージ済みPRに関連するクリーンアップ候補を取得
|
|
400
|
+
* @returns {Promise<CleanupTarget[]>} クリーンアップ候補の配列
|
|
401
|
+
*/
|
|
402
|
+
async function getOrphanedLocalBranches({
|
|
403
|
+
mergedPRs,
|
|
404
|
+
baseBranch,
|
|
405
|
+
repoRoot,
|
|
406
|
+
}: {
|
|
407
|
+
mergedPRs: MergedPullRequest[];
|
|
408
|
+
baseBranch: string;
|
|
409
|
+
repoRoot: string;
|
|
410
|
+
}): Promise<CleanupTarget[]> {
|
|
411
|
+
try {
|
|
412
|
+
// 並列実行で高速化
|
|
413
|
+
const [localBranches, worktrees] = await Promise.all([
|
|
414
|
+
getLocalBranches(),
|
|
415
|
+
listAdditionalWorktrees(),
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
const cleanupTargets: CleanupTarget[] = [];
|
|
419
|
+
const worktreeBranches = new Set(
|
|
420
|
+
worktrees.map((w) => w.branch).filter(Boolean),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
424
|
+
console.log(
|
|
425
|
+
chalk.cyan("Debug: Orphaned branch scan - Available local branches:"),
|
|
426
|
+
);
|
|
427
|
+
localBranches.forEach((b) =>
|
|
428
|
+
console.log(` ${b.name} (type: ${b.type})`),
|
|
429
|
+
);
|
|
430
|
+
console.log(
|
|
431
|
+
chalk.cyan(
|
|
432
|
+
`Debug: Worktree branches: ${Array.from(worktreeBranches).join(", ")}`,
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
for (const localBranch of localBranches) {
|
|
438
|
+
// 保護対象ブランチはスキップ
|
|
439
|
+
if (isProtectedBranchName(localBranch.name)) {
|
|
440
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
441
|
+
console.log(
|
|
442
|
+
chalk.yellow(
|
|
443
|
+
`Debug: Skipping protected branch ${localBranch.name}`,
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// worktreeに存在しないローカルブランチのみ対象
|
|
451
|
+
if (!worktreeBranches.has(localBranch.name)) {
|
|
452
|
+
const mergedPR = findMatchingPR(localBranch.name, mergedPRs);
|
|
453
|
+
let hasUnpushed = false;
|
|
454
|
+
try {
|
|
455
|
+
hasUnpushed = await hasUnpushedCommitsInRepo(
|
|
456
|
+
localBranch.name,
|
|
457
|
+
repoRoot,
|
|
458
|
+
);
|
|
459
|
+
} catch {
|
|
460
|
+
hasUnpushed = true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const reasons: CleanupReason[] = [];
|
|
464
|
+
|
|
465
|
+
if (mergedPR) {
|
|
466
|
+
reasons.push("merged-pr");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!hasUnpushed) {
|
|
470
|
+
const hasUniqueCommits = await branchHasUniqueCommitsComparedToBase(
|
|
471
|
+
localBranch.name,
|
|
472
|
+
baseBranch,
|
|
473
|
+
repoRoot,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
if (!hasUniqueCommits) {
|
|
477
|
+
reasons.push("no-diff-with-base");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
482
|
+
console.log(
|
|
483
|
+
chalk.gray(
|
|
484
|
+
`Debug: Checking orphaned branch ${localBranch.name} -> PR: ${mergedPR ? "MATCH" : "NO MATCH"}, reasons: ${reasons.join(", ")}`,
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (reasons.length > 0) {
|
|
490
|
+
let hasRemoteBranch = false;
|
|
491
|
+
try {
|
|
492
|
+
hasRemoteBranch = await checkRemoteBranchExists(localBranch.name);
|
|
493
|
+
} catch {
|
|
494
|
+
hasRemoteBranch = false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
cleanupTargets.push({
|
|
498
|
+
worktreePath: null, // worktreeは存在しない
|
|
499
|
+
branch: localBranch.name,
|
|
500
|
+
pullRequest: mergedPR ?? null,
|
|
501
|
+
hasUncommittedChanges: false, // worktreeが存在しないため常にfalse
|
|
502
|
+
hasUnpushedCommits: hasUnpushed,
|
|
503
|
+
cleanupType: "branch-only",
|
|
504
|
+
hasRemoteBranch,
|
|
505
|
+
reasons,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
512
|
+
console.log(
|
|
513
|
+
chalk.cyan(
|
|
514
|
+
`Debug: Found ${cleanupTargets.length} orphaned branch cleanup targets`,
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return cleanupTargets;
|
|
520
|
+
} catch (error) {
|
|
521
|
+
console.error(chalk.red("Error: Failed to get orphaned local branches"));
|
|
522
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
523
|
+
console.error(chalk.red("Debug: Full error details:"), error);
|
|
524
|
+
}
|
|
525
|
+
return [];
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function normalizeBranchName(branchName: string): string {
|
|
530
|
+
return branchName
|
|
531
|
+
.replace(/^origin\//, "")
|
|
532
|
+
.replace(/^refs\/heads\//, "")
|
|
533
|
+
.replace(/^refs\/remotes\/origin\//, "")
|
|
534
|
+
.trim();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function findMatchingPR(
|
|
538
|
+
worktreeBranch: string,
|
|
539
|
+
mergedPRs: MergedPullRequest[],
|
|
540
|
+
): MergedPullRequest | null {
|
|
541
|
+
const normalizedWorktreeBranch = normalizeBranchName(worktreeBranch);
|
|
542
|
+
|
|
543
|
+
for (const pr of mergedPRs) {
|
|
544
|
+
const normalizedPRBranch = normalizeBranchName(pr.branch);
|
|
545
|
+
|
|
546
|
+
if (normalizedWorktreeBranch === normalizedPRBranch) {
|
|
547
|
+
return pr;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* マージ済みPRに関連するworktreeおよびローカルブランチのクリーンアップ候補を取得
|
|
556
|
+
* @returns {Promise<CleanupTarget[]>} クリーンアップ候補の配列
|
|
557
|
+
*/
|
|
558
|
+
export async function getMergedPRWorktrees(): Promise<CleanupTarget[]> {
|
|
559
|
+
const [config, repoRoot] = await Promise.all([
|
|
560
|
+
getConfig(),
|
|
561
|
+
getRepositoryRoot(),
|
|
562
|
+
]);
|
|
563
|
+
const baseBranch = config.defaultBaseBranch || GIT_CONFIG.DEFAULT_BASE_BRANCH;
|
|
564
|
+
|
|
565
|
+
// 並列実行で高速化 - worktreeとマージ済みPRの両方を取得
|
|
566
|
+
const [mergedPRs, worktreesWithPR] = await Promise.all([
|
|
567
|
+
getMergedPullRequests(),
|
|
568
|
+
getWorktreesWithPRStatus(),
|
|
569
|
+
]);
|
|
570
|
+
const orphanedBranches = await getOrphanedLocalBranches({
|
|
571
|
+
mergedPRs,
|
|
572
|
+
baseBranch,
|
|
573
|
+
repoRoot,
|
|
574
|
+
});
|
|
575
|
+
const cleanupTargets: CleanupTarget[] = [];
|
|
576
|
+
|
|
577
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
578
|
+
console.log(chalk.cyan("Debug: Available worktrees:"));
|
|
579
|
+
worktreesWithPR.forEach((w) =>
|
|
580
|
+
console.log(` ${w.branch} -> ${w.worktreePath}`),
|
|
581
|
+
);
|
|
582
|
+
console.log(chalk.cyan("Debug: Merged PRs:"));
|
|
583
|
+
mergedPRs.forEach((pr) => console.log(` ${pr.branch} (PR #${pr.number})`));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
for (const worktree of worktreesWithPR) {
|
|
587
|
+
// 保護対象ブランチはスキップ
|
|
588
|
+
if (isProtectedBranchName(worktree.branch)) {
|
|
589
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
590
|
+
console.log(
|
|
591
|
+
chalk.yellow(`Debug: Skipping protected branch ${worktree.branch}`),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const mergedPR = findMatchingPR(worktree.branch, mergedPRs);
|
|
598
|
+
|
|
599
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
600
|
+
const normalizedWorktree = normalizeBranchName(worktree.branch);
|
|
601
|
+
console.log(
|
|
602
|
+
chalk.gray(
|
|
603
|
+
`Debug: Checking worktree ${worktree.branch} (normalized: ${normalizedWorktree}) -> ${mergedPR ? "MATCH" : "NO MATCH"}`,
|
|
604
|
+
),
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const cleanupReasons: CleanupReason[] = [];
|
|
609
|
+
|
|
610
|
+
if (mergedPR) {
|
|
611
|
+
cleanupReasons.push("merged-pr");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// worktreeパスの存在を確認
|
|
615
|
+
const fs = await import("node:fs");
|
|
616
|
+
const isAccessible = fs.existsSync(worktree.worktreePath);
|
|
617
|
+
|
|
618
|
+
let hasUncommitted = false;
|
|
619
|
+
let hasUnpushed = false;
|
|
620
|
+
|
|
621
|
+
if (isAccessible) {
|
|
622
|
+
// worktreeが存在する場合のみ状態をチェック
|
|
623
|
+
try {
|
|
624
|
+
[hasUncommitted, hasUnpushed] = await Promise.all([
|
|
625
|
+
hasUncommittedChanges(worktree.worktreePath),
|
|
626
|
+
hasUnpushedCommits(worktree.worktreePath, worktree.branch),
|
|
627
|
+
]);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
// エラーが発生した場合はデフォルト値を使用
|
|
630
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
631
|
+
console.log(
|
|
632
|
+
chalk.yellow(
|
|
633
|
+
`Debug: Failed to check status for worktree ${worktree.worktreePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
634
|
+
),
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!hasUnpushed) {
|
|
641
|
+
const hasUniqueCommits = await branchHasUniqueCommitsComparedToBase(
|
|
642
|
+
worktree.branch,
|
|
643
|
+
baseBranch,
|
|
644
|
+
repoRoot,
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
if (!hasUniqueCommits) {
|
|
648
|
+
cleanupReasons.push("no-diff-with-base");
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
653
|
+
console.log(
|
|
654
|
+
chalk.gray(
|
|
655
|
+
`Debug: Cleanup reasons for ${worktree.branch}: ${cleanupReasons.length > 0 ? cleanupReasons.join(", ") : "none"}`,
|
|
656
|
+
),
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (cleanupReasons.length === 0) {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const hasRemoteBranch = await checkRemoteBranchExists(worktree.branch);
|
|
665
|
+
|
|
666
|
+
const target: CleanupTarget = {
|
|
667
|
+
worktreePath: worktree.worktreePath,
|
|
668
|
+
branch: worktree.branch,
|
|
669
|
+
pullRequest: mergedPR ?? null,
|
|
670
|
+
hasUncommittedChanges: hasUncommitted,
|
|
671
|
+
hasUnpushedCommits: hasUnpushed,
|
|
672
|
+
cleanupType: "worktree-and-branch",
|
|
673
|
+
hasRemoteBranch,
|
|
674
|
+
isAccessible,
|
|
675
|
+
reasons: cleanupReasons,
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
if (!isAccessible) {
|
|
679
|
+
target.invalidReason = "Path not accessible in current environment";
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
cleanupTargets.push(target);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// orphanedBranches (ローカルブランチのみの削除対象) を追加
|
|
686
|
+
cleanupTargets.push(...orphanedBranches);
|
|
687
|
+
|
|
688
|
+
if (process.env.DEBUG_CLEANUP) {
|
|
689
|
+
const worktreeTargets = cleanupTargets.filter(
|
|
690
|
+
(t) => t.cleanupType === "worktree-and-branch",
|
|
691
|
+
).length;
|
|
692
|
+
const branchOnlyTargets = cleanupTargets.filter(
|
|
693
|
+
(t) => t.cleanupType === "branch-only",
|
|
694
|
+
).length;
|
|
695
|
+
console.log(
|
|
696
|
+
chalk.cyan(
|
|
697
|
+
`Debug: Found ${cleanupTargets.length} cleanup targets (${worktreeTargets} worktree+branch, ${branchOnlyTargets} branch-only)`,
|
|
698
|
+
),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return cleanupTargets;
|
|
703
|
+
}
|