@saeed42/worktree-worker 1.0.0 → 1.3.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.md +28 -7
- package/dist/main.js +210 -70
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @saeed42/worktree-worker
|
|
2
2
|
|
|
3
3
|
Git worktree management service for AI agent trials. Runs as an npm package in CodeSandbox or any Node.js environment.
|
|
4
4
|
|
|
5
|
+
## Security
|
|
6
|
+
|
|
7
|
+
**GitHub tokens are NEVER persisted to disk.** All authenticated operations require the token to be passed in the request body. This is critical for sandbox environments where credentials should not be stored.
|
|
8
|
+
|
|
5
9
|
## Installation
|
|
6
10
|
|
|
7
11
|
```bash
|
|
8
12
|
# Install globally
|
|
9
|
-
npm install -g @
|
|
13
|
+
npm install -g @saeed42/worktree-worker
|
|
10
14
|
|
|
11
15
|
# Or add to your project
|
|
12
|
-
npm install @
|
|
16
|
+
npm install @saeed42/worktree-worker
|
|
13
17
|
```
|
|
14
18
|
|
|
15
19
|
## Quick Start
|
|
@@ -32,7 +36,7 @@ Add to your `package.json`:
|
|
|
32
36
|
```json
|
|
33
37
|
{
|
|
34
38
|
"dependencies": {
|
|
35
|
-
"@
|
|
39
|
+
"@saeed42/worktree-worker": "^1.0.0"
|
|
36
40
|
},
|
|
37
41
|
"scripts": {
|
|
38
42
|
"worktree-worker": "worktree-worker"
|
|
@@ -43,13 +47,13 @@ Add to your `package.json`:
|
|
|
43
47
|
Or run directly:
|
|
44
48
|
|
|
45
49
|
```bash
|
|
46
|
-
npx @
|
|
50
|
+
npx @saeed42/worktree-worker
|
|
47
51
|
```
|
|
48
52
|
|
|
49
53
|
### Run Programmatically
|
|
50
54
|
|
|
51
55
|
```typescript
|
|
52
|
-
import { app } from '@
|
|
56
|
+
import { app } from '@saeed42/worktree-worker';
|
|
53
57
|
|
|
54
58
|
// The app is a Hono instance
|
|
55
59
|
// You can extend it or use it as middleware
|
|
@@ -133,10 +137,13 @@ curl -X POST http://localhost:8787/v1/trials/abc123/worktree/reset \
|
|
|
133
137
|
-H "Content-Type: application/json" \
|
|
134
138
|
-d '{
|
|
135
139
|
"baseBranch": "main",
|
|
136
|
-
"trialBranch": "feature/my-feature"
|
|
140
|
+
"trialBranch": "feature/my-feature",
|
|
141
|
+
"githubToken": "ghp_xxx"
|
|
137
142
|
}'
|
|
138
143
|
```
|
|
139
144
|
|
|
145
|
+
> **Note:** `githubToken` is required for private repositories. The token is used inline and **never stored on disk**.
|
|
146
|
+
|
|
140
147
|
Response:
|
|
141
148
|
|
|
142
149
|
```json
|
|
@@ -165,6 +172,20 @@ curl -X POST http://localhost:8787/v1/trials/abc123/worktree/commit \
|
|
|
165
172
|
}'
|
|
166
173
|
```
|
|
167
174
|
|
|
175
|
+
### Push Changes
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
curl -X POST http://localhost:8787/v1/trials/abc123/worktree/push \
|
|
179
|
+
-H "Authorization: Bearer your-token" \
|
|
180
|
+
-H "Content-Type: application/json" \
|
|
181
|
+
-d '{
|
|
182
|
+
"githubToken": "ghp_xxx",
|
|
183
|
+
"force": false
|
|
184
|
+
}'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
> **Note:** `githubToken` is **required** for push operations. The token is used inline and **never stored on disk**.
|
|
188
|
+
|
|
168
189
|
## Development
|
|
169
190
|
|
|
170
191
|
```bash
|
package/dist/main.js
CHANGED
|
@@ -188,13 +188,36 @@ var GitService = class {
|
|
|
188
188
|
return result.stdout.trim();
|
|
189
189
|
}
|
|
190
190
|
/**
|
|
191
|
-
*
|
|
191
|
+
* Build authenticated URL (token is NEVER persisted)
|
|
192
192
|
*/
|
|
193
|
-
|
|
193
|
+
buildAuthUrl(repoUrl, githubToken) {
|
|
194
|
+
if (!githubToken) return repoUrl;
|
|
195
|
+
const urlWithoutProtocol = repoUrl.replace(/^https?:\/\//, "");
|
|
196
|
+
return `https://x-access-token:${githubToken}@${urlWithoutProtocol}`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Get remote URL and inject auth if token provided
|
|
200
|
+
*/
|
|
201
|
+
async getAuthenticatedRemoteUrl(remote, cwd, githubToken) {
|
|
202
|
+
const url = await this.getRemoteUrl(remote, cwd);
|
|
203
|
+
if (!url) return null;
|
|
204
|
+
return githubToken ? this.buildAuthUrl(url, githubToken) : url;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Fetch from remote (token used inline, never persisted)
|
|
208
|
+
*/
|
|
209
|
+
async fetch(remote = "origin", cwd, auth) {
|
|
210
|
+
if (auth?.githubToken) {
|
|
211
|
+
const authUrl = await this.getAuthenticatedRemoteUrl(remote, cwd || env.BASE_WORKSPACE_DIR, auth.githubToken);
|
|
212
|
+
if (authUrl) {
|
|
213
|
+
await this.execOrThrow(["fetch", authUrl], cwd);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
194
217
|
await this.execOrThrow(["fetch", remote], cwd);
|
|
195
218
|
}
|
|
196
219
|
/**
|
|
197
|
-
* Clone a repository
|
|
220
|
+
* Clone a repository (token used inline, never persisted)
|
|
198
221
|
*/
|
|
199
222
|
async cloneRepo(repoUrl, targetDir, options) {
|
|
200
223
|
const args = ["clone"];
|
|
@@ -206,7 +229,8 @@ var GitService = class {
|
|
|
206
229
|
if (options?.branch) {
|
|
207
230
|
args.push("--branch", options.branch);
|
|
208
231
|
}
|
|
209
|
-
|
|
232
|
+
const cloneUrl = options?.githubToken ? this.buildAuthUrl(repoUrl, options.githubToken) : repoUrl;
|
|
233
|
+
args.push(cloneUrl, targetDir);
|
|
210
234
|
const parentDir = targetDir.split("/").slice(0, -1).join("/") || "/";
|
|
211
235
|
await this.execOrThrow(args, parentDir);
|
|
212
236
|
}
|
|
@@ -290,9 +314,14 @@ var GitService = class {
|
|
|
290
314
|
return result.stdout.trim();
|
|
291
315
|
}
|
|
292
316
|
/**
|
|
293
|
-
* Check if branch exists
|
|
317
|
+
* Check if branch exists (local or remote)
|
|
318
|
+
* For remote branches, pass "origin/branch-name"
|
|
294
319
|
*/
|
|
295
320
|
async branchExists(branch, cwd) {
|
|
321
|
+
if (branch.includes("/")) {
|
|
322
|
+
const result2 = await this.exec(["show-ref", "--verify", "--quiet", `refs/remotes/${branch}`], cwd);
|
|
323
|
+
return result2.code === 0;
|
|
324
|
+
}
|
|
296
325
|
const result = await this.exec(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd);
|
|
297
326
|
return result.code === 0;
|
|
298
327
|
}
|
|
@@ -329,9 +358,18 @@ var GitService = class {
|
|
|
329
358
|
return this.getHeadSha(cwd);
|
|
330
359
|
}
|
|
331
360
|
/**
|
|
332
|
-
* Push
|
|
361
|
+
* Push (token used inline, never persisted)
|
|
333
362
|
*/
|
|
334
|
-
async push(remote, branch, force = false, cwd) {
|
|
363
|
+
async push(remote, branch, force = false, cwd, auth) {
|
|
364
|
+
if (auth?.githubToken) {
|
|
365
|
+
const authUrl = await this.getAuthenticatedRemoteUrl(remote, cwd || env.BASE_WORKSPACE_DIR, auth.githubToken);
|
|
366
|
+
if (authUrl) {
|
|
367
|
+
const args2 = ["push", authUrl, branch];
|
|
368
|
+
if (force) args2.push("--force");
|
|
369
|
+
await this.execOrThrow(args2, cwd);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
335
373
|
const args = ["push", remote, branch];
|
|
336
374
|
if (force) args.push("--force");
|
|
337
375
|
await this.execOrThrow(args, cwd);
|
|
@@ -417,7 +455,7 @@ import { Hono as Hono2 } from "hono";
|
|
|
417
455
|
import { z } from "zod";
|
|
418
456
|
|
|
419
457
|
// src/services/repo.service.ts
|
|
420
|
-
import { mkdir as mkdir2, rm, stat as stat2
|
|
458
|
+
import { mkdir as mkdir2, readdir, rm, stat as stat2 } from "fs/promises";
|
|
421
459
|
var RepoService = class {
|
|
422
460
|
/**
|
|
423
461
|
* Check if the repository is initialized
|
|
@@ -467,71 +505,117 @@ var RepoService = class {
|
|
|
467
505
|
}
|
|
468
506
|
/**
|
|
469
507
|
* Initialize/clone the repository
|
|
508
|
+
* SECURITY: githubToken is used inline and NEVER persisted
|
|
509
|
+
*
|
|
510
|
+
* Smart init logic:
|
|
511
|
+
* - No repo exists → Clone it
|
|
512
|
+
* - Repo exists, same URL → Fetch latest (idempotent)
|
|
513
|
+
* - Repo exists, different URL, force=false → Error
|
|
514
|
+
* - Repo exists, different URL, force=true → Clean all worktrees → Delete repo → Re-clone
|
|
470
515
|
*/
|
|
471
516
|
async initRepo(options) {
|
|
472
517
|
const log = logger.child({ service: "repo", action: "init" });
|
|
473
518
|
const repoRoot = env.BASE_WORKSPACE_DIR;
|
|
519
|
+
const auth = options.githubToken ? { githubToken: options.githubToken } : void 0;
|
|
520
|
+
const normalizeUrl = (url) => {
|
|
521
|
+
return url.replace(/^https:\/\/[^@]+@/, "https://").replace(/\.git$/, "").replace(/\/$/, "").toLowerCase();
|
|
522
|
+
};
|
|
523
|
+
const requestedUrl = normalizeUrl(options.repoUrl);
|
|
474
524
|
log.info("Initializing repository", {
|
|
475
525
|
repoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***"),
|
|
476
526
|
branch: options.branch,
|
|
477
|
-
force: options.force
|
|
527
|
+
force: options.force,
|
|
528
|
+
hasToken: !!options.githubToken
|
|
478
529
|
});
|
|
479
530
|
const status = await this.getStatus();
|
|
480
|
-
if (status.initialized
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
531
|
+
if (status.initialized) {
|
|
532
|
+
const currentUrl = normalizeUrl(status.remote || "");
|
|
533
|
+
const isSameRepo = currentUrl === requestedUrl;
|
|
534
|
+
if (isSameRepo) {
|
|
535
|
+
log.info("Repository already initialized with same URL, fetching latest");
|
|
536
|
+
await gitService.fetch("origin", repoRoot, auth);
|
|
537
|
+
const targetBranch = options.branch || env.DEFAULT_BRANCH;
|
|
538
|
+
const currentBranch = status.branch || "";
|
|
539
|
+
if (targetBranch !== currentBranch) {
|
|
540
|
+
log.info("Switching to requested branch", { from: currentBranch, to: targetBranch });
|
|
541
|
+
const checkoutResult = await gitService.exec(["checkout", targetBranch], repoRoot);
|
|
542
|
+
if (checkoutResult.code !== 0) {
|
|
543
|
+
await gitService.exec(["checkout", "-b", targetBranch, `origin/${targetBranch}`], repoRoot);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const headSha2 = await gitService.getHeadSha(repoRoot);
|
|
547
|
+
const branch2 = await gitService.getCurrentBranch(repoRoot);
|
|
548
|
+
return { path: repoRoot, branch: branch2, headSha: headSha2, remote: status.remote };
|
|
549
|
+
}
|
|
550
|
+
if (!options.force) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`Repository already initialized with different URL. Current: ${currentUrl}, Requested: ${requestedUrl}. Use force=true to re-initialize (this will delete all worktrees).`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
log.warn("Force re-init: cleaning all worktrees and repo");
|
|
556
|
+
await this.cleanAllWorktrees();
|
|
491
557
|
await rm(repoRoot, { recursive: true, force: true });
|
|
492
558
|
}
|
|
493
559
|
const parentDir = repoRoot.split("/").slice(0, -1).join("/");
|
|
494
560
|
await mkdir2(parentDir, { recursive: true });
|
|
495
561
|
await mkdir2(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
496
|
-
let cloneUrl = options.repoUrl;
|
|
497
|
-
if (options.githubToken) {
|
|
498
|
-
const urlWithoutProtocol = options.repoUrl.replace(/^https?:\/\//, "");
|
|
499
|
-
cloneUrl = `https://x-access-token:${options.githubToken}@${urlWithoutProtocol}`;
|
|
500
|
-
}
|
|
501
562
|
const branch = options.branch || env.DEFAULT_BRANCH;
|
|
502
563
|
log.info("Cloning repository", { branch });
|
|
503
|
-
await gitService.cloneRepo(
|
|
564
|
+
await gitService.cloneRepo(options.repoUrl, repoRoot, {
|
|
504
565
|
branch,
|
|
505
|
-
blobless: true
|
|
566
|
+
blobless: true,
|
|
567
|
+
githubToken: options.githubToken
|
|
506
568
|
});
|
|
507
569
|
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
|
|
508
570
|
await gitService.exec(["config", "--local", "user.email", "origin-agent[bot]@users.noreply.github.com"], repoRoot);
|
|
509
571
|
await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
|
|
510
572
|
const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
|
|
511
573
|
await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
574
|
+
log.info("Fetching all remote refs");
|
|
575
|
+
await gitService.fetch("origin", repoRoot, auth);
|
|
576
|
+
await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(() => {
|
|
577
|
+
});
|
|
515
578
|
const headSha = await gitService.getHeadSha(repoRoot);
|
|
516
579
|
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
517
580
|
log.info("Repository initialized", { branch, headSha });
|
|
518
581
|
return { path: repoRoot, branch, headSha, remote };
|
|
519
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* Clean all worktrees (used before force re-init)
|
|
585
|
+
*/
|
|
586
|
+
async cleanAllWorktrees() {
|
|
587
|
+
const log = logger.child({ service: "repo", action: "cleanAllWorktrees" });
|
|
588
|
+
try {
|
|
589
|
+
const entries = await readdir(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
|
|
590
|
+
for (const entry of entries) {
|
|
591
|
+
if (entry.isDirectory()) {
|
|
592
|
+
const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
|
|
593
|
+
log.info("Removing worktree", { path: worktreePath });
|
|
594
|
+
await rm(worktreePath, { recursive: true, force: true });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR).catch(() => {
|
|
598
|
+
});
|
|
599
|
+
log.info("All worktrees cleaned");
|
|
600
|
+
} catch (err) {
|
|
601
|
+
log.debug("No worktrees to clean", { error: err.message });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
520
604
|
/**
|
|
521
605
|
* Update/pull the repository
|
|
606
|
+
* SECURITY: githubToken is used inline and NEVER persisted
|
|
522
607
|
*/
|
|
523
608
|
async updateRepo(options) {
|
|
524
609
|
const log = logger.child({ service: "repo", action: "update" });
|
|
525
610
|
const repoRoot = env.BASE_WORKSPACE_DIR;
|
|
611
|
+
const auth = options?.githubToken ? { githubToken: options.githubToken } : void 0;
|
|
526
612
|
const status = await this.getStatus();
|
|
527
613
|
if (!status.initialized) {
|
|
528
614
|
throw new Error("Repository not initialized");
|
|
529
615
|
}
|
|
530
|
-
|
|
531
|
-
await this.updateCredentials(options.githubToken);
|
|
532
|
-
}
|
|
616
|
+
log.info("Updating repository", { hasToken: !!options?.githubToken });
|
|
533
617
|
log.info("Fetching from remote");
|
|
534
|
-
await gitService.fetch("origin", repoRoot);
|
|
618
|
+
await gitService.fetch("origin", repoRoot, auth);
|
|
535
619
|
if (options?.branch && options.branch !== status.branch) {
|
|
536
620
|
log.info("Checking out branch", { branch: options.branch });
|
|
537
621
|
await gitService.execOrThrow(["checkout", options.branch], repoRoot);
|
|
@@ -545,19 +629,6 @@ var RepoService = class {
|
|
|
545
629
|
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
546
630
|
return { path: repoRoot, branch, headSha, remote };
|
|
547
631
|
}
|
|
548
|
-
/**
|
|
549
|
-
* Update git credentials
|
|
550
|
-
*/
|
|
551
|
-
async updateCredentials(githubToken) {
|
|
552
|
-
const log = logger.child({ service: "repo", action: "updateCredentials" });
|
|
553
|
-
const repoRoot = env.BASE_WORKSPACE_DIR;
|
|
554
|
-
await gitService.exec(["config", "--global", "credential.helper", "store --file=/tmp/.git-credentials"], repoRoot);
|
|
555
|
-
await gitService.exec(["config", "--global", "core.askPass", ""], repoRoot);
|
|
556
|
-
const credentialsContent = `https://x-access-token:${githubToken}@github.com
|
|
557
|
-
`;
|
|
558
|
-
await writeFile("/tmp/.git-credentials", credentialsContent, { mode: 384 });
|
|
559
|
-
log.info("Credentials updated");
|
|
560
|
-
}
|
|
561
632
|
/**
|
|
562
633
|
* List branches
|
|
563
634
|
*/
|
|
@@ -651,7 +722,7 @@ import { Hono as Hono3 } from "hono";
|
|
|
651
722
|
import { z as z2 } from "zod";
|
|
652
723
|
|
|
653
724
|
// src/services/worktree.service.ts
|
|
654
|
-
import { mkdir as mkdir3, rm as rm2, stat as stat3, readdir } from "fs/promises";
|
|
725
|
+
import { mkdir as mkdir3, rm as rm2, stat as stat3, readdir as readdir2 } from "fs/promises";
|
|
655
726
|
var WorktreeService = class {
|
|
656
727
|
/**
|
|
657
728
|
* Get the isolated workspace directory for a specific trial.
|
|
@@ -674,6 +745,7 @@ var WorktreeService = class {
|
|
|
674
745
|
}
|
|
675
746
|
/**
|
|
676
747
|
* Reset or create a worktree for a trial
|
|
748
|
+
* SECURITY: githubToken is used inline and NEVER persisted
|
|
677
749
|
*/
|
|
678
750
|
async resetWorktree(trialId, options) {
|
|
679
751
|
const log = logger.child({ trialId, service: "worktree" });
|
|
@@ -681,20 +753,37 @@ var WorktreeService = class {
|
|
|
681
753
|
const worktreePath = this.getWorktreePath(trialId, branchName);
|
|
682
754
|
const baseBranch = options.baseBranch || env.DEFAULT_BRANCH;
|
|
683
755
|
const baseRepoDir = env.BASE_WORKSPACE_DIR;
|
|
756
|
+
const auth = options.githubToken ? { githubToken: options.githubToken } : void 0;
|
|
684
757
|
log.info("Starting worktree reset (v3 architecture)", {
|
|
685
758
|
worktreePath,
|
|
686
759
|
branchName,
|
|
687
760
|
baseBranch,
|
|
688
|
-
baseRepoDir
|
|
761
|
+
baseRepoDir,
|
|
762
|
+
hasToken: !!options.githubToken,
|
|
763
|
+
hasRepoUrl: !!options.repoUrl
|
|
689
764
|
});
|
|
690
765
|
await mkdir3(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
766
|
+
const repoStatus = await repoService.getStatus();
|
|
767
|
+
if (!repoStatus.initialized) {
|
|
768
|
+
if (!options.repoUrl) {
|
|
769
|
+
throw new Error(
|
|
770
|
+
"Main repository not initialized. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
log.info("Main repo not initialized, auto-initializing", { repoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***") });
|
|
774
|
+
await repoService.initRepo({
|
|
775
|
+
repoUrl: options.repoUrl,
|
|
776
|
+
branch: baseBranch,
|
|
777
|
+
githubToken: options.githubToken
|
|
778
|
+
});
|
|
779
|
+
}
|
|
691
780
|
try {
|
|
692
781
|
const stats = await stat3(worktreePath);
|
|
693
782
|
if (stats.isDirectory()) {
|
|
694
783
|
const gitFile = await stat3(`${worktreePath}/.git`).catch(() => null);
|
|
695
784
|
if (gitFile) {
|
|
696
785
|
log.info("Worktree already exists, fetching latest");
|
|
697
|
-
await gitService.fetch("origin", worktreePath).catch(() => {
|
|
786
|
+
await gitService.fetch("origin", worktreePath, auth).catch(() => {
|
|
698
787
|
});
|
|
699
788
|
const headSha2 = await gitService.getHeadSha(worktreePath);
|
|
700
789
|
return { worktreePath, branch: branchName, headSha: headSha2 };
|
|
@@ -703,7 +792,7 @@ var WorktreeService = class {
|
|
|
703
792
|
} catch {
|
|
704
793
|
}
|
|
705
794
|
log.info("Fetching from remote");
|
|
706
|
-
await gitService.fetch("origin", baseRepoDir);
|
|
795
|
+
await gitService.fetch("origin", baseRepoDir, auth);
|
|
707
796
|
try {
|
|
708
797
|
await gitService.removeWorktree(worktreePath, true, baseRepoDir);
|
|
709
798
|
} catch {
|
|
@@ -727,7 +816,33 @@ var WorktreeService = class {
|
|
|
727
816
|
await gitService.exec(["fetch", "origin", `${branchName}:${branchName}`], baseRepoDir);
|
|
728
817
|
await gitService.execOrThrow(["worktree", "add", worktreePath, branchName], baseRepoDir);
|
|
729
818
|
} else {
|
|
730
|
-
|
|
819
|
+
let baseRef = `origin/${baseBranch}`;
|
|
820
|
+
const baseBranchExistsOnRemote = await gitService.branchExists(baseRef, baseRepoDir);
|
|
821
|
+
if (!baseBranchExistsOnRemote) {
|
|
822
|
+
log.warn("Base branch not found, detecting default branch", { tried: baseRef });
|
|
823
|
+
const fallbackBranches = ["origin/main", "origin/master", "origin/dev", "origin/develop"];
|
|
824
|
+
let found = false;
|
|
825
|
+
for (const fallback of fallbackBranches) {
|
|
826
|
+
const exists = await gitService.branchExists(fallback, baseRepoDir);
|
|
827
|
+
if (exists) {
|
|
828
|
+
baseRef = fallback;
|
|
829
|
+
found = true;
|
|
830
|
+
log.info("Found fallback base branch", { baseRef });
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (!found) {
|
|
835
|
+
const currentBranch = await gitService.getCurrentBranch(baseRepoDir);
|
|
836
|
+
if (currentBranch && currentBranch !== "HEAD") {
|
|
837
|
+
baseRef = currentBranch;
|
|
838
|
+
log.info("Using current branch as base", { baseRef });
|
|
839
|
+
} else {
|
|
840
|
+
throw new Error(
|
|
841
|
+
`Cannot find base branch. Tried: origin/${baseBranch}, ${fallbackBranches.join(", ")}. Ensure the repository has been fetched properly.`
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
731
846
|
log.info("Creating worktree with new branch from base", { baseRef, branchName });
|
|
732
847
|
await gitService.addWorktreeWithNewBranch(worktreePath, branchName, baseRef, baseRepoDir);
|
|
733
848
|
}
|
|
@@ -848,12 +963,14 @@ var WorktreeService = class {
|
|
|
848
963
|
}
|
|
849
964
|
/**
|
|
850
965
|
* Push branch
|
|
966
|
+
* SECURITY: githubToken is used inline and NEVER persisted
|
|
851
967
|
*/
|
|
852
|
-
async pushBranch(trialId, remote = "origin", force = false, trialBranch) {
|
|
968
|
+
async pushBranch(trialId, remote = "origin", force = false, trialBranch, githubToken) {
|
|
853
969
|
const branchName = trialBranch || this.getBranchName(trialId);
|
|
854
970
|
const worktreePath = this.getWorktreePath(trialId, branchName);
|
|
971
|
+
const auth = githubToken ? { githubToken } : void 0;
|
|
855
972
|
const branch = await gitService.getCurrentBranch(worktreePath);
|
|
856
|
-
await gitService.push(remote, branch, force, worktreePath);
|
|
973
|
+
await gitService.push(remote, branch, force, worktreePath, auth);
|
|
857
974
|
return { branch, pushed: true };
|
|
858
975
|
}
|
|
859
976
|
/**
|
|
@@ -865,7 +982,7 @@ var WorktreeService = class {
|
|
|
865
982
|
const errors = [];
|
|
866
983
|
const cutoffTime = Date.now() - env.CLEANUP_AFTER_HOURS * 60 * 60 * 1e3;
|
|
867
984
|
try {
|
|
868
|
-
const entries = await
|
|
985
|
+
const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
|
|
869
986
|
for (const entry of entries) {
|
|
870
987
|
if (!entry.isDirectory()) continue;
|
|
871
988
|
const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
|
|
@@ -897,18 +1014,25 @@ var worktree = new Hono3();
|
|
|
897
1014
|
var resetWorktreeSchema = z2.object({
|
|
898
1015
|
baseBranch: z2.string().default("main"),
|
|
899
1016
|
trialBranch: z2.string().optional(),
|
|
900
|
-
force: z2.boolean().default(false)
|
|
1017
|
+
force: z2.boolean().default(false),
|
|
1018
|
+
githubToken: z2.string().optional(),
|
|
1019
|
+
// Required for private repos
|
|
1020
|
+
repoUrl: z2.string().url().optional()
|
|
1021
|
+
// Required if repo not yet initialized
|
|
901
1022
|
});
|
|
902
1023
|
var commitSchema = z2.object({
|
|
903
1024
|
message: z2.string().min(1),
|
|
904
1025
|
author: z2.object({
|
|
905
1026
|
name: z2.string(),
|
|
906
1027
|
email: z2.string().email()
|
|
907
|
-
}).optional()
|
|
1028
|
+
}).optional(),
|
|
1029
|
+
trialBranch: z2.string().optional()
|
|
908
1030
|
});
|
|
909
1031
|
var pushSchema = z2.object({
|
|
910
1032
|
remote: z2.string().default("origin"),
|
|
911
|
-
force: z2.boolean().default(false)
|
|
1033
|
+
force: z2.boolean().default(false),
|
|
1034
|
+
githubToken: z2.string().min(1, "GitHub token is required for push operations"),
|
|
1035
|
+
trialBranch: z2.string().optional()
|
|
912
1036
|
});
|
|
913
1037
|
worktree.post("/trials/:trialId/worktree/reset", async (c) => {
|
|
914
1038
|
const trialId = c.req.param("trialId");
|
|
@@ -926,12 +1050,15 @@ worktree.post("/trials/:trialId/worktree/reset", async (c) => {
|
|
|
926
1050
|
log.info("Creating/resetting worktree", {
|
|
927
1051
|
baseBranch: input.baseBranch,
|
|
928
1052
|
trialBranch: input.trialBranch,
|
|
929
|
-
force: input.force
|
|
1053
|
+
force: input.force,
|
|
1054
|
+
hasToken: !!input.githubToken
|
|
930
1055
|
});
|
|
931
1056
|
const result = await worktreeService.resetWorktree(trialId, {
|
|
932
1057
|
baseBranch: input.baseBranch,
|
|
933
1058
|
trialBranch: input.trialBranch,
|
|
934
|
-
force: input.force
|
|
1059
|
+
force: input.force,
|
|
1060
|
+
githubToken: input.githubToken,
|
|
1061
|
+
repoUrl: input.repoUrl
|
|
935
1062
|
});
|
|
936
1063
|
return c.json({ success: true, data: result });
|
|
937
1064
|
} catch (err) {
|
|
@@ -942,10 +1069,11 @@ worktree.post("/trials/:trialId/worktree/reset", async (c) => {
|
|
|
942
1069
|
});
|
|
943
1070
|
worktree.delete("/trials/:trialId/worktree", async (c) => {
|
|
944
1071
|
const trialId = c.req.param("trialId");
|
|
945
|
-
const
|
|
1072
|
+
const trialBranch = c.req.query("trialBranch");
|
|
1073
|
+
const log = logger.child({ route: "worktree/delete", trialId, trialBranch });
|
|
946
1074
|
try {
|
|
947
1075
|
log.info("Deleting worktree");
|
|
948
|
-
await worktreeService.deleteWorktree(trialId);
|
|
1076
|
+
await worktreeService.deleteWorktree(trialId, trialBranch);
|
|
949
1077
|
return c.json({ success: true, data: { deleted: true } });
|
|
950
1078
|
} catch (err) {
|
|
951
1079
|
const error = err;
|
|
@@ -955,9 +1083,10 @@ worktree.delete("/trials/:trialId/worktree", async (c) => {
|
|
|
955
1083
|
});
|
|
956
1084
|
worktree.get("/trials/:trialId/worktree/status", async (c) => {
|
|
957
1085
|
const trialId = c.req.param("trialId");
|
|
958
|
-
const
|
|
1086
|
+
const trialBranch = c.req.query("trialBranch");
|
|
1087
|
+
const log = logger.child({ route: "worktree/status", trialId, trialBranch });
|
|
959
1088
|
try {
|
|
960
|
-
const status = await worktreeService.getWorktreeStatus(trialId);
|
|
1089
|
+
const status = await worktreeService.getWorktreeStatus(trialId, trialBranch);
|
|
961
1090
|
return c.json({ success: true, data: status });
|
|
962
1091
|
} catch (err) {
|
|
963
1092
|
const error = err;
|
|
@@ -967,9 +1096,10 @@ worktree.get("/trials/:trialId/worktree/status", async (c) => {
|
|
|
967
1096
|
});
|
|
968
1097
|
worktree.get("/trials/:trialId/worktree/diff", async (c) => {
|
|
969
1098
|
const trialId = c.req.param("trialId");
|
|
970
|
-
const
|
|
1099
|
+
const trialBranch = c.req.query("trialBranch");
|
|
1100
|
+
const log = logger.child({ route: "worktree/diff", trialId, trialBranch });
|
|
971
1101
|
try {
|
|
972
|
-
const diff = await worktreeService.getWorktreeDiff(trialId);
|
|
1102
|
+
const diff = await worktreeService.getWorktreeDiff(trialId, trialBranch);
|
|
973
1103
|
return c.json({ success: true, data: diff });
|
|
974
1104
|
} catch (err) {
|
|
975
1105
|
const error = err;
|
|
@@ -989,11 +1119,15 @@ worktree.post("/trials/:trialId/worktree/commit", async (c) => {
|
|
|
989
1119
|
400
|
|
990
1120
|
);
|
|
991
1121
|
}
|
|
992
|
-
log.info("Committing changes", {
|
|
1122
|
+
log.info("Committing changes", {
|
|
1123
|
+
message: parsed.data.message.slice(0, 50),
|
|
1124
|
+
trialBranch: parsed.data.trialBranch
|
|
1125
|
+
});
|
|
993
1126
|
const result = await worktreeService.commitChanges(
|
|
994
1127
|
trialId,
|
|
995
1128
|
parsed.data.message,
|
|
996
|
-
parsed.data.author
|
|
1129
|
+
parsed.data.author,
|
|
1130
|
+
parsed.data.trialBranch
|
|
997
1131
|
);
|
|
998
1132
|
return c.json({ success: true, data: result });
|
|
999
1133
|
} catch (err) {
|
|
@@ -1014,11 +1148,17 @@ worktree.post("/trials/:trialId/worktree/push", async (c) => {
|
|
|
1014
1148
|
400
|
|
1015
1149
|
);
|
|
1016
1150
|
}
|
|
1017
|
-
log.info("Pushing to remote", {
|
|
1151
|
+
log.info("Pushing to remote", {
|
|
1152
|
+
remote: parsed.data.remote,
|
|
1153
|
+
force: parsed.data.force,
|
|
1154
|
+
trialBranch: parsed.data.trialBranch
|
|
1155
|
+
});
|
|
1018
1156
|
const result = await worktreeService.pushBranch(
|
|
1019
1157
|
trialId,
|
|
1020
1158
|
parsed.data.remote,
|
|
1021
|
-
parsed.data.force
|
|
1159
|
+
parsed.data.force,
|
|
1160
|
+
parsed.data.trialBranch,
|
|
1161
|
+
parsed.data.githubToken
|
|
1022
1162
|
);
|
|
1023
1163
|
return c.json({ success: true, data: result });
|
|
1024
1164
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saeed42/worktree-worker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Git worktree management service for AI agent trials",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/main.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"
|
|
8
|
+
"worktree-worker": "./dist/main.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsup",
|