@cyanheads/git-mcp-server 1.2.4 → 2.0.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.
- package/README.md +172 -285
- package/dist/config/index.js +69 -0
- package/dist/index.js +135 -0
- package/dist/mcp-server/server.js +572 -0
- package/dist/mcp-server/tools/gitAdd/index.js +7 -0
- package/dist/mcp-server/tools/gitAdd/logic.js +118 -0
- package/dist/mcp-server/tools/gitAdd/registration.js +73 -0
- package/dist/mcp-server/tools/gitBranch/index.js +7 -0
- package/dist/mcp-server/tools/gitBranch/logic.js +180 -0
- package/dist/mcp-server/tools/gitBranch/registration.js +72 -0
- package/dist/mcp-server/tools/gitCheckout/index.js +6 -0
- package/dist/mcp-server/tools/gitCheckout/logic.js +165 -0
- package/dist/mcp-server/tools/gitCheckout/registration.js +78 -0
- package/dist/mcp-server/tools/gitCherryPick/index.js +7 -0
- package/dist/mcp-server/tools/gitCherryPick/logic.js +115 -0
- package/dist/mcp-server/tools/gitCherryPick/registration.js +69 -0
- package/dist/mcp-server/tools/gitClean/index.js +7 -0
- package/dist/mcp-server/tools/gitClean/logic.js +110 -0
- package/dist/mcp-server/tools/gitClean/registration.js +98 -0
- package/dist/mcp-server/tools/gitClearWorkingDir/index.js +7 -0
- package/dist/mcp-server/tools/gitClearWorkingDir/logic.js +35 -0
- package/dist/mcp-server/tools/gitClearWorkingDir/registration.js +73 -0
- package/dist/mcp-server/tools/gitClone/index.js +7 -0
- package/dist/mcp-server/tools/gitClone/logic.js +136 -0
- package/dist/mcp-server/tools/gitClone/registration.js +44 -0
- package/dist/mcp-server/tools/gitCommit/index.js +7 -0
- package/dist/mcp-server/tools/gitCommit/logic.js +129 -0
- package/dist/mcp-server/tools/gitCommit/registration.js +100 -0
- package/dist/mcp-server/tools/gitDiff/index.js +6 -0
- package/dist/mcp-server/tools/gitDiff/logic.js +114 -0
- package/dist/mcp-server/tools/gitDiff/registration.js +74 -0
- package/dist/mcp-server/tools/gitFetch/index.js +6 -0
- package/dist/mcp-server/tools/gitFetch/logic.js +116 -0
- package/dist/mcp-server/tools/gitFetch/registration.js +71 -0
- package/dist/mcp-server/tools/gitInit/index.js +7 -0
- package/dist/mcp-server/tools/gitInit/logic.js +117 -0
- package/dist/mcp-server/tools/gitInit/registration.js +44 -0
- package/dist/mcp-server/tools/gitLog/index.js +6 -0
- package/dist/mcp-server/tools/gitLog/logic.js +148 -0
- package/dist/mcp-server/tools/gitLog/registration.js +71 -0
- package/dist/mcp-server/tools/gitMerge/index.js +7 -0
- package/dist/mcp-server/tools/gitMerge/logic.js +160 -0
- package/dist/mcp-server/tools/gitMerge/registration.js +77 -0
- package/dist/mcp-server/tools/gitPull/index.js +6 -0
- package/dist/mcp-server/tools/gitPull/logic.js +144 -0
- package/dist/mcp-server/tools/gitPull/registration.js +81 -0
- package/dist/mcp-server/tools/gitPush/index.js +6 -0
- package/dist/mcp-server/tools/gitPush/logic.js +188 -0
- package/dist/mcp-server/tools/gitPush/registration.js +81 -0
- package/dist/mcp-server/tools/gitRebase/index.js +7 -0
- package/dist/mcp-server/tools/gitRebase/logic.js +171 -0
- package/dist/mcp-server/tools/gitRebase/registration.js +72 -0
- package/dist/mcp-server/tools/gitRemote/index.js +7 -0
- package/dist/mcp-server/tools/gitRemote/logic.js +158 -0
- package/dist/mcp-server/tools/gitRemote/registration.js +76 -0
- package/dist/mcp-server/tools/gitReset/index.js +6 -0
- package/dist/mcp-server/tools/gitReset/logic.js +116 -0
- package/dist/mcp-server/tools/gitReset/registration.js +71 -0
- package/dist/mcp-server/tools/gitSetWorkingDir/index.js +7 -0
- package/dist/mcp-server/tools/gitSetWorkingDir/logic.js +91 -0
- package/dist/mcp-server/tools/gitSetWorkingDir/registration.js +78 -0
- package/dist/mcp-server/tools/gitShow/index.js +7 -0
- package/dist/mcp-server/tools/gitShow/logic.js +99 -0
- package/dist/mcp-server/tools/gitShow/registration.js +83 -0
- package/dist/mcp-server/tools/gitStash/index.js +7 -0
- package/dist/mcp-server/tools/gitStash/logic.js +161 -0
- package/dist/mcp-server/tools/gitStash/registration.js +84 -0
- package/dist/mcp-server/tools/gitStatus/index.js +7 -0
- package/dist/mcp-server/tools/gitStatus/logic.js +215 -0
- package/dist/mcp-server/tools/gitStatus/registration.js +77 -0
- package/dist/mcp-server/tools/gitTag/index.js +7 -0
- package/dist/mcp-server/tools/gitTag/logic.js +142 -0
- package/dist/mcp-server/tools/gitTag/registration.js +84 -0
- package/dist/types-global/errors.js +68 -0
- package/dist/types-global/mcp.js +59 -0
- package/dist/types-global/tool.js +1 -0
- package/dist/utils/errorHandler.js +237 -0
- package/dist/utils/idGenerator.js +148 -0
- package/dist/utils/index.js +11 -0
- package/dist/utils/jsonParser.js +78 -0
- package/dist/utils/logger.js +266 -0
- package/dist/utils/rateLimiter.js +177 -0
- package/dist/utils/requestContext.js +49 -0
- package/dist/utils/sanitization.js +371 -0
- package/dist/utils/tokenCounter.js +124 -0
- package/package.json +62 -17
- package/build/index.js +0 -54
- package/build/resources/descriptors.js +0 -77
- package/build/resources/diff.js +0 -241
- package/build/resources/file.js +0 -222
- package/build/resources/history.js +0 -242
- package/build/resources/index.js +0 -99
- package/build/resources/repository.js +0 -286
- package/build/server.js +0 -120
- package/build/services/error-service.js +0 -73
- package/build/services/git-service.js +0 -965
- package/build/tools/advanced.js +0 -526
- package/build/tools/branch.js +0 -296
- package/build/tools/index.js +0 -29
- package/build/tools/remote.js +0 -279
- package/build/tools/repository.js +0 -170
- package/build/tools/workdir.js +0 -445
- package/build/types/git.js +0 -7
- package/build/utils/global-settings.js +0 -64
- package/build/utils/validation.js +0 -108
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { logger } from '../../../utils/logger.js';
|
|
5
|
+
import { McpError, BaseErrorCode } from '../../../types-global/errors.js';
|
|
6
|
+
import { sanitization } from '../../../utils/sanitization.js';
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
// Define the input schema for the git_push tool using Zod
|
|
9
|
+
export const GitPushInputSchema = z.object({
|
|
10
|
+
path: z.string().min(1).optional().default('.').describe("Path to the Git repository. Defaults to the session's working directory if set."),
|
|
11
|
+
remote: z.string().optional().describe("The remote repository to push to (e.g., 'origin'). Defaults to the tracked upstream or 'origin'."),
|
|
12
|
+
branch: z.string().optional().describe("The local branch to push. Defaults to the current branch."),
|
|
13
|
+
remoteBranch: z.string().optional().describe("The remote branch to push to. Defaults to the same name as the local branch."),
|
|
14
|
+
force: z.boolean().optional().default(false).describe("Force the push (use with caution: `--force-with-lease` is generally safer)."),
|
|
15
|
+
forceWithLease: z.boolean().optional().default(false).describe("Force the push only if the remote ref is the expected value (`--force-with-lease`). Safer than --force."),
|
|
16
|
+
setUpstream: z.boolean().optional().default(false).describe("Set the upstream tracking configuration (`-u` or `--set-upstream`)."),
|
|
17
|
+
tags: z.boolean().optional().default(false).describe("Push all tags (`--tags`)."),
|
|
18
|
+
delete: z.boolean().optional().default(false).describe("Delete the remote branch (`--delete`). Requires `branch` to be specified. Use with caution, as deleting remote branches can affect collaborators."),
|
|
19
|
+
// Add other relevant git push options as needed (e.g., --prune, --all)
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Executes the 'git push' command and returns structured JSON output.
|
|
23
|
+
*
|
|
24
|
+
* @param {GitPushInput} input - The validated input object.
|
|
25
|
+
* @param {RequestContext} context - The request context for logging and error handling.
|
|
26
|
+
* @returns {Promise<GitPushResult>} A promise that resolves with the structured push result.
|
|
27
|
+
* @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly.
|
|
28
|
+
*/
|
|
29
|
+
export async function pushGitChanges(input, context) {
|
|
30
|
+
const operation = 'pushGitChanges';
|
|
31
|
+
logger.debug(`Executing ${operation}`, { ...context, input });
|
|
32
|
+
let targetPath;
|
|
33
|
+
try {
|
|
34
|
+
// Resolve and sanitize the target path
|
|
35
|
+
if (input.path && input.path !== '.') {
|
|
36
|
+
targetPath = input.path;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
const workingDir = context.getWorkingDirectory();
|
|
40
|
+
if (!workingDir) {
|
|
41
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation });
|
|
42
|
+
}
|
|
43
|
+
targetPath = workingDir;
|
|
44
|
+
}
|
|
45
|
+
targetPath = sanitization.sanitizePath(targetPath);
|
|
46
|
+
logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
logger.error('Path resolution or sanitization failed', { ...context, operation, error });
|
|
50
|
+
if (error instanceof McpError)
|
|
51
|
+
throw error;
|
|
52
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
|
|
53
|
+
}
|
|
54
|
+
// Validate specific input combinations
|
|
55
|
+
if (input.delete && !input.branch) {
|
|
56
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Cannot use --delete without specifying a branch to delete.", { context, operation });
|
|
57
|
+
}
|
|
58
|
+
if (input.force && input.forceWithLease) {
|
|
59
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Cannot use --force and --force-with-lease together.", { context, operation });
|
|
60
|
+
}
|
|
61
|
+
if (input.delete && (input.force || input.forceWithLease || input.setUpstream || input.tags)) {
|
|
62
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "Cannot combine --delete with --force, --force-with-lease, --set-upstream, or --tags.", { context, operation });
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
// Construct the git push command
|
|
66
|
+
let command = `git -C "${targetPath}" push`;
|
|
67
|
+
if (input.force) {
|
|
68
|
+
command += ' --force';
|
|
69
|
+
}
|
|
70
|
+
else if (input.forceWithLease) {
|
|
71
|
+
command += ' --force-with-lease';
|
|
72
|
+
}
|
|
73
|
+
if (input.setUpstream) {
|
|
74
|
+
command += ' --set-upstream';
|
|
75
|
+
}
|
|
76
|
+
if (input.tags) {
|
|
77
|
+
command += ' --tags';
|
|
78
|
+
}
|
|
79
|
+
if (input.delete) {
|
|
80
|
+
command += ' --delete';
|
|
81
|
+
}
|
|
82
|
+
// Add remote and branch specification
|
|
83
|
+
const remote = input.remote ? input.remote.replace(/[^a-zA-Z0-9_.\-/]/g, '') : 'origin'; // Default to origin
|
|
84
|
+
command += ` ${remote}`;
|
|
85
|
+
if (input.branch) {
|
|
86
|
+
const localBranch = input.branch.replace(/[^a-zA-Z0-9_.\-/]/g, '');
|
|
87
|
+
command += ` ${localBranch}`;
|
|
88
|
+
if (input.remoteBranch && !input.delete) { // remoteBranch only makes sense if not deleting
|
|
89
|
+
const remoteBranch = input.remoteBranch.replace(/[^a-zA-Z0-9_.\-/]/g, '');
|
|
90
|
+
command += `:${remoteBranch}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (!input.tags && !input.delete) {
|
|
94
|
+
// If no branch, tags, or delete specified, push the current branch by default
|
|
95
|
+
// Git might handle this automatically, but being explicit can be clearer
|
|
96
|
+
// command += ' HEAD'; // Or let git figure out the default push behavior
|
|
97
|
+
logger.debug('No specific branch, tags, or delete specified. Relying on default git push behavior for current branch.', { ...context, operation });
|
|
98
|
+
}
|
|
99
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
100
|
+
// Execute command. Note: Git push often uses stderr for progress and success messages.
|
|
101
|
+
const { stdout, stderr } = await execAsync(command);
|
|
102
|
+
logger.info(`Git push stdout: ${stdout}`, { ...context, operation });
|
|
103
|
+
logger.info(`Git push stderr: ${stderr}`, { ...context, operation }); // Log stderr as info as it's commonly used
|
|
104
|
+
// Analyze stderr primarily, fallback to stdout
|
|
105
|
+
let message = stderr.trim() || stdout.trim();
|
|
106
|
+
let success = true;
|
|
107
|
+
let rejected = false;
|
|
108
|
+
let deleted = false;
|
|
109
|
+
let summary = undefined;
|
|
110
|
+
// Check for common success/status messages in stderr
|
|
111
|
+
if (message.includes('Everything up-to-date')) {
|
|
112
|
+
message = 'Everything up-to-date.';
|
|
113
|
+
}
|
|
114
|
+
else if (message.match(/->\s+\[new branch\]/) || message.match(/->\s+\[new tag\]/)) {
|
|
115
|
+
message = 'Push successful (new branch/tag created).';
|
|
116
|
+
// Extract summary if possible (e.g., commit range)
|
|
117
|
+
const summaryMatch = message.match(/([a-f0-9]+\.\.[a-f0-9]+)\s+\S+\s+->\s+\S+/);
|
|
118
|
+
if (summaryMatch)
|
|
119
|
+
summary = summaryMatch[1];
|
|
120
|
+
}
|
|
121
|
+
else if (message.includes('Done.')) { // Common part of successful push output
|
|
122
|
+
// Try to find a more specific message
|
|
123
|
+
if (stderr.includes('updating') || stdout.includes('updating')) {
|
|
124
|
+
message = 'Push successful.';
|
|
125
|
+
const summaryMatch = message.match(/([a-f0-9]+\.\.[a-f0-9]+)\s+\S+\s+->\s+\S+/);
|
|
126
|
+
if (summaryMatch)
|
|
127
|
+
summary = summaryMatch[1];
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
message = 'Push completed (check logs for details).'; // Generic success if specific pattern not found
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else if (message.includes('[rejected]')) {
|
|
134
|
+
message = 'Push rejected.';
|
|
135
|
+
success = false;
|
|
136
|
+
rejected = true;
|
|
137
|
+
// Extract reason if possible
|
|
138
|
+
const reasonMatch = message.match(/\[rejected\].*->.*?\((.*?)\)/);
|
|
139
|
+
if (reasonMatch) {
|
|
140
|
+
message += ` Reason: ${reasonMatch[1]}.`;
|
|
141
|
+
if (reasonMatch[1].includes('non-fast-forward')) {
|
|
142
|
+
message += ' Hint: Try pulling first or use force options if necessary.';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else if (message.includes('[deleted]')) {
|
|
147
|
+
message = 'Remote branch deleted successfully.';
|
|
148
|
+
deleted = true;
|
|
149
|
+
}
|
|
150
|
+
else if (message.includes('fatal:')) {
|
|
151
|
+
// If a fatal error wasn't caught by execAsync but is in stderr/stdout
|
|
152
|
+
success = false;
|
|
153
|
+
message = `Push failed: ${message}`;
|
|
154
|
+
logger.error(`Git push command indicated failure: ${message}`, { ...context, operation, stdout, stderr });
|
|
155
|
+
}
|
|
156
|
+
else if (!message && !stdout && !stderr) {
|
|
157
|
+
// If command succeeds with no output (can happen in some cases)
|
|
158
|
+
message = 'Push command executed, but no output received.';
|
|
159
|
+
logger.warning(message, { ...context, operation });
|
|
160
|
+
}
|
|
161
|
+
logger.info(`${operation} completed`, { ...context, operation, path: targetPath, success, message, rejected, deleted });
|
|
162
|
+
return { success, message, summary, rejected, deleted };
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
logger.error(`Failed to execute git push command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr, stdout: error.stdout });
|
|
166
|
+
const errorMessage = error.stderr || error.stdout || error.message || '';
|
|
167
|
+
// Handle specific error cases
|
|
168
|
+
if (errorMessage.toLowerCase().includes('not a git repository')) {
|
|
169
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
|
|
170
|
+
}
|
|
171
|
+
if (errorMessage.includes('resolve host') || errorMessage.includes('Could not read from remote repository') || errorMessage.includes('Connection timed out')) {
|
|
172
|
+
throw new McpError(BaseErrorCode.NETWORK_ERROR, `Failed to connect to remote repository. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
173
|
+
}
|
|
174
|
+
if (errorMessage.includes('rejected') || errorMessage.includes('failed to push some refs')) {
|
|
175
|
+
// This might be caught here if execAsync throws due to non-zero exit code on rejection
|
|
176
|
+
logger.warning('Push rejected (caught as error)', { ...context, operation, path: targetPath, errorMessage });
|
|
177
|
+
return { success: false, message: `Push rejected: ${errorMessage}`, rejected: true };
|
|
178
|
+
}
|
|
179
|
+
if (errorMessage.includes('Authentication failed') || errorMessage.includes('Permission denied')) {
|
|
180
|
+
throw new McpError(BaseErrorCode.UNAUTHORIZED, `Authentication failed for remote repository. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
181
|
+
}
|
|
182
|
+
if (errorMessage.includes('src refspec') && errorMessage.includes('does not match any')) {
|
|
183
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Push failed: Source branch/refspec does not exist locally. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
184
|
+
}
|
|
185
|
+
// Generic internal error for other failures
|
|
186
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to push changes for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ErrorHandler } from '../../../utils/errorHandler.js';
|
|
2
|
+
import { logger } from '../../../utils/logger.js';
|
|
3
|
+
import { requestContextService } from '../../../utils/requestContext.js';
|
|
4
|
+
import { GitPushInputSchema, pushGitChanges } from './logic.js';
|
|
5
|
+
import { BaseErrorCode } from '../../../types-global/errors.js';
|
|
6
|
+
let _getWorkingDirectory;
|
|
7
|
+
let _getSessionId;
|
|
8
|
+
/**
|
|
9
|
+
* Initializes the state accessors needed by the tool registration.
|
|
10
|
+
* This should be called once during server setup.
|
|
11
|
+
* @param getWdFn - Function to get the working directory for a session.
|
|
12
|
+
* @param getSidFn - Function to get the session ID from context.
|
|
13
|
+
*/
|
|
14
|
+
export function initializeGitPushStateAccessors(getWdFn, getSidFn) {
|
|
15
|
+
_getWorkingDirectory = getWdFn;
|
|
16
|
+
_getSessionId = getSidFn;
|
|
17
|
+
logger.info('State accessors initialized for git_push tool registration.');
|
|
18
|
+
}
|
|
19
|
+
const TOOL_NAME = 'git_push';
|
|
20
|
+
const TOOL_DESCRIPTION = "Updates remote refs using local refs, sending objects necessary to complete the given refs. Supports pushing specific branches, tags, forcing, setting upstream, and deleting remote branches. Returns the push result as a JSON object.";
|
|
21
|
+
/**
|
|
22
|
+
* Registers the git_push tool with the MCP server.
|
|
23
|
+
*
|
|
24
|
+
* @param {McpServer} server - The MCP server instance.
|
|
25
|
+
* @throws {Error} If state accessors are not initialized.
|
|
26
|
+
*/
|
|
27
|
+
export async function registerGitPushTool(server) {
|
|
28
|
+
if (!_getWorkingDirectory || !_getSessionId) {
|
|
29
|
+
throw new Error('State accessors for git_push must be initialized before registration.');
|
|
30
|
+
}
|
|
31
|
+
const operation = 'registerGitPushTool';
|
|
32
|
+
const context = requestContextService.createRequestContext({ operation });
|
|
33
|
+
await ErrorHandler.tryCatch(async () => {
|
|
34
|
+
server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitPushInputSchema.shape, // Provide the Zod schema shape
|
|
35
|
+
async (validatedArgs, callContext) => {
|
|
36
|
+
const toolOperation = 'tool:git_push';
|
|
37
|
+
const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
|
|
38
|
+
const sessionId = _getSessionId(requestContext);
|
|
39
|
+
const getWorkingDirectoryForSession = () => {
|
|
40
|
+
return _getWorkingDirectory(sessionId);
|
|
41
|
+
};
|
|
42
|
+
const logicContext = {
|
|
43
|
+
...requestContext,
|
|
44
|
+
sessionId: sessionId,
|
|
45
|
+
getWorkingDirectory: getWorkingDirectoryForSession,
|
|
46
|
+
};
|
|
47
|
+
logger.info(`Executing tool: ${TOOL_NAME}`, logicContext);
|
|
48
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
49
|
+
// Call the core logic function
|
|
50
|
+
const pushResult = await pushGitChanges(validatedArgs, logicContext);
|
|
51
|
+
// Format the result as a JSON string within TextContent
|
|
52
|
+
const resultContent = {
|
|
53
|
+
type: 'text',
|
|
54
|
+
text: JSON.stringify(pushResult, null, 2), // Pretty-print JSON
|
|
55
|
+
contentType: 'application/json',
|
|
56
|
+
};
|
|
57
|
+
// Log based on the success flag in the result
|
|
58
|
+
if (pushResult.success) {
|
|
59
|
+
logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, logicContext);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Log non-fatal conditions like rejections differently
|
|
63
|
+
const logMessage = pushResult.rejected
|
|
64
|
+
? `Tool ${TOOL_NAME} completed but push was rejected, returning JSON`
|
|
65
|
+
: `Tool ${TOOL_NAME} completed with status: ${pushResult.message}, returning JSON`;
|
|
66
|
+
logger.info(logMessage, logicContext);
|
|
67
|
+
}
|
|
68
|
+
// Even if success is false (e.g., rejected), it's not necessarily a tool execution *error*
|
|
69
|
+
// unless the logic threw an McpError. The success flag in the JSON indicates the Git outcome.
|
|
70
|
+
return { content: [resultContent] };
|
|
71
|
+
}, {
|
|
72
|
+
operation: toolOperation,
|
|
73
|
+
context: logicContext,
|
|
74
|
+
input: validatedArgs,
|
|
75
|
+
errorCode: BaseErrorCode.INTERNAL_ERROR, // Default if unexpected error in logic
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
logger.info(`Tool registered: ${TOOL_NAME}`, context);
|
|
79
|
+
}, { operation, context, critical: true });
|
|
80
|
+
}
|
|
81
|
+
;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Barrel file for the git_rebase tool.
|
|
3
|
+
* Exports the registration function and state accessor initialization function.
|
|
4
|
+
*/
|
|
5
|
+
export { registerGitRebaseTool, initializeGitRebaseStateAccessors } from './registration.js';
|
|
6
|
+
// Export types if needed elsewhere, e.g.:
|
|
7
|
+
// export type { GitRebaseInput, GitRebaseResult } from './logic.js';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { logger } from '../../../utils/logger.js';
|
|
5
|
+
import { McpError, BaseErrorCode } from '../../../types-global/errors.js';
|
|
6
|
+
import { sanitization } from '../../../utils/sanitization.js';
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
// Define the BASE input schema for the git_rebase tool using Zod
|
|
9
|
+
export const GitRebaseBaseSchema = z.object({
|
|
10
|
+
path: z.string().min(1).optional().default('.').describe("Path to the local Git repository. If omitted, defaults to the path set by `git_set_working_dir` for the current session, or the server's CWD if no session path is set."),
|
|
11
|
+
mode: z.enum(['start', 'continue', 'abort', 'skip']).default('start').describe("Rebase operation mode: 'start' (initiate rebase), 'continue', 'abort', 'skip' (manage ongoing rebase)."),
|
|
12
|
+
upstream: z.string().min(1).optional().describe("The upstream branch or commit to rebase onto. Required for 'start' mode unless 'interactive' is true with default base."),
|
|
13
|
+
branch: z.string().min(1).optional().describe("The branch to rebase. Defaults to the current branch if omitted."),
|
|
14
|
+
interactive: z.boolean().default(false).describe("Perform an interactive rebase (`-i`). 'upstream' can be omitted to rebase current branch's tracked upstream or use fork-point."),
|
|
15
|
+
strategy: z.enum(['recursive', 'resolve', 'ours', 'theirs', 'octopus', 'subtree']).optional().describe("Specifies the merge strategy to use during rebase."),
|
|
16
|
+
strategyOption: z.string().optional().describe("Pass a specific option to the merge strategy (e.g., 'ours', 'theirs' for recursive). Use with -X."),
|
|
17
|
+
onto: z.string().min(1).optional().describe("Rebase onto a specific commit/branch instead of the upstream's base. Requires 'upstream' to be specified."),
|
|
18
|
+
// TODO: Add options like --preserve-merges, --autosquash, --autostash?
|
|
19
|
+
});
|
|
20
|
+
// Apply refinements and export the FINAL schema for validation within the handler
|
|
21
|
+
export const GitRebaseInputSchema = GitRebaseBaseSchema.refine(data => !(data.mode === 'start' && !data.interactive && !data.upstream), {
|
|
22
|
+
message: "An 'upstream' branch/commit is required for 'start' mode unless 'interactive' is true.", path: ["upstream"],
|
|
23
|
+
}).refine(data => !(data.mode !== 'start' && (data.upstream || data.branch || data.interactive || data.strategy || data.onto)), {
|
|
24
|
+
message: "Parameters like 'upstream', 'branch', 'interactive', 'strategy', 'onto' are only applicable for 'start' mode.", path: ["mode"],
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* Executes the 'git rebase' command based on the specified mode.
|
|
28
|
+
*
|
|
29
|
+
* @param {GitRebaseInput} input - The validated input object.
|
|
30
|
+
* @param {RequestContext} context - The request context for logging and error handling.
|
|
31
|
+
* @returns {Promise<GitRebaseResult>} A promise that resolves with the structured result.
|
|
32
|
+
* @throws {McpError} Throws an McpError for path resolution/validation failures or unexpected errors.
|
|
33
|
+
*/
|
|
34
|
+
export async function gitRebaseLogic(input, context) {
|
|
35
|
+
const operation = `gitRebaseLogic:${input.mode}`;
|
|
36
|
+
logger.debug(`Executing ${operation}`, { ...context, input });
|
|
37
|
+
let targetPath;
|
|
38
|
+
try {
|
|
39
|
+
// Resolve and sanitize the target path
|
|
40
|
+
const workingDir = context.getWorkingDirectory();
|
|
41
|
+
targetPath = (input.path && input.path !== '.')
|
|
42
|
+
? input.path
|
|
43
|
+
: workingDir ?? '.';
|
|
44
|
+
if (targetPath === '.' && !workingDir) {
|
|
45
|
+
logger.warning("Executing git rebase in server's CWD as no path provided and no session WD set.", { ...context, operation });
|
|
46
|
+
targetPath = process.cwd();
|
|
47
|
+
}
|
|
48
|
+
else if (targetPath === '.' && workingDir) {
|
|
49
|
+
targetPath = workingDir;
|
|
50
|
+
logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
|
|
54
|
+
}
|
|
55
|
+
targetPath = sanitization.sanitizePath(targetPath);
|
|
56
|
+
logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
logger.error('Path resolution or sanitization failed', { ...context, operation, error });
|
|
60
|
+
if (error instanceof McpError)
|
|
61
|
+
throw error;
|
|
62
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
let command = `git -C "${targetPath}" rebase`;
|
|
66
|
+
switch (input.mode) {
|
|
67
|
+
case 'start':
|
|
68
|
+
if (input.interactive)
|
|
69
|
+
command += ' -i';
|
|
70
|
+
if (input.strategy)
|
|
71
|
+
command += ` --strategy=${input.strategy}`;
|
|
72
|
+
if (input.strategyOption)
|
|
73
|
+
command += ` -X${input.strategyOption}`; // Note: -X for strategy options
|
|
74
|
+
if (input.onto)
|
|
75
|
+
command += ` --onto "${input.onto.replace(/"/g, '\\"')}"`;
|
|
76
|
+
// Upstream is required by refine unless interactive
|
|
77
|
+
if (input.upstream)
|
|
78
|
+
command += ` "${input.upstream.replace(/"/g, '\\"')}"`;
|
|
79
|
+
if (input.branch)
|
|
80
|
+
command += ` "${input.branch.replace(/"/g, '\\"')}"`;
|
|
81
|
+
break;
|
|
82
|
+
case 'continue':
|
|
83
|
+
command += ' --continue';
|
|
84
|
+
break;
|
|
85
|
+
case 'abort':
|
|
86
|
+
command += ' --abort';
|
|
87
|
+
break;
|
|
88
|
+
case 'skip':
|
|
89
|
+
command += ' --skip';
|
|
90
|
+
break;
|
|
91
|
+
default:
|
|
92
|
+
// Should not happen due to Zod validation
|
|
93
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
|
|
94
|
+
}
|
|
95
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
96
|
+
try {
|
|
97
|
+
const { stdout, stderr } = await execAsync(command);
|
|
98
|
+
const output = stdout + stderr;
|
|
99
|
+
// Check for common success messages
|
|
100
|
+
if (/successfully rebased and updated/i.test(output) || (input.mode === 'abort' && !stderr) || (input.mode === 'skip' && !stderr) || (input.mode === 'continue' && /applying/i.test(stdout))) {
|
|
101
|
+
const message = input.mode === 'start'
|
|
102
|
+
? `Rebase started successfully. Output: ${output.trim()}`
|
|
103
|
+
: `Rebase ${input.mode} executed successfully. Output: ${output.trim()}`;
|
|
104
|
+
logger.info(message, { ...context, operation, path: targetPath });
|
|
105
|
+
return { success: true, mode: input.mode, message, rebaseCompleted: /successfully rebased/.test(output), needsManualAction: false };
|
|
106
|
+
}
|
|
107
|
+
// Check for interactive rebase start
|
|
108
|
+
if (input.mode === 'start' && input.interactive && /noop/i.test(stderr) && /hint: use 'git rebase --edit-todo'/i.test(stderr)) {
|
|
109
|
+
const message = `Interactive rebase started. Edit the todo list in your editor. Output: ${output.trim()}`;
|
|
110
|
+
logger.info(message, { ...context, operation, path: targetPath });
|
|
111
|
+
return { success: true, mode: input.mode, message, rebaseCompleted: false, needsManualAction: true };
|
|
112
|
+
}
|
|
113
|
+
if (input.mode === 'start' && input.interactive && /applying/i.test(stdout)) {
|
|
114
|
+
const message = `Interactive rebase started and processing commits. Output: ${output.trim()}`;
|
|
115
|
+
logger.info(message, { ...context, operation, path: targetPath });
|
|
116
|
+
return { success: true, mode: input.mode, message, rebaseCompleted: false, needsManualAction: false }; // Might complete or hit conflict/edit
|
|
117
|
+
}
|
|
118
|
+
// Check for conflicts even if exit code is 0 (can happen with --continue sometimes)
|
|
119
|
+
if (/conflict/i.test(output)) {
|
|
120
|
+
const message = `Rebase ${input.mode} resulted in conflicts. Resolve conflicts and use 'git rebase --continue'. Output: ${output.trim()}`;
|
|
121
|
+
logger.warning(message, { ...context, operation, path: targetPath });
|
|
122
|
+
return { success: true, mode: input.mode, message, rebaseCompleted: false, needsManualAction: true };
|
|
123
|
+
}
|
|
124
|
+
// Default success message if no specific pattern matched but no error thrown
|
|
125
|
+
const defaultMessage = `Rebase ${input.mode} command finished. Output: ${output.trim()}`;
|
|
126
|
+
logger.info(defaultMessage, { ...context, operation, path: targetPath });
|
|
127
|
+
return { success: true, mode: input.mode, message: defaultMessage, rebaseCompleted: !/applying|stopped/i.test(output), needsManualAction: /stopped at|edit/.test(output) };
|
|
128
|
+
}
|
|
129
|
+
catch (rebaseError) {
|
|
130
|
+
const errorMessage = rebaseError.stderr || rebaseError.stdout || rebaseError.message || '';
|
|
131
|
+
logger.error(`Git rebase ${input.mode} command failed`, { ...context, operation, path: targetPath, error: errorMessage, stderr: rebaseError.stderr, stdout: rebaseError.stdout });
|
|
132
|
+
// Handle specific error cases
|
|
133
|
+
if (/conflict/i.test(errorMessage)) {
|
|
134
|
+
return { success: false, mode: input.mode, message: `Rebase ${input.mode} failed due to conflicts. Resolve conflicts and use 'git rebase --continue'.`, error: errorMessage, conflicts: true };
|
|
135
|
+
}
|
|
136
|
+
if (/no rebase in progress/i.test(errorMessage)) {
|
|
137
|
+
return { success: false, mode: input.mode, message: `Failed to ${input.mode} rebase: No rebase is currently in progress.`, error: errorMessage };
|
|
138
|
+
}
|
|
139
|
+
if (/cannot rebase onto multiple branches/i.test(errorMessage)) {
|
|
140
|
+
return { success: false, mode: 'start', message: `Failed to start rebase: Cannot rebase onto multiple branches. Check your 'upstream' parameter.`, error: errorMessage };
|
|
141
|
+
}
|
|
142
|
+
if (/does not point to a valid commit/i.test(errorMessage)) {
|
|
143
|
+
return { success: false, mode: 'start', message: `Failed to start rebase: Invalid upstream, branch, or onto reference provided.`, error: errorMessage };
|
|
144
|
+
}
|
|
145
|
+
if (/your local changes would be overwritten/i.test(errorMessage)) {
|
|
146
|
+
return { success: false, mode: input.mode, message: `Failed to ${input.mode} rebase: Your local changes to tracked files would be overwritten. Please commit or stash them.`, error: errorMessage };
|
|
147
|
+
}
|
|
148
|
+
if (/interactive rebase already started/i.test(errorMessage)) {
|
|
149
|
+
return { success: false, mode: 'start', message: `Failed to start rebase: An interactive rebase is already in progress. Use 'continue', 'abort', or 'skip'.`, error: errorMessage };
|
|
150
|
+
}
|
|
151
|
+
// Throw McpError for critical issues like non-existent repo
|
|
152
|
+
if (errorMessage.toLowerCase().includes('not a git repository')) {
|
|
153
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: rebaseError });
|
|
154
|
+
}
|
|
155
|
+
// Return structured failure for other git errors
|
|
156
|
+
return {
|
|
157
|
+
success: false,
|
|
158
|
+
mode: input.mode,
|
|
159
|
+
message: `Git rebase ${input.mode} failed for path: ${targetPath}.`,
|
|
160
|
+
error: errorMessage
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
// Catch errors from path resolution or unexpected issues before command execution
|
|
166
|
+
logger.error(`Unexpected error during git rebase setup or execution`, { ...context, operation, path: targetPath, error: error.message });
|
|
167
|
+
if (error instanceof McpError)
|
|
168
|
+
throw error;
|
|
169
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `An unexpected error occurred during git rebase ${input.mode}: ${error.message}`, { context, operation, originalError: error });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { logger } from '../../../utils/logger.js';
|
|
2
|
+
import { ErrorHandler } from '../../../utils/errorHandler.js';
|
|
3
|
+
import { requestContextService } from '../../../utils/requestContext.js';
|
|
4
|
+
import { gitRebaseLogic, GitRebaseBaseSchema } from './logic.js';
|
|
5
|
+
import { BaseErrorCode } from '../../../types-global/errors.js';
|
|
6
|
+
let _getWorkingDirectory;
|
|
7
|
+
let _getSessionId;
|
|
8
|
+
/**
|
|
9
|
+
* Initializes the state accessors needed by the git_rebase tool registration.
|
|
10
|
+
* @param getWdFn - Function to get the working directory for a session.
|
|
11
|
+
* @param getSidFn - Function to get the session ID from context.
|
|
12
|
+
*/
|
|
13
|
+
export function initializeGitRebaseStateAccessors(getWdFn, getSidFn) {
|
|
14
|
+
_getWorkingDirectory = getWdFn;
|
|
15
|
+
_getSessionId = getSidFn;
|
|
16
|
+
logger.info('State accessors initialized for git_rebase tool registration.');
|
|
17
|
+
}
|
|
18
|
+
const TOOL_NAME = 'git_rebase';
|
|
19
|
+
const TOOL_DESCRIPTION = 'Reapplies commits on top of another base tip. Supports starting a rebase (standard or interactive), continuing, aborting, or skipping steps in an ongoing rebase. Returns results as a JSON object.';
|
|
20
|
+
/**
|
|
21
|
+
* Registers the git_rebase tool with the MCP server.
|
|
22
|
+
*
|
|
23
|
+
* @param {McpServer} server - The McpServer instance to register the tool with.
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
* @throws {Error} If registration fails or state accessors are not initialized.
|
|
26
|
+
*/
|
|
27
|
+
export const registerGitRebaseTool = async (server) => {
|
|
28
|
+
if (!_getWorkingDirectory || !_getSessionId) {
|
|
29
|
+
throw new Error('State accessors for git_rebase must be initialized before registration.');
|
|
30
|
+
}
|
|
31
|
+
const operation = 'registerGitRebaseTool';
|
|
32
|
+
const context = requestContextService.createRequestContext({ operation });
|
|
33
|
+
await ErrorHandler.tryCatch(async () => {
|
|
34
|
+
// Register using the BASE schema shape
|
|
35
|
+
server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitRebaseBaseSchema.shape,
|
|
36
|
+
// SDK validates against the full GitRebaseInputSchema before calling this handler
|
|
37
|
+
async (validatedArgs, callContext) => {
|
|
38
|
+
const toolInput = validatedArgs; // Cast for use
|
|
39
|
+
const toolOperation = `tool:${TOOL_NAME}:${toolInput.mode}`;
|
|
40
|
+
const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
|
|
41
|
+
const sessionId = _getSessionId(requestContext);
|
|
42
|
+
const getWorkingDirectoryForSession = () => _getWorkingDirectory(sessionId);
|
|
43
|
+
const logicContext = {
|
|
44
|
+
...requestContext,
|
|
45
|
+
sessionId: sessionId,
|
|
46
|
+
getWorkingDirectory: getWorkingDirectoryForSession,
|
|
47
|
+
};
|
|
48
|
+
logger.info(`Executing tool: ${TOOL_NAME} (mode: ${toolInput.mode})`, logicContext);
|
|
49
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
50
|
+
const rebaseResult = await gitRebaseLogic(toolInput, logicContext);
|
|
51
|
+
const resultContent = {
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: JSON.stringify(rebaseResult, null, 2), // Pretty-print JSON
|
|
54
|
+
contentType: 'application/json',
|
|
55
|
+
};
|
|
56
|
+
if (rebaseResult.success) {
|
|
57
|
+
logger.info(`Tool ${TOOL_NAME} (mode: ${toolInput.mode}) executed successfully (Needs Manual Action: ${!!rebaseResult.needsManualAction}), returning JSON`, logicContext);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
logger.warning(`Tool ${TOOL_NAME} (mode: ${toolInput.mode}) failed: ${rebaseResult.message}`, { ...logicContext, errorDetails: rebaseResult.error, conflicts: rebaseResult.conflicts });
|
|
61
|
+
}
|
|
62
|
+
return { content: [resultContent] };
|
|
63
|
+
}, {
|
|
64
|
+
operation: toolOperation,
|
|
65
|
+
context: logicContext,
|
|
66
|
+
input: validatedArgs,
|
|
67
|
+
errorCode: BaseErrorCode.INTERNAL_ERROR,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
logger.info(`Tool registered: ${TOOL_NAME}`, context);
|
|
71
|
+
}, { operation, context, critical: true });
|
|
72
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Barrel file for the git_remote tool.
|
|
3
|
+
* Exports the registration function and state accessor initialization function.
|
|
4
|
+
*/
|
|
5
|
+
export { initializeGitRemoteStateAccessors, registerGitRemoteTool } from './registration.js';
|
|
6
|
+
// Export types if needed elsewhere, e.g.:
|
|
7
|
+
// export type { GitRemoteInput, GitRemoteResult } from './logic.js';
|