hjworktree-cli 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +10 -0
- package/.context-snapshots/context-snapshot-20260106-180000.md +108 -0
- package/.context-snapshots/context-snapshot-20260106-235500.md +108 -0
- package/README.md +134 -0
- package/dist/server/routes/api.d.ts.map +1 -1
- package/dist/server/routes/api.js +45 -1
- package/dist/server/routes/api.js.map +1 -1
- package/dist/server/services/worktreeService.d.ts +23 -0
- package/dist/server/services/worktreeService.d.ts.map +1 -1
- package/dist/server/services/worktreeService.js +156 -14
- package/dist/server/services/worktreeService.js.map +1 -1
- package/dist/server/socketHandlers.d.ts.map +1 -1
- package/dist/server/socketHandlers.js +74 -1
- package/dist/server/socketHandlers.js.map +1 -1
- package/dist/shared/types/index.d.ts +36 -0
- package/dist/shared/types/index.d.ts.map +1 -1
- package/dist/web/assets/{index-C61yAbey.css → index-CsixHL-D.css} +1 -1
- package/dist/web/assets/index-D8dr9mJa.js +53 -0
- package/dist/web/assets/index-D8dr9mJa.js.map +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/server/routes/api.ts +51 -2
- package/server/services/worktreeService.ts +181 -13
- package/server/socketHandlers.ts +91 -2
- package/shared/types/index.ts +45 -0
- package/web/src/App.tsx +3 -0
- package/web/src/components/Layout/LeftNavBar.tsx +290 -26
- package/web/src/components/Layout/MainLayout.tsx +2 -6
- package/web/src/components/Modals/AddWorktreeModal.tsx +87 -0
- package/web/src/components/Modals/ConfirmDeleteModal.tsx +114 -0
- package/web/src/components/Modals/ModalContainer.tsx +21 -0
- package/web/src/components/Terminal/TerminalPanel.tsx +32 -37
- package/web/src/stores/useAppStore.ts +200 -3
- package/web/src/styles/global.css +522 -0
- package/dist/web/assets/index-WEdVUKxb.js +0 -53
- package/dist/web/assets/index-WEdVUKxb.js.map +0 -1
package/dist/web/index.html
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-D8dr9mJa.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CsixHL-D.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
package/package.json
CHANGED
package/server/routes/api.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Router, Request, Response } from 'express';
|
|
2
2
|
import { GitService } from '../services/gitService.js';
|
|
3
3
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
4
|
-
import type { CreateWorktreesRequest } from '../../shared/types/index.js';
|
|
4
|
+
import type { CreateWorktreesRequest, CreateSingleWorktreeRequest, BatchDeleteRequest } from '../../shared/types/index.js';
|
|
5
5
|
|
|
6
6
|
export function apiRouter(cwd: string): Router {
|
|
7
7
|
const router = Router();
|
|
@@ -52,7 +52,7 @@ export function apiRouter(cwd: string): Router {
|
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
// Create worktrees
|
|
55
|
+
// Create worktrees (multiple)
|
|
56
56
|
router.post('/worktrees', async (req: Request, res: Response) => {
|
|
57
57
|
try {
|
|
58
58
|
const { branch, count } = req.body as CreateWorktreesRequest;
|
|
@@ -71,6 +71,48 @@ export function apiRouter(cwd: string): Router {
|
|
|
71
71
|
}
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
// Create single worktree
|
|
75
|
+
router.post('/worktrees/single', async (req: Request, res: Response) => {
|
|
76
|
+
try {
|
|
77
|
+
const { branch } = req.body as CreateSingleWorktreeRequest;
|
|
78
|
+
|
|
79
|
+
if (!branch) {
|
|
80
|
+
res.status(400).json({ error: 'Invalid request: branch is required' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const worktree = await worktreeService.createSingleWorktree(branch);
|
|
85
|
+
res.json({ worktree });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
res.status(500).json({
|
|
88
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Batch delete worktrees
|
|
94
|
+
router.delete('/worktrees/batch', async (req: Request, res: Response) => {
|
|
95
|
+
try {
|
|
96
|
+
const { names } = req.body as BatchDeleteRequest;
|
|
97
|
+
|
|
98
|
+
if (!names || !Array.isArray(names) || names.length === 0) {
|
|
99
|
+
res.status(400).json({ error: 'Invalid request: names array is required' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = await worktreeService.removeWorktreesByNames(names);
|
|
104
|
+
res.json({
|
|
105
|
+
success: result.failed.length === 0,
|
|
106
|
+
deleted: result.deleted,
|
|
107
|
+
failed: result.failed,
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
res.status(500).json({
|
|
111
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
74
116
|
// Delete a worktree
|
|
75
117
|
router.delete('/worktrees/:name', async (req: Request, res: Response) => {
|
|
76
118
|
try {
|
|
@@ -83,6 +125,13 @@ export function apiRouter(cwd: string): Router {
|
|
|
83
125
|
return;
|
|
84
126
|
}
|
|
85
127
|
|
|
128
|
+
// SECURITY: Never delete main worktree
|
|
129
|
+
if (worktree.isMainWorktree) {
|
|
130
|
+
console.warn(`[SECURITY] API blocked attempt to delete main worktree: ${name}`);
|
|
131
|
+
res.status(403).json({ error: 'Cannot delete main worktree' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
86
135
|
await worktreeService.removeWorktree(worktree.path);
|
|
87
136
|
res.json({ success: true });
|
|
88
137
|
} catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { simpleGit, SimpleGit } from 'simple-git';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
|
+
import { realpathSync, statSync } from 'fs';
|
|
4
5
|
import type { Worktree } from '../../shared/types/index.js';
|
|
5
6
|
|
|
6
7
|
export class WorktreeService {
|
|
@@ -13,11 +14,56 @@ export class WorktreeService {
|
|
|
13
14
|
this.git = simpleGit(cwd);
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Safely resolves a path to its real path, handling symlinks.
|
|
19
|
+
* Returns null if path cannot be resolved.
|
|
20
|
+
*/
|
|
21
|
+
private safeRealpath(targetPath: string): string | null {
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(targetPath);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Checks if a path is a linked worktree (has .git file) vs main repo (has .git directory).
|
|
31
|
+
* Linked worktrees have a .git FILE that points to the main .git directory.
|
|
32
|
+
* Main repo has a .git DIRECTORY.
|
|
33
|
+
*/
|
|
34
|
+
private isLinkedWorktree(worktreePath: string): boolean {
|
|
35
|
+
const gitPath = path.join(worktreePath, '.git');
|
|
36
|
+
try {
|
|
37
|
+
const stat = statSync(gitPath);
|
|
38
|
+
// .git is a directory = main repository
|
|
39
|
+
// .git is a file = linked worktree
|
|
40
|
+
return !stat.isDirectory();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Checks if the given path is the main repository.
|
|
48
|
+
*/
|
|
49
|
+
private isMainRepository(targetPath: string): boolean {
|
|
50
|
+
const resolvedTarget = this.safeRealpath(targetPath);
|
|
51
|
+
const resolvedRoot = this.safeRealpath(this.rootDir);
|
|
52
|
+
|
|
53
|
+
if (!resolvedTarget || !resolvedRoot) {
|
|
54
|
+
// If we can't resolve paths, assume it might be main repo (fail safe)
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return resolvedTarget === resolvedRoot;
|
|
59
|
+
}
|
|
60
|
+
|
|
16
61
|
async listWorktrees(): Promise<Worktree[]> {
|
|
17
62
|
const result = await this.git.raw(['worktree', 'list', '--porcelain']);
|
|
18
63
|
const worktrees: Worktree[] = [];
|
|
19
64
|
|
|
20
65
|
const entries = result.trim().split('\n\n').filter(Boolean);
|
|
66
|
+
const resolvedRootDir = this.safeRealpath(this.rootDir);
|
|
21
67
|
|
|
22
68
|
for (const entry of entries) {
|
|
23
69
|
const lines = entry.split('\n');
|
|
@@ -26,10 +72,17 @@ export class WorktreeService {
|
|
|
26
72
|
const branch = branchLine?.replace('branch refs/heads/', '');
|
|
27
73
|
|
|
28
74
|
if (worktreePath) {
|
|
75
|
+
// Determine if this is the main worktree using realpath comparison
|
|
76
|
+
const resolvedWorktreePath = this.safeRealpath(worktreePath);
|
|
77
|
+
const isMainWorktree = resolvedWorktreePath !== null &&
|
|
78
|
+
resolvedRootDir !== null &&
|
|
79
|
+
resolvedWorktreePath === resolvedRootDir;
|
|
80
|
+
|
|
29
81
|
worktrees.push({
|
|
30
82
|
path: worktreePath,
|
|
31
83
|
branch: branch || 'detached',
|
|
32
84
|
name: path.basename(worktreePath),
|
|
85
|
+
isMainWorktree,
|
|
33
86
|
});
|
|
34
87
|
}
|
|
35
88
|
}
|
|
@@ -72,6 +125,7 @@ export class WorktreeService {
|
|
|
72
125
|
path: worktreePath,
|
|
73
126
|
branch: newBranchName,
|
|
74
127
|
name: worktreeName,
|
|
128
|
+
isMainWorktree: false, // Created worktrees are always linked, not main
|
|
75
129
|
};
|
|
76
130
|
}
|
|
77
131
|
|
|
@@ -103,28 +157,66 @@ export class WorktreeService {
|
|
|
103
157
|
}
|
|
104
158
|
|
|
105
159
|
async removeWorktree(worktreePath: string): Promise<void> {
|
|
160
|
+
// SECURITY GUARD 1: Use realpathSync for symlink resolution
|
|
161
|
+
const resolvedWorktreePath = this.safeRealpath(worktreePath);
|
|
162
|
+
const resolvedRootDir = this.safeRealpath(this.rootDir);
|
|
163
|
+
|
|
164
|
+
// If we cannot resolve paths, abort for safety
|
|
165
|
+
if (!resolvedWorktreePath) {
|
|
166
|
+
console.warn(`[SECURITY] Cannot resolve worktree path: ${worktreePath}. Aborting deletion.`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!resolvedRootDir) {
|
|
171
|
+
console.warn(`[SECURITY] Cannot resolve root directory path. Aborting deletion.`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// SECURITY GUARD 2: Check exact match with main repo
|
|
176
|
+
if (resolvedWorktreePath === resolvedRootDir) {
|
|
177
|
+
console.warn(`[SECURITY] Attempted to remove main repository: ${worktreePath}. Ignoring.`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// SECURITY GUARD 3: Check if path is inside main repo
|
|
182
|
+
if (resolvedWorktreePath.startsWith(resolvedRootDir + path.sep)) {
|
|
183
|
+
console.warn(`[SECURITY] Attempted to remove path inside main repository: ${worktreePath}. Ignoring.`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// SECURITY GUARD 4: Check if main repo is inside target path (catastrophic prevention)
|
|
188
|
+
if (resolvedRootDir.startsWith(resolvedWorktreePath + path.sep)) {
|
|
189
|
+
console.warn(`[SECURITY] Attempted to remove parent of main repository: ${worktreePath}. Ignoring.`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// SECURITY GUARD 5: Verify it's actually a linked worktree (has .git FILE, not directory)
|
|
194
|
+
const isLinked = this.isLinkedWorktree(worktreePath);
|
|
195
|
+
if (!isLinked) {
|
|
196
|
+
console.warn(`[SECURITY] Path is not a linked worktree (missing .git file or has .git directory): ${worktreePath}. Ignoring.`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
106
200
|
const branchName = path.basename(worktreePath);
|
|
107
201
|
|
|
202
|
+
// CRITICAL: Only use git worktree remove - NEVER use fs.rm() as fallback
|
|
108
203
|
try {
|
|
109
|
-
// 1. Force remove worktree
|
|
110
204
|
await this.git.raw(['worktree', 'remove', worktreePath, '--force']);
|
|
111
|
-
} catch {
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// Ignore errors
|
|
117
|
-
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// Log the error but DO NOT fall back to fs.rm
|
|
207
|
+
console.error(`[SECURITY] Failed to remove worktree via git: ${worktreePath}`, error);
|
|
208
|
+
// Propagate error instead of dangerous fallback
|
|
209
|
+
throw new Error(`Failed to remove worktree: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
118
210
|
}
|
|
119
211
|
|
|
120
|
-
//
|
|
212
|
+
// Prune orphan worktree references
|
|
121
213
|
try {
|
|
122
214
|
await this.git.raw(['worktree', 'prune']);
|
|
123
215
|
} catch {
|
|
124
|
-
// Ignore prune errors
|
|
216
|
+
// Ignore prune errors - non-critical
|
|
125
217
|
}
|
|
126
218
|
|
|
127
|
-
//
|
|
219
|
+
// Delete the branch
|
|
128
220
|
try {
|
|
129
221
|
await this.git.branch(['-D', branchName]);
|
|
130
222
|
} catch {
|
|
@@ -137,8 +229,25 @@ export class WorktreeService {
|
|
|
137
229
|
async removeAllWorktrees(): Promise<void> {
|
|
138
230
|
const worktrees = await this.listWorktrees();
|
|
139
231
|
|
|
140
|
-
// Filter out the main worktree
|
|
141
|
-
const additionalWorktrees = worktrees.filter(wt =>
|
|
232
|
+
// Filter out the main worktree using the isMainWorktree flag and path comparison
|
|
233
|
+
const additionalWorktrees = worktrees.filter(wt => {
|
|
234
|
+
// Primary check: use isMainWorktree flag
|
|
235
|
+
if (wt.isMainWorktree) {
|
|
236
|
+
console.log(`[SECURITY] Skipping main worktree in removeAll: ${wt.path}`);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Secondary check: realpath comparison as defense in depth
|
|
241
|
+
const resolvedPath = this.safeRealpath(wt.path);
|
|
242
|
+
const resolvedRoot = this.safeRealpath(this.rootDir);
|
|
243
|
+
|
|
244
|
+
if (resolvedPath && resolvedRoot && resolvedPath === resolvedRoot) {
|
|
245
|
+
console.warn(`[SECURITY] Detected main worktree via realpath (flag was false): ${wt.path}`);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
142
251
|
|
|
143
252
|
for (const worktree of additionalWorktrees) {
|
|
144
253
|
try {
|
|
@@ -149,6 +258,65 @@ export class WorktreeService {
|
|
|
149
258
|
}
|
|
150
259
|
}
|
|
151
260
|
|
|
261
|
+
async createSingleWorktree(baseBranch: string): Promise<Worktree> {
|
|
262
|
+
// Find next available index for this branch
|
|
263
|
+
const existingWorktrees = await this.listWorktrees();
|
|
264
|
+
const branchWorktrees = existingWorktrees.filter(
|
|
265
|
+
wt => wt.name.startsWith(`${baseBranch}-project-`)
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Find the highest index used
|
|
269
|
+
let maxIndex = 0;
|
|
270
|
+
for (const wt of branchWorktrees) {
|
|
271
|
+
const match = wt.name.match(/-project-(\d+)$/);
|
|
272
|
+
if (match) {
|
|
273
|
+
const index = parseInt(match[1], 10);
|
|
274
|
+
if (index > maxIndex) maxIndex = index;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const nextIndex = maxIndex + 1;
|
|
279
|
+
return this.createWorktree(baseBranch, nextIndex);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async removeWorktreesByNames(names: string[]): Promise<{
|
|
283
|
+
deleted: string[];
|
|
284
|
+
failed: { name: string; error: string }[];
|
|
285
|
+
}> {
|
|
286
|
+
const worktrees = await this.listWorktrees();
|
|
287
|
+
const results: { deleted: string[]; failed: { name: string; error: string }[] } = {
|
|
288
|
+
deleted: [],
|
|
289
|
+
failed: [],
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
for (const name of names) {
|
|
293
|
+
const worktree = worktrees.find(wt => wt.name === name);
|
|
294
|
+
if (!worktree) {
|
|
295
|
+
results.failed.push({ name, error: 'Worktree not found' });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// SECURITY: Never delete main worktree
|
|
300
|
+
if (worktree.isMainWorktree) {
|
|
301
|
+
console.warn(`[SECURITY] Blocked attempt to delete main worktree by name: ${name}`);
|
|
302
|
+
results.failed.push({ name, error: 'Cannot delete main worktree' });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await this.removeWorktree(worktree.path);
|
|
308
|
+
results.deleted.push(name);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
results.failed.push({
|
|
311
|
+
name,
|
|
312
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return results;
|
|
318
|
+
}
|
|
319
|
+
|
|
152
320
|
async cleanup(): Promise<void> {
|
|
153
321
|
// Remove all worktrees created in this session
|
|
154
322
|
for (const worktreePath of this.createdWorktrees) {
|
package/server/socketHandlers.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Server, Socket } from 'socket.io';
|
|
2
2
|
import * as pty from 'node-pty';
|
|
3
|
-
import type { IPty } from 'node-pty';
|
|
3
|
+
import type { IPty, IPtyForkOptions } from 'node-pty';
|
|
4
4
|
import type {
|
|
5
5
|
TerminalCreateData,
|
|
6
6
|
TerminalInputData,
|
|
@@ -9,6 +9,80 @@ import type {
|
|
|
9
9
|
AgentId
|
|
10
10
|
} from '../shared/types/index.js';
|
|
11
11
|
import { AI_AGENTS } from '../shared/constants.js';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import { promisify } from 'util';
|
|
14
|
+
|
|
15
|
+
const access = promisify(fs.access);
|
|
16
|
+
|
|
17
|
+
// Validation utilities
|
|
18
|
+
async function validatePath(pathToCheck: string): Promise<{ valid: boolean; error?: string }> {
|
|
19
|
+
try {
|
|
20
|
+
await access(pathToCheck, fs.constants.R_OK | fs.constants.X_OK);
|
|
21
|
+
return { valid: true };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return {
|
|
24
|
+
valid: false,
|
|
25
|
+
error: error instanceof Error ? error.message : 'Path not accessible'
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function validateShell(shell: string): Promise<{ valid: boolean; error?: string }> {
|
|
31
|
+
if (process.platform === 'win32') {
|
|
32
|
+
return { valid: true };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await access(shell, fs.constants.X_OK);
|
|
36
|
+
return { valid: true };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
error: `Shell not executable: ${shell}`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// PTY spawn with retry logic
|
|
46
|
+
async function spawnWithRetry(
|
|
47
|
+
shell: string,
|
|
48
|
+
args: string[],
|
|
49
|
+
options: IPtyForkOptions,
|
|
50
|
+
maxRetries: number = 3
|
|
51
|
+
): Promise<IPty> {
|
|
52
|
+
let lastError: Error | null = null;
|
|
53
|
+
|
|
54
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
55
|
+
try {
|
|
56
|
+
const ptyProcess = pty.spawn(shell, args, options);
|
|
57
|
+
return ptyProcess;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
60
|
+
console.error(`[PTY] Spawn attempt ${attempt + 1}/${maxRetries} failed:`, lastError.message);
|
|
61
|
+
|
|
62
|
+
if (attempt < maxRetries - 1) {
|
|
63
|
+
const delay = Math.pow(2, attempt) * 100;
|
|
64
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw lastError || new Error('Failed to spawn PTY after retries');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Spawn interval control to prevent resource contention
|
|
73
|
+
let lastSpawnTime = 0;
|
|
74
|
+
const MIN_SPAWN_INTERVAL = 150;
|
|
75
|
+
|
|
76
|
+
async function waitForSpawnInterval(): Promise<void> {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const elapsed = now - lastSpawnTime;
|
|
79
|
+
|
|
80
|
+
if (elapsed < MIN_SPAWN_INTERVAL) {
|
|
81
|
+
await new Promise(resolve => setTimeout(resolve, MIN_SPAWN_INTERVAL - elapsed));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
lastSpawnTime = Date.now();
|
|
85
|
+
}
|
|
12
86
|
|
|
13
87
|
interface TerminalSession {
|
|
14
88
|
pty: IPty;
|
|
@@ -42,7 +116,22 @@ export function setupSocketHandlers(io: Server, cwd: string) {
|
|
|
42
116
|
const shell = getShell();
|
|
43
117
|
const agentCommand = getAgentCommand(agentType);
|
|
44
118
|
|
|
45
|
-
|
|
119
|
+
// Validate path before spawning
|
|
120
|
+
const pathValidation = await validatePath(worktreePath);
|
|
121
|
+
if (!pathValidation.valid) {
|
|
122
|
+
throw new Error(`Invalid worktree path: ${pathValidation.error}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Validate shell before spawning
|
|
126
|
+
const shellValidation = await validateShell(shell);
|
|
127
|
+
if (!shellValidation.valid) {
|
|
128
|
+
throw new Error(`Invalid shell: ${shellValidation.error}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Wait for spawn interval to prevent resource contention
|
|
132
|
+
await waitForSpawnInterval();
|
|
133
|
+
|
|
134
|
+
const ptyProcess = await spawnWithRetry(shell, [], {
|
|
46
135
|
name: 'xterm-256color',
|
|
47
136
|
cols: 120,
|
|
48
137
|
rows: 30,
|
package/shared/types/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ export interface Worktree {
|
|
|
51
51
|
path: string;
|
|
52
52
|
branch: string;
|
|
53
53
|
name: string;
|
|
54
|
+
isMainWorktree: boolean;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
// Socket.IO event types
|
|
@@ -90,3 +91,47 @@ export interface CreateWorktreesRequest {
|
|
|
90
91
|
export interface CreateWorktreesResponse {
|
|
91
92
|
worktrees: Worktree[];
|
|
92
93
|
}
|
|
94
|
+
|
|
95
|
+
// Single worktree creation
|
|
96
|
+
export interface CreateSingleWorktreeRequest {
|
|
97
|
+
branch: string;
|
|
98
|
+
agentType: AgentId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface CreateSingleWorktreeResponse {
|
|
102
|
+
worktree: Worktree;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Batch delete
|
|
106
|
+
export interface BatchDeleteRequest {
|
|
107
|
+
names: string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BatchDeleteResponse {
|
|
111
|
+
success: boolean;
|
|
112
|
+
deleted: string[];
|
|
113
|
+
failed: { name: string; error: string }[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Session management types
|
|
117
|
+
export interface WorktreeSession extends TerminalInfo {
|
|
118
|
+
id: string;
|
|
119
|
+
createdAt: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface SessionGroup {
|
|
123
|
+
branchName: string;
|
|
124
|
+
sessions: WorktreeSession[];
|
|
125
|
+
isCollapsed: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Modal types
|
|
129
|
+
export type ModalType = 'addWorktree' | 'confirmDelete' | null;
|
|
130
|
+
|
|
131
|
+
export interface ModalState {
|
|
132
|
+
type: ModalType;
|
|
133
|
+
data?: {
|
|
134
|
+
sessionIds?: string[];
|
|
135
|
+
branchName?: string;
|
|
136
|
+
};
|
|
137
|
+
}
|
package/web/src/App.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import BranchStep from './components/Steps/BranchStep.js';
|
|
|
7
7
|
import AgentStep from './components/Steps/AgentStep.js';
|
|
8
8
|
import WorktreeStep from './components/Steps/WorktreeStep.js';
|
|
9
9
|
import TerminalPanel from './components/Terminal/TerminalPanel.js';
|
|
10
|
+
import ModalContainer from './components/Modals/ModalContainer.js';
|
|
10
11
|
|
|
11
12
|
function App() {
|
|
12
13
|
const { step, isLoading, loadingMessage, error } = useAppStore();
|
|
@@ -58,6 +59,8 @@ function App() {
|
|
|
58
59
|
<span className={`dot ${connected ? 'connected' : 'disconnected'}`} />
|
|
59
60
|
{connected ? 'Connected' : 'Disconnected'}
|
|
60
61
|
</div>
|
|
62
|
+
|
|
63
|
+
<ModalContainer />
|
|
61
64
|
</div>
|
|
62
65
|
);
|
|
63
66
|
}
|