hjworktree-cli 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/.context-snapshots/context-snapshot-20260106-110353.md +66 -0
- package/.context-snapshots/context-snapshot-20260106-110441.md +66 -0
- package/.context-snapshots/context-snapshot-20260106-220000.md +99 -0
- package/AGENTS.md +29 -0
- package/CLAUDE.md +88 -0
- package/bin/cli.js +85 -0
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/routes/api.d.ts +3 -0
- package/dist/server/routes/api.d.ts.map +1 -0
- package/dist/server/routes/api.js +101 -0
- package/dist/server/routes/api.js.map +1 -0
- package/dist/server/services/gitService.d.ts +13 -0
- package/dist/server/services/gitService.d.ts.map +1 -0
- package/dist/server/services/gitService.js +84 -0
- package/dist/server/services/gitService.js.map +1 -0
- package/dist/server/services/worktreeService.d.ts +17 -0
- package/dist/server/services/worktreeService.d.ts.map +1 -0
- package/dist/server/services/worktreeService.js +161 -0
- package/dist/server/services/worktreeService.js.map +1 -0
- package/dist/server/socketHandlers.d.ts +4 -0
- package/dist/server/socketHandlers.d.ts.map +1 -0
- package/dist/server/socketHandlers.js +118 -0
- package/dist/server/socketHandlers.js.map +1 -0
- package/dist/shared/constants.d.ts +10 -0
- package/dist/shared/constants.d.ts.map +1 -0
- package/dist/shared/constants.js +31 -0
- package/dist/shared/constants.js.map +1 -0
- package/dist/shared/types/index.d.ts +67 -0
- package/dist/shared/types/index.d.ts.map +1 -0
- package/dist/shared/types/index.js +3 -0
- package/dist/shared/types/index.js.map +1 -0
- package/dist/web/assets/index-C61yAbey.css +32 -0
- package/dist/web/assets/index-WEdVUKxb.js +53 -0
- package/dist/web/assets/index-WEdVUKxb.js.map +1 -0
- package/dist/web/index.html +16 -0
- package/package.json +63 -0
- package/server/index.ts +75 -0
- package/server/routes/api.ts +108 -0
- package/server/services/gitService.ts +91 -0
- package/server/services/worktreeService.ts +181 -0
- package/server/socketHandlers.ts +157 -0
- package/shared/constants.ts +35 -0
- package/shared/types/index.ts +92 -0
- package/tsconfig.json +20 -0
- package/web/index.html +15 -0
- package/web/src/App.tsx +65 -0
- package/web/src/components/Layout/Header.tsx +29 -0
- package/web/src/components/Layout/LeftNavBar.tsx +67 -0
- package/web/src/components/Layout/MainLayout.tsx +23 -0
- package/web/src/components/Layout/StepContainer.tsx +71 -0
- package/web/src/components/Setup/AgentSelector.tsx +27 -0
- package/web/src/components/Setup/BranchSelector.tsx +28 -0
- package/web/src/components/Setup/SetupPanel.tsx +32 -0
- package/web/src/components/Setup/WorktreeCountSelector.tsx +30 -0
- package/web/src/components/Steps/AgentStep.tsx +20 -0
- package/web/src/components/Steps/BranchStep.tsx +20 -0
- package/web/src/components/Steps/WorktreeStep.tsx +41 -0
- package/web/src/components/Terminal/TerminalPanel.tsx +113 -0
- package/web/src/components/Terminal/XTerminal.tsx +203 -0
- package/web/src/hooks/useSocket.ts +80 -0
- package/web/src/main.tsx +10 -0
- package/web/src/stores/useAppStore.ts +348 -0
- package/web/src/styles/global.css +695 -0
- package/web/tsconfig.json +23 -0
- package/web/vite.config.ts +32 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>hjWorktree CLI</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
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-WEdVUKxb.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C61yAbey.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hjworktree-cli",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Web-based git worktree parallel AI coding agent runner",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/server/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"hjWorktree": "./bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "concurrently \"npm run dev:server\" \"npm run dev:web\"",
|
|
12
|
+
"dev:server": "tsx watch server/index.ts",
|
|
13
|
+
"dev:web": "vite --config web/vite.config.ts",
|
|
14
|
+
"build": "npm run build:server && npm run build:web",
|
|
15
|
+
"build:server": "tsc",
|
|
16
|
+
"build:web": "vite build --config web/vite.config.ts",
|
|
17
|
+
"start": "node dist/server/index.js",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"cli",
|
|
22
|
+
"git",
|
|
23
|
+
"worktree",
|
|
24
|
+
"ai",
|
|
25
|
+
"codex",
|
|
26
|
+
"claude",
|
|
27
|
+
"gemini",
|
|
28
|
+
"parallel",
|
|
29
|
+
"terminal",
|
|
30
|
+
"web"
|
|
31
|
+
],
|
|
32
|
+
"author": "hyungju-lee <beegizee1220@gmail.com>",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"cors": "^2.8.5",
|
|
39
|
+
"express": "^4.21.0",
|
|
40
|
+
"node-pty": "^1.0.0",
|
|
41
|
+
"open": "^10.1.0",
|
|
42
|
+
"simple-git": "^3.27.0",
|
|
43
|
+
"socket.io": "^4.7.5"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/cors": "^2.8.17",
|
|
47
|
+
"@types/express": "^4.17.21",
|
|
48
|
+
"@types/node": "^22.10.5",
|
|
49
|
+
"@types/react": "^18.3.18",
|
|
50
|
+
"@types/react-dom": "^18.3.5",
|
|
51
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
52
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
53
|
+
"@xterm/xterm": "^5.5.0",
|
|
54
|
+
"concurrently": "^9.1.0",
|
|
55
|
+
"react": "^18.3.1",
|
|
56
|
+
"react-dom": "^18.3.1",
|
|
57
|
+
"socket.io-client": "^4.7.5",
|
|
58
|
+
"tsx": "^4.19.2",
|
|
59
|
+
"typescript": "^5.7.3",
|
|
60
|
+
"vite": "^6.0.7",
|
|
61
|
+
"zustand": "^5.0.2"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { Server } from 'socket.io';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import cors from 'cors';
|
|
7
|
+
import { setupSocketHandlers, killAllSessions } from './socketHandlers.js';
|
|
8
|
+
import { apiRouter } from './routes/api.js';
|
|
9
|
+
import { DEFAULT_PORT, APP_NAME, APP_VERSION } from '../shared/constants.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
const app = express();
|
|
15
|
+
const httpServer = createServer(app);
|
|
16
|
+
const io = new Server(httpServer, {
|
|
17
|
+
cors: {
|
|
18
|
+
origin: '*',
|
|
19
|
+
methods: ['GET', 'POST']
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const PORT = parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
|
|
24
|
+
const CWD = process.env.CWD || process.cwd();
|
|
25
|
+
|
|
26
|
+
// Middleware
|
|
27
|
+
app.use(cors());
|
|
28
|
+
app.use(express.json());
|
|
29
|
+
|
|
30
|
+
// Serve static files from the web build directory
|
|
31
|
+
const webDir = path.join(__dirname, '../web');
|
|
32
|
+
app.use(express.static(webDir));
|
|
33
|
+
|
|
34
|
+
// API routes
|
|
35
|
+
app.use('/api', apiRouter(CWD));
|
|
36
|
+
|
|
37
|
+
// Socket.IO handlers
|
|
38
|
+
setupSocketHandlers(io, CWD);
|
|
39
|
+
|
|
40
|
+
// Fallback to index.html for SPA routing
|
|
41
|
+
app.get('*', (req, res) => {
|
|
42
|
+
res.sendFile(path.join(webDir, 'index.html'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Graceful shutdown
|
|
46
|
+
process.on('SIGINT', () => {
|
|
47
|
+
console.log('\nShutting down...');
|
|
48
|
+
killAllSessions();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
process.on('SIGTERM', () => {
|
|
53
|
+
console.log('\nShutting down...');
|
|
54
|
+
killAllSessions();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Start server
|
|
59
|
+
httpServer.listen(PORT, () => {
|
|
60
|
+
console.log(`
|
|
61
|
+
╔════════════════════════════════════════════╗
|
|
62
|
+
║ ║
|
|
63
|
+
║ ${APP_NAME} v${APP_VERSION} ║
|
|
64
|
+
║ ║
|
|
65
|
+
║ Server running at: ║
|
|
66
|
+
║ http://localhost:${PORT} ║
|
|
67
|
+
║ ║
|
|
68
|
+
║ Working directory: ║
|
|
69
|
+
║ ${CWD.substring(0, 38)}${CWD.length > 38 ? '...' : ''}
|
|
70
|
+
║ ║
|
|
71
|
+
╚════════════════════════════════════════════╝
|
|
72
|
+
`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export { app, httpServer, io };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Router, Request, Response } from 'express';
|
|
2
|
+
import { GitService } from '../services/gitService.js';
|
|
3
|
+
import { WorktreeService } from '../services/worktreeService.js';
|
|
4
|
+
import type { CreateWorktreesRequest } from '../../shared/types/index.js';
|
|
5
|
+
|
|
6
|
+
export function apiRouter(cwd: string): Router {
|
|
7
|
+
const router = Router();
|
|
8
|
+
const gitService = new GitService(cwd);
|
|
9
|
+
const worktreeService = new WorktreeService(cwd);
|
|
10
|
+
|
|
11
|
+
// Get project info
|
|
12
|
+
router.get('/info', async (req: Request, res: Response) => {
|
|
13
|
+
try {
|
|
14
|
+
const isGitRepo = await gitService.isGitRepository();
|
|
15
|
+
const currentBranch = isGitRepo ? await gitService.getCurrentBranch() : null;
|
|
16
|
+
|
|
17
|
+
res.json({
|
|
18
|
+
cwd,
|
|
19
|
+
isGitRepository: isGitRepo,
|
|
20
|
+
currentBranch,
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
res.status(500).json({
|
|
24
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Get branches
|
|
30
|
+
router.get('/branches', async (req: Request, res: Response) => {
|
|
31
|
+
try {
|
|
32
|
+
// Fetch latest from remote first
|
|
33
|
+
await gitService.fetch();
|
|
34
|
+
const branches = await gitService.getBranches();
|
|
35
|
+
res.json(branches);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
res.status(500).json({
|
|
38
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Get worktrees
|
|
44
|
+
router.get('/worktrees', async (req: Request, res: Response) => {
|
|
45
|
+
try {
|
|
46
|
+
const worktrees = await worktreeService.listWorktrees();
|
|
47
|
+
res.json(worktrees);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
res.status(500).json({
|
|
50
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Create worktrees
|
|
56
|
+
router.post('/worktrees', async (req: Request, res: Response) => {
|
|
57
|
+
try {
|
|
58
|
+
const { branch, count } = req.body as CreateWorktreesRequest;
|
|
59
|
+
|
|
60
|
+
if (!branch || !count || count < 1) {
|
|
61
|
+
res.status(400).json({ error: 'Invalid request: branch and count are required' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const worktrees = await worktreeService.createMultipleWorktrees(branch, count);
|
|
66
|
+
res.json({ worktrees });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
res.status(500).json({
|
|
69
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Delete a worktree
|
|
75
|
+
router.delete('/worktrees/:name', async (req: Request, res: Response) => {
|
|
76
|
+
try {
|
|
77
|
+
const { name } = req.params;
|
|
78
|
+
const worktrees = await worktreeService.listWorktrees();
|
|
79
|
+
const worktree = worktrees.find(wt => wt.name === name);
|
|
80
|
+
|
|
81
|
+
if (!worktree) {
|
|
82
|
+
res.status(404).json({ error: 'Worktree not found' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await worktreeService.removeWorktree(worktree.path);
|
|
87
|
+
res.json({ success: true });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
res.status(500).json({
|
|
90
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Delete all worktrees
|
|
96
|
+
router.delete('/worktrees', async (req: Request, res: Response) => {
|
|
97
|
+
try {
|
|
98
|
+
await worktreeService.removeAllWorktrees();
|
|
99
|
+
res.json({ success: true });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
res.status(500).json({
|
|
102
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return router;
|
|
108
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { simpleGit, SimpleGit } from 'simple-git';
|
|
2
|
+
import type { Branch } from '../../shared/types/index.js';
|
|
3
|
+
import { BRANCH_POLL_INTERVAL } from '../../shared/constants.js';
|
|
4
|
+
|
|
5
|
+
export class GitService {
|
|
6
|
+
private git: SimpleGit;
|
|
7
|
+
private cwd: string;
|
|
8
|
+
|
|
9
|
+
constructor(cwd: string = process.cwd()) {
|
|
10
|
+
this.cwd = cwd;
|
|
11
|
+
this.git = simpleGit(cwd);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async isGitRepository(): Promise<boolean> {
|
|
15
|
+
try {
|
|
16
|
+
await this.git.status();
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getBranches(): Promise<Branch[]> {
|
|
24
|
+
const branchSummary = await this.git.branch(['-a']);
|
|
25
|
+
const branches: Branch[] = [];
|
|
26
|
+
const localBranchNames = new Set<string>();
|
|
27
|
+
|
|
28
|
+
// First pass: collect local branches
|
|
29
|
+
for (const [name, data] of Object.entries(branchSummary.branches)) {
|
|
30
|
+
if (!name.startsWith('remotes/')) {
|
|
31
|
+
localBranchNames.add(name);
|
|
32
|
+
branches.push({
|
|
33
|
+
name,
|
|
34
|
+
isCurrent: data.current,
|
|
35
|
+
isRemote: false,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Second pass: add remote branches that don't have local counterparts
|
|
41
|
+
for (const [name] of Object.entries(branchSummary.branches)) {
|
|
42
|
+
if (name.startsWith('remotes/origin/')) {
|
|
43
|
+
const localName = name.replace('remotes/origin/', '');
|
|
44
|
+
// Skip HEAD reference and branches that exist locally
|
|
45
|
+
if (localName !== 'HEAD' && !localBranchNames.has(localName)) {
|
|
46
|
+
branches.push({
|
|
47
|
+
name: localName,
|
|
48
|
+
isCurrent: false,
|
|
49
|
+
isRemote: true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Sort: current first, then local, then remote, alphabetically within each group
|
|
56
|
+
return branches.sort((a, b) => {
|
|
57
|
+
if (a.isCurrent) return -1;
|
|
58
|
+
if (b.isCurrent) return 1;
|
|
59
|
+
if (!a.isRemote && b.isRemote) return -1;
|
|
60
|
+
if (a.isRemote && !b.isRemote) return 1;
|
|
61
|
+
return a.name.localeCompare(b.name);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getCurrentBranch(): Promise<string> {
|
|
66
|
+
const status = await this.git.status();
|
|
67
|
+
return status.current || 'HEAD';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async fetch(): Promise<void> {
|
|
71
|
+
try {
|
|
72
|
+
await this.git.fetch(['--prune']);
|
|
73
|
+
} catch {
|
|
74
|
+
// Silently fail fetch - might not have remote configured
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getCwd(): string {
|
|
79
|
+
return this.cwd;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Singleton instance
|
|
84
|
+
let gitServiceInstance: GitService | null = null;
|
|
85
|
+
|
|
86
|
+
export function getGitService(cwd?: string): GitService {
|
|
87
|
+
if (!gitServiceInstance || (cwd && gitServiceInstance.getCwd() !== cwd)) {
|
|
88
|
+
gitServiceInstance = new GitService(cwd);
|
|
89
|
+
}
|
|
90
|
+
return gitServiceInstance;
|
|
91
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { simpleGit, SimpleGit } from 'simple-git';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import type { Worktree } from '../../shared/types/index.js';
|
|
5
|
+
|
|
6
|
+
export class WorktreeService {
|
|
7
|
+
private git: SimpleGit;
|
|
8
|
+
private rootDir: string;
|
|
9
|
+
private createdWorktrees: Set<string> = new Set();
|
|
10
|
+
|
|
11
|
+
constructor(cwd: string = process.cwd()) {
|
|
12
|
+
this.rootDir = cwd;
|
|
13
|
+
this.git = simpleGit(cwd);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async listWorktrees(): Promise<Worktree[]> {
|
|
17
|
+
const result = await this.git.raw(['worktree', 'list', '--porcelain']);
|
|
18
|
+
const worktrees: Worktree[] = [];
|
|
19
|
+
|
|
20
|
+
const entries = result.trim().split('\n\n').filter(Boolean);
|
|
21
|
+
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const lines = entry.split('\n');
|
|
24
|
+
const worktreePath = lines.find((l) => l.startsWith('worktree '))?.replace('worktree ', '');
|
|
25
|
+
const branchLine = lines.find((l) => l.startsWith('branch '));
|
|
26
|
+
const branch = branchLine?.replace('branch refs/heads/', '');
|
|
27
|
+
|
|
28
|
+
if (worktreePath) {
|
|
29
|
+
worktrees.push({
|
|
30
|
+
path: worktreePath,
|
|
31
|
+
branch: branch || 'detached',
|
|
32
|
+
name: path.basename(worktreePath),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return worktrees;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async createWorktree(baseBranch: string, index: number): Promise<Worktree> {
|
|
41
|
+
const worktreeName = `${baseBranch}-project-${index}`;
|
|
42
|
+
// Create worktree in parent directory of the main repo
|
|
43
|
+
const worktreePath = path.join(path.dirname(this.rootDir), worktreeName);
|
|
44
|
+
const newBranchName = worktreeName;
|
|
45
|
+
|
|
46
|
+
// Check if directory already exists
|
|
47
|
+
try {
|
|
48
|
+
await fs.access(worktreePath);
|
|
49
|
+
// Directory exists, try to remove it first
|
|
50
|
+
await this.removeWorktree(worktreePath);
|
|
51
|
+
} catch {
|
|
52
|
+
// Directory doesn't exist, which is good
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check if branch already exists
|
|
56
|
+
try {
|
|
57
|
+
const branches = await this.git.branch();
|
|
58
|
+
if (branches.all.includes(newBranchName)) {
|
|
59
|
+
// Branch exists, delete it first
|
|
60
|
+
await this.git.branch(['-D', newBranchName]);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Branch doesn't exist, which is good
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Create worktree with new branch based on the selected branch
|
|
67
|
+
await this.git.raw(['worktree', 'add', '-b', newBranchName, worktreePath, baseBranch]);
|
|
68
|
+
|
|
69
|
+
this.createdWorktrees.add(worktreePath);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
path: worktreePath,
|
|
73
|
+
branch: newBranchName,
|
|
74
|
+
name: worktreeName,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async createMultipleWorktrees(baseBranch: string, count: number): Promise<Worktree[]> {
|
|
79
|
+
const worktrees: Worktree[] = [];
|
|
80
|
+
const createdPaths: string[] = [];
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
for (let i = 1; i <= count; i++) {
|
|
84
|
+
const worktree = await this.createWorktree(baseBranch, i);
|
|
85
|
+
worktrees.push(worktree);
|
|
86
|
+
createdPaths.push(worktree.path);
|
|
87
|
+
}
|
|
88
|
+
return worktrees;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// Rollback: remove already created worktrees
|
|
91
|
+
console.error(`Failed to create worktree ${createdPaths.length + 1}, rolling back...`);
|
|
92
|
+
|
|
93
|
+
for (const worktreePath of createdPaths) {
|
|
94
|
+
try {
|
|
95
|
+
await this.removeWorktree(worktreePath);
|
|
96
|
+
} catch (rollbackError) {
|
|
97
|
+
console.error(`Failed to rollback worktree: ${worktreePath}`, rollbackError);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async removeWorktree(worktreePath: string): Promise<void> {
|
|
106
|
+
const branchName = path.basename(worktreePath);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
// 1. Force remove worktree
|
|
110
|
+
await this.git.raw(['worktree', 'remove', worktreePath, '--force']);
|
|
111
|
+
} catch {
|
|
112
|
+
// 2. If worktree remove fails, try to remove directory manually
|
|
113
|
+
try {
|
|
114
|
+
await fs.rm(worktreePath, { recursive: true, force: true });
|
|
115
|
+
} catch {
|
|
116
|
+
// Ignore errors
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. CRITICAL: Prune orphan worktree references
|
|
121
|
+
try {
|
|
122
|
+
await this.git.raw(['worktree', 'prune']);
|
|
123
|
+
} catch {
|
|
124
|
+
// Ignore prune errors
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 4. Now we can safely delete the branch
|
|
128
|
+
try {
|
|
129
|
+
await this.git.branch(['-D', branchName]);
|
|
130
|
+
} catch {
|
|
131
|
+
// Branch deletion failure is acceptable
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.createdWorktrees.delete(worktreePath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async removeAllWorktrees(): Promise<void> {
|
|
138
|
+
const worktrees = await this.listWorktrees();
|
|
139
|
+
|
|
140
|
+
// Filter out the main worktree (usually the first one / the main repo)
|
|
141
|
+
const additionalWorktrees = worktrees.filter(wt => wt.path !== this.rootDir);
|
|
142
|
+
|
|
143
|
+
for (const worktree of additionalWorktrees) {
|
|
144
|
+
try {
|
|
145
|
+
await this.removeWorktree(worktree.path);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`Failed to remove worktree: ${worktree.path}`, error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async cleanup(): Promise<void> {
|
|
153
|
+
// Remove all worktrees created in this session
|
|
154
|
+
for (const worktreePath of this.createdWorktrees) {
|
|
155
|
+
try {
|
|
156
|
+
await this.removeWorktree(worktreePath);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error(`Failed to cleanup worktree: ${worktreePath}`, error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
this.createdWorktrees.clear();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
getCreatedWorktrees(): string[] {
|
|
165
|
+
return Array.from(this.createdWorktrees);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getRootDir(): string {
|
|
169
|
+
return this.rootDir;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Singleton instance
|
|
174
|
+
let worktreeServiceInstance: WorktreeService | null = null;
|
|
175
|
+
|
|
176
|
+
export function getWorktreeService(cwd?: string): WorktreeService {
|
|
177
|
+
if (!worktreeServiceInstance || (cwd && worktreeServiceInstance.getRootDir() !== cwd)) {
|
|
178
|
+
worktreeServiceInstance = new WorktreeService(cwd);
|
|
179
|
+
}
|
|
180
|
+
return worktreeServiceInstance;
|
|
181
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Server, Socket } from 'socket.io';
|
|
2
|
+
import * as pty from 'node-pty';
|
|
3
|
+
import type { IPty } from 'node-pty';
|
|
4
|
+
import type {
|
|
5
|
+
TerminalCreateData,
|
|
6
|
+
TerminalInputData,
|
|
7
|
+
TerminalResizeData,
|
|
8
|
+
TerminalKillData,
|
|
9
|
+
AgentId
|
|
10
|
+
} from '../shared/types/index.js';
|
|
11
|
+
import { AI_AGENTS } from '../shared/constants.js';
|
|
12
|
+
|
|
13
|
+
interface TerminalSession {
|
|
14
|
+
pty: IPty;
|
|
15
|
+
worktreePath: string;
|
|
16
|
+
agentType: AgentId;
|
|
17
|
+
socketId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sessions = new Map<string, TerminalSession>();
|
|
21
|
+
|
|
22
|
+
function getAgentCommand(agentType: AgentId): string {
|
|
23
|
+
const agent = AI_AGENTS.find(a => a.id === agentType);
|
|
24
|
+
return agent?.command || 'bash';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getShell(): string {
|
|
28
|
+
return process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setupSocketHandlers(io: Server, cwd: string) {
|
|
32
|
+
io.on('connection', (socket: Socket) => {
|
|
33
|
+
console.log(`Client connected: ${socket.id}`);
|
|
34
|
+
|
|
35
|
+
// Terminal create
|
|
36
|
+
socket.on('terminal:create', async (data: TerminalCreateData) => {
|
|
37
|
+
const { sessionId, worktreePath, agentType } = data;
|
|
38
|
+
|
|
39
|
+
console.log(`Creating terminal session: ${sessionId} at ${worktreePath} with ${agentType}`);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const shell = getShell();
|
|
43
|
+
const agentCommand = getAgentCommand(agentType);
|
|
44
|
+
|
|
45
|
+
const ptyProcess = pty.spawn(shell, [], {
|
|
46
|
+
name: 'xterm-256color',
|
|
47
|
+
cols: 120,
|
|
48
|
+
rows: 30,
|
|
49
|
+
cwd: worktreePath,
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
TERM: 'xterm-256color',
|
|
53
|
+
FORCE_COLOR: '1',
|
|
54
|
+
COLORTERM: 'truecolor',
|
|
55
|
+
} as Record<string, string>,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
sessions.set(sessionId, {
|
|
59
|
+
pty: ptyProcess,
|
|
60
|
+
worktreePath,
|
|
61
|
+
agentType,
|
|
62
|
+
socketId: socket.id,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Send output to client
|
|
66
|
+
ptyProcess.onData((output: string) => {
|
|
67
|
+
socket.emit('terminal:output', { sessionId, data: output });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Handle PTY exit
|
|
71
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
72
|
+
console.log(`Terminal session ${sessionId} exited with code ${exitCode}`);
|
|
73
|
+
socket.emit('terminal:exit', { sessionId, exitCode });
|
|
74
|
+
sessions.delete(sessionId);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Start the AI agent after a short delay
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
ptyProcess.write(`${agentCommand}\r`);
|
|
80
|
+
}, 500);
|
|
81
|
+
|
|
82
|
+
socket.emit('terminal:created', { sessionId });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error(`Failed to create terminal session: ${sessionId}`, error);
|
|
85
|
+
socket.emit('terminal:error', {
|
|
86
|
+
sessionId,
|
|
87
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Terminal input
|
|
93
|
+
socket.on('terminal:input', (data: TerminalInputData) => {
|
|
94
|
+
const { sessionId, data: input } = data;
|
|
95
|
+
const session = sessions.get(sessionId);
|
|
96
|
+
|
|
97
|
+
if (session) {
|
|
98
|
+
session.pty.write(input);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Terminal resize
|
|
103
|
+
socket.on('terminal:resize', (data: TerminalResizeData) => {
|
|
104
|
+
const { sessionId, cols, rows } = data;
|
|
105
|
+
const session = sessions.get(sessionId);
|
|
106
|
+
|
|
107
|
+
if (session) {
|
|
108
|
+
session.pty.resize(cols, rows);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Terminal kill
|
|
113
|
+
socket.on('terminal:kill', (data: TerminalKillData) => {
|
|
114
|
+
const { sessionId } = data;
|
|
115
|
+
const session = sessions.get(sessionId);
|
|
116
|
+
|
|
117
|
+
if (session) {
|
|
118
|
+
console.log(`Killing terminal session: ${sessionId}`);
|
|
119
|
+
session.pty.kill();
|
|
120
|
+
sessions.delete(sessionId);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Broadcast input to all terminals
|
|
125
|
+
socket.on('terminal:broadcast', (data: { data: string }) => {
|
|
126
|
+
const socketSessions = Array.from(sessions.entries())
|
|
127
|
+
.filter(([, session]) => session.socketId === socket.id);
|
|
128
|
+
|
|
129
|
+
for (const [sessionId, session] of socketSessions) {
|
|
130
|
+
session.pty.write(data.data);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Disconnect handling
|
|
135
|
+
socket.on('disconnect', () => {
|
|
136
|
+
console.log(`Client disconnected: ${socket.id}`);
|
|
137
|
+
|
|
138
|
+
// Kill all sessions owned by this socket
|
|
139
|
+
const socketSessions = Array.from(sessions.entries())
|
|
140
|
+
.filter(([, session]) => session.socketId === socket.id);
|
|
141
|
+
|
|
142
|
+
for (const [sessionId, session] of socketSessions) {
|
|
143
|
+
console.log(`Killing orphaned terminal session: ${sessionId}`);
|
|
144
|
+
session.pty.kill();
|
|
145
|
+
sessions.delete(sessionId);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function killAllSessions(): void {
|
|
152
|
+
for (const [sessionId, session] of sessions) {
|
|
153
|
+
console.log(`Killing terminal session: ${sessionId}`);
|
|
154
|
+
session.pty.kill();
|
|
155
|
+
}
|
|
156
|
+
sessions.clear();
|
|
157
|
+
}
|