git-workspace-service 0.1.0 → 0.2.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,12 +359,31 @@ 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
341
365
  };
342
366
  this.workspaces.set(workspaceId, workspace);
343
367
  try {
344
- await this.cloneRepo(workspace, credential.token);
345
- await this.createBranch(workspace);
368
+ if (strategy === "clone") {
369
+ await this.cloneRepo(workspace, credential.token);
370
+ await this.createBranch(workspace);
371
+ } else {
372
+ const parent = this.workspaces.get(config.parentWorkspace);
373
+ await this.addWorktreeFromParent(parent, workspace);
374
+ if (!parent.worktreeIds) {
375
+ parent.worktreeIds = [];
376
+ }
377
+ parent.worktreeIds.push(workspaceId);
378
+ this.workspaces.set(parent.id, parent);
379
+ await this.emitEvent({
380
+ type: "worktree:added",
381
+ workspaceId,
382
+ executionId: config.execution.id,
383
+ timestamp: /* @__PURE__ */ new Date(),
384
+ data: { parentWorkspaceId: parent.id }
385
+ });
386
+ }
346
387
  await this.configureGit(workspace);
347
388
  workspace.status = "ready";
348
389
  this.workspaces.set(workspaceId, workspace);
@@ -351,7 +392,8 @@ var WorkspaceService = class {
351
392
  {
352
393
  workspaceId,
353
394
  path: workspacePath,
354
- branch: branchInfo.name
395
+ branch: branchInfo.name,
396
+ strategy
355
397
  },
356
398
  "Workspace provisioned"
357
399
  );
@@ -377,6 +419,50 @@ var WorkspaceService = class {
377
419
  throw error;
378
420
  }
379
421
  }
422
+ /**
423
+ * Add a worktree to an existing clone workspace (convenience method)
424
+ */
425
+ async addWorktree(parentWorkspaceId, options) {
426
+ const parent = this.workspaces.get(parentWorkspaceId);
427
+ if (!parent) {
428
+ throw new Error(`Parent workspace not found: ${parentWorkspaceId}`);
429
+ }
430
+ return this.provision({
431
+ repo: parent.repo,
432
+ strategy: "worktree",
433
+ parentWorkspace: parentWorkspaceId,
434
+ branchStrategy: "feature_branch",
435
+ baseBranch: options.branch,
436
+ execution: options.execution,
437
+ task: options.task
438
+ });
439
+ }
440
+ /**
441
+ * List all worktrees for a parent workspace
442
+ */
443
+ listWorktrees(parentWorkspaceId) {
444
+ const parent = this.workspaces.get(parentWorkspaceId);
445
+ if (!parent) {
446
+ return [];
447
+ }
448
+ if (!parent.worktreeIds || parent.worktreeIds.length === 0) {
449
+ return [];
450
+ }
451
+ return parent.worktreeIds.map((id) => this.workspaces.get(id)).filter((w) => w !== void 0);
452
+ }
453
+ /**
454
+ * Remove a worktree (alias for cleanup with worktree-specific handling)
455
+ */
456
+ async removeWorktree(workspaceId) {
457
+ const workspace = this.workspaces.get(workspaceId);
458
+ if (!workspace) {
459
+ return;
460
+ }
461
+ if (workspace.strategy !== "worktree") {
462
+ throw new Error("Workspace is not a worktree. Use cleanup() instead.");
463
+ }
464
+ await this.cleanup(workspaceId);
465
+ }
380
466
  /**
381
467
  * Finalize a workspace (push, create PR, cleanup)
382
468
  */
@@ -456,21 +542,55 @@ var WorkspaceService = class {
456
542
  if (!workspace) {
457
543
  return;
458
544
  }
459
- this.log("info", { workspaceId }, "Cleaning up workspace");
545
+ this.log("info", { workspaceId, strategy: workspace.strategy }, "Cleaning up workspace");
546
+ if (workspace.strategy === "clone" && workspace.worktreeIds?.length) {
547
+ this.log(
548
+ "info",
549
+ { workspaceId, worktreeCount: workspace.worktreeIds.length },
550
+ "Cleaning up child worktrees first"
551
+ );
552
+ for (const worktreeId of workspace.worktreeIds) {
553
+ await this.cleanup(worktreeId);
554
+ }
555
+ }
460
556
  try {
461
557
  await cleanupCredentialFiles(workspace.path);
462
558
  } catch (error) {
463
559
  const errorMessage = error instanceof Error ? error.message : String(error);
464
560
  this.log("warn", { workspaceId, error: errorMessage }, "Failed to clean up credential files");
465
561
  }
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
- });
562
+ if (workspace.strategy === "worktree" && workspace.parentWorkspaceId) {
563
+ const parent = this.workspaces.get(workspace.parentWorkspaceId);
564
+ if (parent) {
565
+ try {
566
+ await this.execInDir(parent.path, `git worktree remove "${workspace.path}" --force`);
567
+ } catch (error) {
568
+ const errorMessage = error instanceof Error ? error.message : String(error);
569
+ this.log("warn", { workspaceId, error: errorMessage }, "Failed to remove worktree via git");
570
+ }
571
+ if (parent.worktreeIds) {
572
+ parent.worktreeIds = parent.worktreeIds.filter((id) => id !== workspaceId);
573
+ this.workspaces.set(parent.id, parent);
574
+ }
575
+ await this.emitEvent({
576
+ type: "worktree:removed",
577
+ workspaceId,
578
+ executionId: workspace.branch.executionId,
579
+ timestamp: /* @__PURE__ */ new Date(),
580
+ data: { parentWorkspaceId: parent.id }
581
+ });
582
+ }
583
+ }
584
+ if (workspace.strategy === "clone") {
585
+ await this.credentialService.revokeCredential(workspace.credential.id);
586
+ await this.emitEvent({
587
+ type: "credential:revoked",
588
+ workspaceId,
589
+ credentialId: workspace.credential.id,
590
+ executionId: workspace.branch.executionId,
591
+ timestamp: /* @__PURE__ */ new Date()
592
+ });
593
+ }
474
594
  try {
475
595
  await fs3__namespace.rm(workspace.path, { recursive: true, force: true });
476
596
  } catch (error) {
@@ -507,6 +627,16 @@ var WorkspaceService = class {
507
627
  async createBranch(workspace) {
508
628
  await this.execInDir(workspace.path, `git checkout -b ${workspace.branch.name}`);
509
629
  }
630
+ async addWorktreeFromParent(parent, workspace) {
631
+ try {
632
+ await this.execInDir(parent.path, `git fetch origin ${workspace.branch.baseBranch}`);
633
+ } catch {
634
+ }
635
+ await this.execInDir(
636
+ parent.path,
637
+ `git worktree add -b ${workspace.branch.name} "${workspace.path}" origin/${workspace.branch.baseBranch}`
638
+ );
639
+ }
510
640
  async configureGit(workspace) {
511
641
  await this.execInDir(workspace.path, 'git config user.name "Workspace Agent"');
512
642
  await this.execInDir(workspace.path, 'git config user.email "agent@workspace.local"');