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 +70 -0
- package/dist/index.cjs +163 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -2
- package/dist/index.d.ts +54 -2
- package/dist/index.js +163 -33
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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"');
|