@saeed42/worktree-worker 1.3.0 → 1.3.2
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.md +38 -34
- package/dist/main.js +331 -63
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# @saeed42/worktree-worker
|
|
2
2
|
|
|
3
|
-
Git worktree management service for AI agent trials. Runs as an npm package in CodeSandbox or any
|
|
3
|
+
Git worktree management service for AI agent trials. Runs as an npm package in CodeSandbox or any
|
|
4
|
+
Node.js environment.
|
|
4
5
|
|
|
5
6
|
## Security
|
|
6
7
|
|
|
7
|
-
**GitHub tokens are NEVER persisted to disk.** All authenticated operations require the token to be
|
|
8
|
+
**GitHub tokens are NEVER persisted to disk.** All authenticated operations require the token to be
|
|
9
|
+
passed in the request body. This is critical for sandbox environments where credentials should not
|
|
10
|
+
be stored.
|
|
8
11
|
|
|
9
12
|
## Installation
|
|
10
13
|
|
|
@@ -75,44 +78,44 @@ import { app } from '@saeed42/worktree-worker';
|
|
|
75
78
|
|
|
76
79
|
### Health Checks
|
|
77
80
|
|
|
78
|
-
| Endpoint
|
|
79
|
-
|
|
80
|
-
| `GET /health`
|
|
81
|
-
| `GET /health/ready` | Readiness check
|
|
82
|
-
| `GET /health/live`
|
|
81
|
+
| Endpoint | Description |
|
|
82
|
+
| ------------------- | ------------------ |
|
|
83
|
+
| `GET /health` | Basic health check |
|
|
84
|
+
| `GET /health/ready` | Readiness check |
|
|
85
|
+
| `GET /health/live` | Liveness check |
|
|
83
86
|
|
|
84
87
|
### Repository Management
|
|
85
88
|
|
|
86
|
-
| Endpoint
|
|
87
|
-
|
|
88
|
-
| `POST /v1/repo/init`
|
|
89
|
-
| `POST /v1/repo/update`
|
|
90
|
-
| `GET /v1/repo/status`
|
|
91
|
-
| `GET /v1/repo/branches` | List branches
|
|
89
|
+
| Endpoint | Description |
|
|
90
|
+
| ----------------------- | ----------------- |
|
|
91
|
+
| `POST /v1/repo/init` | Clone repository |
|
|
92
|
+
| `POST /v1/repo/update` | Fetch/pull latest |
|
|
93
|
+
| `GET /v1/repo/status` | Get repo status |
|
|
94
|
+
| `GET /v1/repo/branches` | List branches |
|
|
92
95
|
|
|
93
96
|
### Worktree Management
|
|
94
97
|
|
|
95
|
-
| Endpoint
|
|
96
|
-
|
|
97
|
-
| `POST /v1/trials/:trialId/worktree/reset`
|
|
98
|
-
| `DELETE /v1/trials/:trialId/worktree`
|
|
99
|
-
| `GET /v1/trials/:trialId/worktree/status`
|
|
100
|
-
| `GET /v1/trials/:trialId/worktree/diff`
|
|
101
|
-
| `POST /v1/trials/:trialId/worktree/commit` | Commit changes
|
|
102
|
-
| `POST /v1/trials/:trialId/worktree/push`
|
|
98
|
+
| Endpoint | Description |
|
|
99
|
+
| ------------------------------------------ | --------------------- |
|
|
100
|
+
| `POST /v1/trials/:trialId/worktree/reset` | Create/reset worktree |
|
|
101
|
+
| `DELETE /v1/trials/:trialId/worktree` | Delete worktree |
|
|
102
|
+
| `GET /v1/trials/:trialId/worktree/status` | Get status |
|
|
103
|
+
| `GET /v1/trials/:trialId/worktree/diff` | Get diff |
|
|
104
|
+
| `POST /v1/trials/:trialId/worktree/commit` | Commit changes |
|
|
105
|
+
| `POST /v1/trials/:trialId/worktree/push` | Push to remote |
|
|
103
106
|
|
|
104
107
|
## Configuration
|
|
105
108
|
|
|
106
|
-
| Variable
|
|
107
|
-
|
|
108
|
-
| `PORT`
|
|
109
|
-
| `NODE_ENV`
|
|
110
|
-
| `WORKER_TOKEN`
|
|
111
|
-
| `BASE_WORKSPACE_DIR`
|
|
112
|
-
| `TRIALS_WORKSPACE_DIR` | `/project/workspaces/trials` | Worktrees path
|
|
113
|
-
| `DEFAULT_BRANCH`
|
|
114
|
-
| `GIT_TIMEOUT_MS`
|
|
115
|
-
| `CLEANUP_AFTER_HOURS`
|
|
109
|
+
| Variable | Default | Description |
|
|
110
|
+
| ---------------------- | ---------------------------- | ---------------------------- |
|
|
111
|
+
| `PORT` | `8787` | HTTP server port |
|
|
112
|
+
| `NODE_ENV` | `development` | Environment mode |
|
|
113
|
+
| `WORKER_TOKEN` | `` | Auth token (empty = no auth) |
|
|
114
|
+
| `BASE_WORKSPACE_DIR` | `/project/sandbox` | Main repo path |
|
|
115
|
+
| `TRIALS_WORKSPACE_DIR` | `/project/workspaces/trials` | Worktrees path |
|
|
116
|
+
| `DEFAULT_BRANCH` | `main` | Default base branch |
|
|
117
|
+
| `GIT_TIMEOUT_MS` | `60000` | Git timeout |
|
|
118
|
+
| `CLEANUP_AFTER_HOURS` | `24` | Stale worktree cleanup |
|
|
116
119
|
|
|
117
120
|
## Usage Examples
|
|
118
121
|
|
|
@@ -142,7 +145,8 @@ curl -X POST http://localhost:8787/v1/trials/abc123/worktree/reset \
|
|
|
142
145
|
}'
|
|
143
146
|
```
|
|
144
147
|
|
|
145
|
-
> **Note:** `githubToken` is required for private repositories. The token is used inline and **never
|
|
148
|
+
> **Note:** `githubToken` is required for private repositories. The token is used inline and **never
|
|
149
|
+
> stored on disk**.
|
|
146
150
|
|
|
147
151
|
Response:
|
|
148
152
|
|
|
@@ -184,7 +188,8 @@ curl -X POST http://localhost:8787/v1/trials/abc123/worktree/push \
|
|
|
184
188
|
}'
|
|
185
189
|
```
|
|
186
190
|
|
|
187
|
-
> **Note:** `githubToken` is **required** for push operations. The token is used inline and **never
|
|
191
|
+
> **Note:** `githubToken` is **required** for push operations. The token is used inline and **never
|
|
192
|
+
> stored on disk**.
|
|
188
193
|
|
|
189
194
|
## Development
|
|
190
195
|
|
|
@@ -218,4 +223,3 @@ npm publish --access public
|
|
|
218
223
|
## License
|
|
219
224
|
|
|
220
225
|
MIT
|
|
221
|
-
|
package/dist/main.js
CHANGED
|
@@ -8,6 +8,7 @@ import { logger as honoLogger } from "hono/logger";
|
|
|
8
8
|
import { secureHeaders } from "hono/secure-headers";
|
|
9
9
|
|
|
10
10
|
// src/config/env.ts
|
|
11
|
+
import process from "process";
|
|
11
12
|
var env = {
|
|
12
13
|
PORT: parseInt(process.env.PORT || "8787", 10),
|
|
13
14
|
NODE_ENV: process.env.NODE_ENV || "development",
|
|
@@ -112,33 +113,44 @@ var logger = {
|
|
|
112
113
|
// src/middleware/auth.ts
|
|
113
114
|
async function authMiddleware(c, next) {
|
|
114
115
|
if (!env.WORKER_TOKEN) {
|
|
115
|
-
|
|
116
|
+
await next();
|
|
117
|
+
return;
|
|
116
118
|
}
|
|
117
119
|
const authHeader = c.req.header("Authorization");
|
|
118
120
|
if (!authHeader) {
|
|
119
|
-
return c.json({
|
|
121
|
+
return c.json({
|
|
122
|
+
success: false,
|
|
123
|
+
error: { code: "UNAUTHORIZED", message: "Missing Authorization header" }
|
|
124
|
+
}, 401);
|
|
120
125
|
}
|
|
121
126
|
const [scheme, token] = authHeader.split(" ");
|
|
122
127
|
if (scheme !== "Bearer" || !token) {
|
|
123
|
-
return c.json({
|
|
128
|
+
return c.json({
|
|
129
|
+
success: false,
|
|
130
|
+
error: { code: "UNAUTHORIZED", message: "Invalid Authorization format" }
|
|
131
|
+
}, 401);
|
|
124
132
|
}
|
|
125
133
|
if (token !== env.WORKER_TOKEN) {
|
|
126
|
-
return c.json(
|
|
134
|
+
return c.json(
|
|
135
|
+
{ success: false, error: { code: "UNAUTHORIZED", message: "Invalid token" } },
|
|
136
|
+
401
|
|
137
|
+
);
|
|
127
138
|
}
|
|
128
|
-
|
|
139
|
+
await next();
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
// src/routes/health.routes.ts
|
|
132
143
|
import { Hono } from "hono";
|
|
133
|
-
import {
|
|
144
|
+
import { mkdir, stat } from "fs/promises";
|
|
134
145
|
|
|
135
146
|
// src/services/git.service.ts
|
|
136
147
|
import { spawn } from "child_process";
|
|
148
|
+
import process2 from "process";
|
|
137
149
|
var GitService = class {
|
|
138
150
|
/**
|
|
139
151
|
* Execute a git command
|
|
140
152
|
*/
|
|
141
|
-
|
|
153
|
+
exec(args, cwd) {
|
|
142
154
|
const workDir = cwd || env.BASE_WORKSPACE_DIR;
|
|
143
155
|
const log = logger.child({ git: args.slice(0, 2).join(" "), cwd: workDir });
|
|
144
156
|
return new Promise((resolve, reject) => {
|
|
@@ -146,7 +158,7 @@ var GitService = class {
|
|
|
146
158
|
cwd: workDir,
|
|
147
159
|
timeout: env.GIT_TIMEOUT_MS,
|
|
148
160
|
env: {
|
|
149
|
-
...
|
|
161
|
+
...process2.env,
|
|
150
162
|
GIT_TERMINAL_PROMPT: "0",
|
|
151
163
|
GIT_ASKPASS: ""
|
|
152
164
|
}
|
|
@@ -208,7 +220,11 @@ var GitService = class {
|
|
|
208
220
|
*/
|
|
209
221
|
async fetch(remote = "origin", cwd, auth) {
|
|
210
222
|
if (auth?.githubToken) {
|
|
211
|
-
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
223
|
+
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
224
|
+
remote,
|
|
225
|
+
cwd || env.BASE_WORKSPACE_DIR,
|
|
226
|
+
auth.githubToken
|
|
227
|
+
);
|
|
212
228
|
if (authUrl) {
|
|
213
229
|
await this.execOrThrow(["fetch", authUrl], cwd);
|
|
214
230
|
return;
|
|
@@ -299,30 +315,83 @@ var GitService = class {
|
|
|
299
315
|
const args = ["branch", force ? "-D" : "-d", branch];
|
|
300
316
|
await this.exec(args, cwd);
|
|
301
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Check if repository is empty (no commits)
|
|
320
|
+
*/
|
|
321
|
+
async isEmptyRepo(cwd) {
|
|
322
|
+
const result = await this.exec(["rev-parse", "HEAD"], cwd);
|
|
323
|
+
if (result.code !== 0) {
|
|
324
|
+
if (result.stderr.includes("unknown revision") || result.stderr.includes("bad revision") || result.stderr.includes("ambiguous argument")) {
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
302
330
|
/**
|
|
303
331
|
* Get HEAD SHA
|
|
332
|
+
* Returns null for empty repositories (no commits)
|
|
304
333
|
*/
|
|
305
334
|
async getHeadSha(cwd) {
|
|
306
|
-
const result = await this.
|
|
335
|
+
const result = await this.exec(["rev-parse", "HEAD"], cwd);
|
|
336
|
+
if (result.code !== 0) {
|
|
337
|
+
if (result.stderr.includes("unknown revision") || result.stderr.includes("bad revision") || result.stderr.includes("ambiguous argument")) {
|
|
338
|
+
throw new Error("Repository is empty (no commits). Please create an initial commit first.");
|
|
339
|
+
}
|
|
340
|
+
throw new Error(`Git command failed: ${result.stderr || result.stdout}`);
|
|
341
|
+
}
|
|
342
|
+
return result.stdout.trim();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Get HEAD SHA, returning null for empty repos instead of throwing
|
|
346
|
+
*/
|
|
347
|
+
async getHeadShaSafe(cwd) {
|
|
348
|
+
const result = await this.exec(["rev-parse", "HEAD"], cwd);
|
|
349
|
+
if (result.code !== 0) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
307
352
|
return result.stdout.trim();
|
|
308
353
|
}
|
|
309
354
|
/**
|
|
310
355
|
* Get current branch
|
|
356
|
+
* Returns null for empty repositories or detached HEAD
|
|
311
357
|
*/
|
|
312
358
|
async getCurrentBranch(cwd) {
|
|
313
|
-
const result = await this.
|
|
359
|
+
const result = await this.exec(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
360
|
+
if (result.code !== 0) {
|
|
361
|
+
if (result.stderr.includes("unknown revision") || result.stderr.includes("bad revision") || result.stderr.includes("ambiguous argument")) {
|
|
362
|
+
throw new Error("Repository is empty (no commits). Cannot determine current branch.");
|
|
363
|
+
}
|
|
364
|
+
throw new Error(`Git command failed: ${result.stderr || result.stdout}`);
|
|
365
|
+
}
|
|
314
366
|
return result.stdout.trim();
|
|
315
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Get current branch, returning null for empty repos instead of throwing
|
|
370
|
+
*/
|
|
371
|
+
async getCurrentBranchSafe(cwd) {
|
|
372
|
+
const result = await this.exec(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
373
|
+
if (result.code !== 0) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const branch = result.stdout.trim();
|
|
377
|
+
return branch === "HEAD" ? null : branch;
|
|
378
|
+
}
|
|
316
379
|
/**
|
|
317
380
|
* Check if branch exists (local or remote)
|
|
318
381
|
* For remote branches, pass "origin/branch-name"
|
|
319
382
|
*/
|
|
320
383
|
async branchExists(branch, cwd) {
|
|
321
384
|
if (branch.includes("/")) {
|
|
322
|
-
const result2 = await this.exec(
|
|
385
|
+
const result2 = await this.exec(
|
|
386
|
+
["show-ref", "--verify", "--quiet", `refs/remotes/${branch}`],
|
|
387
|
+
cwd
|
|
388
|
+
);
|
|
323
389
|
return result2.code === 0;
|
|
324
390
|
}
|
|
325
|
-
const result = await this.exec(
|
|
391
|
+
const result = await this.exec(
|
|
392
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
393
|
+
cwd
|
|
394
|
+
);
|
|
326
395
|
return result.code === 0;
|
|
327
396
|
}
|
|
328
397
|
/**
|
|
@@ -362,7 +431,11 @@ var GitService = class {
|
|
|
362
431
|
*/
|
|
363
432
|
async push(remote, branch, force = false, cwd, auth) {
|
|
364
433
|
if (auth?.githubToken) {
|
|
365
|
-
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
434
|
+
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
435
|
+
remote,
|
|
436
|
+
cwd || env.BASE_WORKSPACE_DIR,
|
|
437
|
+
auth.githubToken
|
|
438
|
+
);
|
|
366
439
|
if (authUrl) {
|
|
367
440
|
const args2 = ["push", authUrl, branch];
|
|
368
441
|
if (force) args2.push("--force");
|
|
@@ -396,6 +469,7 @@ var GitService = class {
|
|
|
396
469
|
var gitService = new GitService();
|
|
397
470
|
|
|
398
471
|
// src/routes/health.routes.ts
|
|
472
|
+
import process3 from "process";
|
|
399
473
|
var health = new Hono();
|
|
400
474
|
health.get("/", (c) => {
|
|
401
475
|
return c.json({
|
|
@@ -446,7 +520,7 @@ health.get("/live", (c) => {
|
|
|
446
520
|
return c.json({
|
|
447
521
|
status: "alive",
|
|
448
522
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
449
|
-
uptime: Math.floor(
|
|
523
|
+
uptime: Math.floor(process3.uptime())
|
|
450
524
|
});
|
|
451
525
|
});
|
|
452
526
|
|
|
@@ -470,7 +544,8 @@ var RepoService = class {
|
|
|
470
544
|
path: repoRoot,
|
|
471
545
|
branch: null,
|
|
472
546
|
remote: null,
|
|
473
|
-
headSha: null
|
|
547
|
+
headSha: null,
|
|
548
|
+
isEmpty: false
|
|
474
549
|
};
|
|
475
550
|
}
|
|
476
551
|
const gitDir = await stat2(`${repoRoot}/.git`).catch(() => null);
|
|
@@ -480,18 +555,21 @@ var RepoService = class {
|
|
|
480
555
|
path: repoRoot,
|
|
481
556
|
branch: null,
|
|
482
557
|
remote: null,
|
|
483
|
-
headSha: null
|
|
558
|
+
headSha: null,
|
|
559
|
+
isEmpty: false
|
|
484
560
|
};
|
|
485
561
|
}
|
|
486
|
-
const branch = await gitService.
|
|
487
|
-
const headSha = await gitService.
|
|
562
|
+
const branch = await gitService.getCurrentBranchSafe(repoRoot);
|
|
563
|
+
const headSha = await gitService.getHeadShaSafe(repoRoot);
|
|
488
564
|
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
565
|
+
const isEmpty = await gitService.isEmptyRepo(repoRoot);
|
|
489
566
|
return {
|
|
490
567
|
initialized: true,
|
|
491
568
|
path: repoRoot,
|
|
492
569
|
branch,
|
|
493
570
|
remote,
|
|
494
|
-
headSha
|
|
571
|
+
headSha,
|
|
572
|
+
isEmpty
|
|
495
573
|
};
|
|
496
574
|
} catch {
|
|
497
575
|
return {
|
|
@@ -499,14 +577,15 @@ var RepoService = class {
|
|
|
499
577
|
path: repoRoot,
|
|
500
578
|
branch: null,
|
|
501
579
|
remote: null,
|
|
502
|
-
headSha: null
|
|
580
|
+
headSha: null,
|
|
581
|
+
isEmpty: false
|
|
503
582
|
};
|
|
504
583
|
}
|
|
505
584
|
}
|
|
506
585
|
/**
|
|
507
586
|
* Initialize/clone the repository
|
|
508
587
|
* SECURITY: githubToken is used inline and NEVER persisted
|
|
509
|
-
*
|
|
588
|
+
*
|
|
510
589
|
* Smart init logic:
|
|
511
590
|
* - No repo exists → Clone it
|
|
512
591
|
* - Repo exists, same URL → Fetch latest (idempotent)
|
|
@@ -534,47 +613,119 @@ var RepoService = class {
|
|
|
534
613
|
if (isSameRepo) {
|
|
535
614
|
log.info("Repository already initialized with same URL, fetching latest");
|
|
536
615
|
await gitService.fetch("origin", repoRoot, auth);
|
|
537
|
-
const
|
|
616
|
+
const targetBranch2 = options.branch || env.DEFAULT_BRANCH;
|
|
538
617
|
const currentBranch = status.branch || "";
|
|
539
|
-
if (
|
|
540
|
-
log.info("Switching to requested branch", { from: currentBranch, to:
|
|
541
|
-
const checkoutResult = await gitService.exec(["checkout",
|
|
618
|
+
if (targetBranch2 !== currentBranch) {
|
|
619
|
+
log.info("Switching to requested branch", { from: currentBranch, to: targetBranch2 });
|
|
620
|
+
const checkoutResult = await gitService.exec(["checkout", targetBranch2], repoRoot);
|
|
542
621
|
if (checkoutResult.code !== 0) {
|
|
543
|
-
await gitService.exec(
|
|
622
|
+
await gitService.exec(
|
|
623
|
+
["checkout", "-b", targetBranch2, `origin/${targetBranch2}`],
|
|
624
|
+
repoRoot
|
|
625
|
+
);
|
|
544
626
|
}
|
|
545
627
|
}
|
|
546
|
-
const
|
|
547
|
-
const
|
|
548
|
-
return { path: repoRoot, branch:
|
|
628
|
+
const headSha3 = await gitService.getHeadSha(repoRoot);
|
|
629
|
+
const branch3 = await gitService.getCurrentBranch(repoRoot);
|
|
630
|
+
return { path: repoRoot, branch: branch3, headSha: headSha3, remote: status.remote };
|
|
549
631
|
}
|
|
550
632
|
if (!options.force) {
|
|
551
633
|
throw new Error(
|
|
552
634
|
`Repository already initialized with different URL. Current: ${currentUrl}, Requested: ${requestedUrl}. Use force=true to re-initialize (this will delete all worktrees).`
|
|
553
635
|
);
|
|
554
636
|
}
|
|
555
|
-
log.warn("Force re-init: cleaning
|
|
637
|
+
log.warn("Force re-init: cleaning worktrees and updating remote in-place");
|
|
556
638
|
await this.cleanAllWorktrees();
|
|
557
|
-
|
|
639
|
+
const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
|
|
640
|
+
await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
|
|
641
|
+
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
|
|
642
|
+
await gitService.exec([
|
|
643
|
+
"config",
|
|
644
|
+
"--local",
|
|
645
|
+
"user.email",
|
|
646
|
+
"origin-agent[bot]@users.noreply.github.com"
|
|
647
|
+
], repoRoot);
|
|
648
|
+
await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
|
|
649
|
+
const targetBranch = options.branch || env.DEFAULT_BRANCH;
|
|
650
|
+
log.info("Fetching from new remote", { branch: targetBranch });
|
|
651
|
+
await gitService.fetch("origin", repoRoot, auth);
|
|
652
|
+
try {
|
|
653
|
+
await gitService.exec(["checkout", "-B", targetBranch, `origin/${targetBranch}`, "--force"], repoRoot);
|
|
654
|
+
} catch {
|
|
655
|
+
await gitService.exec(["checkout", "-B", targetBranch, "--force"], repoRoot);
|
|
656
|
+
}
|
|
657
|
+
await gitService.exec(["branch", `--set-upstream-to=origin/${targetBranch}`, targetBranch], repoRoot).catch(
|
|
658
|
+
() => {
|
|
659
|
+
}
|
|
660
|
+
);
|
|
661
|
+
const headSha2 = await gitService.getHeadSha(repoRoot);
|
|
662
|
+
const branch2 = await gitService.getCurrentBranch(repoRoot);
|
|
663
|
+
const remote2 = await gitService.getRemoteUrl("origin", repoRoot);
|
|
664
|
+
log.info("Repository re-initialized in-place", { branch: branch2, headSha: headSha2 });
|
|
665
|
+
return { path: repoRoot, branch: branch2, headSha: headSha2, remote: remote2 };
|
|
558
666
|
}
|
|
559
667
|
const parentDir = repoRoot.split("/").slice(0, -1).join("/");
|
|
560
668
|
await mkdir2(parentDir, { recursive: true });
|
|
561
669
|
await mkdir2(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
670
|
+
const dirExists = await stat2(repoRoot).then(() => true).catch(() => false);
|
|
562
671
|
const branch = options.branch || env.DEFAULT_BRANCH;
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
672
|
+
if (dirExists) {
|
|
673
|
+
log.info("Directory exists, initializing git in-place", { branch });
|
|
674
|
+
await gitService.exec(["init"], repoRoot);
|
|
675
|
+
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
|
|
676
|
+
await gitService.exec([
|
|
677
|
+
"config",
|
|
678
|
+
"--local",
|
|
679
|
+
"user.email",
|
|
680
|
+
"origin-agent[bot]@users.noreply.github.com"
|
|
681
|
+
], repoRoot);
|
|
682
|
+
await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
|
|
683
|
+
const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
|
|
684
|
+
await gitService.exec(["remote", "add", "origin", cleanUrl], repoRoot).catch(async () => {
|
|
685
|
+
await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
|
|
686
|
+
});
|
|
687
|
+
await gitService.fetch("origin", repoRoot, auth);
|
|
688
|
+
await gitService.exec(["checkout", "-B", branch, `origin/${branch}`, "--force"], repoRoot);
|
|
689
|
+
await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(
|
|
690
|
+
() => {
|
|
691
|
+
}
|
|
692
|
+
);
|
|
693
|
+
} else {
|
|
694
|
+
log.info("Cloning repository", { branch });
|
|
695
|
+
await gitService.cloneRepo(options.repoUrl, repoRoot, {
|
|
696
|
+
branch,
|
|
697
|
+
blobless: true,
|
|
698
|
+
githubToken: options.githubToken
|
|
699
|
+
});
|
|
700
|
+
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
|
|
701
|
+
await gitService.exec([
|
|
702
|
+
"config",
|
|
703
|
+
"--local",
|
|
704
|
+
"user.email",
|
|
705
|
+
"origin-agent[bot]@users.noreply.github.com"
|
|
706
|
+
], repoRoot);
|
|
707
|
+
await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
|
|
708
|
+
const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
|
|
709
|
+
await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
|
|
710
|
+
log.info("Fetching all remote refs");
|
|
711
|
+
await gitService.fetch("origin", repoRoot, auth);
|
|
712
|
+
await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(
|
|
713
|
+
() => {
|
|
714
|
+
}
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
const isEmpty = await gitService.isEmptyRepo(repoRoot);
|
|
718
|
+
if (isEmpty) {
|
|
719
|
+
log.warn("Repository is empty (no commits)", { repoUrl: options.repoUrl });
|
|
720
|
+
const remote2 = await gitService.getRemoteUrl("origin", repoRoot);
|
|
721
|
+
return {
|
|
722
|
+
path: repoRoot,
|
|
723
|
+
branch,
|
|
724
|
+
headSha: null,
|
|
725
|
+
// Empty repo has no HEAD
|
|
726
|
+
remote: remote2
|
|
727
|
+
};
|
|
728
|
+
}
|
|
578
729
|
const headSha = await gitService.getHeadSha(repoRoot);
|
|
579
730
|
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
580
731
|
log.info("Repository initialized", { branch, headSha });
|
|
@@ -663,7 +814,10 @@ repo.get("/status", async (c) => {
|
|
|
663
814
|
} catch (err) {
|
|
664
815
|
const error = err;
|
|
665
816
|
logger.error("Failed to get repo status", { error: error.message });
|
|
666
|
-
return c.json(
|
|
817
|
+
return c.json(
|
|
818
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
819
|
+
500
|
|
820
|
+
);
|
|
667
821
|
}
|
|
668
822
|
});
|
|
669
823
|
repo.get("/branches", async (c) => {
|
|
@@ -673,7 +827,10 @@ repo.get("/branches", async (c) => {
|
|
|
673
827
|
} catch (err) {
|
|
674
828
|
const error = err;
|
|
675
829
|
logger.error("Failed to list branches", { error: error.message });
|
|
676
|
-
return c.json(
|
|
830
|
+
return c.json(
|
|
831
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
832
|
+
500
|
|
833
|
+
);
|
|
677
834
|
}
|
|
678
835
|
});
|
|
679
836
|
repo.post("/init", async (c) => {
|
|
@@ -687,7 +844,10 @@ repo.post("/init", async (c) => {
|
|
|
687
844
|
400
|
|
688
845
|
);
|
|
689
846
|
}
|
|
690
|
-
log.info("Initializing repository", {
|
|
847
|
+
log.info("Initializing repository", {
|
|
848
|
+
repoUrl: parsed.data.repoUrl,
|
|
849
|
+
branch: parsed.data.branch
|
|
850
|
+
});
|
|
691
851
|
const result = await repoService.initRepo(parsed.data);
|
|
692
852
|
return c.json({ success: true, data: result });
|
|
693
853
|
} catch (err) {
|
|
@@ -722,7 +882,7 @@ import { Hono as Hono3 } from "hono";
|
|
|
722
882
|
import { z as z2 } from "zod";
|
|
723
883
|
|
|
724
884
|
// src/services/worktree.service.ts
|
|
725
|
-
import { mkdir as mkdir3,
|
|
885
|
+
import { mkdir as mkdir3, readdir as readdir2, rm as rm2, stat as stat3 } from "fs/promises";
|
|
726
886
|
var WorktreeService = class {
|
|
727
887
|
/**
|
|
728
888
|
* Get the isolated workspace directory for a specific trial.
|
|
@@ -763,19 +923,43 @@ var WorktreeService = class {
|
|
|
763
923
|
hasRepoUrl: !!options.repoUrl
|
|
764
924
|
});
|
|
765
925
|
await mkdir3(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
766
|
-
|
|
767
|
-
|
|
926
|
+
await this.opportunisticCleanup();
|
|
927
|
+
let repoStatus = await repoService.getStatus();
|
|
928
|
+
const isValidGitHubRemote = (remote) => {
|
|
929
|
+
if (!remote) return false;
|
|
930
|
+
return remote.includes("github.com") || remote.startsWith("https://");
|
|
931
|
+
};
|
|
932
|
+
const needsInit = !repoStatus.initialized;
|
|
933
|
+
const hasInvalidRemote = repoStatus.initialized && !isValidGitHubRemote(repoStatus.remote);
|
|
934
|
+
if (needsInit || hasInvalidRemote) {
|
|
768
935
|
if (!options.repoUrl) {
|
|
769
936
|
throw new Error(
|
|
770
|
-
"Main repository not initialized. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
|
|
937
|
+
"Main repository not initialized or has invalid remote. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
|
|
771
938
|
);
|
|
772
939
|
}
|
|
773
|
-
|
|
940
|
+
if (hasInvalidRemote) {
|
|
941
|
+
log.warn("Repo has invalid remote (likely CodeSandbox template), re-initializing", {
|
|
942
|
+
currentRemote: repoStatus.remote,
|
|
943
|
+
newRepoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***")
|
|
944
|
+
});
|
|
945
|
+
} else {
|
|
946
|
+
log.info("Main repo not initialized, auto-initializing", {
|
|
947
|
+
repoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***")
|
|
948
|
+
});
|
|
949
|
+
}
|
|
774
950
|
await repoService.initRepo({
|
|
775
951
|
repoUrl: options.repoUrl,
|
|
776
952
|
branch: baseBranch,
|
|
777
|
-
githubToken: options.githubToken
|
|
953
|
+
githubToken: options.githubToken,
|
|
954
|
+
force: hasInvalidRemote
|
|
955
|
+
// Force re-init if remote is invalid
|
|
778
956
|
});
|
|
957
|
+
repoStatus = await repoService.getStatus();
|
|
958
|
+
}
|
|
959
|
+
if (repoStatus.isEmpty) {
|
|
960
|
+
throw new Error(
|
|
961
|
+
"Cannot create worktree: repository is empty (no commits). Please create an initial commit in the repository first."
|
|
962
|
+
);
|
|
779
963
|
}
|
|
780
964
|
try {
|
|
781
965
|
const stats = await stat3(worktreePath);
|
|
@@ -785,7 +969,7 @@ var WorktreeService = class {
|
|
|
785
969
|
log.info("Worktree already exists, fetching latest");
|
|
786
970
|
await gitService.fetch("origin", worktreePath, auth).catch(() => {
|
|
787
971
|
});
|
|
788
|
-
const headSha2 = await gitService.
|
|
972
|
+
const headSha2 = await gitService.getHeadShaSafe(worktreePath);
|
|
789
973
|
return { worktreePath, branch: branchName, headSha: headSha2 };
|
|
790
974
|
}
|
|
791
975
|
}
|
|
@@ -832,11 +1016,17 @@ var WorktreeService = class {
|
|
|
832
1016
|
}
|
|
833
1017
|
}
|
|
834
1018
|
if (!found) {
|
|
835
|
-
const currentBranch = await gitService.
|
|
1019
|
+
const currentBranch = await gitService.getCurrentBranchSafe(baseRepoDir);
|
|
836
1020
|
if (currentBranch && currentBranch !== "HEAD") {
|
|
837
1021
|
baseRef = currentBranch;
|
|
838
1022
|
log.info("Using current branch as base", { baseRef });
|
|
839
1023
|
} else {
|
|
1024
|
+
const isEmpty = await gitService.isEmptyRepo(baseRepoDir);
|
|
1025
|
+
if (isEmpty) {
|
|
1026
|
+
throw new Error(
|
|
1027
|
+
`Cannot create worktree: repository is empty (no commits). Please create an initial commit in the repository first.`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
840
1030
|
throw new Error(
|
|
841
1031
|
`Cannot find base branch. Tried: origin/${baseBranch}, ${fallbackBranches.join(", ")}. Ensure the repository has been fetched properly.`
|
|
842
1032
|
);
|
|
@@ -848,11 +1038,16 @@ var WorktreeService = class {
|
|
|
848
1038
|
}
|
|
849
1039
|
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], worktreePath).catch(() => {
|
|
850
1040
|
});
|
|
851
|
-
await gitService.exec([
|
|
1041
|
+
await gitService.exec([
|
|
1042
|
+
"config",
|
|
1043
|
+
"--local",
|
|
1044
|
+
"user.email",
|
|
1045
|
+
"origin-agent[bot]@users.noreply.github.com"
|
|
1046
|
+
], worktreePath).catch(() => {
|
|
852
1047
|
});
|
|
853
1048
|
await gitService.exec(["config", "--local", "safe.directory", worktreePath], worktreePath).catch(() => {
|
|
854
1049
|
});
|
|
855
|
-
const headSha = await gitService.
|
|
1050
|
+
const headSha = await gitService.getHeadShaSafe(worktreePath);
|
|
856
1051
|
log.info("Worktree created successfully", { headSha });
|
|
857
1052
|
return { worktreePath, branch: branchName, headSha };
|
|
858
1053
|
}
|
|
@@ -914,8 +1109,8 @@ var WorktreeService = class {
|
|
|
914
1109
|
changedFiles: 0
|
|
915
1110
|
};
|
|
916
1111
|
}
|
|
917
|
-
const headSha = await gitService.
|
|
918
|
-
const branch = await gitService.
|
|
1112
|
+
const headSha = await gitService.getHeadShaSafe(worktreePath);
|
|
1113
|
+
const branch = await gitService.getCurrentBranchSafe(worktreePath);
|
|
919
1114
|
const status = await gitService.getStatus(worktreePath);
|
|
920
1115
|
return {
|
|
921
1116
|
exists: true,
|
|
@@ -973,6 +1168,76 @@ var WorktreeService = class {
|
|
|
973
1168
|
await gitService.push(remote, branch, force, worktreePath, auth);
|
|
974
1169
|
return { branch, pushed: true };
|
|
975
1170
|
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Count current worktrees
|
|
1173
|
+
*/
|
|
1174
|
+
async countWorktrees() {
|
|
1175
|
+
try {
|
|
1176
|
+
const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
|
|
1177
|
+
return entries.filter((e) => e.isDirectory()).length;
|
|
1178
|
+
} catch {
|
|
1179
|
+
return 0;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Get worktrees sorted by modification time (oldest first)
|
|
1184
|
+
*/
|
|
1185
|
+
async getWorktreesByAge() {
|
|
1186
|
+
try {
|
|
1187
|
+
const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
|
|
1188
|
+
const worktrees = [];
|
|
1189
|
+
for (const entry of entries) {
|
|
1190
|
+
if (!entry.isDirectory()) continue;
|
|
1191
|
+
const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
|
|
1192
|
+
try {
|
|
1193
|
+
const stats = await stat3(worktreePath);
|
|
1194
|
+
worktrees.push({ path: worktreePath, mtime: stats.mtimeMs });
|
|
1195
|
+
} catch {
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return worktrees.sort((a, b) => a.mtime - b.mtime);
|
|
1199
|
+
} catch {
|
|
1200
|
+
return [];
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Opportunistic cleanup - run before creating new worktrees
|
|
1205
|
+
*
|
|
1206
|
+
* Strategy:
|
|
1207
|
+
* 1. Always clean worktrees older than CLEANUP_AFTER_HOURS
|
|
1208
|
+
* 2. If count exceeds MAX_WORKTREES, clean oldest until under limit
|
|
1209
|
+
*/
|
|
1210
|
+
async opportunisticCleanup(maxWorktrees = 20) {
|
|
1211
|
+
const log = logger.child({ service: "worktree", action: "opportunistic-cleanup" });
|
|
1212
|
+
try {
|
|
1213
|
+
const worktrees = await this.getWorktreesByAge();
|
|
1214
|
+
const count = worktrees.length;
|
|
1215
|
+
const cutoffTime = Date.now() - env.CLEANUP_AFTER_HOURS * 60 * 60 * 1e3;
|
|
1216
|
+
let cleaned = 0;
|
|
1217
|
+
for (const wt of worktrees) {
|
|
1218
|
+
const isStale = wt.mtime < cutoffTime;
|
|
1219
|
+
const isOverLimit = count - cleaned > maxWorktrees;
|
|
1220
|
+
if (isStale || isOverLimit) {
|
|
1221
|
+
try {
|
|
1222
|
+
await rm2(wt.path, { recursive: true, force: true });
|
|
1223
|
+
cleaned++;
|
|
1224
|
+
log.debug("Cleaned worktree", {
|
|
1225
|
+
path: wt.path,
|
|
1226
|
+
reason: isStale ? "stale" : "over_limit",
|
|
1227
|
+
ageHours: Math.round((Date.now() - wt.mtime) / (60 * 60 * 1e3))
|
|
1228
|
+
});
|
|
1229
|
+
} catch {
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (cleaned > 0) {
|
|
1234
|
+
log.info("Opportunistic cleanup completed", { cleaned, remaining: count - cleaned });
|
|
1235
|
+
await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR).catch(() => {
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
} catch {
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
976
1241
|
/**
|
|
977
1242
|
* Cleanup stale worktrees
|
|
978
1243
|
*/
|
|
@@ -1176,7 +1441,10 @@ worktree.post("/worktrees/cleanup", async (c) => {
|
|
|
1176
1441
|
} catch (err) {
|
|
1177
1442
|
const error = err;
|
|
1178
1443
|
log.error("Failed to cleanup worktrees", { error: error.message });
|
|
1179
|
-
return c.json(
|
|
1444
|
+
return c.json(
|
|
1445
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
1446
|
+
500
|
|
1447
|
+
);
|
|
1180
1448
|
}
|
|
1181
1449
|
});
|
|
1182
1450
|
|
package/package.json
CHANGED