@saeed42/worktree-worker 1.1.0 → 1.3.1

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 +294 -48
  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,25 +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
  }
316
368
  /**
317
- * Check if branch exists
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
+ }
379
+ /**
380
+ * Check if branch exists (local or remote)
381
+ * For remote branches, pass "origin/branch-name"
318
382
  */
319
383
  async branchExists(branch, cwd) {
320
- const result = await this.exec(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd);
384
+ if (branch.includes("/")) {
385
+ const result2 = await this.exec(
386
+ ["show-ref", "--verify", "--quiet", `refs/remotes/${branch}`],
387
+ cwd
388
+ );
389
+ return result2.code === 0;
390
+ }
391
+ const result = await this.exec(
392
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
393
+ cwd
394
+ );
321
395
  return result.code === 0;
322
396
  }
323
397
  /**
@@ -357,7 +431,11 @@ var GitService = class {
357
431
  */
358
432
  async push(remote, branch, force = false, cwd, auth) {
359
433
  if (auth?.githubToken) {
360
- 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
+ );
361
439
  if (authUrl) {
362
440
  const args2 = ["push", authUrl, branch];
363
441
  if (force) args2.push("--force");
@@ -391,6 +469,7 @@ var GitService = class {
391
469
  var gitService = new GitService();
392
470
 
393
471
  // src/routes/health.routes.ts
472
+ import process3 from "process";
394
473
  var health = new Hono();
395
474
  health.get("/", (c) => {
396
475
  return c.json({
@@ -441,7 +520,7 @@ health.get("/live", (c) => {
441
520
  return c.json({
442
521
  status: "alive",
443
522
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
444
- uptime: Math.floor(process.uptime())
523
+ uptime: Math.floor(process3.uptime())
445
524
  });
446
525
  });
447
526
 
@@ -465,7 +544,8 @@ var RepoService = class {
465
544
  path: repoRoot,
466
545
  branch: null,
467
546
  remote: null,
468
- headSha: null
547
+ headSha: null,
548
+ isEmpty: false
469
549
  };
470
550
  }
471
551
  const gitDir = await stat2(`${repoRoot}/.git`).catch(() => null);
@@ -475,18 +555,21 @@ var RepoService = class {
475
555
  path: repoRoot,
476
556
  branch: null,
477
557
  remote: null,
478
- headSha: null
558
+ headSha: null,
559
+ isEmpty: false
479
560
  };
480
561
  }
481
- const branch = await gitService.getCurrentBranch(repoRoot).catch(() => null);
482
- const headSha = await gitService.getHeadSha(repoRoot).catch(() => null);
562
+ const branch = await gitService.getCurrentBranchSafe(repoRoot);
563
+ const headSha = await gitService.getHeadShaSafe(repoRoot);
483
564
  const remote = await gitService.getRemoteUrl("origin", repoRoot);
565
+ const isEmpty = await gitService.isEmptyRepo(repoRoot);
484
566
  return {
485
567
  initialized: true,
486
568
  path: repoRoot,
487
569
  branch,
488
570
  remote,
489
- headSha
571
+ headSha,
572
+ isEmpty
490
573
  };
491
574
  } catch {
492
575
  return {
@@ -494,14 +577,15 @@ var RepoService = class {
494
577
  path: repoRoot,
495
578
  branch: null,
496
579
  remote: null,
497
- headSha: null
580
+ headSha: null,
581
+ isEmpty: false
498
582
  };
499
583
  }
500
584
  }
501
585
  /**
502
586
  * Initialize/clone the repository
503
587
  * SECURITY: githubToken is used inline and NEVER persisted
504
- *
588
+ *
505
589
  * Smart init logic:
506
590
  * - No repo exists → Clone it
507
591
  * - Repo exists, same URL → Fetch latest (idempotent)
@@ -535,7 +619,10 @@ var RepoService = class {
535
619
  log.info("Switching to requested branch", { from: currentBranch, to: targetBranch });
536
620
  const checkoutResult = await gitService.exec(["checkout", targetBranch], repoRoot);
537
621
  if (checkoutResult.code !== 0) {
538
- await gitService.exec(["checkout", "-b", targetBranch, `origin/${targetBranch}`], repoRoot);
622
+ await gitService.exec(
623
+ ["checkout", "-b", targetBranch, `origin/${targetBranch}`],
624
+ repoRoot
625
+ );
539
626
  }
540
627
  }
541
628
  const headSha2 = await gitService.getHeadSha(repoRoot);
@@ -562,10 +649,33 @@ var RepoService = class {
562
649
  githubToken: options.githubToken
563
650
  });
564
651
  await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
565
- await gitService.exec(["config", "--local", "user.email", "origin-agent[bot]@users.noreply.github.com"], repoRoot);
652
+ await gitService.exec([
653
+ "config",
654
+ "--local",
655
+ "user.email",
656
+ "origin-agent[bot]@users.noreply.github.com"
657
+ ], repoRoot);
566
658
  await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
567
659
  const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
568
660
  await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
661
+ log.info("Fetching all remote refs");
662
+ await gitService.fetch("origin", repoRoot, auth);
663
+ await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(
664
+ () => {
665
+ }
666
+ );
667
+ const isEmpty = await gitService.isEmptyRepo(repoRoot);
668
+ if (isEmpty) {
669
+ log.warn("Repository is empty (no commits)", { repoUrl: options.repoUrl });
670
+ const remote2 = await gitService.getRemoteUrl("origin", repoRoot);
671
+ return {
672
+ path: repoRoot,
673
+ branch,
674
+ headSha: null,
675
+ // Empty repo has no HEAD
676
+ remote: remote2
677
+ };
678
+ }
569
679
  const headSha = await gitService.getHeadSha(repoRoot);
570
680
  const remote = await gitService.getRemoteUrl("origin", repoRoot);
571
681
  log.info("Repository initialized", { branch, headSha });
@@ -654,7 +764,10 @@ repo.get("/status", async (c) => {
654
764
  } catch (err) {
655
765
  const error = err;
656
766
  logger.error("Failed to get repo status", { error: error.message });
657
- return c.json({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
767
+ return c.json(
768
+ { success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
769
+ 500
770
+ );
658
771
  }
659
772
  });
660
773
  repo.get("/branches", async (c) => {
@@ -664,7 +777,10 @@ repo.get("/branches", async (c) => {
664
777
  } catch (err) {
665
778
  const error = err;
666
779
  logger.error("Failed to list branches", { error: error.message });
667
- return c.json({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
780
+ return c.json(
781
+ { success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
782
+ 500
783
+ );
668
784
  }
669
785
  });
670
786
  repo.post("/init", async (c) => {
@@ -678,7 +794,10 @@ repo.post("/init", async (c) => {
678
794
  400
679
795
  );
680
796
  }
681
- log.info("Initializing repository", { repoUrl: parsed.data.repoUrl, branch: parsed.data.branch });
797
+ log.info("Initializing repository", {
798
+ repoUrl: parsed.data.repoUrl,
799
+ branch: parsed.data.branch
800
+ });
682
801
  const result = await repoService.initRepo(parsed.data);
683
802
  return c.json({ success: true, data: result });
684
803
  } catch (err) {
@@ -713,7 +832,7 @@ import { Hono as Hono3 } from "hono";
713
832
  import { z as z2 } from "zod";
714
833
 
715
834
  // src/services/worktree.service.ts
716
- import { mkdir as mkdir3, rm as rm2, stat as stat3, readdir as readdir2 } from "fs/promises";
835
+ import { mkdir as mkdir3, readdir as readdir2, rm as rm2, stat as stat3 } from "fs/promises";
717
836
  var WorktreeService = class {
718
837
  /**
719
838
  * Get the isolated workspace directory for a specific trial.
@@ -750,9 +869,47 @@ var WorktreeService = class {
750
869
  branchName,
751
870
  baseBranch,
752
871
  baseRepoDir,
753
- hasToken: !!options.githubToken
872
+ hasToken: !!options.githubToken,
873
+ hasRepoUrl: !!options.repoUrl
754
874
  });
755
875
  await mkdir3(env.TRIALS_WORKSPACE_DIR, { recursive: true });
876
+ let repoStatus = await repoService.getStatus();
877
+ const isValidGitHubRemote = (remote) => {
878
+ if (!remote) return false;
879
+ return remote.includes("github.com") || remote.startsWith("https://");
880
+ };
881
+ const needsInit = !repoStatus.initialized;
882
+ const hasInvalidRemote = repoStatus.initialized && !isValidGitHubRemote(repoStatus.remote);
883
+ if (needsInit || hasInvalidRemote) {
884
+ if (!options.repoUrl) {
885
+ throw new Error(
886
+ "Main repository not initialized or has invalid remote. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
887
+ );
888
+ }
889
+ if (hasInvalidRemote) {
890
+ log.warn("Repo has invalid remote (likely CodeSandbox template), re-initializing", {
891
+ currentRemote: repoStatus.remote,
892
+ newRepoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***")
893
+ });
894
+ } else {
895
+ log.info("Main repo not initialized, auto-initializing", {
896
+ repoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***")
897
+ });
898
+ }
899
+ await repoService.initRepo({
900
+ repoUrl: options.repoUrl,
901
+ branch: baseBranch,
902
+ githubToken: options.githubToken,
903
+ force: hasInvalidRemote
904
+ // Force re-init if remote is invalid
905
+ });
906
+ repoStatus = await repoService.getStatus();
907
+ }
908
+ if (repoStatus.isEmpty) {
909
+ throw new Error(
910
+ "Cannot create worktree: repository is empty (no commits). Please create an initial commit in the repository first."
911
+ );
912
+ }
756
913
  try {
757
914
  const stats = await stat3(worktreePath);
758
915
  if (stats.isDirectory()) {
@@ -761,7 +918,7 @@ var WorktreeService = class {
761
918
  log.info("Worktree already exists, fetching latest");
762
919
  await gitService.fetch("origin", worktreePath, auth).catch(() => {
763
920
  });
764
- const headSha2 = await gitService.getHeadSha(worktreePath);
921
+ const headSha2 = await gitService.getHeadShaSafe(worktreePath);
765
922
  return { worktreePath, branch: branchName, headSha: headSha2 };
766
923
  }
767
924
  }
@@ -792,17 +949,54 @@ var WorktreeService = class {
792
949
  await gitService.exec(["fetch", "origin", `${branchName}:${branchName}`], baseRepoDir);
793
950
  await gitService.execOrThrow(["worktree", "add", worktreePath, branchName], baseRepoDir);
794
951
  } else {
795
- const baseRef = `origin/${baseBranch}`;
952
+ let baseRef = `origin/${baseBranch}`;
953
+ const baseBranchExistsOnRemote = await gitService.branchExists(baseRef, baseRepoDir);
954
+ if (!baseBranchExistsOnRemote) {
955
+ log.warn("Base branch not found, detecting default branch", { tried: baseRef });
956
+ const fallbackBranches = ["origin/main", "origin/master", "origin/dev", "origin/develop"];
957
+ let found = false;
958
+ for (const fallback of fallbackBranches) {
959
+ const exists = await gitService.branchExists(fallback, baseRepoDir);
960
+ if (exists) {
961
+ baseRef = fallback;
962
+ found = true;
963
+ log.info("Found fallback base branch", { baseRef });
964
+ break;
965
+ }
966
+ }
967
+ if (!found) {
968
+ const currentBranch = await gitService.getCurrentBranchSafe(baseRepoDir);
969
+ if (currentBranch && currentBranch !== "HEAD") {
970
+ baseRef = currentBranch;
971
+ log.info("Using current branch as base", { baseRef });
972
+ } else {
973
+ const isEmpty = await gitService.isEmptyRepo(baseRepoDir);
974
+ if (isEmpty) {
975
+ throw new Error(
976
+ `Cannot create worktree: repository is empty (no commits). Please create an initial commit in the repository first.`
977
+ );
978
+ }
979
+ throw new Error(
980
+ `Cannot find base branch. Tried: origin/${baseBranch}, ${fallbackBranches.join(", ")}. Ensure the repository has been fetched properly.`
981
+ );
982
+ }
983
+ }
984
+ }
796
985
  log.info("Creating worktree with new branch from base", { baseRef, branchName });
797
986
  await gitService.addWorktreeWithNewBranch(worktreePath, branchName, baseRef, baseRepoDir);
798
987
  }
799
988
  await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], worktreePath).catch(() => {
800
989
  });
801
- await gitService.exec(["config", "--local", "user.email", "origin-agent[bot]@users.noreply.github.com"], worktreePath).catch(() => {
990
+ await gitService.exec([
991
+ "config",
992
+ "--local",
993
+ "user.email",
994
+ "origin-agent[bot]@users.noreply.github.com"
995
+ ], worktreePath).catch(() => {
802
996
  });
803
997
  await gitService.exec(["config", "--local", "safe.directory", worktreePath], worktreePath).catch(() => {
804
998
  });
805
- const headSha = await gitService.getHeadSha(worktreePath);
999
+ const headSha = await gitService.getHeadShaSafe(worktreePath);
806
1000
  log.info("Worktree created successfully", { headSha });
807
1001
  return { worktreePath, branch: branchName, headSha };
808
1002
  }
@@ -864,8 +1058,8 @@ var WorktreeService = class {
864
1058
  changedFiles: 0
865
1059
  };
866
1060
  }
867
- const headSha = await gitService.getHeadSha(worktreePath);
868
- const branch = await gitService.getCurrentBranch(worktreePath);
1061
+ const headSha = await gitService.getHeadShaSafe(worktreePath);
1062
+ const branch = await gitService.getCurrentBranchSafe(worktreePath);
869
1063
  const status = await gitService.getStatus(worktreePath);
870
1064
  return {
871
1065
  exists: true,
@@ -924,28 +1118,59 @@ var WorktreeService = class {
924
1118
  return { branch, pushed: true };
925
1119
  }
926
1120
  /**
927
- * Cleanup stale worktrees
1121
+ * Cleanup stale worktrees based on age and optionally disk pressure
1122
+ *
1123
+ * Strategy:
1124
+ * 1. Always clean worktrees older than CLEANUP_AFTER_HOURS (default 24h)
1125
+ * 2. If disk usage is high (>80%), also clean worktrees older than 6h
1126
+ * 3. If disk usage is critical (>90%), clean all worktrees older than 1h
928
1127
  */
929
1128
  async cleanupStaleWorktrees() {
930
1129
  const log = logger.child({ service: "worktree", action: "cleanup" });
931
1130
  let cleaned = 0;
932
1131
  const errors = [];
933
- const cutoffTime = Date.now() - env.CLEANUP_AFTER_HOURS * 60 * 60 * 1e3;
1132
+ let diskUsagePercent;
1133
+ let cutoffHours = env.CLEANUP_AFTER_HOURS;
1134
+ try {
1135
+ const diskInfo = await this.getDiskUsage();
1136
+ diskUsagePercent = diskInfo.usedPercent;
1137
+ if (diskUsagePercent > 90) {
1138
+ cutoffHours = 1;
1139
+ log.warn("Critical disk usage, aggressive cleanup", { diskUsagePercent, cutoffHours });
1140
+ } else if (diskUsagePercent > 80) {
1141
+ cutoffHours = 6;
1142
+ log.info("High disk usage, moderate cleanup", { diskUsagePercent, cutoffHours });
1143
+ }
1144
+ } catch (err) {
1145
+ log.warn("Could not check disk usage", { error: err instanceof Error ? err.message : String(err) });
1146
+ }
1147
+ const cutoffTime = Date.now() - cutoffHours * 60 * 60 * 1e3;
934
1148
  try {
935
1149
  const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
1150
+ const worktreesWithStats = [];
936
1151
  for (const entry of entries) {
937
1152
  if (!entry.isDirectory()) continue;
938
1153
  const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
939
1154
  try {
940
1155
  const stats = await stat3(worktreePath);
941
- if (stats.mtimeMs < cutoffTime) {
942
- log.info("Cleaning up stale worktree", { path: worktreePath });
943
- await rm2(worktreePath, { recursive: true, force: true });
1156
+ worktreesWithStats.push({ path: worktreePath, mtime: stats.mtimeMs });
1157
+ } catch {
1158
+ }
1159
+ }
1160
+ worktreesWithStats.sort((a, b) => a.mtime - b.mtime);
1161
+ for (const wt of worktreesWithStats) {
1162
+ if (wt.mtime < cutoffTime) {
1163
+ try {
1164
+ log.info("Cleaning up stale worktree", {
1165
+ path: wt.path,
1166
+ ageHours: Math.round((Date.now() - wt.mtime) / (60 * 60 * 1e3))
1167
+ });
1168
+ await rm2(wt.path, { recursive: true, force: true });
944
1169
  cleaned++;
1170
+ } catch (err) {
1171
+ const errMsg = err instanceof Error ? err.message : String(err);
1172
+ errors.push(`${wt.path}: ${errMsg}`);
945
1173
  }
946
- } catch (err) {
947
- const errMsg = err instanceof Error ? err.message : String(err);
948
- errors.push(`${worktreePath}: ${errMsg}`);
949
1174
  }
950
1175
  }
951
1176
  await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR);
@@ -953,8 +1178,23 @@ var WorktreeService = class {
953
1178
  const errMsg = err instanceof Error ? err.message : String(err);
954
1179
  errors.push(`readdir: ${errMsg}`);
955
1180
  }
956
- log.info("Cleanup completed", { cleaned, errorCount: errors.length });
957
- return { cleaned, errors };
1181
+ log.info("Cleanup completed", { cleaned, errorCount: errors.length, diskUsagePercent });
1182
+ return { cleaned, errors, diskUsagePercent };
1183
+ }
1184
+ /**
1185
+ * Get disk usage information for the workspace partition
1186
+ */
1187
+ async getDiskUsage() {
1188
+ const { exec } = await import("child_process");
1189
+ const { promisify } = await import("util");
1190
+ const execAsync = promisify(exec);
1191
+ const { stdout } = await execAsync(`df -B1 ${env.BASE_WORKSPACE_DIR} | tail -1`);
1192
+ const parts = stdout.trim().split(/\s+/);
1193
+ const total = parseInt(parts[1], 10);
1194
+ const used = parseInt(parts[2], 10);
1195
+ const free = parseInt(parts[3], 10);
1196
+ const usedPercent = parseInt(parts[4].replace("%", ""), 10);
1197
+ return { total, used, free, usedPercent };
958
1198
  }
959
1199
  };
960
1200
  var worktreeService = new WorktreeService();
@@ -965,8 +1205,10 @@ var resetWorktreeSchema = z2.object({
965
1205
  baseBranch: z2.string().default("main"),
966
1206
  trialBranch: z2.string().optional(),
967
1207
  force: z2.boolean().default(false),
968
- githubToken: z2.string().optional()
1208
+ githubToken: z2.string().optional(),
969
1209
  // Required for private repos
1210
+ repoUrl: z2.string().url().optional()
1211
+ // Required if repo not yet initialized
970
1212
  });
971
1213
  var commitSchema = z2.object({
972
1214
  message: z2.string().min(1),
@@ -1005,7 +1247,8 @@ worktree.post("/trials/:trialId/worktree/reset", async (c) => {
1005
1247
  baseBranch: input.baseBranch,
1006
1248
  trialBranch: input.trialBranch,
1007
1249
  force: input.force,
1008
- githubToken: input.githubToken
1250
+ githubToken: input.githubToken,
1251
+ repoUrl: input.repoUrl
1009
1252
  });
1010
1253
  return c.json({ success: true, data: result });
1011
1254
  } catch (err) {
@@ -1123,7 +1366,10 @@ worktree.post("/worktrees/cleanup", async (c) => {
1123
1366
  } catch (err) {
1124
1367
  const error = err;
1125
1368
  log.error("Failed to cleanup worktrees", { error: error.message });
1126
- return c.json({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
1369
+ return c.json(
1370
+ { success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
1371
+ 500
1372
+ );
1127
1373
  }
1128
1374
  });
1129
1375
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saeed42/worktree-worker",
3
- "version": "1.1.0",
3
+ "version": "1.3.1",
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
-