@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.
Files changed (3) hide show
  1. package/README.md +38 -34
  2. package/dist/main.js +331 -63
  3. 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 Node.js environment.
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 passed in the request body. This is critical for sandbox environments where credentials should not be stored.
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 | Description |
79
- |----------|-------------|
80
- | `GET /health` | Basic health check |
81
- | `GET /health/ready` | Readiness check |
82
- | `GET /health/live` | Liveness check |
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 | Description |
87
- |----------|-------------|
88
- | `POST /v1/repo/init` | Clone repository |
89
- | `POST /v1/repo/update` | Fetch/pull latest |
90
- | `GET /v1/repo/status` | Get 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 | Description |
96
- |----------|-------------|
97
- | `POST /v1/trials/:trialId/worktree/reset` | Create/reset worktree |
98
- | `DELETE /v1/trials/:trialId/worktree` | Delete worktree |
99
- | `GET /v1/trials/:trialId/worktree/status` | Get status |
100
- | `GET /v1/trials/:trialId/worktree/diff` | Get diff |
101
- | `POST /v1/trials/:trialId/worktree/commit` | Commit changes |
102
- | `POST /v1/trials/:trialId/worktree/push` | Push to remote |
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 | Default | Description |
107
- |----------|---------|-------------|
108
- | `PORT` | `8787` | HTTP server port |
109
- | `NODE_ENV` | `development` | Environment mode |
110
- | `WORKER_TOKEN` | `` | Auth token (empty = no auth) |
111
- | `BASE_WORKSPACE_DIR` | `/project/sandbox` | Main repo path |
112
- | `TRIALS_WORKSPACE_DIR` | `/project/workspaces/trials` | Worktrees path |
113
- | `DEFAULT_BRANCH` | `main` | Default base branch |
114
- | `GIT_TIMEOUT_MS` | `60000` | Git timeout |
115
- | `CLEANUP_AFTER_HOURS` | `24` | Stale worktree cleanup |
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 stored on disk**.
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 stored on disk**.
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
- return next();
116
+ await next();
117
+ return;
116
118
  }
117
119
  const authHeader = c.req.header("Authorization");
118
120
  if (!authHeader) {
119
- return c.json({ success: false, error: { code: "UNAUTHORIZED", message: "Missing Authorization header" } }, 401);
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({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid Authorization format" } }, 401);
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({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid token" } }, 401);
134
+ return c.json(
135
+ { success: false, error: { code: "UNAUTHORIZED", message: "Invalid token" } },
136
+ 401
137
+ );
127
138
  }
128
- return next();
139
+ await next();
129
140
  }
130
141
 
131
142
  // src/routes/health.routes.ts
132
143
  import { Hono } from "hono";
133
- import { stat, mkdir } from "fs/promises";
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
- async exec(args, cwd) {
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
- ...process.env,
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(remote, cwd || env.BASE_WORKSPACE_DIR, auth.githubToken);
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.execOrThrow(["rev-parse", "HEAD"], cwd);
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.execOrThrow(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
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(["show-ref", "--verify", "--quiet", `refs/remotes/${branch}`], cwd);
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(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd);
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(remote, cwd || env.BASE_WORKSPACE_DIR, auth.githubToken);
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(process.uptime())
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.getCurrentBranch(repoRoot).catch(() => null);
487
- const headSha = await gitService.getHeadSha(repoRoot).catch(() => null);
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 targetBranch = options.branch || env.DEFAULT_BRANCH;
616
+ const targetBranch2 = options.branch || env.DEFAULT_BRANCH;
538
617
  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);
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(["checkout", "-b", targetBranch, `origin/${targetBranch}`], repoRoot);
622
+ await gitService.exec(
623
+ ["checkout", "-b", targetBranch2, `origin/${targetBranch2}`],
624
+ repoRoot
625
+ );
544
626
  }
545
627
  }
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 };
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 all worktrees and repo");
637
+ log.warn("Force re-init: cleaning worktrees and updating remote in-place");
556
638
  await this.cleanAllWorktrees();
557
- await rm(repoRoot, { recursive: true, force: true });
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
- log.info("Cloning repository", { branch });
564
- await gitService.cloneRepo(options.repoUrl, repoRoot, {
565
- branch,
566
- blobless: true,
567
- githubToken: options.githubToken
568
- });
569
- await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
570
- await gitService.exec(["config", "--local", "user.email", "origin-agent[bot]@users.noreply.github.com"], repoRoot);
571
- await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
572
- const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
573
- await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
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
- });
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({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
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({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
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", { repoUrl: parsed.data.repoUrl, branch: parsed.data.branch });
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, rm as rm2, stat as stat3, readdir as readdir2 } from "fs/promises";
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
- const repoStatus = await repoService.getStatus();
767
- if (!repoStatus.initialized) {
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
- log.info("Main repo not initialized, auto-initializing", { repoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***") });
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.getHeadSha(worktreePath);
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.getCurrentBranch(baseRepoDir);
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(["config", "--local", "user.email", "origin-agent[bot]@users.noreply.github.com"], worktreePath).catch(() => {
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.getHeadSha(worktreePath);
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.getHeadSha(worktreePath);
918
- const branch = await gitService.getCurrentBranch(worktreePath);
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({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saeed42/worktree-worker",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Git worktree management service for AI agent trials",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -48,4 +48,3 @@
48
48
  "access": "public"
49
49
  }
50
50
  }
51
-