git-workspace-service 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,7 @@ Git workspace provisioning and credential management service. Handles cloning re
5
5
  ## Features
6
6
 
7
7
  - **Workspace provisioning** - Clone repos, create branches, configure git
8
+ - **Git Worktrees** - Fast parallel workspaces with shared .git directory
8
9
  - **Credential management** - Secure credential handling with TTL and revocation
9
10
  - **Multiple providers** - Support for GitHub, GitLab, Bitbucket, Azure DevOps
10
11
  - **GitHub App support** - First-class GitHub App authentication
@@ -213,6 +214,73 @@ const permissions = {
213
214
  };
214
215
  ```
215
216
 
217
+ ## Git Worktrees
218
+
219
+ For parallel work on the same repository, use worktrees instead of clones. Worktrees share the `.git` directory, making them faster to create and using less disk space.
220
+
221
+ ```
222
+ ┌──────────────────────────┬────────────────────────────────────────┐
223
+ │ Clone │ Worktree │
224
+ ├──────────────────────────┼────────────────────────────────────────┤
225
+ │ Full .git copy each time │ Shared .git directory │
226
+ │ Slower for same repo │ Fast - just checkout │
227
+ │ More disk space │ Minimal disk │
228
+ │ Good for different repos │ Perfect for parallel work on SAME repo │
229
+ └──────────────────────────┴────────────────────────────────────────┘
230
+ ```
231
+
232
+ ### Creating a Worktree
233
+
234
+ ```typescript
235
+ // First, create a clone workspace (the parent)
236
+ const parent = await workspaceService.provision({
237
+ repo: 'https://github.com/owner/repo',
238
+ strategy: 'clone', // explicit, but this is the default
239
+ branchStrategy: 'feature_branch',
240
+ baseBranch: 'main',
241
+ execution: { id: 'exec-123', patternName: 'review' },
242
+ task: { id: 'task-1', role: 'architect' },
243
+ });
244
+
245
+ // Then create worktrees from it for parallel agents
246
+ const reviewerWorkspace = await workspaceService.provision({
247
+ repo: 'https://github.com/owner/repo',
248
+ strategy: 'worktree',
249
+ parentWorkspace: parent.id, // Required for worktrees
250
+ branchStrategy: 'feature_branch',
251
+ baseBranch: 'main',
252
+ execution: { id: 'exec-123', patternName: 'review' },
253
+ task: { id: 'task-2', role: 'reviewer' },
254
+ });
255
+
256
+ // Or use the convenience method
257
+ const testerWorkspace = await workspaceService.addWorktree(parent.id, {
258
+ branch: 'main',
259
+ execution: { id: 'exec-123', patternName: 'review' },
260
+ task: { id: 'task-3', role: 'tester' },
261
+ });
262
+ ```
263
+
264
+ ### Managing Worktrees
265
+
266
+ ```typescript
267
+ // List all worktrees for a parent
268
+ const worktrees = workspaceService.listWorktrees(parent.id);
269
+
270
+ // Remove a specific worktree
271
+ await workspaceService.removeWorktree(reviewerWorkspace.id);
272
+
273
+ // Cleanup parent also cleans up all its worktrees
274
+ await workspaceService.cleanup(parent.id);
275
+ ```
276
+
277
+ ### Worktree Benefits for Multi-Agent Systems
278
+
279
+ - **Shared credentials**: Worktrees reuse the parent's credential
280
+ - **Faster provisioning**: No network clone, just local checkout
281
+ - **Less disk space**: Single .git directory shared across all worktrees
282
+ - **Isolated branches**: Each worktree can work on a different branch
283
+
216
284
  ## Event System
217
285
 
218
286
  Subscribe to workspace lifecycle events:
@@ -406,6 +474,8 @@ class FileTokenStore extends TokenStore {
406
474
  | `workspace:error` | Workspace provisioning failed |
407
475
  | `workspace:finalizing` | Workspace finalization started |
408
476
  | `workspace:cleaned_up` | Workspace has been cleaned up |
477
+ | `worktree:added` | Git worktree added to parent |
478
+ | `worktree:removed` | Git worktree removed |
409
479
  | `credential:granted` | Credential was granted |
410
480
  | `credential:revoked` | Credential was revoked |
411
481
  | `pr:created` | Pull request was created |
package/dist/index.cjs CHANGED
@@ -283,6 +283,22 @@ var WorkspaceService = class {
283
283
  * Provision a new workspace for a task
284
284
  */
285
285
  async provision(config) {
286
+ const strategy = config.strategy || "clone";
287
+ if (strategy === "worktree") {
288
+ if (!config.parentWorkspace) {
289
+ throw new Error('parentWorkspace is required when strategy is "worktree"');
290
+ }
291
+ const parent = this.workspaces.get(config.parentWorkspace);
292
+ if (!parent) {
293
+ throw new Error(`Parent workspace not found: ${config.parentWorkspace}`);
294
+ }
295
+ if (parent.strategy !== "clone") {
296
+ throw new Error("Parent workspace must be a clone, not a worktree");
297
+ }
298
+ if (parent.repo !== config.repo) {
299
+ throw new Error("Worktree must be for the same repository as parent");
300
+ }
301
+ }
286
302
  const workspaceId = crypto.randomUUID();
287
303
  this.log(
288
304
  "info",
@@ -290,7 +306,8 @@ var WorkspaceService = class {
290
306
  workspaceId,
291
307
  repo: config.repo,
292
308
  executionId: config.execution.id,
293
- role: config.task.role
309
+ role: config.task.role,
310
+ strategy
294
311
  },
295
312
  "Provisioning workspace"
296
313
  );
@@ -301,26 +318,31 @@ var WorkspaceService = class {
301
318
  timestamp: /* @__PURE__ */ new Date()
302
319
  });
303
320
  const workspacePath = path__namespace.join(this.baseDir, workspaceId);
304
- await fs3__namespace.mkdir(workspacePath, { recursive: true });
305
- const credential = await this.credentialService.getCredentials({
306
- repo: config.repo,
307
- access: "write",
308
- context: {
321
+ let credential;
322
+ if (strategy === "worktree" && config.parentWorkspace) {
323
+ const parent = this.workspaces.get(config.parentWorkspace);
324
+ credential = parent.credential;
325
+ } else {
326
+ await fs3__namespace.mkdir(workspacePath, { recursive: true });
327
+ credential = await this.credentialService.getCredentials({
328
+ repo: config.repo,
329
+ access: "write",
330
+ context: {
331
+ executionId: config.execution.id,
332
+ taskId: config.task.id,
333
+ userId: config.user?.id,
334
+ reason: `Workspace for ${config.task.role} in ${config.execution.patternName}`
335
+ },
336
+ userProvided: config.userCredentials
337
+ });
338
+ await this.emitEvent({
339
+ type: "credential:granted",
340
+ workspaceId,
341
+ credentialId: credential.id,
309
342
  executionId: config.execution.id,
310
- taskId: config.task.id,
311
- userId: config.user?.id,
312
- reason: `Workspace for ${config.task.role} in ${config.execution.patternName}`
313
- },
314
- // Pass user-provided credentials if available
315
- userProvided: config.userCredentials
316
- });
317
- await this.emitEvent({
318
- type: "credential:granted",
319
- workspaceId,
320
- credentialId: credential.id,
321
- executionId: config.execution.id,
322
- timestamp: /* @__PURE__ */ new Date()
323
- });
343
+ timestamp: /* @__PURE__ */ new Date()
344
+ });
345
+ }
324
346
  const branchInfo = createBranchInfo(
325
347
  {
326
348
  executionId: config.execution.id,
@@ -337,21 +359,52 @@ var WorkspaceService = class {
337
359
  branch: branchInfo,
338
360
  credential,
339
361
  provisionedAt: /* @__PURE__ */ new Date(),
340
- status: "provisioning"
362
+ status: "provisioning",
363
+ strategy,
364
+ parentWorkspaceId: config.parentWorkspace,
365
+ onComplete: config.onComplete,
366
+ progress: {
367
+ phase: "initializing",
368
+ message: "Initializing workspace",
369
+ updatedAt: /* @__PURE__ */ new Date()
370
+ }
341
371
  };
342
372
  this.workspaces.set(workspaceId, workspace);
343
373
  try {
344
- await this.cloneRepo(workspace, credential.token);
345
- await this.createBranch(workspace);
374
+ if (strategy === "clone") {
375
+ this.updateProgress(workspace, "cloning", "Cloning repository");
376
+ await this.cloneRepo(workspace, credential.token);
377
+ this.updateProgress(workspace, "creating_branch", "Creating branch");
378
+ await this.createBranch(workspace);
379
+ } else {
380
+ const parent = this.workspaces.get(config.parentWorkspace);
381
+ await this.addWorktreeFromParent(parent, workspace);
382
+ if (!parent.worktreeIds) {
383
+ parent.worktreeIds = [];
384
+ }
385
+ parent.worktreeIds.push(workspaceId);
386
+ this.workspaces.set(parent.id, parent);
387
+ await this.emitEvent({
388
+ type: "worktree:added",
389
+ workspaceId,
390
+ executionId: config.execution.id,
391
+ timestamp: /* @__PURE__ */ new Date(),
392
+ data: { parentWorkspaceId: parent.id }
393
+ });
394
+ }
395
+ this.updateProgress(workspace, "configuring", "Configuring git");
346
396
  await this.configureGit(workspace);
347
397
  workspace.status = "ready";
398
+ this.updateProgress(workspace, "ready", "Workspace ready");
348
399
  this.workspaces.set(workspaceId, workspace);
400
+ await this.executeCompletionHook(workspace, "success");
349
401
  this.log(
350
402
  "info",
351
403
  {
352
404
  workspaceId,
353
405
  path: workspacePath,
354
- branch: branchInfo.name
406
+ branch: branchInfo.name,
407
+ strategy
355
408
  },
356
409
  "Workspace provisioned"
357
410
  );
@@ -364,8 +417,9 @@ var WorkspaceService = class {
364
417
  return workspace;
365
418
  } catch (error) {
366
419
  workspace.status = "error";
367
- this.workspaces.set(workspaceId, workspace);
368
420
  const errorMessage = error instanceof Error ? error.message : String(error);
421
+ this.updateProgress(workspace, "error", errorMessage);
422
+ this.workspaces.set(workspaceId, workspace);
369
423
  this.log("error", { workspaceId, error: errorMessage }, "Failed to provision workspace");
370
424
  await this.emitEvent({
371
425
  type: "workspace:error",
@@ -374,9 +428,54 @@ var WorkspaceService = class {
374
428
  timestamp: /* @__PURE__ */ new Date(),
375
429
  error: errorMessage
376
430
  });
431
+ await this.executeCompletionHook(workspace, "error");
377
432
  throw error;
378
433
  }
379
434
  }
435
+ /**
436
+ * Add a worktree to an existing clone workspace (convenience method)
437
+ */
438
+ async addWorktree(parentWorkspaceId, options) {
439
+ const parent = this.workspaces.get(parentWorkspaceId);
440
+ if (!parent) {
441
+ throw new Error(`Parent workspace not found: ${parentWorkspaceId}`);
442
+ }
443
+ return this.provision({
444
+ repo: parent.repo,
445
+ strategy: "worktree",
446
+ parentWorkspace: parentWorkspaceId,
447
+ branchStrategy: "feature_branch",
448
+ baseBranch: options.branch,
449
+ execution: options.execution,
450
+ task: options.task
451
+ });
452
+ }
453
+ /**
454
+ * List all worktrees for a parent workspace
455
+ */
456
+ listWorktrees(parentWorkspaceId) {
457
+ const parent = this.workspaces.get(parentWorkspaceId);
458
+ if (!parent) {
459
+ return [];
460
+ }
461
+ if (!parent.worktreeIds || parent.worktreeIds.length === 0) {
462
+ return [];
463
+ }
464
+ return parent.worktreeIds.map((id) => this.workspaces.get(id)).filter((w) => w !== void 0);
465
+ }
466
+ /**
467
+ * Remove a worktree (alias for cleanup with worktree-specific handling)
468
+ */
469
+ async removeWorktree(workspaceId) {
470
+ const workspace = this.workspaces.get(workspaceId);
471
+ if (!workspace) {
472
+ return;
473
+ }
474
+ if (workspace.strategy !== "worktree") {
475
+ throw new Error("Workspace is not a worktree. Use cleanup() instead.");
476
+ }
477
+ await this.cleanup(workspaceId);
478
+ }
380
479
  /**
381
480
  * Finalize a workspace (push, create PR, cleanup)
382
481
  */
@@ -456,21 +555,55 @@ var WorkspaceService = class {
456
555
  if (!workspace) {
457
556
  return;
458
557
  }
459
- this.log("info", { workspaceId }, "Cleaning up workspace");
558
+ this.log("info", { workspaceId, strategy: workspace.strategy }, "Cleaning up workspace");
559
+ if (workspace.strategy === "clone" && workspace.worktreeIds?.length) {
560
+ this.log(
561
+ "info",
562
+ { workspaceId, worktreeCount: workspace.worktreeIds.length },
563
+ "Cleaning up child worktrees first"
564
+ );
565
+ for (const worktreeId of workspace.worktreeIds) {
566
+ await this.cleanup(worktreeId);
567
+ }
568
+ }
460
569
  try {
461
570
  await cleanupCredentialFiles(workspace.path);
462
571
  } catch (error) {
463
572
  const errorMessage = error instanceof Error ? error.message : String(error);
464
573
  this.log("warn", { workspaceId, error: errorMessage }, "Failed to clean up credential files");
465
574
  }
466
- await this.credentialService.revokeCredential(workspace.credential.id);
467
- await this.emitEvent({
468
- type: "credential:revoked",
469
- workspaceId,
470
- credentialId: workspace.credential.id,
471
- executionId: workspace.branch.executionId,
472
- timestamp: /* @__PURE__ */ new Date()
473
- });
575
+ if (workspace.strategy === "worktree" && workspace.parentWorkspaceId) {
576
+ const parent = this.workspaces.get(workspace.parentWorkspaceId);
577
+ if (parent) {
578
+ try {
579
+ await this.execInDir(parent.path, `git worktree remove "${workspace.path}" --force`);
580
+ } catch (error) {
581
+ const errorMessage = error instanceof Error ? error.message : String(error);
582
+ this.log("warn", { workspaceId, error: errorMessage }, "Failed to remove worktree via git");
583
+ }
584
+ if (parent.worktreeIds) {
585
+ parent.worktreeIds = parent.worktreeIds.filter((id) => id !== workspaceId);
586
+ this.workspaces.set(parent.id, parent);
587
+ }
588
+ await this.emitEvent({
589
+ type: "worktree:removed",
590
+ workspaceId,
591
+ executionId: workspace.branch.executionId,
592
+ timestamp: /* @__PURE__ */ new Date(),
593
+ data: { parentWorkspaceId: parent.id }
594
+ });
595
+ }
596
+ }
597
+ if (workspace.strategy === "clone") {
598
+ await this.credentialService.revokeCredential(workspace.credential.id);
599
+ await this.emitEvent({
600
+ type: "credential:revoked",
601
+ workspaceId,
602
+ credentialId: workspace.credential.id,
603
+ executionId: workspace.branch.executionId,
604
+ timestamp: /* @__PURE__ */ new Date()
605
+ });
606
+ }
474
607
  try {
475
608
  await fs3__namespace.rm(workspace.path, { recursive: true, force: true });
476
609
  } catch (error) {
@@ -507,6 +640,16 @@ var WorkspaceService = class {
507
640
  async createBranch(workspace) {
508
641
  await this.execInDir(workspace.path, `git checkout -b ${workspace.branch.name}`);
509
642
  }
643
+ async addWorktreeFromParent(parent, workspace) {
644
+ try {
645
+ await this.execInDir(parent.path, `git fetch origin ${workspace.branch.baseBranch}`);
646
+ } catch {
647
+ }
648
+ await this.execInDir(
649
+ parent.path,
650
+ `git worktree add -b ${workspace.branch.name} "${workspace.path}" origin/${workspace.branch.baseBranch}`
651
+ );
652
+ }
510
653
  async configureGit(workspace) {
511
654
  await this.execInDir(workspace.path, 'git config user.name "Workspace Agent"');
512
655
  await this.execInDir(workspace.path, 'git config user.email "agent@workspace.local"');
@@ -622,6 +765,72 @@ var WorkspaceService = class {
622
765
  }
623
766
  }
624
767
  }
768
+ /**
769
+ * Update workspace progress
770
+ */
771
+ updateProgress(workspace, phase, message) {
772
+ workspace.progress = {
773
+ phase,
774
+ message,
775
+ updatedAt: /* @__PURE__ */ new Date()
776
+ };
777
+ this.workspaces.set(workspace.id, workspace);
778
+ this.log(
779
+ "debug",
780
+ { workspaceId: workspace.id, phase, message },
781
+ "Progress updated"
782
+ );
783
+ }
784
+ /**
785
+ * Execute completion hook if configured
786
+ */
787
+ async executeCompletionHook(workspace, status) {
788
+ const hook = workspace.onComplete;
789
+ if (!hook) return;
790
+ if (status === "error" && hook.runOnError === false) {
791
+ return;
792
+ }
793
+ const env = {
794
+ ...process.env,
795
+ WORKSPACE_ID: workspace.id,
796
+ REPO: workspace.repo,
797
+ BRANCH: workspace.branch.name,
798
+ STATUS: status,
799
+ WORKSPACE_PATH: workspace.path
800
+ };
801
+ if (hook.command) {
802
+ try {
803
+ this.log("info", { workspaceId: workspace.id, command: hook.command }, "Executing completion hook command");
804
+ await execAsync(hook.command, { env });
805
+ } catch (error) {
806
+ const errorMessage = error instanceof Error ? error.message : String(error);
807
+ this.log("warn", { workspaceId: workspace.id, error: errorMessage }, "Completion hook command failed");
808
+ }
809
+ }
810
+ if (hook.webhook) {
811
+ try {
812
+ this.log("info", { workspaceId: workspace.id, webhook: hook.webhook }, "Calling completion webhook");
813
+ const payload = {
814
+ workspaceId: workspace.id,
815
+ repo: workspace.repo,
816
+ branch: workspace.branch.name,
817
+ status,
818
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
819
+ };
820
+ await fetch(hook.webhook, {
821
+ method: "POST",
822
+ headers: {
823
+ "Content-Type": "application/json",
824
+ ...hook.webhookHeaders
825
+ },
826
+ body: JSON.stringify(payload)
827
+ });
828
+ } catch (error) {
829
+ const errorMessage = error instanceof Error ? error.message : String(error);
830
+ this.log("warn", { workspaceId: workspace.id, error: errorMessage }, "Completion webhook failed");
831
+ }
832
+ }
833
+ }
625
834
  };
626
835
 
627
836
  // src/oauth/device-flow.ts