sync-worktrees 3.0.1 → 3.1.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.
- package/dist/index.js +43 -12
- package/dist/index.js.map +2 -2
- package/dist/mcp-server.js +235 -93
- package/dist/mcp-server.js.map +4 -4
- package/package.json +1 -1
package/dist/mcp-server.js
CHANGED
|
@@ -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 {
|
|
@@ -1173,6 +1196,7 @@ var WorktreeStatusService = class {
|
|
|
1173
1196
|
if (hasUnpushedCommits) reasons.push("unpushed commits");
|
|
1174
1197
|
if (hasOperationInProgress) reasons.push("operation in progress");
|
|
1175
1198
|
if (hasModifiedSubmodules) reasons.push("modified submodules");
|
|
1199
|
+
if (upstreamGone) reasons.push("upstream gone");
|
|
1176
1200
|
const canRemove = isClean && !hasUnpushedCommits && !hasOperationInProgress && !hasModifiedSubmodules;
|
|
1177
1201
|
const details = includeDetails ? this.buildStatusDetails(snap) : void 0;
|
|
1178
1202
|
return {
|
|
@@ -2804,15 +2828,18 @@ var WorktreeSyncService = class {
|
|
|
2804
2828
|
// src/mcp/context.ts
|
|
2805
2829
|
var AUTO_DETECT_PREFIX = "__auto_detected__:";
|
|
2806
2830
|
var DISCOVERY_CACHE_TTL_MS = 5e3;
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
}
|
|
2831
|
+
function emptyCapabilities(reason) {
|
|
2832
|
+
const state = reason ? { available: false, reason } : { available: false };
|
|
2833
|
+
return {
|
|
2834
|
+
listWorktrees: { ...state },
|
|
2835
|
+
getStatus: { ...state },
|
|
2836
|
+
createWorktree: { ...state },
|
|
2837
|
+
removeWorktree: { ...state },
|
|
2838
|
+
updateWorktree: { ...state },
|
|
2839
|
+
sync: { ...state },
|
|
2840
|
+
initialize: { ...state }
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2816
2843
|
function buildUnsupportedContext(currentPath, reason) {
|
|
2817
2844
|
return {
|
|
2818
2845
|
isWorktree: false,
|
|
@@ -2823,10 +2850,11 @@ function buildUnsupportedContext(currentPath, reason) {
|
|
|
2823
2850
|
repoUrl: null,
|
|
2824
2851
|
worktreeDir: null,
|
|
2825
2852
|
allWorktrees: [],
|
|
2826
|
-
|
|
2853
|
+
siblingRepositories: [],
|
|
2854
|
+
configPath: null,
|
|
2827
2855
|
repoName: null,
|
|
2828
|
-
capabilities:
|
|
2829
|
-
|
|
2856
|
+
capabilities: emptyCapabilities(reason),
|
|
2857
|
+
notes: [reason]
|
|
2830
2858
|
};
|
|
2831
2859
|
}
|
|
2832
2860
|
function createStderrLogger(repoName) {
|
|
@@ -2843,7 +2871,8 @@ var RepositoryContext = class {
|
|
|
2843
2871
|
configPath = null;
|
|
2844
2872
|
configLoader = new ConfigLoaderService();
|
|
2845
2873
|
discoveryCache = /* @__PURE__ */ new Map();
|
|
2846
|
-
async loadConfig(configPath) {
|
|
2874
|
+
async loadConfig(configPath, options = {}) {
|
|
2875
|
+
const setDefaultCurrent = options.setDefaultCurrent ?? true;
|
|
2847
2876
|
const absolutePath = path8.resolve(configPath);
|
|
2848
2877
|
const configFile = await this.configLoader.loadConfigFile(absolutePath);
|
|
2849
2878
|
for (const [name, entry] of this.repos) {
|
|
@@ -2865,9 +2894,10 @@ var RepositoryContext = class {
|
|
|
2865
2894
|
if (this.currentRepo && !this.repos.has(this.currentRepo)) {
|
|
2866
2895
|
this.currentRepo = null;
|
|
2867
2896
|
}
|
|
2868
|
-
if (!this.currentRepo && configFile.repositories.length > 0) {
|
|
2897
|
+
if (setDefaultCurrent && !this.currentRepo && configFile.repositories.length > 0) {
|
|
2869
2898
|
this.currentRepo = configFile.repositories[0].name;
|
|
2870
2899
|
}
|
|
2900
|
+
this.discoveryCache.clear();
|
|
2871
2901
|
return configFile.repositories;
|
|
2872
2902
|
}
|
|
2873
2903
|
async detectFromPath(dirPath) {
|
|
@@ -2876,6 +2906,17 @@ var RepositoryContext = class {
|
|
|
2876
2906
|
if (cached && await this.isCacheFresh(cached)) {
|
|
2877
2907
|
return cached.result;
|
|
2878
2908
|
}
|
|
2909
|
+
if (this.configPath === null) {
|
|
2910
|
+
const found = await this.configLoader.findConfigUpward(absolutePath);
|
|
2911
|
+
if (found) {
|
|
2912
|
+
try {
|
|
2913
|
+
await this.loadConfig(found, { setDefaultCurrent: false });
|
|
2914
|
+
} catch (err) {
|
|
2915
|
+
process.stderr.write(`[sync-worktrees] auto-loaded config failed: ${err.message}
|
|
2916
|
+
`);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2879
2920
|
const { result, adminDir } = await this.detectFromPathUncached(absolutePath);
|
|
2880
2921
|
if (result.isWorktree && result.bareRepoPath && adminDir) {
|
|
2881
2922
|
const [worktreeHeadMtimeMs, worktreesDirMtimeMs] = await Promise.all([
|
|
@@ -2911,10 +2952,49 @@ var RepositoryContext = class {
|
|
|
2911
2952
|
__discoveryCacheSizeForTest() {
|
|
2912
2953
|
return this.discoveryCache.size;
|
|
2913
2954
|
}
|
|
2914
|
-
|
|
2955
|
+
async discoverSiblingRepositories(currentBareRepoPath) {
|
|
2956
|
+
const repoDir = path8.dirname(currentBareRepoPath);
|
|
2957
|
+
const workspaceRoot = path8.dirname(repoDir);
|
|
2958
|
+
if (workspaceRoot === repoDir) return [];
|
|
2959
|
+
let entries;
|
|
2960
|
+
try {
|
|
2961
|
+
entries = await fs7.readdir(workspaceRoot);
|
|
2962
|
+
} catch {
|
|
2963
|
+
return [];
|
|
2964
|
+
}
|
|
2965
|
+
const configBares = /* @__PURE__ */ new Map();
|
|
2966
|
+
for (const entry of this.repos.values()) {
|
|
2967
|
+
if (entry.source === "config" && entry.config.bareRepoDir) {
|
|
2968
|
+
configBares.set(normalizePathForCompare(entry.config.bareRepoDir), entry.name);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
const results = [];
|
|
2972
|
+
await Promise.all(
|
|
2973
|
+
entries.map(async (entry) => {
|
|
2974
|
+
const candidate = path8.join(workspaceRoot, entry);
|
|
2975
|
+
const bareCandidate = path8.join(candidate, GIT_CONSTANTS.BARE_DIR_NAME);
|
|
2976
|
+
try {
|
|
2977
|
+
const stat4 = await fs7.stat(bareCandidate);
|
|
2978
|
+
if (!stat4.isDirectory()) return;
|
|
2979
|
+
} catch {
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
const resolvedBare = path8.resolve(bareCandidate);
|
|
2983
|
+
const matchedName = configBares.get(normalizePathForCompare(resolvedBare));
|
|
2984
|
+
results.push({
|
|
2985
|
+
name: matchedName ?? entry,
|
|
2986
|
+
bareRepoPath: resolvedBare,
|
|
2987
|
+
configMatched: matchedName !== void 0
|
|
2988
|
+
});
|
|
2989
|
+
})
|
|
2990
|
+
);
|
|
2991
|
+
results.sort((a, b) => a.name.localeCompare(b.name));
|
|
2992
|
+
return results;
|
|
2993
|
+
}
|
|
2994
|
+
bootstrapCurrentRepo(candidate, force = false) {
|
|
2915
2995
|
if (this.currentRepo !== null) return;
|
|
2916
2996
|
if (!this.repos.has(candidate)) return;
|
|
2917
|
-
if (this.repos.size !== 1) return;
|
|
2997
|
+
if (!force && this.repos.size !== 1) return;
|
|
2918
2998
|
this.currentRepo = candidate;
|
|
2919
2999
|
}
|
|
2920
3000
|
async isCacheFresh(cached) {
|
|
@@ -2927,14 +3007,14 @@ var RepositoryContext = class {
|
|
|
2927
3007
|
return currentHeadMtime === cached.worktreeHeadMtimeMs && currentWorktreesDirMtime === cached.worktreesDirMtimeMs;
|
|
2928
3008
|
}
|
|
2929
3009
|
async detectFromPathUncached(absolutePath) {
|
|
2930
|
-
const
|
|
3010
|
+
const notes = [];
|
|
2931
3011
|
const located = await findWorktreeRoot(absolutePath);
|
|
2932
3012
|
const worktreeRoot = located?.worktreeRoot ?? absolutePath;
|
|
2933
|
-
const unsupported = (reason
|
|
2934
|
-
|
|
3013
|
+
const unsupported = (reason) => {
|
|
3014
|
+
notes.push(reason);
|
|
2935
3015
|
return {
|
|
2936
3016
|
result: {
|
|
2937
|
-
isWorktree,
|
|
3017
|
+
isWorktree: false,
|
|
2938
3018
|
kind: "unsupported",
|
|
2939
3019
|
currentBranch: null,
|
|
2940
3020
|
currentWorktreePath: worktreeRoot,
|
|
@@ -2942,10 +3022,11 @@ var RepositoryContext = class {
|
|
|
2942
3022
|
repoUrl: null,
|
|
2943
3023
|
worktreeDir: null,
|
|
2944
3024
|
allWorktrees: [],
|
|
2945
|
-
|
|
3025
|
+
siblingRepositories: [],
|
|
3026
|
+
configPath: this.configPath,
|
|
2946
3027
|
repoName: null,
|
|
2947
|
-
capabilities:
|
|
2948
|
-
|
|
3028
|
+
capabilities: emptyCapabilities(reason),
|
|
3029
|
+
notes
|
|
2949
3030
|
},
|
|
2950
3031
|
adminDir: null
|
|
2951
3032
|
};
|
|
@@ -2979,7 +3060,7 @@ var RepositoryContext = class {
|
|
|
2979
3060
|
const urlStr = typeof remoteResult === "string" ? remoteResult.trim() : "";
|
|
2980
3061
|
repoUrl = urlStr || null;
|
|
2981
3062
|
} catch {
|
|
2982
|
-
|
|
3063
|
+
notes.push("Could not read remote origin URL");
|
|
2983
3064
|
}
|
|
2984
3065
|
const listOutput = await bareGit.raw(["worktree", "list", "--porcelain"]);
|
|
2985
3066
|
worktrees = parseWorktreeList(listOutput, worktreeRoot);
|
|
@@ -2988,7 +3069,8 @@ var RepositoryContext = class {
|
|
|
2988
3069
|
currentBranch = current.branch;
|
|
2989
3070
|
}
|
|
2990
3071
|
} catch (err) {
|
|
2991
|
-
|
|
3072
|
+
const reason = `Failed to read bare repo at ${bareRepoPath}: ${err.message}`;
|
|
3073
|
+
notes.push(reason);
|
|
2992
3074
|
return {
|
|
2993
3075
|
result: {
|
|
2994
3076
|
isWorktree: true,
|
|
@@ -2999,34 +3081,31 @@ var RepositoryContext = class {
|
|
|
2999
3081
|
repoUrl: null,
|
|
3000
3082
|
worktreeDir: null,
|
|
3001
3083
|
allWorktrees: [],
|
|
3002
|
-
|
|
3084
|
+
siblingRepositories: [],
|
|
3085
|
+
configPath: this.configPath,
|
|
3003
3086
|
repoName: null,
|
|
3004
|
-
capabilities:
|
|
3005
|
-
|
|
3087
|
+
capabilities: emptyCapabilities(reason),
|
|
3088
|
+
notes
|
|
3006
3089
|
},
|
|
3007
3090
|
adminDir
|
|
3008
3091
|
};
|
|
3009
3092
|
}
|
|
3010
3093
|
const worktreeDir = path8.dirname(worktreeRoot);
|
|
3094
|
+
const noUrlReason = "no remote origin URL detected";
|
|
3011
3095
|
const capabilities = {
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3096
|
+
listWorktrees: { available: true },
|
|
3097
|
+
getStatus: { available: true },
|
|
3098
|
+
createWorktree: repoUrl !== null ? { available: true } : { available: false, reason: noUrlReason },
|
|
3099
|
+
removeWorktree: { available: true },
|
|
3100
|
+
updateWorktree: { available: true },
|
|
3101
|
+
sync: { available: false, reason: "no config and no remote URL" },
|
|
3102
|
+
initialize: { available: false, reason: "no config and no remote URL" }
|
|
3019
3103
|
};
|
|
3020
|
-
|
|
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);
|
|
3104
|
+
const foldedBare = normalizePathForCompare(bareRepoPath);
|
|
3025
3105
|
let matchedConfig = null;
|
|
3026
3106
|
for (const entry of this.repos.values()) {
|
|
3027
|
-
if (entry.source === "config") {
|
|
3028
|
-
|
|
3029
|
-
if (entryBare && foldPath(entryBare) === foldedBare) {
|
|
3107
|
+
if (entry.source === "config" && entry.config.bareRepoDir) {
|
|
3108
|
+
if (normalizePathForCompare(entry.config.bareRepoDir) === foldedBare) {
|
|
3030
3109
|
matchedConfig = entry;
|
|
3031
3110
|
break;
|
|
3032
3111
|
}
|
|
@@ -3037,8 +3116,8 @@ var RepositoryContext = class {
|
|
|
3037
3116
|
if (matchedConfig) {
|
|
3038
3117
|
repoName = matchedConfig.name;
|
|
3039
3118
|
kind = "managed";
|
|
3040
|
-
capabilities.
|
|
3041
|
-
capabilities.
|
|
3119
|
+
capabilities.sync = { available: true };
|
|
3120
|
+
capabilities.initialize = { available: true };
|
|
3042
3121
|
} else if (repoUrl) {
|
|
3043
3122
|
const syntheticConfig = {
|
|
3044
3123
|
repoUrl,
|
|
@@ -3056,13 +3135,14 @@ var RepositoryContext = class {
|
|
|
3056
3135
|
});
|
|
3057
3136
|
}
|
|
3058
3137
|
repoName = detectedKey;
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3138
|
+
const autoReason = "no config file loaded (running in auto-detect mode)";
|
|
3139
|
+
capabilities.sync = { available: false, reason: autoReason };
|
|
3140
|
+
capabilities.initialize = { available: false, reason: autoReason };
|
|
3062
3141
|
}
|
|
3063
3142
|
if (repoName) {
|
|
3064
|
-
this.bootstrapCurrentRepo(repoName);
|
|
3143
|
+
this.bootstrapCurrentRepo(repoName, matchedConfig !== null);
|
|
3065
3144
|
}
|
|
3145
|
+
const siblingRepositories = await this.discoverSiblingRepositories(bareRepoPath);
|
|
3066
3146
|
const discovered = {
|
|
3067
3147
|
isWorktree: true,
|
|
3068
3148
|
kind,
|
|
@@ -3072,10 +3152,11 @@ var RepositoryContext = class {
|
|
|
3072
3152
|
repoUrl,
|
|
3073
3153
|
worktreeDir,
|
|
3074
3154
|
allWorktrees: worktrees,
|
|
3075
|
-
|
|
3155
|
+
siblingRepositories,
|
|
3156
|
+
configPath: this.configPath,
|
|
3076
3157
|
repoName,
|
|
3077
3158
|
capabilities,
|
|
3078
|
-
|
|
3159
|
+
notes
|
|
3079
3160
|
};
|
|
3080
3161
|
if (repoName) {
|
|
3081
3162
|
const entry = this.repos.get(repoName);
|
|
@@ -3134,9 +3215,7 @@ var RepositoryContext = class {
|
|
|
3134
3215
|
}
|
|
3135
3216
|
};
|
|
3136
3217
|
function parseWorktreeList(output, currentPath) {
|
|
3137
|
-
const
|
|
3138
|
-
const fold = (p) => isCaseInsensitiveFs() ? p.toLowerCase() : p;
|
|
3139
|
-
const foldedCurrent = fold(resolvedCurrent);
|
|
3218
|
+
const foldedCurrent = normalizePathForCompare(currentPath);
|
|
3140
3219
|
const results = [];
|
|
3141
3220
|
for (const wt of parseWorktreeListPorcelain(output)) {
|
|
3142
3221
|
const resolved = path8.resolve(wt.path);
|
|
@@ -3145,7 +3224,7 @@ function parseWorktreeList(output, currentPath) {
|
|
|
3145
3224
|
results.push({
|
|
3146
3225
|
path: resolved,
|
|
3147
3226
|
branch,
|
|
3148
|
-
isCurrent:
|
|
3227
|
+
isCurrent: normalizePathForCompare(resolved) === foldedCurrent
|
|
3149
3228
|
});
|
|
3150
3229
|
}
|
|
3151
3230
|
return results;
|
|
@@ -3189,7 +3268,24 @@ import { z } from "zod";
|
|
|
3189
3268
|
// src/mcp/handlers.ts
|
|
3190
3269
|
import * as path9 from "path";
|
|
3191
3270
|
import pLimit2 from "p-limit";
|
|
3192
|
-
|
|
3271
|
+
|
|
3272
|
+
// src/utils/disk-space.ts
|
|
3273
|
+
import fastFolderSize from "fast-folder-size";
|
|
3274
|
+
async function calculateDirectorySize(dirPath) {
|
|
3275
|
+
return new Promise((resolve9, reject) => {
|
|
3276
|
+
fastFolderSize(dirPath, (err, bytes) => {
|
|
3277
|
+
if (err) {
|
|
3278
|
+
reject(err);
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
3281
|
+
if (bytes === void 0) {
|
|
3282
|
+
reject(new Error(`fast-folder-size returned no bytes for ${dirPath}`));
|
|
3283
|
+
return;
|
|
3284
|
+
}
|
|
3285
|
+
resolve9(bytes);
|
|
3286
|
+
});
|
|
3287
|
+
});
|
|
3288
|
+
}
|
|
3193
3289
|
|
|
3194
3290
|
// src/utils/git-validation.ts
|
|
3195
3291
|
function isValidGitBranchName(name) {
|
|
@@ -3299,12 +3395,45 @@ function wrapHandler(fn) {
|
|
|
3299
3395
|
};
|
|
3300
3396
|
}
|
|
3301
3397
|
|
|
3398
|
+
// src/mcp/worktree-summary.ts
|
|
3399
|
+
import simpleGit5 from "simple-git";
|
|
3400
|
+
function deriveLabel(status, isCurrent) {
|
|
3401
|
+
if (isCurrent) return "current";
|
|
3402
|
+
if (!status.isClean || status.hasUnpushedCommits || status.hasStashedChanges) return "dirty";
|
|
3403
|
+
if (status.upstreamGone) return "stale";
|
|
3404
|
+
return "clean";
|
|
3405
|
+
}
|
|
3406
|
+
function deriveSafeToRemove(status) {
|
|
3407
|
+
if (status.canRemove && !status.upstreamGone) {
|
|
3408
|
+
return { safe: true, reason: "clean tree, no unpushed commits" };
|
|
3409
|
+
}
|
|
3410
|
+
if (status.canRemove && status.upstreamGone) {
|
|
3411
|
+
return { safe: false, reason: "branch deleted upstream \u2014 verify no work is lost before removal" };
|
|
3412
|
+
}
|
|
3413
|
+
if (status.reasons.length > 0) {
|
|
3414
|
+
return { safe: false, reason: status.reasons.join(", ") };
|
|
3415
|
+
}
|
|
3416
|
+
return { safe: false, reason: "not safe to remove" };
|
|
3417
|
+
}
|
|
3418
|
+
async function getDivergence(worktreePath) {
|
|
3419
|
+
try {
|
|
3420
|
+
const git = simpleGit5(worktreePath);
|
|
3421
|
+
const output = await git.raw(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]);
|
|
3422
|
+
const [aheadStr, behindStr] = output.trim().split(/\s+/);
|
|
3423
|
+
return { ahead: parseInt(aheadStr, 10), behind: parseInt(behindStr, 10) };
|
|
3424
|
+
} catch {
|
|
3425
|
+
return null;
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3302
3429
|
// src/mcp/handlers.ts
|
|
3303
3430
|
var pathResolution = new PathResolutionService();
|
|
3304
3431
|
function ensureCapability(discovered, key, toolName) {
|
|
3305
3432
|
if (!discovered) return;
|
|
3306
|
-
|
|
3307
|
-
|
|
3433
|
+
const cap = discovered.capabilities[key];
|
|
3434
|
+
if (!cap.available) {
|
|
3435
|
+
const reasons = cap.reason ? [cap.reason] : discovered.notes;
|
|
3436
|
+
throw new CapabilityUnavailableError(toolName, reasons);
|
|
3308
3437
|
}
|
|
3309
3438
|
}
|
|
3310
3439
|
async function ensureNotSyncing(ctx, repoName) {
|
|
@@ -3349,30 +3478,35 @@ async function ensurePathBelongsToRepo(ctx, targetPath, repoName, git) {
|
|
|
3349
3478
|
}
|
|
3350
3479
|
throw new Error(`Path '${targetPath}' is not a registered worktree of the current repository`);
|
|
3351
3480
|
}
|
|
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
3481
|
async function handleDetectContext(ctx, params, _extra) {
|
|
3369
3482
|
const target = params.path ?? process.cwd();
|
|
3370
3483
|
const discovered = await ctx.detectFromPath(target);
|
|
3371
|
-
|
|
3484
|
+
if (!params.includeStatus || discovered.allWorktrees.length === 0) {
|
|
3485
|
+
return formatToolResponse(discovered);
|
|
3486
|
+
}
|
|
3487
|
+
const statusService = new WorktreeStatusService();
|
|
3488
|
+
const limit = pLimit2(DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS);
|
|
3489
|
+
const enriched = await Promise.all(
|
|
3490
|
+
discovered.allWorktrees.map(
|
|
3491
|
+
(wt) => limit(async () => {
|
|
3492
|
+
const [status, divergence] = await Promise.all([
|
|
3493
|
+
statusService.getFullWorktreeStatus(wt.path, false).catch(() => null),
|
|
3494
|
+
getDivergence(wt.path)
|
|
3495
|
+
]);
|
|
3496
|
+
return {
|
|
3497
|
+
...wt,
|
|
3498
|
+
label: status ? deriveLabel(status, wt.isCurrent) : wt.isCurrent ? "current" : "unknown",
|
|
3499
|
+
divergence,
|
|
3500
|
+
staleHint: status?.upstreamGone ?? false
|
|
3501
|
+
};
|
|
3502
|
+
})
|
|
3503
|
+
)
|
|
3504
|
+
);
|
|
3505
|
+
return formatToolResponse({ ...discovered, allWorktrees: enriched });
|
|
3372
3506
|
}
|
|
3373
3507
|
async function handleListWorktrees(ctx, params, _extra) {
|
|
3374
3508
|
const { discovered, git } = await getReadyService(ctx, params.repoName, {
|
|
3375
|
-
capability: "
|
|
3509
|
+
capability: "listWorktrees",
|
|
3376
3510
|
toolName: "list_worktrees"
|
|
3377
3511
|
});
|
|
3378
3512
|
let worktrees;
|
|
@@ -3392,20 +3526,22 @@ async function handleListWorktrees(ctx, params, _extra) {
|
|
|
3392
3526
|
(wt) => limit(async () => {
|
|
3393
3527
|
const resolvedPath = path9.resolve(wt.path);
|
|
3394
3528
|
const isCurrent = currentPath !== null && pathsEqual(wt.path, currentPath);
|
|
3395
|
-
const [status, divergence, metadata] = await Promise.all([
|
|
3529
|
+
const [status, divergence, metadata, sizeBytes] = await Promise.all([
|
|
3396
3530
|
git.getFullWorktreeStatus(wt.path, false).catch(() => null),
|
|
3397
3531
|
getDivergence(wt.path),
|
|
3398
|
-
git.getWorktreeMetadata(wt.path).catch(() => null)
|
|
3532
|
+
git.getWorktreeMetadata(wt.path).catch(() => null),
|
|
3533
|
+
params.includeSize ? calculateDirectorySize(wt.path).catch(() => null) : Promise.resolve(null)
|
|
3399
3534
|
]);
|
|
3400
3535
|
return {
|
|
3401
3536
|
path: resolvedPath,
|
|
3402
3537
|
branch: wt.branch,
|
|
3403
3538
|
isCurrent,
|
|
3404
|
-
label: status ? deriveLabel(status, isCurrent) : "unknown",
|
|
3539
|
+
label: status ? deriveLabel(status, isCurrent) : isCurrent ? "current" : "unknown",
|
|
3405
3540
|
status,
|
|
3406
3541
|
divergence,
|
|
3407
|
-
safeToRemove: status ? status
|
|
3408
|
-
lastSyncAt: metadata?.lastSyncDate ?? null
|
|
3542
|
+
safeToRemove: status ? deriveSafeToRemove(status) : { safe: false, reason: "status unavailable" },
|
|
3543
|
+
lastSyncAt: metadata?.lastSyncDate ?? null,
|
|
3544
|
+
sizeBytes
|
|
3409
3545
|
};
|
|
3410
3546
|
})
|
|
3411
3547
|
)
|
|
@@ -3414,7 +3550,7 @@ async function handleListWorktrees(ctx, params, _extra) {
|
|
|
3414
3550
|
}
|
|
3415
3551
|
async function handleGetWorktreeStatus(ctx, params, _extra) {
|
|
3416
3552
|
const { git } = await getReadyService(ctx, params.repoName, {
|
|
3417
|
-
capability: "
|
|
3553
|
+
capability: "getStatus",
|
|
3418
3554
|
toolName: "get_worktree_status"
|
|
3419
3555
|
});
|
|
3420
3556
|
const resolvedPath = await ensureRepoWorktreePath(ctx, params, git);
|
|
@@ -3435,7 +3571,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
3435
3571
|
throw new Error(`Invalid branch name '${branchName}': ${validation.error}`);
|
|
3436
3572
|
}
|
|
3437
3573
|
const { service, git } = await getReadyService(ctx, params.repoName, {
|
|
3438
|
-
capability: "
|
|
3574
|
+
capability: "createWorktree",
|
|
3439
3575
|
toolName: "create_worktree",
|
|
3440
3576
|
ensureInitialized: true,
|
|
3441
3577
|
ensureNotSyncing: true
|
|
@@ -3475,7 +3611,7 @@ async function handleCreateWorktree(ctx, params, _extra) {
|
|
|
3475
3611
|
}
|
|
3476
3612
|
async function handleRemoveWorktree(ctx, params, _extra) {
|
|
3477
3613
|
const { git } = await getReadyService(ctx, params.repoName, {
|
|
3478
|
-
capability: "
|
|
3614
|
+
capability: "removeWorktree",
|
|
3479
3615
|
toolName: "remove_worktree",
|
|
3480
3616
|
ensureInitialized: true,
|
|
3481
3617
|
ensureNotSyncing: true
|
|
@@ -3496,7 +3632,7 @@ async function handleRemoveWorktree(ctx, params, _extra) {
|
|
|
3496
3632
|
}
|
|
3497
3633
|
async function handleSync(ctx, params, extra) {
|
|
3498
3634
|
const { service } = await getReadyService(ctx, params.repoName, {
|
|
3499
|
-
capability: "
|
|
3635
|
+
capability: "sync",
|
|
3500
3636
|
toolName: "sync",
|
|
3501
3637
|
ensureInitialized: true
|
|
3502
3638
|
});
|
|
@@ -3516,7 +3652,7 @@ async function handleSync(ctx, params, extra) {
|
|
|
3516
3652
|
}
|
|
3517
3653
|
async function handleUpdateWorktree(ctx, params, _extra) {
|
|
3518
3654
|
const { git } = await getReadyService(ctx, params.repoName, {
|
|
3519
|
-
capability: "
|
|
3655
|
+
capability: "updateWorktree",
|
|
3520
3656
|
toolName: "update_worktree",
|
|
3521
3657
|
ensureInitialized: true,
|
|
3522
3658
|
ensureNotSyncing: true
|
|
@@ -3531,7 +3667,7 @@ async function handleUpdateWorktree(ctx, params, _extra) {
|
|
|
3531
3667
|
}
|
|
3532
3668
|
async function handleInitialize(ctx, params, extra) {
|
|
3533
3669
|
const { service } = await getReadyService(ctx, params.repoName, {
|
|
3534
|
-
capability: "
|
|
3670
|
+
capability: "initialize",
|
|
3535
3671
|
toolName: "initialize",
|
|
3536
3672
|
ensureNotSyncing: true
|
|
3537
3673
|
});
|
|
@@ -3593,7 +3729,7 @@ function attachProgressReporter(service, extra) {
|
|
|
3593
3729
|
// src/mcp/server.ts
|
|
3594
3730
|
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
3731
|
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
|
|
3732
|
+
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.";
|
|
3597
3733
|
function createServer(context) {
|
|
3598
3734
|
const server = new McpServer(
|
|
3599
3735
|
{
|
|
@@ -3609,7 +3745,7 @@ function createServer(context) {
|
|
|
3609
3745
|
"sync-worktrees://workspace",
|
|
3610
3746
|
{
|
|
3611
3747
|
title: "Workspace context",
|
|
3612
|
-
description: "Current sync-worktrees workspace context: whether CWD is inside a managed worktree, the current branch, sibling worktrees, and available
|
|
3748
|
+
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
3749
|
mimeType: "application/json"
|
|
3614
3750
|
},
|
|
3615
3751
|
async (uri) => {
|
|
@@ -3633,9 +3769,12 @@ function createServer(context) {
|
|
|
3633
3769
|
server.registerTool(
|
|
3634
3770
|
"detect_context",
|
|
3635
3771
|
{
|
|
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
|
|
3772
|
+
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
3773
|
inputSchema: {
|
|
3638
|
-
path: z.string().optional().describe("Directory path to inspect. Defaults to the server's CWD.")
|
|
3774
|
+
path: z.string().optional().describe("Directory path to inspect. Defaults to the server's CWD."),
|
|
3775
|
+
includeStatus: z.boolean().optional().describe(
|
|
3776
|
+
"If true, enriches each entry in allWorktrees with label, divergence, and staleHint. Adds one git status + rev-list per worktree. Default: false (cheap path)."
|
|
3777
|
+
)
|
|
3639
3778
|
},
|
|
3640
3779
|
annotations: {
|
|
3641
3780
|
title: "Detect sync-worktrees context",
|
|
@@ -3649,9 +3788,12 @@ function createServer(context) {
|
|
|
3649
3788
|
server.registerTool(
|
|
3650
3789
|
"list_worktrees",
|
|
3651
3790
|
{
|
|
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 }.",
|
|
3791
|
+
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
3792
|
inputSchema: {
|
|
3654
|
-
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE)
|
|
3793
|
+
repoName: z.string().optional().describe(REPO_NAME_DESCRIBE),
|
|
3794
|
+
includeSize: z.boolean().optional().describe(
|
|
3795
|
+
"If true, computes the on-disk size of each worktree (in bytes). Slow on large worktrees. Default: false (sizeBytes returned as null)."
|
|
3796
|
+
)
|
|
3655
3797
|
},
|
|
3656
3798
|
annotations: {
|
|
3657
3799
|
title: "List worktrees with status",
|