@teamclaws/teamclaw 2026.3.21

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.
@@ -0,0 +1,690 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import type { PluginLogger } from "../api.js";
6
+ import type { GitRepoState, PluginConfig, RepoSyncInfo } from "./types.js";
7
+ import { resolveDefaultOpenClawWorkspaceDir } from "./openclaw-workspace.js";
8
+
9
+ const TEAMCLAW_IMPORT_REF_PREFIX = "refs/teamclaw/imports";
10
+ const TEAMCLAW_RUNTIME_EXCLUDES = [
11
+ ".openclaw/",
12
+ "AGENTS.md",
13
+ "BOOTSTRAP.md",
14
+ "HEARTBEAT.md",
15
+ "IDENTITY.md",
16
+ "SOUL.md",
17
+ "TOOLS.md",
18
+ "USER.md",
19
+ ];
20
+
21
+ const repoLocks = new Map<string, Promise<void>>();
22
+
23
+ type CommandResult = {
24
+ stdout: string;
25
+ stderr: string;
26
+ exitCode: number;
27
+ };
28
+
29
+ type RepoImportResult = {
30
+ merged: boolean;
31
+ fastForwarded: boolean;
32
+ alreadyUpToDate: boolean;
33
+ repo: GitRepoState;
34
+ message: string;
35
+ };
36
+
37
+ type RepoSyncResult = {
38
+ repo: GitRepoState;
39
+ message: string;
40
+ };
41
+
42
+ type RepoPublishResult = {
43
+ repo: GitRepoState;
44
+ published: boolean;
45
+ message: string;
46
+ };
47
+
48
+ export async function ensureControllerGitRepo(
49
+ config: PluginConfig,
50
+ logger: PluginLogger,
51
+ ): Promise<GitRepoState | null> {
52
+ const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
53
+ return await withRepoLock(workspaceDir, async () => ensureControllerGitRepoUnlocked(config, logger, workspaceDir));
54
+ }
55
+
56
+ async function ensureControllerGitRepoUnlocked(
57
+ config: PluginConfig,
58
+ logger: PluginLogger,
59
+ workspaceDir: string,
60
+ ): Promise<GitRepoState | null> {
61
+ if (!config.gitEnabled) {
62
+ return null;
63
+ }
64
+
65
+ await fs.mkdir(workspaceDir, { recursive: true });
66
+
67
+ const gitDir = path.join(workspaceDir, ".git");
68
+ const repoAlreadyExists = await pathExists(gitDir);
69
+
70
+ if (!repoAlreadyExists) {
71
+ logger.info(`TeamClaw: initializing git workspace repo at ${workspaceDir}`);
72
+ await runGit(["init"], { cwd: workspaceDir });
73
+ }
74
+
75
+ await configureGitIdentity(workspaceDir, config);
76
+ await configureGitWorkspaceExcludes(workspaceDir);
77
+ await ensureBranchHead(workspaceDir, config.gitDefaultBranch);
78
+
79
+ if (!repoAlreadyExists && !await hasHeadCommit(workspaceDir)) {
80
+ await runGit(["add", "-A"], { cwd: workspaceDir });
81
+ await runGit(["commit", "--allow-empty", "-m", "chore: bootstrap TeamClaw workspace"], { cwd: workspaceDir });
82
+ }
83
+
84
+ let remoteReady = false;
85
+ if (config.gitRemoteUrl) {
86
+ remoteReady = await ensureOriginRemote(workspaceDir, config, logger);
87
+ }
88
+
89
+ return await readGitRepoState(config, remoteReady);
90
+ }
91
+
92
+ export function buildRepoSyncInfo(
93
+ repo: GitRepoState | null | undefined,
94
+ sharedWorkspace: boolean,
95
+ ): RepoSyncInfo | undefined {
96
+ if (!repo?.enabled) {
97
+ return undefined;
98
+ }
99
+
100
+ if (sharedWorkspace) {
101
+ return {
102
+ enabled: true,
103
+ mode: "shared",
104
+ defaultBranch: repo.defaultBranch,
105
+ headCommit: repo.headCommit,
106
+ headSummary: repo.headSummary,
107
+ };
108
+ }
109
+
110
+ if (repo.remoteReady && repo.remoteUrl) {
111
+ return {
112
+ enabled: true,
113
+ mode: "remote",
114
+ defaultBranch: repo.defaultBranch,
115
+ remoteUrl: repo.remoteUrl,
116
+ headCommit: repo.headCommit,
117
+ headSummary: repo.headSummary,
118
+ };
119
+ }
120
+
121
+ return {
122
+ enabled: true,
123
+ mode: "bundle",
124
+ defaultBranch: repo.defaultBranch,
125
+ bundleUrl: "/api/v1/repo/bundle",
126
+ importUrl: "/api/v1/repo/import",
127
+ headCommit: repo.headCommit,
128
+ headSummary: repo.headSummary,
129
+ };
130
+ }
131
+
132
+ export async function exportControllerGitBundle(
133
+ config: PluginConfig,
134
+ logger: PluginLogger,
135
+ ): Promise<{ repo: GitRepoState; data: Buffer; filename: string }> {
136
+ const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
137
+ return await withRepoLock(workspaceDir, async () => {
138
+ const repo = await ensureControllerGitRepoUnlocked(config, logger, workspaceDir);
139
+ if (!repo?.enabled) {
140
+ throw new Error("TeamClaw git collaboration is disabled");
141
+ }
142
+
143
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-bundle-"));
144
+ const bundlePath = path.join(tempDir, "workspace.bundle");
145
+
146
+ try {
147
+ await runGit(["bundle", "create", bundlePath, config.gitDefaultBranch], { cwd: workspaceDir });
148
+ const data = await fs.readFile(bundlePath);
149
+ return {
150
+ repo,
151
+ data,
152
+ filename: `teamclaw-${sanitizeRefPart(config.teamName)}-${sanitizeRefPart(config.gitDefaultBranch)}.bundle`,
153
+ };
154
+ } finally {
155
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
156
+ // best-effort temp cleanup
157
+ });
158
+ }
159
+ });
160
+ }
161
+
162
+ export async function importControllerGitBundle(
163
+ config: PluginConfig,
164
+ logger: PluginLogger,
165
+ bundle: Buffer,
166
+ meta: {
167
+ taskId?: string;
168
+ workerId?: string;
169
+ } = {},
170
+ ): Promise<RepoImportResult> {
171
+ const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
172
+ return await withRepoLock(workspaceDir, async () => {
173
+ const repo = await ensureControllerGitRepoUnlocked(config, logger, workspaceDir);
174
+ if (!repo?.enabled) {
175
+ throw new Error("TeamClaw git collaboration is disabled");
176
+ }
177
+
178
+ const refreshedBeforeImport = await readGitRepoState(config, repo.remoteReady);
179
+ if (refreshedBeforeImport.dirty) {
180
+ return {
181
+ merged: false,
182
+ fastForwarded: false,
183
+ alreadyUpToDate: false,
184
+ repo: refreshedBeforeImport,
185
+ message: "Controller workspace has uncommitted changes; refusing bundle import until the shared repo is clean.",
186
+ };
187
+ }
188
+
189
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-import-"));
190
+ const bundlePath = path.join(tempDir, "worker.bundle");
191
+ const importRef = `${TEAMCLAW_IMPORT_REF_PREFIX}/${sanitizeRefPart(meta.workerId ?? "worker")}-${Date.now().toString(36)}`;
192
+
193
+ try {
194
+ await fs.writeFile(bundlePath, bundle);
195
+ await runGit(["fetch", bundlePath, `refs/heads/${config.gitDefaultBranch}:${importRef}`], { cwd: workspaceDir });
196
+
197
+ const importedCommit = await revParse(workspaceDir, importRef);
198
+ const currentHead = await revParseOrEmpty(workspaceDir, "HEAD");
199
+ if (currentHead && currentHead === importedCommit) {
200
+ const currentRepo = await readGitRepoState(config, repo.remoteReady);
201
+ return {
202
+ merged: false,
203
+ fastForwarded: false,
204
+ alreadyUpToDate: true,
205
+ repo: currentRepo,
206
+ message: "Controller repo already includes the worker commit.",
207
+ };
208
+ }
209
+
210
+ let fastForwarded = true;
211
+ const ffResult = await tryGit(["merge", "--ff-only", importRef], { cwd: workspaceDir });
212
+ if (ffResult.exitCode !== 0) {
213
+ fastForwarded = false;
214
+ const mergeResult = await tryGit(["merge", "--no-edit", importRef], { cwd: workspaceDir });
215
+ if (mergeResult.exitCode !== 0) {
216
+ await abortMergeIfNeeded(workspaceDir);
217
+ const currentRepo = await readGitRepoState(config, repo.remoteReady);
218
+ return {
219
+ merged: false,
220
+ fastForwarded: false,
221
+ alreadyUpToDate: false,
222
+ repo: currentRepo,
223
+ message: `Failed to merge worker bundle for task ${meta.taskId ?? "unknown"}: ${formatCommandError("git merge", mergeResult)}`,
224
+ };
225
+ }
226
+ }
227
+
228
+ const currentRepo = await readGitRepoState(config, repo.remoteReady);
229
+ return {
230
+ merged: true,
231
+ fastForwarded,
232
+ alreadyUpToDate: false,
233
+ repo: currentRepo,
234
+ message: fastForwarded
235
+ ? `Imported worker bundle from ${meta.workerId ?? "worker"} with a fast-forward update.`
236
+ : `Imported worker bundle from ${meta.workerId ?? "worker"} with a merge commit.`,
237
+ };
238
+ } finally {
239
+ await tryGit(["update-ref", "-d", importRef], { cwd: workspaceDir });
240
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
241
+ // best-effort temp cleanup
242
+ });
243
+ }
244
+ });
245
+ }
246
+
247
+ export async function syncWorkerRepo(
248
+ config: PluginConfig,
249
+ logger: PluginLogger,
250
+ controllerUrl: string,
251
+ repoInfo: RepoSyncInfo,
252
+ ): Promise<RepoSyncResult> {
253
+ const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
254
+ return await withRepoLock(workspaceDir, async () => syncWorkerRepoUnlocked(config, logger, controllerUrl, repoInfo, workspaceDir));
255
+ }
256
+
257
+ async function syncWorkerRepoUnlocked(
258
+ config: PluginConfig,
259
+ logger: PluginLogger,
260
+ controllerUrl: string,
261
+ repoInfo: RepoSyncInfo,
262
+ workspaceDir: string,
263
+ ): Promise<RepoSyncResult> {
264
+ await fs.mkdir(workspaceDir, { recursive: true });
265
+
266
+ if (repoInfo.mode === "shared") {
267
+ if (!await pathExists(path.join(workspaceDir, ".git"))) {
268
+ throw new Error("Shared workspace repo is missing its .git directory");
269
+ }
270
+ const repo = await readGitRepoState(config, false);
271
+ return {
272
+ repo,
273
+ message: `Using shared git workspace on branch ${repo.defaultBranch}.`,
274
+ };
275
+ }
276
+
277
+ if (!await pathExists(path.join(workspaceDir, ".git"))) {
278
+ await runGit(["init"], { cwd: workspaceDir });
279
+ await ensureBranchHead(workspaceDir, repoInfo.defaultBranch);
280
+ }
281
+ await configureGitIdentity(workspaceDir, config);
282
+ await configureGitWorkspaceExcludes(workspaceDir);
283
+
284
+ const localRepo = await readGitRepoState(config, false);
285
+ if (localRepo.dirty) {
286
+ throw new Error("Worker workspace has uncommitted changes; refusing repo sync until the checkout is clean");
287
+ }
288
+
289
+ if (repoInfo.mode === "remote") {
290
+ if (!repoInfo.remoteUrl) {
291
+ throw new Error("Remote repo sync requested but no remoteUrl was provided");
292
+ }
293
+ await runGit(["remote", "remove", "origin"], { cwd: workspaceDir }).catch(() => {
294
+ // ignore missing remote
295
+ });
296
+ await runGit(["remote", "add", "origin", repoInfo.remoteUrl], { cwd: workspaceDir });
297
+ await runGit(["fetch", "origin", repoInfo.defaultBranch], { cwd: workspaceDir });
298
+ await checkoutTrackingBranch(workspaceDir, repoInfo.defaultBranch, `refs/remotes/origin/${repoInfo.defaultBranch}`);
299
+ const mergeResult = await tryGit(["merge", "--ff-only", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
300
+ if (mergeResult.exitCode !== 0) {
301
+ throw new Error(`Failed to fast-forward worker checkout from origin/${repoInfo.defaultBranch}: ${formatCommandError("git merge", mergeResult)}`);
302
+ }
303
+ } else {
304
+ if (!repoInfo.bundleUrl) {
305
+ throw new Error("Bundle repo sync requested but no bundleUrl was provided");
306
+ }
307
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-worker-sync-"));
308
+ const bundlePath = path.join(tempDir, "controller.bundle");
309
+ try {
310
+ const res = await fetch(resolveApiUrl(repoInfo.bundleUrl, controllerUrl));
311
+ if (!res.ok) {
312
+ throw new Error(`Bundle download failed with status ${res.status}`);
313
+ }
314
+ const buffer = Buffer.from(await res.arrayBuffer());
315
+ await fs.writeFile(bundlePath, buffer);
316
+ await runGit(["fetch", bundlePath, `refs/heads/${repoInfo.defaultBranch}:refs/remotes/teamclaw/${repoInfo.defaultBranch}`], {
317
+ cwd: workspaceDir,
318
+ });
319
+ await checkoutTrackingBranch(workspaceDir, repoInfo.defaultBranch, `refs/remotes/teamclaw/${repoInfo.defaultBranch}`);
320
+ const mergeResult = await tryGit(["merge", "--ff-only", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
321
+ if (mergeResult.exitCode !== 0) {
322
+ throw new Error(`Failed to fast-forward worker checkout from the controller bundle: ${formatCommandError("git merge", mergeResult)}`);
323
+ }
324
+ } finally {
325
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
326
+ // best-effort temp cleanup
327
+ });
328
+ }
329
+ }
330
+
331
+ const repo = await readGitRepoState(config, repoInfo.mode === "remote");
332
+ return {
333
+ repo,
334
+ message: `Repo sync complete on ${repoInfo.mode} mode (${repo.defaultBranch}).`,
335
+ };
336
+ }
337
+
338
+ export async function publishWorkerRepo(
339
+ config: PluginConfig,
340
+ logger: PluginLogger,
341
+ controllerUrl: string,
342
+ repoInfo: RepoSyncInfo,
343
+ meta: {
344
+ taskId: string;
345
+ workerId: string;
346
+ role?: string;
347
+ },
348
+ ): Promise<RepoPublishResult> {
349
+ const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
350
+ return await withRepoLock(workspaceDir, async () => publishWorkerRepoUnlocked(config, logger, controllerUrl, repoInfo, meta, workspaceDir));
351
+ }
352
+
353
+ async function publishWorkerRepoUnlocked(
354
+ config: PluginConfig,
355
+ logger: PluginLogger,
356
+ controllerUrl: string,
357
+ repoInfo: RepoSyncInfo,
358
+ meta: {
359
+ taskId: string;
360
+ workerId: string;
361
+ role?: string;
362
+ },
363
+ workspaceDir: string,
364
+ ): Promise<RepoPublishResult> {
365
+ if (repoInfo.mode === "shared") {
366
+ const repo = await readGitRepoState(config, false);
367
+ return {
368
+ repo,
369
+ published: false,
370
+ message: "Shared workspace repo does not require controller-mediated publish.",
371
+ };
372
+ }
373
+
374
+ if (!await pathExists(path.join(workspaceDir, ".git"))) {
375
+ throw new Error("Worker repo is not initialized");
376
+ }
377
+
378
+ await configureGitIdentity(workspaceDir, config);
379
+ await configureGitWorkspaceExcludes(workspaceDir);
380
+
381
+ const dirtyCheck = await runGit(["status", "--porcelain"], { cwd: workspaceDir });
382
+ if (dirtyCheck.stdout.trim()) {
383
+ await runGit(["add", "-A"], { cwd: workspaceDir });
384
+ const commitMessage = `chore(teamclaw): checkpoint ${meta.taskId} (${meta.role ?? "worker"})`;
385
+ await runGit(["commit", "-m", commitMessage], { cwd: workspaceDir });
386
+ }
387
+
388
+ if (repoInfo.mode === "remote") {
389
+ if (!repoInfo.remoteUrl) {
390
+ throw new Error("Remote publish requested but no remoteUrl was provided");
391
+ }
392
+
393
+ await runGit(["fetch", "origin", repoInfo.defaultBranch], { cwd: workspaceDir });
394
+ const remoteRef = `refs/remotes/origin/${repoInfo.defaultBranch}`;
395
+ if (await revParseOrEmpty(workspaceDir, remoteRef)) {
396
+ const ffResult = await tryGit(["merge", "--ff-only", remoteRef], { cwd: workspaceDir });
397
+ if (ffResult.exitCode !== 0) {
398
+ const mergeResult = await tryGit(["merge", "--no-edit", remoteRef], { cwd: workspaceDir });
399
+ if (mergeResult.exitCode !== 0) {
400
+ await abortMergeIfNeeded(workspaceDir);
401
+ throw new Error(`Failed to merge latest remote changes before push: ${formatCommandError("git merge", mergeResult)}`);
402
+ }
403
+ }
404
+ }
405
+
406
+ await runGit(["push", "origin", `HEAD:refs/heads/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
407
+ const repo = await readGitRepoState(config, true);
408
+ return {
409
+ repo,
410
+ published: true,
411
+ message: `Pushed worker changes for task ${meta.taskId} to origin/${repoInfo.defaultBranch}.`,
412
+ };
413
+ }
414
+
415
+ if (!repoInfo.importUrl) {
416
+ throw new Error("Bundle publish requested but no importUrl was provided");
417
+ }
418
+
419
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-worker-publish-"));
420
+ const bundlePath = path.join(tempDir, "worker.bundle");
421
+ try {
422
+ await runGit(["bundle", "create", bundlePath, repoInfo.defaultBranch], { cwd: workspaceDir });
423
+ const bundle = await fs.readFile(bundlePath);
424
+ const importUrl = new URL(resolveApiUrl(repoInfo.importUrl, controllerUrl));
425
+ importUrl.searchParams.set("taskId", meta.taskId);
426
+ importUrl.searchParams.set("workerId", meta.workerId);
427
+ if (meta.role) {
428
+ importUrl.searchParams.set("role", meta.role);
429
+ }
430
+
431
+ const res = await fetch(importUrl.toString(), {
432
+ method: "POST",
433
+ headers: { "Content-Type": "application/octet-stream" },
434
+ body: bundle,
435
+ });
436
+
437
+ if (!res.ok) {
438
+ const text = await res.text();
439
+ throw new Error(`Bundle import failed with status ${res.status}: ${text}`);
440
+ }
441
+
442
+ const payload = await res.json() as { repo?: GitRepoState; message?: string };
443
+ return {
444
+ repo: payload.repo ?? await readGitRepoState(config, false),
445
+ published: true,
446
+ message: payload.message ?? `Imported bundle for task ${meta.taskId}.`,
447
+ };
448
+ } finally {
449
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
450
+ // best-effort temp cleanup
451
+ });
452
+ }
453
+ }
454
+
455
+ export async function readGitRepoState(
456
+ config: PluginConfig,
457
+ remoteReady: boolean,
458
+ ): Promise<GitRepoState> {
459
+ const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
460
+ const headCommit = await revParseOrEmpty(workspaceDir, "HEAD");
461
+ const headSummary = headCommit
462
+ ? (await runGit(["log", "-1", "--pretty=%s"], { cwd: workspaceDir })).stdout.trim() || undefined
463
+ : undefined;
464
+ const dirty = await hasDirtyWorktree(workspaceDir);
465
+
466
+ return {
467
+ enabled: config.gitEnabled,
468
+ mode: remoteReady && config.gitRemoteUrl ? "remote" : "bundle",
469
+ defaultBranch: config.gitDefaultBranch,
470
+ remoteUrl: config.gitRemoteUrl || undefined,
471
+ remoteReady,
472
+ headCommit: headCommit || undefined,
473
+ headSummary,
474
+ dirty,
475
+ lastPreparedAt: Date.now(),
476
+ };
477
+ }
478
+
479
+ async function ensureOriginRemote(
480
+ workspaceDir: string,
481
+ config: PluginConfig,
482
+ logger: PluginLogger,
483
+ ): Promise<boolean> {
484
+ if (!config.gitRemoteUrl) {
485
+ return false;
486
+ }
487
+
488
+ const currentOrigin = await runGit(["remote", "get-url", "origin"], { cwd: workspaceDir }).catch(() => null);
489
+ if (!currentOrigin) {
490
+ await runGit(["remote", "add", "origin", config.gitRemoteUrl], { cwd: workspaceDir });
491
+ } else if (currentOrigin.stdout.trim() !== config.gitRemoteUrl) {
492
+ await runGit(["remote", "set-url", "origin", config.gitRemoteUrl], { cwd: workspaceDir });
493
+ }
494
+
495
+ const remoteHeads = await tryGit(["ls-remote", "--heads", "origin", config.gitDefaultBranch], { cwd: workspaceDir });
496
+ if (remoteHeads.exitCode === 0 && remoteHeads.stdout.trim()) {
497
+ return true;
498
+ }
499
+
500
+ const pushResult = await tryGit(["push", "-u", "origin", `HEAD:refs/heads/${config.gitDefaultBranch}`], { cwd: workspaceDir });
501
+ if (pushResult.exitCode === 0) {
502
+ return true;
503
+ }
504
+
505
+ logger.warn(`TeamClaw: configured git remote is not ready; falling back to bundle sync. ${formatCommandError("git push", pushResult)}`);
506
+ return false;
507
+ }
508
+
509
+ async function checkoutTrackingBranch(workspaceDir: string, branch: string, trackingRef: string): Promise<void> {
510
+ const currentBranch = await currentBranchName(workspaceDir);
511
+ if (!currentBranch) {
512
+ await runGit(["checkout", "-f", "-B", branch, trackingRef], { cwd: workspaceDir });
513
+ return;
514
+ }
515
+
516
+ if (currentBranch !== branch) {
517
+ const switchResult = await tryGit(["checkout", "-f", branch], { cwd: workspaceDir });
518
+ if (switchResult.exitCode !== 0) {
519
+ await runGit(["checkout", "-f", "-B", branch, trackingRef], { cwd: workspaceDir });
520
+ }
521
+ }
522
+ }
523
+
524
+ async function configureGitIdentity(workspaceDir: string, config: PluginConfig): Promise<void> {
525
+ const currentName = (await tryGit(["config", "--get", "user.name"], { cwd: workspaceDir })).stdout.trim();
526
+ if (currentName !== config.gitAuthorName) {
527
+ await runGit(["config", "user.name", config.gitAuthorName], { cwd: workspaceDir });
528
+ }
529
+
530
+ const currentEmail = (await tryGit(["config", "--get", "user.email"], { cwd: workspaceDir })).stdout.trim();
531
+ if (currentEmail !== config.gitAuthorEmail) {
532
+ await runGit(["config", "user.email", config.gitAuthorEmail], { cwd: workspaceDir });
533
+ }
534
+ }
535
+
536
+ async function configureGitWorkspaceExcludes(workspaceDir: string): Promise<void> {
537
+ const gitDir = path.join(workspaceDir, ".git");
538
+ if (!await pathExists(gitDir)) {
539
+ return;
540
+ }
541
+
542
+ const infoDir = path.join(gitDir, "info");
543
+ await fs.mkdir(infoDir, { recursive: true });
544
+ const excludePath = path.join(infoDir, "exclude");
545
+ const existing = await fs.readFile(excludePath, "utf8").catch(() => "");
546
+ const existingLines = new Set(existing.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
547
+ const missingPatterns = TEAMCLAW_RUNTIME_EXCLUDES.filter((pattern) => !existingLines.has(pattern));
548
+ if (missingPatterns.length === 0) {
549
+ return;
550
+ }
551
+
552
+ const prefix = existing.length === 0 ? "" : (existing.endsWith("\n") ? "" : "\n");
553
+ const header = existing.includes("# TeamClaw runtime workspace noise") ? "" : "# TeamClaw runtime workspace noise\n";
554
+ await fs.writeFile(excludePath, `${existing}${prefix}${header}${missingPatterns.join("\n")}\n`);
555
+ }
556
+
557
+ async function ensureBranchHead(workspaceDir: string, branch: string): Promise<void> {
558
+ const hasGit = await pathExists(path.join(workspaceDir, ".git"));
559
+ if (!hasGit) {
560
+ return;
561
+ }
562
+
563
+ await runGit(["symbolic-ref", "HEAD", `refs/heads/${branch}`], { cwd: workspaceDir }).catch(() => {
564
+ // symbolic-ref can fail if HEAD is already attached to a branch; ignore
565
+ });
566
+ }
567
+
568
+ async function hasHeadCommit(workspaceDir: string): Promise<boolean> {
569
+ const result = await tryGit(["rev-parse", "--verify", "HEAD"], { cwd: workspaceDir });
570
+ return result.exitCode === 0;
571
+ }
572
+
573
+ async function hasDirtyWorktree(workspaceDir: string): Promise<boolean> {
574
+ if (!await pathExists(path.join(workspaceDir, ".git"))) {
575
+ return false;
576
+ }
577
+ const status = await runGit(["status", "--porcelain"], { cwd: workspaceDir });
578
+ return status.stdout.trim().length > 0;
579
+ }
580
+
581
+ async function revParse(workspaceDir: string, ref: string): Promise<string> {
582
+ const result = await runGit(["rev-parse", ref], { cwd: workspaceDir });
583
+ return result.stdout.trim();
584
+ }
585
+
586
+ async function revParseOrEmpty(workspaceDir: string, ref: string): Promise<string> {
587
+ const result = await tryGit(["rev-parse", ref], { cwd: workspaceDir });
588
+ return result.exitCode === 0 ? result.stdout.trim() : "";
589
+ }
590
+
591
+ async function currentBranchName(workspaceDir: string): Promise<string> {
592
+ const result = await tryGit(["branch", "--show-current"], { cwd: workspaceDir });
593
+ return result.exitCode === 0 ? result.stdout.trim() : "";
594
+ }
595
+
596
+ async function abortMergeIfNeeded(workspaceDir: string): Promise<void> {
597
+ await tryGit(["merge", "--abort"], { cwd: workspaceDir });
598
+ }
599
+
600
+ async function pathExists(filePath: string): Promise<boolean> {
601
+ try {
602
+ await fs.access(filePath);
603
+ return true;
604
+ } catch {
605
+ return false;
606
+ }
607
+ }
608
+
609
+ async function runGit(args: string[], options: { cwd: string }): Promise<CommandResult> {
610
+ const result = await runCommand("git", args, options);
611
+ if (result.exitCode !== 0) {
612
+ throw new Error(formatCommandError(`git ${args.join(" ")}`, result));
613
+ }
614
+ return result;
615
+ }
616
+
617
+ async function tryGit(args: string[], options: { cwd: string }): Promise<CommandResult> {
618
+ return await runCommand("git", args, options);
619
+ }
620
+
621
+ async function withRepoLock<T>(lockKey: string, operation: () => Promise<T>): Promise<T> {
622
+ const prior = repoLocks.get(lockKey) ?? Promise.resolve();
623
+ let release!: () => void;
624
+ const gate = new Promise<void>((resolve) => {
625
+ release = resolve;
626
+ });
627
+ const queued = prior.then(() => gate);
628
+ repoLocks.set(lockKey, queued);
629
+ await prior;
630
+ try {
631
+ return await operation();
632
+ } finally {
633
+ release();
634
+ if (repoLocks.get(lockKey) === queued) {
635
+ repoLocks.delete(lockKey);
636
+ }
637
+ }
638
+ }
639
+
640
+ async function runCommand(
641
+ command: string,
642
+ args: string[],
643
+ options: { cwd: string },
644
+ ): Promise<CommandResult> {
645
+ return await new Promise<CommandResult>((resolve, reject) => {
646
+ const child = spawn(command, args, {
647
+ cwd: options.cwd,
648
+ env: {
649
+ ...process.env,
650
+ GIT_TERMINAL_PROMPT: "0",
651
+ },
652
+ stdio: ["ignore", "pipe", "pipe"],
653
+ });
654
+
655
+ const stdoutChunks: Buffer[] = [];
656
+ const stderrChunks: Buffer[] = [];
657
+
658
+ child.stdout.on("data", (chunk: Buffer | string) => {
659
+ stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
660
+ });
661
+ child.stderr.on("data", (chunk: Buffer | string) => {
662
+ stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
663
+ });
664
+
665
+ child.on("error", reject);
666
+ child.on("close", (code: number | null) => {
667
+ resolve({
668
+ stdout: Buffer.concat(stdoutChunks).toString("utf8"),
669
+ stderr: Buffer.concat(stderrChunks).toString("utf8"),
670
+ exitCode: code ?? 0,
671
+ });
672
+ });
673
+ });
674
+ }
675
+
676
+ function formatCommandError(command: string, result: CommandResult): string {
677
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`;
678
+ return `${command}: ${detail}`;
679
+ }
680
+
681
+ function resolveApiUrl(urlOrPath: string | undefined, controllerUrl: string): string {
682
+ if (!urlOrPath) {
683
+ throw new Error("Missing controller API URL");
684
+ }
685
+ return new URL(urlOrPath, controllerUrl).toString();
686
+ }
687
+
688
+ function sanitizeRefPart(value: string): string {
689
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
690
+ }