sync-worktrees 3.6.1 → 3.6.2

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", 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");
2734
- try {
2735
- await fs6.access(lockTarget);
2736
- } catch {
2737
- return async () => {
2738
- };
2739
- }
2756
+ await fs6.mkdir(barePath, { recursive: true });
2740
2757
  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,
@@ -4001,21 +4018,11 @@ function ensureCapability(discovered, key, toolName) {
4001
4018
  throw new CapabilityUnavailableError(toolName, reasons);
4002
4019
  }
4003
4020
  }
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
4021
  async function getReadyService(ctx, repoName, options = {}) {
4012
4022
  const discovered = ctx.getDiscoveredContext(repoName);
4013
4023
  if (options.capability && options.toolName) {
4014
4024
  ensureCapability(discovered, options.capability, options.toolName);
4015
4025
  }
4016
- if (options.ensureNotSyncing) {
4017
- await ensureNotSyncing(ctx, repoName);
4018
- }
4019
4026
  const service = await ctx.getService(repoName);
4020
4027
  if (options.ensureInitialized && !service.isInitialized()) {
4021
4028
  await service.initialize();
@@ -4026,6 +4033,14 @@ async function getReadyService(ctx, repoName, options = {}) {
4026
4033
  git: service.getGitService()
4027
4034
  };
4028
4035
  }
4036
+ async function runExclusiveRepoOperation(ctx, repoName, service, operation) {
4037
+ const result = await service.runExclusiveRepoOperation(operation);
4038
+ if (!result.started) {
4039
+ const name = ctx.getEntry(repoName)?.name ?? repoName ?? "unknown";
4040
+ throw new SyncInProgressError(name);
4041
+ }
4042
+ return result.value;
4043
+ }
4029
4044
  async function ensureRepoWorktreePath(ctx, params, git) {
4030
4045
  await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
4031
4046
  return path10.resolve(params.path);
@@ -4137,69 +4152,74 @@ async function handleCreateWorktree(ctx, params, _extra) {
4137
4152
  }
4138
4153
  const { service, git } = await getReadyService(ctx, params.repoName, {
4139
4154
  capability: "createWorktree",
4140
- toolName: "create_worktree",
4141
- ensureInitialized: true,
4142
- ensureNotSyncing: true
4155
+ toolName: "create_worktree"
4143
4156
  });
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
4157
+ return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4158
+ if (!service.isInitialized()) {
4159
+ await service.initialize();
4160
+ }
4161
+ const existence = await git.branchExists(branchName);
4162
+ let created = false;
4163
+ let pushed = false;
4164
+ if (!existence.local && !existence.remote) {
4165
+ if (!baseBranch) {
4166
+ throw new Error(`Branch '${branchName}' does not exist. Provide 'baseBranch' to create it.`);
4167
+ }
4168
+ await git.createBranch(branchName, baseBranch);
4169
+ created = true;
4170
+ }
4171
+ const worktreeDir = service.config.worktreeDir;
4172
+ const worktreePath = pathResolution.getBranchWorktreePath(worktreeDir, branchName);
4173
+ const existing = await git.getWorktrees();
4174
+ const collision = existing.find((w) => pathsEqual(w.path, worktreePath) && w.branch !== branchName);
4175
+ if (collision) {
4176
+ throw new Error(
4177
+ `Sanitized worktree path '${worktreePath}' collides with existing branch '${collision.branch}'. Rename or remove the conflicting branch first.`
4178
+ );
4179
+ }
4180
+ await git.addWorktree(branchName, worktreePath);
4181
+ ctx.invalidateDiscovered();
4182
+ if (created && push) {
4183
+ await git.pushBranch(branchName);
4184
+ pushed = true;
4185
+ }
4186
+ return formatToolResponse({
4187
+ success: true,
4188
+ branchName,
4189
+ worktreePath: path10.resolve(worktreePath),
4190
+ created,
4191
+ pushed
4192
+ });
4175
4193
  });
4176
4194
  }
4177
4195
  async function handleRemoveWorktree(ctx, params, _extra) {
4178
- const { git } = await getReadyService(ctx, params.repoName, {
4196
+ const { service, git } = await getReadyService(ctx, params.repoName, {
4179
4197
  capability: "removeWorktree",
4180
- toolName: "remove_worktree",
4181
- ensureInitialized: true,
4182
- ensureNotSyncing: true
4198
+ toolName: "remove_worktree"
4183
4199
  });
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.`);
4200
+ return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4201
+ if (!service.isInitialized()) {
4202
+ await service.initialize();
4189
4203
  }
4190
- }
4191
- await git.removeWorktree(params.path);
4192
- ctx.invalidateDiscovered();
4193
- return formatToolResponse({
4194
- success: true,
4195
- removedPath
4204
+ const removedPath = await ensureRepoWorktreePath(ctx, params, git);
4205
+ if (!params.force) {
4206
+ const status = await git.getFullWorktreeStatus(params.path, false);
4207
+ if (!status.canRemove) {
4208
+ throw new Error(`Cannot remove worktree: ${status.reasons.join(", ")}. Use force=true to override.`);
4209
+ }
4210
+ }
4211
+ await git.removeWorktree(params.path);
4212
+ ctx.invalidateDiscovered();
4213
+ return formatToolResponse({
4214
+ success: true,
4215
+ removedPath
4216
+ });
4196
4217
  });
4197
4218
  }
4198
4219
  async function handleSync(ctx, params, extra) {
4199
4220
  const { service } = await getReadyService(ctx, params.repoName, {
4200
4221
  capability: "sync",
4201
- toolName: "sync",
4202
- ensureInitialized: true
4222
+ toolName: "sync"
4203
4223
  });
4204
4224
  const dispose = attachProgressReporter(service, extra);
4205
4225
  try {
@@ -4216,35 +4236,39 @@ async function handleSync(ctx, params, extra) {
4216
4236
  }
4217
4237
  }
4218
4238
  async function handleUpdateWorktree(ctx, params, _extra) {
4219
- const { git } = await getReadyService(ctx, params.repoName, {
4239
+ const { service, git } = await getReadyService(ctx, params.repoName, {
4220
4240
  capability: "updateWorktree",
4221
- toolName: "update_worktree",
4222
- ensureInitialized: true,
4223
- ensureNotSyncing: true
4241
+ toolName: "update_worktree"
4224
4242
  });
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
4243
+ return runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4244
+ if (!service.isInitialized()) {
4245
+ await service.initialize();
4246
+ }
4247
+ const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
4248
+ await git.updateWorktree(params.path);
4249
+ ctx.invalidateDiscovered();
4250
+ return formatToolResponse({
4251
+ success: true,
4252
+ worktreePath
4253
+ });
4231
4254
  });
4232
4255
  }
4233
4256
  async function handleInitialize(ctx, params, extra) {
4234
4257
  const { service } = await getReadyService(ctx, params.repoName, {
4235
4258
  capability: "initialize",
4236
- toolName: "initialize",
4237
- ensureNotSyncing: true
4259
+ toolName: "initialize"
4238
4260
  });
4239
4261
  const dispose = attachProgressReporter(service, extra);
4240
4262
  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
4263
+ return await runExclusiveRepoOperation(ctx, params.repoName, service, async () => {
4264
+ await service.initialize();
4265
+ const git = service.getGitService();
4266
+ ctx.invalidateDiscovered();
4267
+ return formatToolResponse({
4268
+ success: true,
4269
+ defaultBranch: git.getDefaultBranch(),
4270
+ worktreeDir: service.config.worktreeDir
4271
+ });
4248
4272
  });
4249
4273
  } finally {
4250
4274
  dispose();
@@ -4497,7 +4521,7 @@ function createServer(context, snapshot) {
4497
4521
  server.registerTool(
4498
4522
  "load_config",
4499
4523
  {
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, ... }] }.",
4524
+ 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
4525
  inputSchema: {
4502
4526
  configPath: z.string().optional().describe(
4503
4527
  "Path to the config file. If omitted, falls back to the SYNC_WORKTREES_CONFIG env var. Errors if neither is set."