sync-worktrees 3.6.1 → 3.6.3

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.
@@ -2538,7 +2538,7 @@ var GitService = class {
2538
2538
  const bareGit = this.getCachedGit(this.bareRepoPath);
2539
2539
  const checkRef = async (ref) => {
2540
2540
  try {
2541
- await bareGit.raw(["show-ref", "--verify", "--quiet", ref]);
2541
+ await bareGit.raw(["show-ref", "--verify", ref]);
2542
2542
  return true;
2543
2543
  } catch {
2544
2544
  return false;
@@ -2555,10 +2555,22 @@ var GitService = class {
2555
2555
  const branches = await bareGit.branch();
2556
2556
  return branches.all;
2557
2557
  }
2558
+ async resolveCreateBranchBaseRef(bareGit, baseBranch) {
2559
+ const candidates = baseBranch.startsWith(GIT_CONSTANTS.REMOTE_PREFIX) || baseBranch.startsWith("refs/") ? [baseBranch] : [`${GIT_CONSTANTS.REMOTE_PREFIX}${baseBranch}`, baseBranch];
2560
+ for (const candidate of candidates) {
2561
+ try {
2562
+ await bareGit.revparse(["--verify", candidate]);
2563
+ return candidate;
2564
+ } catch {
2565
+ }
2566
+ }
2567
+ return candidates[0];
2568
+ }
2558
2569
  async createBranch(branchName, baseBranch) {
2559
2570
  const bareGit = this.getCachedGit(this.bareRepoPath);
2560
- await bareGit.raw(["branch", branchName, `origin/${baseBranch}`]);
2561
- this.logger.info(`Created branch '${branchName}' from '${baseBranch}'`);
2571
+ const baseRef = await this.resolveCreateBranchBaseRef(bareGit, baseBranch);
2572
+ await bareGit.raw(["branch", "--no-track", branchName, baseRef]);
2573
+ this.logger.info(`Created branch '${branchName}' from '${baseRef}'`);
2562
2574
  }
2563
2575
  async pushBranch(branchName) {
2564
2576
  const bareGit = this.getCachedGit(this.bareRepoPath);
@@ -2670,17 +2682,9 @@ var WorktreeSyncService = class {
2670
2682
  this.progressListeners.add(listener);
2671
2683
  return () => this.progressListeners.delete(listener);
2672
2684
  }
2673
- emitProgress(event) {
2674
- for (const listener of this.progressListeners) {
2675
- try {
2676
- listener(event);
2677
- } catch {
2678
- }
2679
- }
2680
- }
2681
- async sync() {
2685
+ async runExclusiveRepoOperation(operation) {
2682
2686
  if (this.syncInProgress) {
2683
- this.logger.warn("\u26A0\uFE0F Sync already in progress, skipping...");
2687
+ this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
2684
2688
  return { started: false, reason: "in_progress" };
2685
2689
  }
2686
2690
  const release = await this.acquireBareLock();
@@ -2689,36 +2693,55 @@ var WorktreeSyncService = class {
2689
2693
  return { started: false, reason: "locked" };
2690
2694
  }
2691
2695
  this.syncInProgress = true;
2692
- this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
2693
- const totalTimer = new Timer();
2694
- const phaseTimer = new PhaseTimer();
2695
- const syncContext = { lfsSkipEnabled: false };
2696
- const retryOptions = this.createRetryOptions(syncContext);
2697
2696
  try {
2698
- await retry(() => this.runSyncAttempt(phaseTimer, syncContext), retryOptions);
2699
- } catch (error) {
2700
- this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
2701
- throw error;
2697
+ return { started: true, value: await operation() };
2702
2698
  } finally {
2703
- if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
2704
- this.gitService.setLfsSkipEnabled(false);
2705
- }
2706
2699
  this.syncInProgress = false;
2707
2700
  try {
2708
2701
  await release();
2709
2702
  } catch (releaseError) {
2710
2703
  this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
2711
2704
  }
2712
- this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
2713
- `);
2714
- if (this.config.debug) {
2715
- const totalDuration = totalTimer.stop();
2716
- const phaseResults = phaseTimer.getResults();
2717
- const repoName = this.config.name;
2718
- this.logger.table(formatTimingTable(totalDuration, phaseResults, repoName));
2705
+ }
2706
+ }
2707
+ emitProgress(event) {
2708
+ for (const listener of this.progressListeners) {
2709
+ try {
2710
+ listener(event);
2711
+ } catch {
2719
2712
  }
2720
2713
  }
2721
- return { started: true };
2714
+ }
2715
+ async sync() {
2716
+ const result = await this.runExclusiveRepoOperation(async () => {
2717
+ if (!this.isInitialized()) {
2718
+ await this.initialize();
2719
+ }
2720
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
2721
+ const totalTimer = new Timer();
2722
+ const phaseTimer = new PhaseTimer();
2723
+ const syncContext = { lfsSkipEnabled: false };
2724
+ const retryOptions = this.createRetryOptions(syncContext);
2725
+ try {
2726
+ await retry(() => this.runSyncAttempt(phaseTimer, syncContext), retryOptions);
2727
+ } catch (error) {
2728
+ this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
2729
+ throw error;
2730
+ } finally {
2731
+ if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
2732
+ this.gitService.setLfsSkipEnabled(false);
2733
+ }
2734
+ this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
2735
+ `);
2736
+ if (this.config.debug) {
2737
+ const totalDuration = totalTimer.stop();
2738
+ const phaseResults = phaseTimer.getResults();
2739
+ const repoName = this.config.name;
2740
+ this.logger.table(formatTimingTable(totalDuration, phaseResults, repoName));
2741
+ }
2742
+ }
2743
+ });
2744
+ return result.started ? { started: true } : result;
2722
2745
  }
2723
2746
  async acquireBareLock() {
2724
2747
  if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -2730,15 +2753,9 @@ var WorktreeSyncService = class {
2730
2753
  };
2731
2754
  }
2732
2755
  const barePath = this.gitService.getBareRepoPath();
2733
- const lockTarget = path8.join(barePath, "HEAD");
2756
+ await fs6.mkdir(barePath, { recursive: true });
2734
2757
  try {
2735
- await fs6.access(lockTarget);
2736
- } catch {
2737
- return async () => {
2738
- };
2739
- }
2740
- try {
2741
- const release = await lockfile.lock(lockTarget, {
2758
+ const release = await lockfile.lock(barePath, {
2742
2759
  stale: DEFAULT_CONFIG.LOCK_STALE_MS,
2743
2760
  update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
2744
2761
  retries: 0,
@@ -3518,43 +3535,60 @@ var RepositoryContext = class {
3518
3535
  return this.discoveryCache.size;
3519
3536
  }
3520
3537
  async discoverSiblingRepositories(currentBareRepoPath) {
3538
+ const currentBare = normalizePathForCompare(currentBareRepoPath);
3539
+ const results = /* @__PURE__ */ new Map();
3540
+ const byName = (a, b) => a.name.localeCompare(b.name);
3541
+ const configCandidates = Array.from(this.repos.values()).filter((entry) => entry.source === "config" && !!entry.config.bareRepoDir).map((entry) => {
3542
+ const bareRepoPath = path9.resolve(entry.config.bareRepoDir);
3543
+ return { entry, bareRepoPath, foldedBare: normalizePathForCompare(bareRepoPath) };
3544
+ }).filter((c) => c.foldedBare !== currentBare);
3545
+ const configPresence = await Promise.all(configCandidates.map((c) => isDirectory(c.bareRepoPath)));
3546
+ configCandidates.forEach(({ entry, bareRepoPath, foldedBare }, i) => {
3547
+ const sibling = {
3548
+ name: entry.name,
3549
+ bareRepoPath,
3550
+ worktreeDir: path9.resolve(entry.config.worktreeDir),
3551
+ repoUrl: entry.config.repoUrl,
3552
+ present: configPresence[i],
3553
+ configMatched: true
3554
+ };
3555
+ if (entry.config.sparseCheckout) {
3556
+ sibling.sparseCheckout = entry.config.sparseCheckout;
3557
+ }
3558
+ results.set(foldedBare, sibling);
3559
+ });
3521
3560
  const repoDir = path9.dirname(currentBareRepoPath);
3522
3561
  const workspaceRoot = path9.dirname(repoDir);
3523
- if (workspaceRoot === repoDir) return [];
3562
+ if (workspaceRoot === repoDir) {
3563
+ return Array.from(results.values()).sort(byName);
3564
+ }
3524
3565
  let entries;
3525
3566
  try {
3526
3567
  entries = await fs7.readdir(workspaceRoot);
3527
3568
  } catch {
3528
- return [];
3529
- }
3530
- const configBares = /* @__PURE__ */ new Map();
3531
- for (const entry of this.repos.values()) {
3532
- if (entry.source === "config" && entry.config.bareRepoDir) {
3533
- configBares.set(normalizePathForCompare(entry.config.bareRepoDir), entry.name);
3534
- }
3569
+ return Array.from(results.values()).sort(byName);
3535
3570
  }
3536
- const results = [];
3571
+ const configBares = new Map(configCandidates.map((c) => [c.foldedBare, c.entry.name]));
3537
3572
  await Promise.all(
3538
3573
  entries.map(async (entry) => {
3539
3574
  const candidate = path9.join(workspaceRoot, entry);
3540
3575
  const bareCandidate = path9.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
3541
- try {
3542
- const stat4 = await fs7.stat(bareCandidate);
3543
- if (!stat4.isDirectory()) return;
3544
- } catch {
3545
- return;
3546
- }
3576
+ if (!await isDirectory(bareCandidate)) return;
3547
3577
  const resolvedBare = path9.resolve(bareCandidate);
3548
- const matchedName = configBares.get(normalizePathForCompare(resolvedBare));
3549
- results.push({
3578
+ const foldedBare = normalizePathForCompare(resolvedBare);
3579
+ if (foldedBare === currentBare || results.has(foldedBare)) return;
3580
+ const matchedName = configBares.get(foldedBare);
3581
+ results.set(foldedBare, {
3550
3582
  name: matchedName ?? entry,
3551
3583
  bareRepoPath: resolvedBare,
3584
+ worktreeDir: null,
3585
+ repoUrl: null,
3586
+ present: true,
3552
3587
  configMatched: matchedName !== void 0
3553
3588
  });
3554
3589
  })
3555
3590
  );
3556
- results.sort((a, b) => a.name.localeCompare(b.name));
3557
- return results;
3591
+ return Array.from(results.values()).sort(byName);
3558
3592
  }
3559
3593
  bootstrapCurrentRepo(candidate, force = false) {
3560
3594
  if (this.currentRepo !== null) return;
@@ -3775,12 +3809,44 @@ var RepositoryContext = class {
3775
3809
  source: e.source
3776
3810
  }));
3777
3811
  }
3812
+ getConfiguredRepositoryNames() {
3813
+ return Array.from(this.repos.values()).filter((entry) => entry.source === "config").map((entry) => entry.name);
3814
+ }
3815
+ async getAllConfiguredWorktreeDetails(currentWorktreePath = null) {
3816
+ const entries = Array.from(this.repos.values()).filter((entry) => entry.source === "config");
3817
+ const results = await Promise.all(
3818
+ entries.map(async (entry) => ({
3819
+ name: entry.name,
3820
+ result: await this.readConfiguredWorktrees(entry, currentWorktreePath)
3821
+ }))
3822
+ );
3823
+ const worktreesByRepo = {};
3824
+ const errorsByRepo = {};
3825
+ for (const entry of results) {
3826
+ worktreesByRepo[entry.name] = entry.result.worktrees;
3827
+ if (entry.result.error) {
3828
+ errorsByRepo[entry.name] = entry.result.error;
3829
+ }
3830
+ }
3831
+ return { worktreesByRepo, errorsByRepo };
3832
+ }
3778
3833
  getConfigPath() {
3779
3834
  return this.configPath;
3780
3835
  }
3836
+ async readConfiguredWorktrees(entry, currentWorktreePath) {
3837
+ if (entry.source !== "config" || !entry.config.bareRepoDir) return { worktrees: [] };
3838
+ const bareRepoPath = path9.resolve(entry.config.bareRepoDir);
3839
+ if (!await isDirectory(bareRepoPath)) return { worktrees: [] };
3840
+ try {
3841
+ const output = await simpleGit5(bareRepoPath).raw(["worktree", "list", "--porcelain"]);
3842
+ return { worktrees: parseWorktreeList(output, currentWorktreePath) };
3843
+ } catch (err) {
3844
+ return { worktrees: [], error: err instanceof Error ? err.message : String(err) };
3845
+ }
3846
+ }
3781
3847
  };
3782
3848
  function parseWorktreeList(output, currentPath) {
3783
- const foldedCurrent = normalizePathForCompare(currentPath);
3849
+ const foldedCurrent = currentPath ? normalizePathForCompare(currentPath) : null;
3784
3850
  const results = [];
3785
3851
  for (const wt of parseWorktreeListPorcelain(output)) {
3786
3852
  const resolved = path9.resolve(wt.path);
@@ -3789,7 +3855,7 @@ function parseWorktreeList(output, currentPath) {
3789
3855
  results.push({
3790
3856
  path: resolved,
3791
3857
  branch,
3792
- isCurrent: normalizePathForCompare(resolved) === foldedCurrent
3858
+ isCurrent: foldedCurrent !== null && normalizePathForCompare(resolved) === foldedCurrent
3793
3859
  });
3794
3860
  }
3795
3861
  return results;
@@ -3802,6 +3868,14 @@ async function safeMtimeMs(filePath) {
3802
3868
  return null;
3803
3869
  }
3804
3870
  }
3871
+ async function isDirectory(filePath) {
3872
+ try {
3873
+ const stat4 = await fs7.stat(filePath);
3874
+ return stat4.isDirectory();
3875
+ } catch {
3876
+ return false;
3877
+ }
3878
+ }
3805
3879
  async function findWorktreeRoot(startPath) {
3806
3880
  let current = path9.resolve(startPath);
3807
3881
  const root = path9.parse(current).root;
@@ -4001,21 +4075,11 @@ function ensureCapability(discovered, key, toolName) {
4001
4075
  throw new CapabilityUnavailableError(toolName, reasons);
4002
4076
  }
4003
4077
  }
4004
- async function ensureNotSyncing(ctx, repoName) {
4005
- const entry = ctx.getEntry(repoName);
4006
- if (!entry?.service) return;
4007
- if (entry.service.isSyncInProgress()) {
4008
- throw new SyncInProgressError(entry.name);
4009
- }
4010
- }
4011
4078
  async function getReadyService(ctx, repoName, options = {}) {
4012
4079
  const discovered = ctx.getDiscoveredContext(repoName);
4013
4080
  if (options.capability && options.toolName) {
4014
4081
  ensureCapability(discovered, options.capability, options.toolName);
4015
4082
  }
4016
- if (options.ensureNotSyncing) {
4017
- await ensureNotSyncing(ctx, repoName);
4018
- }
4019
4083
  const service = await ctx.getService(repoName);
4020
4084
  if (options.ensureInitialized && !service.isInitialized()) {
4021
4085
  await service.initialize();
@@ -4026,6 +4090,14 @@ async function getReadyService(ctx, repoName, options = {}) {
4026
4090
  git: service.getGitService()
4027
4091
  };
4028
4092
  }
4093
+ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
4094
+ const result = await service.runExclusiveRepoOperation(operation);
4095
+ if (!result.started) {
4096
+ const name = ctx.getEntry(repoName)?.name ?? repoName ?? "unknown";
4097
+ throw new SyncInProgressError(name);
4098
+ }
4099
+ return result.value;
4100
+ }
4029
4101
  async function ensureRepoWorktreePath(ctx, params, git) {
4030
4102
  await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
4031
4103
  return path10.resolve(params.path);
@@ -4046,13 +4118,38 @@ async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
4046
4118
  async function handleDetectContext(ctx, params, _extra) {
4047
4119
  const target = params.path ?? process.cwd();
4048
4120
  const discovered = await ctx.detectFromPath(target);
4049
- if (!params.includeStatus || discovered.allWorktrees.length === 0) {
4050
- return formatToolResponse(discovered);
4121
+ let response = discovered;
4122
+ if (params.includeAllWorktrees) {
4123
+ const details = await ctx.getAllConfiguredWorktreeDetails(discovered.currentWorktreePath);
4124
+ const errorsByRepo = Object.keys(details.errorsByRepo).length > 0 ? details.errorsByRepo : void 0;
4125
+ response = {
4126
+ ...response,
4127
+ allWorktreesByRepo: details.worktreesByRepo,
4128
+ allWorktreeErrorsByRepo: errorsByRepo
4129
+ };
4130
+ }
4131
+ if (!params.includeStatus) {
4132
+ return formatToolResponse(response);
4051
4133
  }
4052
4134
  const statusService = new WorktreeStatusService();
4053
- const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4054
- const enriched = await Promise.all(
4055
- discovered.allWorktrees.map(
4135
+ const statusLimit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4136
+ const enriched = await enrichDetectedWorktrees(response.allWorktrees, statusService, statusLimit);
4137
+ let allWorktreesByRepo = response.allWorktreesByRepo;
4138
+ if (allWorktreesByRepo) {
4139
+ const entries = await Promise.all(
4140
+ Object.entries(allWorktreesByRepo).map(async ([repoName, worktrees]) => [
4141
+ repoName,
4142
+ await enrichDetectedWorktrees(worktrees, statusService, statusLimit)
4143
+ ])
4144
+ );
4145
+ allWorktreesByRepo = Object.fromEntries(entries);
4146
+ }
4147
+ return formatToolResponse({ ...response, allWorktrees: enriched, allWorktreesByRepo });
4148
+ }
4149
+ async function enrichDetectedWorktrees(worktrees, statusService, limit) {
4150
+ if (worktrees.length === 0) return worktrees;
4151
+ return Promise.all(
4152
+ worktrees.map(
4056
4153
  (wt) => limit(async () => {
4057
4154
  const [status, divergence] = await Promise.all([
4058
4155
  statusService.getFullWorktreeStatus(wt.path, false).catch(() => null),
@@ -4067,10 +4164,41 @@ async function handleDetectContext(ctx, params, _extra) {
4067
4164
  })
4068
4165
  )
4069
4166
  );
4070
- return formatToolResponse({ ...discovered, allWorktrees: enriched });
4071
4167
  }
4072
4168
  async function handleListWorktrees(ctx, params, _extra) {
4073
- const { discovered, git } = await getReadyService(ctx, params.repoName, {
4169
+ const configuredRepoNames = params.repoName ? [] : ctx.getConfiguredRepositoryNames();
4170
+ if (configuredRepoNames.length > 0) {
4171
+ const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES);
4172
+ const statusLimit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4173
+ const repositories = await Promise.all(
4174
+ configuredRepoNames.map(
4175
+ (repoName) => limit(async () => {
4176
+ try {
4177
+ return [
4178
+ repoName,
4179
+ {
4180
+ worktrees: await listWorktreesForRepo(ctx, repoName, params.includeSize, statusLimit)
4181
+ }
4182
+ ];
4183
+ } catch (err) {
4184
+ return [
4185
+ repoName,
4186
+ {
4187
+ worktrees: [],
4188
+ error: err instanceof Error ? err.message : String(err)
4189
+ }
4190
+ ];
4191
+ }
4192
+ })
4193
+ )
4194
+ );
4195
+ return formatToolResponse({ repositories: Object.fromEntries(repositories) });
4196
+ }
4197
+ const results = await listWorktreesForRepo(ctx, params.repoName, params.includeSize);
4198
+ return formatToolResponse({ worktrees: results });
4199
+ }
4200
+ async function listWorktreesForRepo(ctx, repoName, includeSize, limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS)) {
4201
+ const { discovered, git } = await getReadyService(ctx, repoName, {
4074
4202
  capability: "listWorktrees",
4075
4203
  toolName: "list_worktrees"
4076
4204
  });
@@ -4085,7 +4213,6 @@ async function handleListWorktrees(ctx, params, _extra) {
4085
4213
  }
4086
4214
  }
4087
4215
  const currentPath = discovered?.currentWorktreePath ?? null;
4088
- const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
4089
4216
  const results = await Promise.all(
4090
4217
  worktrees.map(
4091
4218
  (wt) => limit(async () => {
@@ -4095,7 +4222,7 @@ async function handleListWorktrees(ctx, params, _extra) {
4095
4222
  git.getFullWorktreeStatus(wt.path, false).catch(() => null),
4096
4223
  getDivergence(wt.path),
4097
4224
  git.getWorktreeMetadata(wt.path).catch(() => null),
4098
- params.includeSize ? calculateDirectorySize(wt.path).catch(() => null) : Promise.resolve(null)
4225
+ includeSize ? calculateDirectorySize(wt.path).catch(() => null) : Promise.resolve(null)
4099
4226
  ]);
4100
4227
  return {
4101
4228
  path: resolvedPath,
@@ -4111,7 +4238,7 @@ async function handleListWorktrees(ctx, params, _extra) {
4111
4238
  })
4112
4239
  )
4113
4240
  );
4114
- return formatToolResponse({ worktrees: results });
4241
+ return results;
4115
4242
  }
4116
4243
  async function handleGetWorktreeStatus(ctx, params, _extra) {
4117
4244
  const { git } = await getReadyService(ctx, params.repoName, {
@@ -4130,76 +4257,82 @@ async function handleGetWorktreeStatus(ctx, params, _extra) {
4130
4257
  });
4131
4258
  }
4132
4259
  async function handleCreateWorktree(ctx, params, _extra) {
4133
- const { branchName, baseBranch, push } = params;
4260
+ const { branchName, baseBranch } = params;
4261
+ const push = params.push ?? true;
4134
4262
  const validation = isValidGitBranchName(branchName);
4135
4263
  if (!validation.valid) {
4136
4264
  throw new Error(`Invalid branch name '${branchName}': ${validation.error}`);
4137
4265
  }
4138
4266
  const { service, git } = await getReadyService(ctx, params.repoName, {
4139
4267
  capability: "createWorktree",
4140
- toolName: "create_worktree",
4141
- ensureInitialized: true,
4142
- ensureNotSyncing: true
4268
+ toolName: "create_worktree"
4143
4269
  });
4144
- const existence = await git.branchExists(branchName);
4145
- let created = false;
4146
- let pushed = false;
4147
- if (!existence.local && !existence.remote) {
4148
- if (!baseBranch) {
4149
- throw new Error(`Branch '${branchName}' does not exist. Provide 'baseBranch' to create it.`);
4150
- }
4151
- await git.createBranch(branchName, baseBranch);
4152
- created = true;
4153
- }
4154
- const worktreeDir = service.config.worktreeDir;
4155
- const worktreePath = pathResolution.getBranchWorktreePath(worktreeDir, branchName);
4156
- const existing = await git.getWorktrees();
4157
- const collision = existing.find((w) => pathsEqual(w.path, worktreePath) && w.branch !== branchName);
4158
- if (collision) {
4159
- throw new Error(
4160
- `Sanitized worktree path '${worktreePath}' collides with existing branch '${collision.branch}'. Rename or remove the conflicting branch first.`
4161
- );
4162
- }
4163
- await git.addWorktree(branchName, worktreePath);
4164
- ctx.invalidateDiscovered();
4165
- if (created && push) {
4166
- await git.pushBranch(branchName);
4167
- pushed = true;
4168
- }
4169
- return formatToolResponse({
4170
- success: true,
4171
- branchName,
4172
- worktreePath: path10.resolve(worktreePath),
4173
- created,
4174
- pushed
4270
+ return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4271
+ if (!service.isInitialized()) {
4272
+ await service.initialize();
4273
+ }
4274
+ const existence = await git.branchExists(branchName);
4275
+ let created = false;
4276
+ let pushed = false;
4277
+ if (!existence.local && !existence.remote) {
4278
+ if (!baseBranch) {
4279
+ throw new Error(`Branch '${branchName}' does not exist. Provide 'baseBranch' to create it.`);
4280
+ }
4281
+ await git.createBranch(branchName, baseBranch);
4282
+ created = true;
4283
+ }
4284
+ const worktreeDir = service.config.worktreeDir;
4285
+ const worktreePath = pathResolution.getBranchWorktreePath(worktreeDir, branchName);
4286
+ const existing = await git.getWorktrees();
4287
+ const collision = existing.find((w) => pathsEqual(w.path, worktreePath) && w.branch !== branchName);
4288
+ if (collision) {
4289
+ throw new Error(
4290
+ `Sanitized worktree path '${worktreePath}' collides with existing branch '${collision.branch}'. Rename or remove the conflicting branch first.`
4291
+ );
4292
+ }
4293
+ await git.addWorktree(branchName, worktreePath);
4294
+ ctx.invalidateDiscovered();
4295
+ if (created && push) {
4296
+ await git.pushBranch(branchName);
4297
+ pushed = true;
4298
+ }
4299
+ return formatToolResponse({
4300
+ success: true,
4301
+ branchName,
4302
+ worktreePath: path10.resolve(worktreePath),
4303
+ created,
4304
+ pushed
4305
+ });
4175
4306
  });
4176
4307
  }
4177
4308
  async function handleRemoveWorktree(ctx, params, _extra) {
4178
- const { git } = await getReadyService(ctx, params.repoName, {
4309
+ const { service, git } = await getReadyService(ctx, params.repoName, {
4179
4310
  capability: "removeWorktree",
4180
- toolName: "remove_worktree",
4181
- ensureInitialized: true,
4182
- ensureNotSyncing: true
4311
+ toolName: "remove_worktree"
4183
4312
  });
4184
- const removedPath = await ensureRepoWorktreePath(ctx, params, git);
4185
- if (!params.force) {
4186
- const status = await git.getFullWorktreeStatus(params.path, false);
4187
- if (!status.canRemove) {
4188
- throw new Error(`Cannot remove worktree: ${status.reasons.join(", ")}. Use force=true to override.`);
4313
+ return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4314
+ if (!service.isInitialized()) {
4315
+ await service.initialize();
4189
4316
  }
4190
- }
4191
- await git.removeWorktree(params.path);
4192
- ctx.invalidateDiscovered();
4193
- return formatToolResponse({
4194
- success: true,
4195
- removedPath
4317
+ const removedPath = await ensureRepoWorktreePath(ctx, params, git);
4318
+ if (!params.force) {
4319
+ const status = await git.getFullWorktreeStatus(params.path, false);
4320
+ if (!status.canRemove) {
4321
+ throw new Error(`Cannot remove worktree: ${status.reasons.join(", ")}. Use force=true to override.`);
4322
+ }
4323
+ }
4324
+ await git.removeWorktree(params.path);
4325
+ ctx.invalidateDiscovered();
4326
+ return formatToolResponse({
4327
+ success: true,
4328
+ removedPath
4329
+ });
4196
4330
  });
4197
4331
  }
4198
4332
  async function handleSync(ctx, params, extra) {
4199
4333
  const { service } = await getReadyService(ctx, params.repoName, {
4200
4334
  capability: "sync",
4201
- toolName: "sync",
4202
- ensureInitialized: true
4335
+ toolName: "sync"
4203
4336
  });
4204
4337
  const dispose = attachProgressReporter(service, extra);
4205
4338
  try {
@@ -4216,35 +4349,39 @@ async function handleSync(ctx, params, extra) {
4216
4349
  }
4217
4350
  }
4218
4351
  async function handleUpdateWorktree(ctx, params, _extra) {
4219
- const { git } = await getReadyService(ctx, params.repoName, {
4352
+ const { service, git } = await getReadyService(ctx, params.repoName, {
4220
4353
  capability: "updateWorktree",
4221
- toolName: "update_worktree",
4222
- ensureInitialized: true,
4223
- ensureNotSyncing: true
4354
+ toolName: "update_worktree"
4224
4355
  });
4225
- const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
4226
- await git.updateWorktree(params.path);
4227
- ctx.invalidateDiscovered();
4228
- return formatToolResponse({
4229
- success: true,
4230
- worktreePath
4356
+ return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4357
+ if (!service.isInitialized()) {
4358
+ await service.initialize();
4359
+ }
4360
+ const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
4361
+ await git.updateWorktree(params.path);
4362
+ ctx.invalidateDiscovered();
4363
+ return formatToolResponse({
4364
+ success: true,
4365
+ worktreePath
4366
+ });
4231
4367
  });
4232
4368
  }
4233
4369
  async function handleInitialize(ctx, params, extra) {
4234
4370
  const { service } = await getReadyService(ctx, params.repoName, {
4235
4371
  capability: "initialize",
4236
- toolName: "initialize",
4237
- ensureNotSyncing: true
4372
+ toolName: "initialize"
4238
4373
  });
4239
4374
  const dispose = attachProgressReporter(service, extra);
4240
4375
  try {
4241
- await service.initialize();
4242
- const git = service.getGitService();
4243
- ctx.invalidateDiscovered();
4244
- return formatToolResponse({
4245
- success: true,
4246
- defaultBranch: git.getDefaultBranch(),
4247
- worktreeDir: service.config.worktreeDir
4376
+ return await runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4377
+ await service.initialize();
4378
+ const git = service.getGitService();
4379
+ ctx.invalidateDiscovered();
4380
+ return formatToolResponse({
4381
+ success: true,
4382
+ defaultBranch: git.getDefaultBranch(),
4383
+ worktreeDir: service.config.worktreeDir
4384
+ });
4248
4385
  });
4249
4386
  } finally {
4250
4387
  dispose();
@@ -4294,7 +4431,7 @@ function attachProgressReporter(service, extra) {
4294
4431
  // src/mcp/server.ts
4295
4432
  var REPO_NAME_DESCRIBE = "Repository name from loaded config. If omitted, uses the current repository set via set_current_repository or the only loaded repo.";
4296
4433
  var PATH_DESCRIBE_SUFFIX = "Absolute path preferred; relative paths resolve from the server's CWD.";
4297
- var SERVER_INSTRUCTIONS = "Before running git worktree operations, call `detect_context` to learn the current repo, current branch, sibling repositories under the workspace root, and which capabilities are available. It walks up to auto-discover sync-worktrees.config.{js,mjs,cjs,ts}, lists sibling worktrees, and reports per-capability {available, reason} so you can tell which tool is gated and why.";
4434
+ var SERVER_INSTRUCTIONS = "Before running git worktree operations, call `detect_context` with `includeAllWorktrees: true` at session start to learn every configured repository and worktree, plus the current repo, current branch, sibling repositories, and available capabilities. It walks up to auto-discover sync-worktrees.config.{js,mjs,cjs,ts}, reports config-driven sibling repositories, and reports per-capability {available, reason} so you can tell which tool is gated and why.";
4298
4435
  function buildInstructions(snapshot) {
4299
4436
  const d = snapshot?.discovered;
4300
4437
  if (!d || !d.isWorktree || d.kind !== "managed") return SERVER_INSTRUCTIONS;
@@ -4346,11 +4483,14 @@ function createServer(context, snapshot) {
4346
4483
  server.registerTool(
4347
4484
  "detect_context",
4348
4485
  {
4349
- description: "Detect sync-worktrees structure from a filesystem path. Reads .git file, resolves bare repo, discovers sibling worktrees, walks up for a sync-worktrees.config.{js,mjs,cjs,ts}, and lists sibling bare repos under the workspace root. Defaults to CWD. Use when: bootstrapping from an unknown checkout. Returns: discovered repo root, bare repo path, all sibling worktrees, sibling repositories, current worktree path, configPath (auto-found), per-capability {available, reason}, notes[].",
4486
+ description: "Detect sync-worktrees structure from a filesystem path. Reads .git file, resolves bare repo, discovers sibling worktrees, walks up for a sync-worktrees.config.{js,mjs,cjs,ts}, and lists configured sibling repositories. Defaults to CWD. Use when: bootstrapping from an unknown checkout. Returns: discovered repo root, bare repo path, all sibling worktrees, sibling repositories, current worktree path, configPath (auto-found), per-capability {available, reason}, notes[].",
4350
4487
  inputSchema: {
4351
4488
  path: z.string().optional().describe("Directory path to inspect. Defaults to the server's CWD."),
4489
+ includeAllWorktrees: z.boolean().optional().describe(
4490
+ "If true, includes allWorktreesByRepo with worktrees for every configured repository, keyed by repoName, and allWorktreeErrorsByRepo for repos that could not be enumerated. Default: false."
4491
+ ),
4352
4492
  includeStatus: z.boolean().optional().describe(
4353
- "If true, enriches each entry in allWorktrees with label, divergence, and staleHint. Adds one git status + rev-list per worktree. Default: false (cheap path)."
4493
+ "If true, enriches worktree entries with label, divergence, and staleHint. Adds one git status + rev-list per worktree. Default: false (cheap path)."
4354
4494
  )
4355
4495
  },
4356
4496
  annotations: {
@@ -4365,9 +4505,11 @@ function createServer(context, snapshot) {
4365
4505
  server.registerTool(
4366
4506
  "list_worktrees",
4367
4507
  {
4368
- description: "List all worktrees of a repository with enriched status. Returns: array of { path, branch, isCurrent, label (clean|dirty|stale|current|unknown), status, divergence (ahead/behind), safeToRemove: { safe, reason }, lastSyncAt, sizeBytes }.",
4508
+ description: "List worktrees with enriched status. Without repoName and with a loaded config, returns all configured repositories grouped by repoName. With repoName, returns that single repository. Returns worktree entries as { path, branch, isCurrent, label (clean|dirty|stale|current|unknown), status, divergence (ahead/behind), safeToRemove: { safe, reason }, lastSyncAt, sizeBytes }.",
4369
4509
  inputSchema: {
4370
- repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
4510
+ repoName: z.string().optional().describe(
4511
+ "Repository name from loaded config. If omitted and a config is loaded, lists all configured repos."
4512
+ ),
4371
4513
  includeSize: z.boolean().optional().describe(
4372
4514
  "If true, computes the on-disk size of each worktree (in bytes). Slow on large worktrees. Default: false (sizeBytes returned as null)."
4373
4515
  )
@@ -4402,13 +4544,13 @@ function createServer(context, snapshot) {
4402
4544
  server.registerTool(
4403
4545
  "create_worktree",
4404
4546
  {
4405
- description: "Create a worktree for a branch. If the branch exists (local or remote), checks it out; otherwise creates it from baseBranch. Optionally pushes the new branch to origin. Key params: baseBranch is required only when the branch does not yet exist \u2014 pass it defensively if unsure. push=true only affects newly created branches. Preconditions: repository must be initialized (auto-runs on first call). Returns: { success, branchName, worktreePath, created, pushed }.",
4547
+ description: "Create a worktree for a branch. If the branch exists (local or remote), checks it out; otherwise creates it from baseBranch and pushes the new branch to origin by default. Key params: baseBranch is required only when the branch does not yet exist \u2014 pass it defensively if unsure. push=false opts out for newly created branches. Preconditions: repository must be initialized (auto-runs on first call). Returns: { success, branchName, worktreePath, created, pushed }.",
4406
4548
  inputSchema: {
4407
4549
  branchName: z.string().describe("Branch name. Slashes and special chars are sanitized for the worktree directory name."),
4408
4550
  baseBranch: z.string().optional().describe(
4409
4551
  "Base branch for creating a new branch. Required if branchName does not exist locally or remotely; ignored otherwise."
4410
4552
  ),
4411
- push: z.boolean().optional().describe("Push the newly created branch to origin. Ignored if the branch already existed."),
4553
+ push: z.boolean().optional().describe("Push the newly created branch to origin. Default: true. Ignored if the branch already existed."),
4412
4554
  repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
4413
4555
  },
4414
4556
  annotations: {
@@ -4497,7 +4639,7 @@ function createServer(context, snapshot) {
4497
4639
  server.registerTool(
4498
4640
  "load_config",
4499
4641
  {
4500
- description: "Load or reload a sync-worktrees JavaScript config file into the server's session. Replaces any previously loaded repositories. Call this before sync/initialize/create_worktree when using a config-driven workflow. Returns: { configPath, currentRepository, repositories: [{ name, repoPath, worktreeDir, ... }] }.",
4642
+ description: "Load or reload a sync-worktrees JavaScript config file into the server's session. Replaces any previously loaded repositories. Call this before sync/initialize/create_worktree when using a config-driven workflow. Returns: { configPath, currentRepository, repositories: [{ name, repoUrl, worktreeDir, source }] }.",
4501
4643
  inputSchema: {
4502
4644
  configPath: z.string().optional().describe(
4503
4645
  "Path to the config file. If omitted, falls back to the SYNC_WORKTREES_CONFIG env var. Errors if neither is set."