sync-worktrees 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -85,6 +85,11 @@ var PATH_CONSTANTS = {
85
85
  GIT_DIR: ".git",
86
86
  README: "README"
87
87
  };
88
+ var CONFIG_FILE_NAMES = [
89
+ "sync-worktrees.config.js",
90
+ "sync-worktrees.config.mjs",
91
+ "sync-worktrees.config.cjs"
92
+ ];
88
93
  var METADATA_CONSTANTS = {
89
94
  MAX_HISTORY_ENTRIES: 10,
90
95
  METADATA_FILENAME: "sync-metadata.json",
@@ -124,6 +129,24 @@ function filterBranchesByName(branches, include, exclude) {
124
129
 
125
130
  // src/services/config-loader.service.ts
126
131
  var ConfigLoaderService = class {
132
+ async findConfigUpward(startDir) {
133
+ let current = path.resolve(startDir);
134
+ const root = path.parse(current).root;
135
+ while (true) {
136
+ for (const name of CONFIG_FILE_NAMES) {
137
+ const candidate = path.join(current, name);
138
+ try {
139
+ await fs.access(candidate);
140
+ return candidate;
141
+ } catch {
142
+ }
143
+ }
144
+ if (current === root) return null;
145
+ const parent = path.dirname(current);
146
+ if (parent === current) return null;
147
+ current = parent;
148
+ }
149
+ }
127
150
  async loadConfigFile(configPath) {
128
151
  const absolutePath = path.resolve(configPath);
129
152
  try {
@@ -780,6 +803,43 @@ import * as fs4 from "fs/promises";
780
803
  import * as path4 from "path";
781
804
  import simpleGit3 from "simple-git";
782
805
 
806
+ // src/errors/index.ts
807
+ var SyncWorktreesError = class extends Error {
808
+ constructor(message, code, cause) {
809
+ super(message);
810
+ this.code = code;
811
+ this.cause = cause;
812
+ this.name = this.constructor.name;
813
+ Object.setPrototypeOf(this, new.target.prototype);
814
+ if (cause && cause.stack) {
815
+ this.stack = `${this.stack}
816
+ Caused by: ${cause.stack}`;
817
+ }
818
+ }
819
+ };
820
+ var GitError = class extends SyncWorktreesError {
821
+ constructor(message, code, cause) {
822
+ super(message, `GIT_${code}`, cause);
823
+ }
824
+ };
825
+ var GitOperationError = class extends GitError {
826
+ constructor(operation, details, cause) {
827
+ super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
828
+ }
829
+ };
830
+ var WorktreeError = class extends SyncWorktreesError {
831
+ constructor(message, code, cause) {
832
+ super(message, `WORKTREE_${code}`, cause);
833
+ }
834
+ };
835
+ var WorktreeNotCleanError = class extends WorktreeError {
836
+ constructor(path10, reasons) {
837
+ super(`Worktree at '${path10}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
838
+ this.path = path10;
839
+ this.reasons = reasons;
840
+ }
841
+ };
842
+
783
843
  // src/utils/git-url.ts
784
844
  function extractRepoNameFromUrl(gitUrl) {
785
845
  const url = gitUrl.trim();
@@ -1078,45 +1138,6 @@ var WorktreeMetadataService = class {
1078
1138
  import * as fs3 from "fs/promises";
1079
1139
  import * as path3 from "path";
1080
1140
  import simpleGit2 from "simple-git";
1081
-
1082
- // src/errors/index.ts
1083
- var SyncWorktreesError = class extends Error {
1084
- constructor(message, code, cause) {
1085
- super(message);
1086
- this.code = code;
1087
- this.cause = cause;
1088
- this.name = this.constructor.name;
1089
- Object.setPrototypeOf(this, new.target.prototype);
1090
- if (cause && cause.stack) {
1091
- this.stack = `${this.stack}
1092
- Caused by: ${cause.stack}`;
1093
- }
1094
- }
1095
- };
1096
- var GitError = class extends SyncWorktreesError {
1097
- constructor(message, code, cause) {
1098
- super(message, `GIT_${code}`, cause);
1099
- }
1100
- };
1101
- var GitOperationError = class extends GitError {
1102
- constructor(operation, details, cause) {
1103
- super(`Git operation '${operation}' failed: ${details}`, "OPERATION_FAILED", cause);
1104
- }
1105
- };
1106
- var WorktreeError = class extends SyncWorktreesError {
1107
- constructor(message, code, cause) {
1108
- super(message, `WORKTREE_${code}`, cause);
1109
- }
1110
- };
1111
- var WorktreeNotCleanError = class extends WorktreeError {
1112
- constructor(path10, reasons) {
1113
- super(`Worktree at '${path10}' is not clean: ${reasons.join(", ")}`, "NOT_CLEAN");
1114
- this.path = path10;
1115
- this.reasons = reasons;
1116
- }
1117
- };
1118
-
1119
- // src/services/worktree-status.service.ts
1120
1141
  var OPERATION_FILES = [
1121
1142
  { file: GIT_OPERATIONS.MERGE_HEAD, type: "merge" },
1122
1143
  { file: GIT_OPERATIONS.CHERRY_PICK_HEAD, type: "cherry-pick" },
@@ -1173,6 +1194,7 @@ var WorktreeStatusService = class {
1173
1194
  if (hasUnpushedCommits) reasons.push("unpushed commits");
1174
1195
  if (hasOperationInProgress) reasons.push("operation in progress");
1175
1196
  if (hasModifiedSubmodules) reasons.push("modified submodules");
1197
+ if (upstreamGone) reasons.push("upstream gone");
1176
1198
  const canRemove = isClean && !hasUnpushedCommits && !hasOperationInProgress && !hasModifiedSubmodules;
1177
1199
  const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
1178
1200
  return {
@@ -1731,24 +1753,19 @@ var GitService = class {
1731
1753
  } catch {
1732
1754
  }
1733
1755
  try {
1734
- const branches = await bareGit.branch();
1735
- const localBranchExists = branches.all.includes(branchName);
1736
- if (localBranchExists || branchName.includes("/")) {
1737
- await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
1738
- const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
1739
- await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
1756
+ const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
1757
+ await this.runWorktreeAddByMatrix(
1758
+ bareGit,
1759
+ branchName,
1760
+ absoluteWorktreePath,
1761
+ localBranchExists,
1762
+ remoteBranchExists
1763
+ );
1764
+ if (localBranchExists && !remoteBranchExists) {
1765
+ this.logger.info(` - Created worktree for '${branchName}' (no remote yet \u2014 push to set upstream)`);
1740
1766
  } else {
1741
- await bareGit.raw([
1742
- "worktree",
1743
- "add",
1744
- "--track",
1745
- "-b",
1746
- branchName,
1747
- absoluteWorktreePath,
1748
- `origin/${branchName}`
1749
- ]);
1767
+ this.logger.info(` - Created worktree for '${branchName}' with tracking to origin/${branchName}`);
1750
1768
  }
1751
- this.logger.info(` - Created worktree for '${branchName}' with tracking to origin/${branchName}`);
1752
1769
  if (!this.isLfsSkipEnabled()) {
1753
1770
  await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
1754
1771
  }
@@ -1764,6 +1781,9 @@ var GitService = class {
1764
1781
  }
1765
1782
  } catch (error) {
1766
1783
  const errorMessage = getErrorMessage(error);
1784
+ if (error?.isUpstreamSetupFailure) {
1785
+ throw error;
1786
+ }
1767
1787
  if (errorMessage.includes("Metadata creation failed")) {
1768
1788
  throw error;
1769
1789
  }
@@ -1781,15 +1801,14 @@ var GitService = class {
1781
1801
  } catch {
1782
1802
  }
1783
1803
  try {
1784
- await bareGit.raw([
1785
- "worktree",
1786
- "add",
1787
- "--track",
1788
- "-b",
1804
+ const { local: localBranchExists, remote: remoteBranchExists } = await this.branchExists(branchName);
1805
+ await this.runWorktreeAddByMatrix(
1806
+ bareGit,
1789
1807
  branchName,
1790
1808
  absoluteWorktreePath,
1791
- `origin/${branchName}`
1792
- ]);
1809
+ localBranchExists,
1810
+ remoteBranchExists
1811
+ );
1793
1812
  this.logger.info(` - Created worktree for '${branchName}' after pruning`);
1794
1813
  if (!this.isLfsSkipEnabled()) {
1795
1814
  await this.verifyLfsFilesDownloaded(absoluteWorktreePath, branchName);
@@ -1858,6 +1877,43 @@ var GitService = class {
1858
1877
  }
1859
1878
  }
1860
1879
  }
1880
+ async runWorktreeAddByMatrix(bareGit, branchName, absoluteWorktreePath, localExists, remoteExists) {
1881
+ if (localExists && remoteExists) {
1882
+ await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
1883
+ try {
1884
+ const worktreeGit = this.getCachedGit(absoluteWorktreePath, this.isLfsSkipEnabled());
1885
+ await worktreeGit.branch(["--set-upstream-to", `origin/${branchName}`, branchName]);
1886
+ } catch (error) {
1887
+ let rollbackFailed = false;
1888
+ try {
1889
+ await bareGit.raw(["worktree", "remove", "--force", absoluteWorktreePath]);
1890
+ } catch (rollbackError) {
1891
+ rollbackFailed = true;
1892
+ this.logger.warn(
1893
+ ` - Rollback failed for '${branchName}' at '${absoluteWorktreePath}' after upstream setup error: ${getErrorMessage(rollbackError)}`
1894
+ );
1895
+ }
1896
+ const detail = getErrorMessage(error);
1897
+ const suffix = rollbackFailed ? " (rollback failed; partial worktree may remain)" : "";
1898
+ const wrapped = new Error(`Failed to set upstream for '${branchName}': ${detail}${suffix}`);
1899
+ wrapped.isUpstreamSetupFailure = true;
1900
+ throw wrapped;
1901
+ }
1902
+ return;
1903
+ }
1904
+ if (localExists) {
1905
+ await bareGit.raw(["worktree", "add", absoluteWorktreePath, branchName]);
1906
+ return;
1907
+ }
1908
+ if (remoteExists) {
1909
+ await bareGit.raw(["worktree", "add", "--track", "-b", branchName, absoluteWorktreePath, `origin/${branchName}`]);
1910
+ return;
1911
+ }
1912
+ throw new WorktreeError(
1913
+ `Branch '${branchName}' does not exist locally or on origin; create it first`,
1914
+ "BRANCH_NOT_FOUND"
1915
+ );
1916
+ }
1861
1917
  async removeWorktree(worktreePath) {
1862
1918
  const bareGit = this.getCachedGit(this.bareRepoPath);
1863
1919
  await bareGit.raw(["worktree", "remove", worktreePath, "--force"]);
@@ -2051,10 +2107,18 @@ var GitService = class {
2051
2107
  }
2052
2108
  async branchExists(branchName) {
2053
2109
  const bareGit = this.getCachedGit(this.bareRepoPath);
2054
- const localBranches = await bareGit.branch();
2055
- const local = localBranches.all.includes(branchName);
2056
- const remoteBranches = await bareGit.branch(["-r"]);
2057
- const remote = remoteBranches.all.includes(`origin/${branchName}`);
2110
+ const checkRef = async (ref) => {
2111
+ try {
2112
+ await bareGit.raw(["show-ref", "--verify", "--quiet", ref]);
2113
+ return true;
2114
+ } catch {
2115
+ return false;
2116
+ }
2117
+ };
2118
+ const [local, remote] = await Promise.all([
2119
+ checkRef(`${GIT_CONSTANTS.REFS.HEADS}${branchName}`),
2120
+ checkRef(`${GIT_CONSTANTS.REFS.REMOTES}/${branchName}`)
2121
+ ]);
2058
2122
  return { local, remote };
2059
2123
  }
2060
2124
  async getLocalBranches() {
@@ -2804,15 +2868,18 @@ var WorktreeSyncService = class {
2804
2868
  // src/mcp/context.ts
2805
2869
  var AUTO_DETECT_PREFIX = "__auto_detected__:";
2806
2870
  var DISCOVERY_CACHE_TTL_MS = 5e3;
2807
- var EMPTY_CAPABILITIES = {
2808
- canListWorktrees: false,
2809
- canGetStatus: false,
2810
- canCreateWorktree: false,
2811
- canRemoveWorktree: false,
2812
- canUpdateWorktree: false,
2813
- canSync: false,
2814
- canInitialize: false
2815
- };
2871
+ function emptyCapabilities(reason) {
2872
+ const state = reason ? { available: false, reason } : { available: false };
2873
+ return {
2874
+ listWorktrees: { ...state },
2875
+ getStatus: { ...state },
2876
+ createWorktree: { ...state },
2877
+ removeWorktree: { ...state },
2878
+ updateWorktree: { ...state },
2879
+ sync: { ...state },
2880
+ initialize: { ...state }
2881
+ };
2882
+ }
2816
2883
  function buildUnsupportedContext(currentPath, reason) {
2817
2884
  return {
2818
2885
  isWorktree: false,
@@ -2823,10 +2890,11 @@ function buildUnsupportedContext(currentPath, reason) {
2823
2890
  repoUrl: null,
2824
2891
  worktreeDir: null,
2825
2892
  allWorktrees: [],
2826
- configLoaded: false,
2893
+ siblingRepositories: [],
2894
+ configPath: null,
2827
2895
  repoName: null,
2828
- capabilities: EMPTY_CAPABILITIES,
2829
- reasons: [reason]
2896
+ capabilities: emptyCapabilities(reason),
2897
+ notes: [reason]
2830
2898
  };
2831
2899
  }
2832
2900
  function createStderrLogger(repoName) {
@@ -2843,7 +2911,8 @@ var RepositoryContext = class {
2843
2911
  configPath = null;
2844
2912
  configLoader = new ConfigLoaderService();
2845
2913
  discoveryCache = /* @__PURE__ */ new Map();
2846
- async loadConfig(configPath) {
2914
+ async loadConfig(configPath, options = {}) {
2915
+ const setDefaultCurrent = options.setDefaultCurrent ?? true;
2847
2916
  const absolutePath = path8.resolve(configPath);
2848
2917
  const configFile = await this.configLoader.loadConfigFile(absolutePath);
2849
2918
  for (const [name, entry] of this.repos) {
@@ -2865,9 +2934,10 @@ var RepositoryContext = class {
2865
2934
  if (this.currentRepo && !this.repos.has(this.currentRepo)) {
2866
2935
  this.currentRepo = null;
2867
2936
  }
2868
- if (!this.currentRepo && configFile.repositories.length > 0) {
2937
+ if (setDefaultCurrent && !this.currentRepo && configFile.repositories.length > 0) {
2869
2938
  this.currentRepo = configFile.repositories[0].name;
2870
2939
  }
2940
+ this.discoveryCache.clear();
2871
2941
  return configFile.repositories;
2872
2942
  }
2873
2943
  async detectFromPath(dirPath) {
@@ -2876,6 +2946,17 @@ var RepositoryContext = class {
2876
2946
  if (cached && await this.isCacheFresh(cached)) {
2877
2947
  return cached.result;
2878
2948
  }
2949
+ if (this.configPath === null) {
2950
+ const found = await this.configLoader.findConfigUpward(absolutePath);
2951
+ if (found) {
2952
+ try {
2953
+ await this.loadConfig(found, { setDefaultCurrent: false });
2954
+ } catch (err) {
2955
+ process.stderr.write(`[sync-worktrees] auto-loaded config failed: ${err.message}
2956
+ `);
2957
+ }
2958
+ }
2959
+ }
2879
2960
  const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
2880
2961
  if (result.isWorktree && result.bareRepoPath && adminDir) {
2881
2962
  const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
@@ -2911,10 +2992,49 @@ var RepositoryContext = class {
2911
2992
  __discoveryCacheSizeForTest() {
2912
2993
  return this.discoveryCache.size;
2913
2994
  }
2914
- bootstrapCurrentRepo(candidate) {
2995
+ async discoverSiblingRepositories(currentBareRepoPath) {
2996
+ const repoDir = path8.dirname(currentBareRepoPath);
2997
+ const workspaceRoot = path8.dirname(repoDir);
2998
+ if (workspaceRoot === repoDir) return [];
2999
+ let entries;
3000
+ try {
3001
+ entries = await fs7.readdir(workspaceRoot);
3002
+ } catch {
3003
+ return [];
3004
+ }
3005
+ const configBares = /* @__PURE__ */ new Map();
3006
+ for (const entry of this.repos.values()) {
3007
+ if (entry.source === "config" && entry.config.bareRepoDir) {
3008
+ configBares.set(normalizePathForCompare(entry.config.bareRepoDir), entry.name);
3009
+ }
3010
+ }
3011
+ const results = [];
3012
+ await Promise.all(
3013
+ entries.map(async (entry) => {
3014
+ const candidate = path8.join(workspaceRoot, entry);
3015
+ const bareCandidate = path8.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
3016
+ try {
3017
+ const stat4 = await fs7.stat(bareCandidate);
3018
+ if (!stat4.isDirectory()) return;
3019
+ } catch {
3020
+ return;
3021
+ }
3022
+ const resolvedBare = path8.resolve(bareCandidate);
3023
+ const matchedName = configBares.get(normalizePathForCompare(resolvedBare));
3024
+ results.push({
3025
+ name: matchedName ?? entry,
3026
+ bareRepoPath: resolvedBare,
3027
+ configMatched: matchedName !== void 0
3028
+ });
3029
+ })
3030
+ );
3031
+ results.sort((a, b) => a.name.localeCompare(b.name));
3032
+ return results;
3033
+ }
3034
+ bootstrapCurrentRepo(candidate, force = false) {
2915
3035
  if (this.currentRepo !== null) return;
2916
3036
  if (!this.repos.has(candidate)) return;
2917
- if (this.repos.size !== 1) return;
3037
+ if (!force && this.repos.size !== 1) return;
2918
3038
  this.currentRepo = candidate;
2919
3039
  }
2920
3040
  async isCacheFresh(cached) {
@@ -2927,14 +3047,14 @@ var RepositoryContext = class {
2927
3047
  return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
2928
3048
  }
2929
3049
  async detectFromPathUncached(absolutePath) {
2930
- const reasons = [];
3050
+ const notes = [];
2931
3051
  const located = await findWorktreeRoot(absolutePath);
2932
3052
  const worktreeRoot = located?.worktreeRoot ?? absolutePath;
2933
- const unsupported = (reason, isWorktree = false) => {
2934
- reasons.push(reason);
3053
+ const unsupported = (reason) => {
3054
+ notes.push(reason);
2935
3055
  return {
2936
3056
  result: {
2937
- isWorktree,
3057
+ isWorktree: false,
2938
3058
  kind: "unsupported",
2939
3059
  currentBranch: null,
2940
3060
  currentWorktreePath: worktreeRoot,
@@ -2942,10 +3062,11 @@ var RepositoryContext = class {
2942
3062
  repoUrl: null,
2943
3063
  worktreeDir: null,
2944
3064
  allWorktrees: [],
2945
- configLoaded: this.configPath !== null,
3065
+ siblingRepositories: [],
3066
+ configPath: this.configPath,
2946
3067
  repoName: null,
2947
- capabilities: EMPTY_CAPABILITIES,
2948
- reasons
3068
+ capabilities: emptyCapabilities(reason),
3069
+ notes
2949
3070
  },
2950
3071
  adminDir: null
2951
3072
  };
@@ -2979,7 +3100,7 @@ var RepositoryContext = class {
2979
3100
  const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
2980
3101
  repoUrl = urlStr || null;
2981
3102
  } catch {
2982
- reasons.push("Could not read remote origin URL");
3103
+ notes.push("Could not read remote origin URL");
2983
3104
  }
2984
3105
  const listOutput = await bareGit.raw(["worktree", "list", "--porcelain"]);
2985
3106
  worktrees = parseWorktreeList(listOutput, worktreeRoot);
@@ -2988,7 +3109,8 @@ var RepositoryContext = class {
2988
3109
  currentBranch = current.branch;
2989
3110
  }
2990
3111
  } catch (err) {
2991
- reasons.push(`Failed to read bare repo at ${bareRepoPath}: ${err.message}`);
3112
+ const reason = `Failed to read bare repo at ${bareRepoPath}: ${err.message}`;
3113
+ notes.push(reason);
2992
3114
  return {
2993
3115
  result: {
2994
3116
  isWorktree: true,
@@ -2999,34 +3121,31 @@ var RepositoryContext = class {
2999
3121
  repoUrl: null,
3000
3122
  worktreeDir: null,
3001
3123
  allWorktrees: [],
3002
- configLoaded: this.configPath !== null,
3124
+ siblingRepositories: [],
3125
+ configPath: this.configPath,
3003
3126
  repoName: null,
3004
- capabilities: EMPTY_CAPABILITIES,
3005
- reasons
3127
+ capabilities: emptyCapabilities(reason),
3128
+ notes
3006
3129
  },
3007
3130
  adminDir
3008
3131
  };
3009
3132
  }
3010
3133
  const worktreeDir = path8.dirname(worktreeRoot);
3134
+ const noUrlReason = "no remote origin URL detected";
3011
3135
  const capabilities = {
3012
- canListWorktrees: true,
3013
- canGetStatus: true,
3014
- canCreateWorktree: repoUrl !== null,
3015
- canRemoveWorktree: true,
3016
- canUpdateWorktree: true,
3017
- canSync: false,
3018
- canInitialize: false
3136
+ listWorktrees: { available: true },
3137
+ getStatus: { available: true },
3138
+ createWorktree: repoUrl !== null ? { available: true } : { available: false, reason: noUrlReason },
3139
+ removeWorktree: { available: true },
3140
+ updateWorktree: { available: true },
3141
+ sync: { available: false, reason: "no config and no remote URL" },
3142
+ initialize: { available: false, reason: "no config and no remote URL" }
3019
3143
  };
3020
- if (!repoUrl) {
3021
- reasons.push("create_worktree unavailable: no remote origin URL detected");
3022
- }
3023
- const foldPath = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
3024
- const foldedBare = foldPath(bareRepoPath);
3144
+ const foldedBare = normalizePathForCompare(bareRepoPath);
3025
3145
  let matchedConfig = null;
3026
3146
  for (const entry of this.repos.values()) {
3027
- if (entry.source === "config") {
3028
- const entryBare = entry.config.bareRepoDir ? path8.resolve(entry.config.bareRepoDir) : null;
3029
- if (entryBare && foldPath(entryBare) === foldedBare) {
3147
+ if (entry.source === "config" && entry.config.bareRepoDir) {
3148
+ if (normalizePathForCompare(entry.config.bareRepoDir) === foldedBare) {
3030
3149
  matchedConfig = entry;
3031
3150
  break;
3032
3151
  }
@@ -3037,8 +3156,8 @@ var RepositoryContext = class {
3037
3156
  if (matchedConfig) {
3038
3157
  repoName = matchedConfig.name;
3039
3158
  kind = "managed";
3040
- capabilities.canSync = true;
3041
- capabilities.canInitialize = true;
3159
+ capabilities.sync = { available: true };
3160
+ capabilities.initialize = { available: true };
3042
3161
  } else if (repoUrl) {
3043
3162
  const syntheticConfig = {
3044
3163
  repoUrl,
@@ -3056,13 +3175,14 @@ var RepositoryContext = class {
3056
3175
  });
3057
3176
  }
3058
3177
  repoName = detectedKey;
3059
- reasons.push("sync/initialize unavailable: no config file loaded (running in auto-detect mode)");
3060
- } else {
3061
- reasons.push("sync/initialize unavailable: no config file and no remote URL");
3178
+ const autoReason = "no config file loaded (running in auto-detect mode)";
3179
+ capabilities.sync = { available: false, reason: autoReason };
3180
+ capabilities.initialize = { available: false, reason: autoReason };
3062
3181
  }
3063
3182
  if (repoName) {
3064
- this.bootstrapCurrentRepo(repoName);
3183
+ this.bootstrapCurrentRepo(repoName, matchedConfig !== null);
3065
3184
  }
3185
+ const siblingRepositories = await this.discoverSiblingRepositories(bareRepoPath);
3066
3186
  const discovered = {
3067
3187
  isWorktree: true,
3068
3188
  kind,
@@ -3072,10 +3192,11 @@ var RepositoryContext = class {
3072
3192
  repoUrl,
3073
3193
  worktreeDir,
3074
3194
  allWorktrees: worktrees,
3075
- configLoaded: this.configPath !== null,
3195
+ siblingRepositories,
3196
+ configPath: this.configPath,
3076
3197
  repoName,
3077
3198
  capabilities,
3078
- reasons
3199
+ notes
3079
3200
  };
3080
3201
  if (repoName) {
3081
3202
  const entry = this.repos.get(repoName);
@@ -3134,9 +3255,7 @@ var RepositoryContext = class {
3134
3255
  }
3135
3256
  };
3136
3257
  function parseWorktreeList(output, currentPath) {
3137
- const resolvedCurrent = path8.resolve(currentPath);
3138
- const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
3139
- const foldedCurrent = fold(resolvedCurrent);
3258
+ const foldedCurrent = normalizePathForCompare(currentPath);
3140
3259
  const results = [];
3141
3260
  for (const wt of parseWorktreeListPorcelain(output)) {
3142
3261
  const resolved = path8.resolve(wt.path);
@@ -3145,7 +3264,7 @@ function parseWorktreeList(output, currentPath) {
3145
3264
  results.push({
3146
3265
  path: resolved,
3147
3266
  branch,
3148
- isCurrent: fold(resolved) === foldedCurrent
3267
+ isCurrent: normalizePathForCompare(resolved) === foldedCurrent
3149
3268
  });
3150
3269
  }
3151
3270
  return results;
@@ -3189,7 +3308,24 @@ import { z } from "zod";
3189
3308
  // src/mcp/handlers.ts
3190
3309
  import * as path9 from "path";
3191
3310
  import pLimit2 from "p-limit";
3192
- import simpleGit5 from "simple-git";
3311
+
3312
+ // src/utils/disk-space.ts
3313
+ import fastFolderSize from "fast-folder-size";
3314
+ async function calculateDirectorySize(dirPath) {
3315
+ return new Promise((resolve9, reject) => {
3316
+ fastFolderSize(dirPath, (err, bytes) => {
3317
+ if (err) {
3318
+ reject(err);
3319
+ return;
3320
+ }
3321
+ if (bytes === void 0) {
3322
+ reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
3323
+ return;
3324
+ }
3325
+ resolve9(bytes);
3326
+ });
3327
+ });
3328
+ }
3193
3329
 
3194
3330
  // src/utils/git-validation.ts
3195
3331
  function isValidGitBranchName(name) {
@@ -3299,12 +3435,45 @@ function wrapHandler(fn) {
3299
3435
  };
3300
3436
  }
3301
3437
 
3438
+ // src/mcp/worktree-summary.ts
3439
+ import simpleGit5 from "simple-git";
3440
+ function deriveLabel(status, isCurrent) {
3441
+ if (isCurrent) return "current";
3442
+ if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
3443
+ if (status.upstreamGone) return "stale";
3444
+ return "clean";
3445
+ }
3446
+ function deriveSafeToRemove(status) {
3447
+ if (status.canRemove && !status.upstreamGone) {
3448
+ return { safe: true, reason: "clean tree, no unpushed commits" };
3449
+ }
3450
+ if (status.canRemove && status.upstreamGone) {
3451
+ return { safe: false, reason: "branch deleted upstream \u2014 verify no work is lost before removal" };
3452
+ }
3453
+ if (status.reasons.length > 0) {
3454
+ return { safe: false, reason: status.reasons.join(", ") };
3455
+ }
3456
+ return { safe: false, reason: "not safe to remove" };
3457
+ }
3458
+ async function getDivergence(worktreePath) {
3459
+ try {
3460
+ const git = simpleGit5(worktreePath);
3461
+ const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
3462
+ const [aheadStr, behindStr] = output.trim().split(/\s+/);
3463
+ return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
3464
+ } catch {
3465
+ return null;
3466
+ }
3467
+ }
3468
+
3302
3469
  // src/mcp/handlers.ts
3303
3470
  var pathResolution = new PathResolutionService();
3304
3471
  function ensureCapability(discovered, key, toolName) {
3305
3472
  if (!discovered) return;
3306
- if (!discovered.capabilities[key]) {
3307
- throw new CapabilityUnavailableError(toolName, discovered.reasons);
3473
+ const cap = discovered.capabilities[key];
3474
+ if (!cap.available) {
3475
+ const reasons = cap.reason ? [cap.reason] : discovered.notes;
3476
+ throw new CapabilityUnavailableError(toolName, reasons);
3308
3477
  }
3309
3478
  }
3310
3479
  async function ensureNotSyncing(ctx, repoName) {
@@ -3349,30 +3518,35 @@ async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
3349
3518
  }
3350
3519
  throw new Error(`Path '${targetPath}' is not a registered worktree of the current repository`);
3351
3520
  }
3352
- function deriveLabel(status, isCurrent) {
3353
- if (isCurrent) return "current";
3354
- if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
3355
- if (status.upstreamGone) return "stale";
3356
- return "clean";
3357
- }
3358
- async function getDivergence(worktreePath) {
3359
- try {
3360
- const git = simpleGit5(worktreePath);
3361
- const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
3362
- const [aheadStr, behindStr] = output.trim().split(/\s+/);
3363
- return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
3364
- } catch {
3365
- return null;
3366
- }
3367
- }
3368
3521
  async function handleDetectContext(ctx, params, _extra) {
3369
3522
  const target = params.path ?? process.cwd();
3370
3523
  const discovered = await ctx.detectFromPath(target);
3371
- return formatToolResponse(discovered);
3524
+ if (!params.includeStatus || discovered.allWorktrees.length === 0) {
3525
+ return formatToolResponse(discovered);
3526
+ }
3527
+ const statusService = new WorktreeStatusService();
3528
+ const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
3529
+ const enriched = await Promise.all(
3530
+ discovered.allWorktrees.map(
3531
+ (wt) => limit(async () => {
3532
+ const [status, divergence] = await Promise.all([
3533
+ statusService.getFullWorktreeStatus(wt.path, false).catch(() => null),
3534
+ getDivergence(wt.path)
3535
+ ]);
3536
+ return {
3537
+ ...wt,
3538
+ label: status ? deriveLabel(status, wt.isCurrent) : wt.isCurrent ? "current" : "unknown",
3539
+ divergence,
3540
+ staleHint: status?.upstreamGone ?? false
3541
+ };
3542
+ })
3543
+ )
3544
+ );
3545
+ return formatToolResponse({ ...discovered, allWorktrees: enriched });
3372
3546
  }
3373
3547
  async function handleListWorktrees(ctx, params, _extra) {
3374
3548
  const { discovered, git } = await getReadyService(ctx, params.repoName, {
3375
- capability: "canListWorktrees",
3549
+ capability: "listWorktrees",
3376
3550
  toolName: "list_worktrees"
3377
3551
  });
3378
3552
  let worktrees;
@@ -3392,20 +3566,22 @@ async function handleListWorktrees(ctx, params, _extra) {
3392
3566
  (wt) => limit(async () => {
3393
3567
  const resolvedPath = path9.resolve(wt.path);
3394
3568
  const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
3395
- const [status, divergence, metadata] = await Promise.all([
3569
+ const [status, divergence, metadata, sizeBytes] = await Promise.all([
3396
3570
  git.getFullWorktreeStatus(wt.path, false).catch(() => null),
3397
3571
  getDivergence(wt.path),
3398
- git.getWorktreeMetadata(wt.path).catch(() => null)
3572
+ git.getWorktreeMetadata(wt.path).catch(() => null),
3573
+ params.includeSize ? calculateDirectorySize(wt.path).catch(() => null) : Promise.resolve(null)
3399
3574
  ]);
3400
3575
  return {
3401
3576
  path: resolvedPath,
3402
3577
  branch: wt.branch,
3403
3578
  isCurrent,
3404
- label: status ? deriveLabel(status, isCurrent) : "unknown",
3579
+ label: status ? deriveLabel(status, isCurrent) : isCurrent ? "current" : "unknown",
3405
3580
  status,
3406
3581
  divergence,
3407
- safeToRemove: status ? status.canRemove && !status.upstreamGone : false,
3408
- lastSyncAt: metadata?.lastSyncDate ?? null
3582
+ safeToRemove: status ? deriveSafeToRemove(status) : { safe: false, reason: "status unavailable" },
3583
+ lastSyncAt: metadata?.lastSyncDate ?? null,
3584
+ sizeBytes
3409
3585
  };
3410
3586
  })
3411
3587
  )
@@ -3414,7 +3590,7 @@ async function handleListWorktrees(ctx, params, _extra) {
3414
3590
  }
3415
3591
  async function handleGetWorktreeStatus(ctx, params, _extra) {
3416
3592
  const { git } = await getReadyService(ctx, params.repoName, {
3417
- capability: "canGetStatus",
3593
+ capability: "getStatus",
3418
3594
  toolName: "get_worktree_status"
3419
3595
  });
3420
3596
  const resolvedPath = await ensureRepoWorktreePath(ctx, params, git);
@@ -3435,7 +3611,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
3435
3611
  throw new Error(`Invalid branch name '${branchName}': ${validation.error}`);
3436
3612
  }
3437
3613
  const { service, git } = await getReadyService(ctx, params.repoName, {
3438
- capability: "canCreateWorktree",
3614
+ capability: "createWorktree",
3439
3615
  toolName: "create_worktree",
3440
3616
  ensureInitialized: true,
3441
3617
  ensureNotSyncing: true
@@ -3475,7 +3651,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
3475
3651
  }
3476
3652
  async function handleRemoveWorktree(ctx, params, _extra) {
3477
3653
  const { git } = await getReadyService(ctx, params.repoName, {
3478
- capability: "canRemoveWorktree",
3654
+ capability: "removeWorktree",
3479
3655
  toolName: "remove_worktree",
3480
3656
  ensureInitialized: true,
3481
3657
  ensureNotSyncing: true
@@ -3496,7 +3672,7 @@ async function handleRemoveWorktree(ctx, params, _extra) {
3496
3672
  }
3497
3673
  async function handleSync(ctx, params, extra) {
3498
3674
  const { service } = await getReadyService(ctx, params.repoName, {
3499
- capability: "canSync",
3675
+ capability: "sync",
3500
3676
  toolName: "sync",
3501
3677
  ensureInitialized: true
3502
3678
  });
@@ -3516,7 +3692,7 @@ async function handleSync(ctx, params, extra) {
3516
3692
  }
3517
3693
  async function handleUpdateWorktree(ctx, params, _extra) {
3518
3694
  const { git } = await getReadyService(ctx, params.repoName, {
3519
- capability: "canUpdateWorktree",
3695
+ capability: "updateWorktree",
3520
3696
  toolName: "update_worktree",
3521
3697
  ensureInitialized: true,
3522
3698
  ensureNotSyncing: true
@@ -3531,7 +3707,7 @@ async function handleUpdateWorktree(ctx, params, _extra) {
3531
3707
  }
3532
3708
  async function handleInitialize(ctx, params, extra) {
3533
3709
  const { service } = await getReadyService(ctx, params.repoName, {
3534
- capability: "canInitialize",
3710
+ capability: "initialize",
3535
3711
  toolName: "initialize",
3536
3712
  ensureNotSyncing: true
3537
3713
  });
@@ -3593,15 +3769,27 @@ function attachProgressReporter(service, extra) {
3593
3769
  // src/mcp/server.ts
3594
3770
  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.";
3595
3771
  var PATH_DESCRIBE_SUFFIX = "Absolute path preferred; relative paths resolve from the server's CWD.";
3596
- var SERVER_INSTRUCTIONS = "Before running git worktree operations, call `detect_context` to learn the current repo, current branch, and which capabilities are available. It reports whether the working directory is inside a sync-worktrees-managed workspace, lists sibling worktrees, and explains why any capability is disabled.";
3597
- function createServer(context) {
3772
+ 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.";
3773
+ function buildInstructions(snapshot) {
3774
+ const d = snapshot?.discovered;
3775
+ if (!d || !d.isWorktree || d.kind !== "managed") return SERVER_INSTRUCTIONS;
3776
+ const lines = ["Connect-time context (call `detect_context` for live state):"];
3777
+ if (d.kind) lines.push(`- kind: ${d.kind}`);
3778
+ if (d.currentWorktreePath) lines.push(`- currentWorktreePath: ${d.currentWorktreePath}`);
3779
+ if (d.currentBranch) lines.push(`- currentBranch: ${d.currentBranch}`);
3780
+ if (d.configPath) lines.push(`- configPath: ${d.configPath}`);
3781
+ return `${SERVER_INSTRUCTIONS}
3782
+
3783
+ ${lines.join("\n")}`;
3784
+ }
3785
+ function createServer(context, snapshot) {
3598
3786
  const server = new McpServer(
3599
3787
  {
3600
3788
  name: "sync-worktrees",
3601
3789
  version: "1.0.0"
3602
3790
  },
3603
3791
  {
3604
- instructions: SERVER_INSTRUCTIONS
3792
+ instructions: buildInstructions(snapshot)
3605
3793
  }
3606
3794
  );
3607
3795
  server.registerResource(
@@ -3609,7 +3797,7 @@ function createServer(context) {
3609
3797
  "sync-worktrees://workspace",
3610
3798
  {
3611
3799
  title: "Workspace context",
3612
- description: "Current sync-worktrees workspace context: whether CWD is inside a managed worktree, the current branch, sibling worktrees, and available capabilities. Returns { isWorktree: false } when CWD is outside any workspace.",
3800
+ description: "Current sync-worktrees workspace context: whether CWD is inside a managed worktree, the current branch, sibling worktrees, sibling repositories, auto-discovered configPath, and per-capability {available, reason}. Returns { isWorktree: false } when CWD is outside any workspace.",
3613
3801
  mimeType: "application/json"
3614
3802
  },
3615
3803
  async (uri) => {
@@ -3633,9 +3821,12 @@ function createServer(context) {
3633
3821
  server.registerTool(
3634
3822
  "detect_context",
3635
3823
  {
3636
- description: "Detect sync-worktrees structure from a filesystem path. Reads .git file, resolves bare repo, discovers sibling worktrees. Defaults to CWD. Use when: bootstrapping from an unknown checkout with no config loaded. Returns: discovered repo root, bare repo path, all sibling worktrees, current worktree path, capabilities.",
3824
+ 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[].",
3637
3825
  inputSchema: {
3638
- path: z.string().optional().describe("Directory path to inspect. Defaults to the server's CWD.")
3826
+ path: z.string().optional().describe("Directory path to inspect. Defaults to the server's CWD."),
3827
+ includeStatus: z.boolean().optional().describe(
3828
+ "If true, enriches each entry in allWorktrees with label, divergence, and staleHint. Adds one git status + rev-list per worktree. Default: false (cheap path)."
3829
+ )
3639
3830
  },
3640
3831
  annotations: {
3641
3832
  title: "Detect sync-worktrees context",
@@ -3649,9 +3840,12 @@ function createServer(context) {
3649
3840
  server.registerTool(
3650
3841
  "list_worktrees",
3651
3842
  {
3652
- 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, lastSyncAt }.",
3843
+ 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 }.",
3653
3844
  inputSchema: {
3654
- repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
3845
+ repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
3846
+ includeSize: z.boolean().optional().describe(
3847
+ "If true, computes the on-disk size of each worktree (in bytes). Slow on large worktrees. Default: false (sizeBytes returned as null)."
3848
+ )
3655
3849
  },
3656
3850
  annotations: {
3657
3851
  title: "List worktrees with status",
@@ -3828,8 +4022,9 @@ async function main() {
3828
4022
  `);
3829
4023
  }
3830
4024
  }
4025
+ let discovered = null;
3831
4026
  try {
3832
- const discovered = await context.detectFromPath(process.cwd());
4027
+ discovered = await context.detectFromPath(process.cwd());
3833
4028
  if (discovered.isWorktree) {
3834
4029
  process.stderr.write(
3835
4030
  `[sync-worktrees-mcp] Auto-detected ${discovered.kind} worktree at ${discovered.currentWorktreePath} (branch: ${discovered.currentBranch})
@@ -3840,7 +4035,7 @@ async function main() {
3840
4035
  process.stderr.write(`[sync-worktrees-mcp] Auto-detect failed: ${err.message}
3841
4036
  `);
3842
4037
  }
3843
- const server = createServer(context);
4038
+ const server = createServer(context, { discovered });
3844
4039
  const transport = new StdioServerTransport();
3845
4040
  await server.connect(transport);
3846
4041
  }