sync-worktrees 3.6.0 → 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.
@@ -1485,10 +1485,11 @@ var WorktreeStatusService = class {
1485
1485
  const reasons = [];
1486
1486
  if (!isClean) reasons.push("uncommitted changes");
1487
1487
  if (hasUnpushedCommits) reasons.push("unpushed commits");
1488
+ if (hasStashedChanges) reasons.push("stashed changes");
1488
1489
  if (hasOperationInProgress) reasons.push("operation in progress");
1489
1490
  if (hasModifiedSubmodules) reasons.push("modified submodules");
1490
1491
  if (upstreamGone) reasons.push("upstream gone");
1491
- const canRemove = isClean && !hasUnpushedCommits && !hasOperationInProgress && !hasModifiedSubmodules;
1492
+ const canRemove = isClean && !hasUnpushedCommits && !hasStashedChanges && !hasOperationInProgress && !hasModifiedSubmodules;
1492
1493
  const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
1493
1494
  return {
1494
1495
  isClean,
@@ -2537,7 +2538,7 @@ var GitService = class {
2537
2538
  const bareGit = this.getCachedGit(this.bareRepoPath);
2538
2539
  const checkRef = async (ref) => {
2539
2540
  try {
2540
- await bareGit.raw(["show-ref", "--verify", "--quiet", ref]);
2541
+ await bareGit.raw(["show-ref", "--verify", ref]);
2541
2542
  return true;
2542
2543
  } catch {
2543
2544
  return false;
@@ -2554,10 +2555,22 @@ var GitService = class {
2554
2555
  const branches = await bareGit.branch();
2555
2556
  return branches.all;
2556
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
+ }
2557
2569
  async createBranch(branchName, baseBranch) {
2558
2570
  const bareGit = this.getCachedGit(this.bareRepoPath);
2559
- await bareGit.raw(["branch", branchName, `origin/${baseBranch}`]);
2560
- 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}'`);
2561
2574
  }
2562
2575
  async pushBranch(branchName) {
2563
2576
  const bareGit = this.getCachedGit(this.bareRepoPath);
@@ -2669,17 +2682,9 @@ var WorktreeSyncService = class {
2669
2682
  this.progressListeners.add(listener);
2670
2683
  return () => this.progressListeners.delete(listener);
2671
2684
  }
2672
- emitProgress(event) {
2673
- for (const listener of this.progressListeners) {
2674
- try {
2675
- listener(event);
2676
- } catch {
2677
- }
2678
- }
2679
- }
2680
- async sync() {
2685
+ async runExclusiveRepoOperation(operation) {
2681
2686
  if (this.syncInProgress) {
2682
- this.logger.warn("\u26A0\uFE0F Sync already in progress, skipping...");
2687
+ this.logger.warn("\u26A0\uFE0F Another repository operation is already in progress, skipping...");
2683
2688
  return { started: false, reason: "in_progress" };
2684
2689
  }
2685
2690
  const release = await this.acquireBareLock();
@@ -2688,36 +2693,55 @@ var WorktreeSyncService = class {
2688
2693
  return { started: false, reason: "locked" };
2689
2694
  }
2690
2695
  this.syncInProgress = true;
2691
- this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Starting worktree synchronization...`);
2692
- const totalTimer = new Timer();
2693
- const phaseTimer = new PhaseTimer();
2694
- const syncContext = { lfsSkipEnabled: false };
2695
- const retryOptions = this.createRetryOptions(syncContext);
2696
2696
  try {
2697
- await retry(() => this.runSyncAttempt(phaseTimer, syncContext), retryOptions);
2698
- } catch (error) {
2699
- this.logger.error("\n\u274C Error during worktree synchronization after all retry attempts:", error);
2700
- throw error;
2697
+ return { started: true, value: await operation() };
2701
2698
  } finally {
2702
- if (syncContext.lfsSkipEnabled && !this.config.skipLfs) {
2703
- this.gitService.setLfsSkipEnabled(false);
2704
- }
2705
2699
  this.syncInProgress = false;
2706
2700
  try {
2707
2701
  await release();
2708
2702
  } catch (releaseError) {
2709
2703
  this.logger.warn(`Failed to release sync lock: ${getErrorMessage(releaseError)}`);
2710
2704
  }
2711
- this.logger.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] Synchronization finished.
2712
- `);
2713
- if (this.config.debug) {
2714
- const totalDuration = totalTimer.stop();
2715
- const phaseResults = phaseTimer.getResults();
2716
- const repoName = this.config.name;
2717
- 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 {
2718
2712
  }
2719
2713
  }
2720
- 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;
2721
2745
  }
2722
2746
  async acquireBareLock() {
2723
2747
  if (process.env.NODE_ENV === ENV_CONSTANTS.NODE_ENV_TEST) {
@@ -2729,15 +2753,9 @@ var WorktreeSyncService = class {
2729
2753
  };
2730
2754
  }
2731
2755
  const barePath = this.gitService.getBareRepoPath();
2732
- const lockTarget = path8.join(barePath, "HEAD");
2733
- try {
2734
- await fs6.access(lockTarget);
2735
- } catch {
2736
- return async () => {
2737
- };
2738
- }
2756
+ await fs6.mkdir(barePath, { recursive: true });
2739
2757
  try {
2740
- const release = await lockfile.lock(lockTarget, {
2758
+ const release = await lockfile.lock(barePath, {
2741
2759
  stale: DEFAULT_CONFIG.LOCK_STALE_MS,
2742
2760
  update: DEFAULT_CONFIG.LOCK_UPDATE_MS,
2743
2761
  retries: 0,
@@ -4000,21 +4018,11 @@ function ensureCapability(discovered, key, toolName) {
4000
4018
  throw new CapabilityUnavailableError(toolName, reasons);
4001
4019
  }
4002
4020
  }
4003
- async function ensureNotSyncing(ctx, repoName) {
4004
- const entry = ctx.getEntry(repoName);
4005
- if (!entry?.service) return;
4006
- if (entry.service.isSyncInProgress()) {
4007
- throw new SyncInProgressError(entry.name);
4008
- }
4009
- }
4010
4021
  async function getReadyService(ctx, repoName, options = {}) {
4011
4022
  const discovered = ctx.getDiscoveredContext(repoName);
4012
4023
  if (options.capability && options.toolName) {
4013
4024
  ensureCapability(discovered, options.capability, options.toolName);
4014
4025
  }
4015
- if (options.ensureNotSyncing) {
4016
- await ensureNotSyncing(ctx, repoName);
4017
- }
4018
4026
  const service = await ctx.getService(repoName);
4019
4027
  if (options.ensureInitialized && !service.isInitialized()) {
4020
4028
  await service.initialize();
@@ -4025,6 +4033,14 @@ async function getReadyService(ctx, repoName, options = {}) {
4025
4033
  git: service.getGitService()
4026
4034
  };
4027
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
+ }
4028
4044
  async function ensureRepoWorktreePath(ctx, params, git) {
4029
4045
  await ensurePathBelongsToRepo(ctx, params.path, params.repoName, git);
4030
4046
  return path10.resolve(params.path);
@@ -4136,69 +4152,74 @@ async function handleCreateWorktree(ctx, params, _extra) {
4136
4152
  }
4137
4153
  const { service, git } = await getReadyService(ctx, params.repoName, {
4138
4154
  capability: "createWorktree",
4139
- toolName: "create_worktree",
4140
- ensureInitialized: true,
4141
- ensureNotSyncing: true
4155
+ toolName: "create_worktree"
4142
4156
  });
4143
- const existence = await git.branchExists(branchName);
4144
- let created = false;
4145
- let pushed = false;
4146
- if (!existence.local && !existence.remote) {
4147
- if (!baseBranch) {
4148
- throw new Error(`Branch '${branchName}' does not exist. Provide 'baseBranch' to create it.`);
4149
- }
4150
- await git.createBranch(branchName, baseBranch);
4151
- created = true;
4152
- }
4153
- const worktreeDir = service.config.worktreeDir;
4154
- const worktreePath = pathResolution.getBranchWorktreePath(worktreeDir, branchName);
4155
- const existing = await git.getWorktrees();
4156
- const collision = existing.find((w) => pathsEqual(w.path, worktreePath) && w.branch !== branchName);
4157
- if (collision) {
4158
- throw new Error(
4159
- `Sanitized worktree path '${worktreePath}' collides with existing branch '${collision.branch}'. Rename or remove the conflicting branch first.`
4160
- );
4161
- }
4162
- await git.addWorktree(branchName, worktreePath);
4163
- ctx.invalidateDiscovered();
4164
- if (created && push) {
4165
- await git.pushBranch(branchName);
4166
- pushed = true;
4167
- }
4168
- return formatToolResponse({
4169
- success: true,
4170
- branchName,
4171
- worktreePath: path10.resolve(worktreePath),
4172
- created,
4173
- 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
+ });
4174
4193
  });
4175
4194
  }
4176
4195
  async function handleRemoveWorktree(ctx, params, _extra) {
4177
- const { git } = await getReadyService(ctx, params.repoName, {
4196
+ const { service, git } = await getReadyService(ctx, params.repoName, {
4178
4197
  capability: "removeWorktree",
4179
- toolName: "remove_worktree",
4180
- ensureInitialized: true,
4181
- ensureNotSyncing: true
4198
+ toolName: "remove_worktree"
4182
4199
  });
4183
- const removedPath = await ensureRepoWorktreePath(ctx, params, git);
4184
- if (!params.force) {
4185
- const status = await git.getFullWorktreeStatus(params.path, false);
4186
- if (!status.canRemove) {
4187
- 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();
4188
4203
  }
4189
- }
4190
- await git.removeWorktree(params.path);
4191
- ctx.invalidateDiscovered();
4192
- return formatToolResponse({
4193
- success: true,
4194
- 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
+ });
4195
4217
  });
4196
4218
  }
4197
4219
  async function handleSync(ctx, params, extra) {
4198
4220
  const { service } = await getReadyService(ctx, params.repoName, {
4199
4221
  capability: "sync",
4200
- toolName: "sync",
4201
- ensureInitialized: true
4222
+ toolName: "sync"
4202
4223
  });
4203
4224
  const dispose = attachProgressReporter(service, extra);
4204
4225
  try {
@@ -4215,35 +4236,39 @@ async function handleSync(ctx, params, extra) {
4215
4236
  }
4216
4237
  }
4217
4238
  async function handleUpdateWorktree(ctx, params, _extra) {
4218
- const { git } = await getReadyService(ctx, params.repoName, {
4239
+ const { service, git } = await getReadyService(ctx, params.repoName, {
4219
4240
  capability: "updateWorktree",
4220
- toolName: "update_worktree",
4221
- ensureInitialized: true,
4222
- ensureNotSyncing: true
4241
+ toolName: "update_worktree"
4223
4242
  });
4224
- const worktreePath = await ensureRepoWorktreePath(ctx, params, git);
4225
- await git.updateWorktree(params.path);
4226
- ctx.invalidateDiscovered();
4227
- return formatToolResponse({
4228
- success: true,
4229
- 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
+ });
4230
4254
  });
4231
4255
  }
4232
4256
  async function handleInitialize(ctx, params, extra) {
4233
4257
  const { service } = await getReadyService(ctx, params.repoName, {
4234
4258
  capability: "initialize",
4235
- toolName: "initialize",
4236
- ensureNotSyncing: true
4259
+ toolName: "initialize"
4237
4260
  });
4238
4261
  const dispose = attachProgressReporter(service, extra);
4239
4262
  try {
4240
- await service.initialize();
4241
- const git = service.getGitService();
4242
- ctx.invalidateDiscovered();
4243
- return formatToolResponse({
4244
- success: true,
4245
- defaultBranch: git.getDefaultBranch(),
4246
- 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
+ });
4247
4272
  });
4248
4273
  } finally {
4249
4274
  dispose();
@@ -4496,7 +4521,7 @@ function createServer(context, snapshot) {
4496
4521
  server.registerTool(
4497
4522
  "load_config",
4498
4523
  {
4499
- 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 }] }.",
4500
4525
  inputSchema: {
4501
4526
  configPath: z.string().optional().describe(
4502
4527
  "Path to the config file. If omitted, falls back to the SYNC_WORKTREES_CONFIG env var. Errors if neither is set."