@saeed42/worktree-worker 1.3.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 +245 -52
  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)
@@ -540,7 +619,10 @@ var RepoService = class {
540
619
  log.info("Switching to requested branch", { from: currentBranch, to: targetBranch });
541
620
  const checkoutResult = await gitService.exec(["checkout", targetBranch], repoRoot);
542
621
  if (checkoutResult.code !== 0) {
543
- await gitService.exec(["checkout", "-b", targetBranch, `origin/${targetBranch}`], repoRoot);
622
+ await gitService.exec(
623
+ ["checkout", "-b", targetBranch, `origin/${targetBranch}`],
624
+ repoRoot
625
+ );
544
626
  }
545
627
  }
546
628
  const headSha2 = await gitService.getHeadSha(repoRoot);
@@ -567,14 +649,33 @@ var RepoService = class {
567
649
  githubToken: options.githubToken
568
650
  });
569
651
  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);
652
+ await gitService.exec([
653
+ "config",
654
+ "--local",
655
+ "user.email",
656
+ "origin-agent[bot]@users.noreply.github.com"
657
+ ], repoRoot);
571
658
  await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
572
659
  const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
573
660
  await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
574
661
  log.info("Fetching all remote refs");
575
662
  await gitService.fetch("origin", repoRoot, auth);
576
- await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(() => {
577
- });
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
+ }
578
679
  const headSha = await gitService.getHeadSha(repoRoot);
579
680
  const remote = await gitService.getRemoteUrl("origin", repoRoot);
580
681
  log.info("Repository initialized", { branch, headSha });
@@ -663,7 +764,10 @@ repo.get("/status", async (c) => {
663
764
  } catch (err) {
664
765
  const error = err;
665
766
  logger.error("Failed to get repo status", { error: error.message });
666
- 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
+ );
667
771
  }
668
772
  });
669
773
  repo.get("/branches", async (c) => {
@@ -673,7 +777,10 @@ repo.get("/branches", async (c) => {
673
777
  } catch (err) {
674
778
  const error = err;
675
779
  logger.error("Failed to list branches", { error: error.message });
676
- 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
+ );
677
784
  }
678
785
  });
679
786
  repo.post("/init", async (c) => {
@@ -687,7 +794,10 @@ repo.post("/init", async (c) => {
687
794
  400
688
795
  );
689
796
  }
690
- 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
+ });
691
801
  const result = await repoService.initRepo(parsed.data);
692
802
  return c.json({ success: true, data: result });
693
803
  } catch (err) {
@@ -722,7 +832,7 @@ import { Hono as Hono3 } from "hono";
722
832
  import { z as z2 } from "zod";
723
833
 
724
834
  // src/services/worktree.service.ts
725
- 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";
726
836
  var WorktreeService = class {
727
837
  /**
728
838
  * Get the isolated workspace directory for a specific trial.
@@ -763,19 +873,42 @@ var WorktreeService = class {
763
873
  hasRepoUrl: !!options.repoUrl
764
874
  });
765
875
  await mkdir3(env.TRIALS_WORKSPACE_DIR, { recursive: true });
766
- const repoStatus = await repoService.getStatus();
767
- if (!repoStatus.initialized) {
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) {
768
884
  if (!options.repoUrl) {
769
885
  throw new Error(
770
- "Main repository not initialized. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
886
+ "Main repository not initialized or has invalid remote. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
771
887
  );
772
888
  }
773
- log.info("Main repo not initialized, auto-initializing", { repoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***") });
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
+ }
774
899
  await repoService.initRepo({
775
900
  repoUrl: options.repoUrl,
776
901
  branch: baseBranch,
777
- githubToken: options.githubToken
902
+ githubToken: options.githubToken,
903
+ force: hasInvalidRemote
904
+ // Force re-init if remote is invalid
778
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
+ );
779
912
  }
780
913
  try {
781
914
  const stats = await stat3(worktreePath);
@@ -785,7 +918,7 @@ var WorktreeService = class {
785
918
  log.info("Worktree already exists, fetching latest");
786
919
  await gitService.fetch("origin", worktreePath, auth).catch(() => {
787
920
  });
788
- const headSha2 = await gitService.getHeadSha(worktreePath);
921
+ const headSha2 = await gitService.getHeadShaSafe(worktreePath);
789
922
  return { worktreePath, branch: branchName, headSha: headSha2 };
790
923
  }
791
924
  }
@@ -832,11 +965,17 @@ var WorktreeService = class {
832
965
  }
833
966
  }
834
967
  if (!found) {
835
- const currentBranch = await gitService.getCurrentBranch(baseRepoDir);
968
+ const currentBranch = await gitService.getCurrentBranchSafe(baseRepoDir);
836
969
  if (currentBranch && currentBranch !== "HEAD") {
837
970
  baseRef = currentBranch;
838
971
  log.info("Using current branch as base", { baseRef });
839
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
+ }
840
979
  throw new Error(
841
980
  `Cannot find base branch. Tried: origin/${baseBranch}, ${fallbackBranches.join(", ")}. Ensure the repository has been fetched properly.`
842
981
  );
@@ -848,11 +987,16 @@ var WorktreeService = class {
848
987
  }
849
988
  await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], worktreePath).catch(() => {
850
989
  });
851
- 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(() => {
852
996
  });
853
997
  await gitService.exec(["config", "--local", "safe.directory", worktreePath], worktreePath).catch(() => {
854
998
  });
855
- const headSha = await gitService.getHeadSha(worktreePath);
999
+ const headSha = await gitService.getHeadShaSafe(worktreePath);
856
1000
  log.info("Worktree created successfully", { headSha });
857
1001
  return { worktreePath, branch: branchName, headSha };
858
1002
  }
@@ -914,8 +1058,8 @@ var WorktreeService = class {
914
1058
  changedFiles: 0
915
1059
  };
916
1060
  }
917
- const headSha = await gitService.getHeadSha(worktreePath);
918
- const branch = await gitService.getCurrentBranch(worktreePath);
1061
+ const headSha = await gitService.getHeadShaSafe(worktreePath);
1062
+ const branch = await gitService.getCurrentBranchSafe(worktreePath);
919
1063
  const status = await gitService.getStatus(worktreePath);
920
1064
  return {
921
1065
  exists: true,
@@ -974,28 +1118,59 @@ var WorktreeService = class {
974
1118
  return { branch, pushed: true };
975
1119
  }
976
1120
  /**
977
- * 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
978
1127
  */
979
1128
  async cleanupStaleWorktrees() {
980
1129
  const log = logger.child({ service: "worktree", action: "cleanup" });
981
1130
  let cleaned = 0;
982
1131
  const errors = [];
983
- 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;
984
1148
  try {
985
1149
  const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
1150
+ const worktreesWithStats = [];
986
1151
  for (const entry of entries) {
987
1152
  if (!entry.isDirectory()) continue;
988
1153
  const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
989
1154
  try {
990
1155
  const stats = await stat3(worktreePath);
991
- if (stats.mtimeMs < cutoffTime) {
992
- log.info("Cleaning up stale worktree", { path: worktreePath });
993
- 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 });
994
1169
  cleaned++;
1170
+ } catch (err) {
1171
+ const errMsg = err instanceof Error ? err.message : String(err);
1172
+ errors.push(`${wt.path}: ${errMsg}`);
995
1173
  }
996
- } catch (err) {
997
- const errMsg = err instanceof Error ? err.message : String(err);
998
- errors.push(`${worktreePath}: ${errMsg}`);
999
1174
  }
1000
1175
  }
1001
1176
  await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR);
@@ -1003,8 +1178,23 @@ var WorktreeService = class {
1003
1178
  const errMsg = err instanceof Error ? err.message : String(err);
1004
1179
  errors.push(`readdir: ${errMsg}`);
1005
1180
  }
1006
- log.info("Cleanup completed", { cleaned, errorCount: errors.length });
1007
- 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 };
1008
1198
  }
1009
1199
  };
1010
1200
  var worktreeService = new WorktreeService();
@@ -1176,7 +1366,10 @@ worktree.post("/worktrees/cleanup", async (c) => {
1176
1366
  } catch (err) {
1177
1367
  const error = err;
1178
1368
  log.error("Failed to cleanup worktrees", { error: error.message });
1179
- 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
+ );
1180
1373
  }
1181
1374
  });
1182
1375
 
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.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
-