@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,118 @@
|
|
|
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_add tool using Zod
|
|
9
|
+
export const GitAddInputSchema = 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 via `git_set_working_dir`, otherwise defaults to the server's current working directory (`.`)."),
|
|
11
|
+
files: z.union([
|
|
12
|
+
z.string().min(1),
|
|
13
|
+
z.array(z.string().min(1))
|
|
14
|
+
]).default('.').describe('Files or patterns to stage, defaults to all changes (\'.\')'),
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Executes the 'git add' command and returns structured JSON output.
|
|
18
|
+
*
|
|
19
|
+
* @param {GitAddInput} input - The validated input object.
|
|
20
|
+
* @param {RequestContext} context - The request context for logging and error handling.
|
|
21
|
+
* @returns {Promise<GitAddResult>} A promise that resolves with the structured add result.
|
|
22
|
+
* @throws {McpError} Throws an McpError if path resolution or validation fails, or if the git command fails unexpectedly.
|
|
23
|
+
*/
|
|
24
|
+
export async function addGitFiles(input, context // Add getter to context
|
|
25
|
+
) {
|
|
26
|
+
const operation = 'addGitFiles';
|
|
27
|
+
logger.debug(`Executing ${operation}`, { ...context, input });
|
|
28
|
+
let targetPath;
|
|
29
|
+
try {
|
|
30
|
+
// Resolve the target path
|
|
31
|
+
if (input.path && input.path !== '.') {
|
|
32
|
+
targetPath = input.path;
|
|
33
|
+
logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const workingDir = context.getWorkingDirectory();
|
|
37
|
+
if (!workingDir) {
|
|
38
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation });
|
|
39
|
+
}
|
|
40
|
+
targetPath = workingDir;
|
|
41
|
+
logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
|
|
42
|
+
}
|
|
43
|
+
// Sanitize the resolved path
|
|
44
|
+
const sanitizedPath = sanitization.sanitizePath(targetPath);
|
|
45
|
+
logger.debug('Sanitized repository path', { ...context, operation, sanitizedPath });
|
|
46
|
+
targetPath = sanitizedPath; // Use the sanitized path going forward
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
logger.error('Path resolution or sanitization failed', { ...context, operation, error });
|
|
50
|
+
if (error instanceof McpError) {
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
|
|
54
|
+
}
|
|
55
|
+
// Prepare the files argument for the command, ensuring proper quoting
|
|
56
|
+
let filesArg;
|
|
57
|
+
const filesToStage = input.files; // Keep original for reporting
|
|
58
|
+
try {
|
|
59
|
+
if (Array.isArray(filesToStage)) {
|
|
60
|
+
if (filesToStage.length === 0) {
|
|
61
|
+
logger.warning('Empty array provided for files, defaulting to staging all changes.', { ...context, operation });
|
|
62
|
+
filesArg = '.'; // Default to staging all if array is empty
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Quote each file path individually
|
|
66
|
+
filesArg = filesToStage.map(file => {
|
|
67
|
+
const sanitizedFile = file.startsWith('-') ? `./${file}` : file; // Prefix with './' if it starts with a dash
|
|
68
|
+
return `"${sanitizedFile.replace(/"/g, '\\"')}"`; // Escape quotes within path
|
|
69
|
+
}).join(' ');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else { // Single string case
|
|
73
|
+
const sanitizedFile = filesToStage.startsWith('-') ? `./${filesToStage}` : filesToStage; // Prefix with './' if it starts with a dash
|
|
74
|
+
filesArg = `"${sanitizedFile.replace(/"/g, '\\"')}"`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
logger.error('File path validation/quoting failed', { ...context, operation, files: filesToStage, error: err });
|
|
79
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid file path/pattern provided: ${err instanceof Error ? err.message : String(err)}`, { context, operation, originalError: err });
|
|
80
|
+
}
|
|
81
|
+
// This check should ideally not be needed now due to the logic above
|
|
82
|
+
if (!filesArg) {
|
|
83
|
+
logger.error('Internal error: filesArg is unexpectedly empty after processing.', { ...context, operation });
|
|
84
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, 'Internal error preparing git add command.', { context, operation });
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// Use the resolved targetPath
|
|
88
|
+
const command = `git -C "${targetPath}" add -- ${filesArg}`;
|
|
89
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
90
|
+
const { stdout, stderr } = await execAsync(command);
|
|
91
|
+
if (stderr) {
|
|
92
|
+
// Log stderr as warning, as 'git add' can produce warnings but still succeed.
|
|
93
|
+
logger.warning(`Git add command produced stderr`, { ...context, operation, stderr });
|
|
94
|
+
}
|
|
95
|
+
logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath, files: filesToStage });
|
|
96
|
+
const filesAddedDesc = Array.isArray(filesToStage) ? filesToStage.join(', ') : filesToStage;
|
|
97
|
+
const reminder = "Remember to write clear, concise commit messages using the Conventional Commits format (e.g., 'feat(scope): subject').";
|
|
98
|
+
// Use statusMessage and add reminder
|
|
99
|
+
return {
|
|
100
|
+
success: true,
|
|
101
|
+
statusMessage: `Successfully staged: ${filesAddedDesc}. ${reminder}`,
|
|
102
|
+
filesStaged: filesToStage
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
logger.error(`Failed to execute git add command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr });
|
|
107
|
+
const errorMessage = error.stderr || error.message || '';
|
|
108
|
+
if (errorMessage.toLowerCase().includes('not a git repository')) {
|
|
109
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
|
|
110
|
+
}
|
|
111
|
+
if (errorMessage.toLowerCase().includes('did not match any files')) {
|
|
112
|
+
// Still throw an error, but return structured info in the catch block of the registration
|
|
113
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Specified files/patterns did not match any files in ${targetPath}: ${filesArg}`, { context, operation, originalError: error, filesStaged: filesToStage });
|
|
114
|
+
}
|
|
115
|
+
// Throw generic error for other cases
|
|
116
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to stage files for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error, filesStaged: filesToStage });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { logger } from '../../../utils/logger.js';
|
|
2
|
+
import { ErrorHandler } from '../../../utils/errorHandler.js';
|
|
3
|
+
import { requestContextService } from '../../../utils/requestContext.js';
|
|
4
|
+
// Import the result type along with the function and input schema
|
|
5
|
+
import { addGitFiles, GitAddInputSchema } from './logic.js';
|
|
6
|
+
import { BaseErrorCode } from '../../../types-global/errors.js'; // Import BaseErrorCode
|
|
7
|
+
let _getWorkingDirectory;
|
|
8
|
+
let _getSessionId;
|
|
9
|
+
/**
|
|
10
|
+
* Initializes the state accessors needed by the tool registration.
|
|
11
|
+
* This should be called once during server setup.
|
|
12
|
+
* @param getWdFn - Function to get the working directory for a session.
|
|
13
|
+
* @param getSidFn - Function to get the session ID from context.
|
|
14
|
+
*/
|
|
15
|
+
export function initializeGitAddStateAccessors(getWdFn, getSidFn) {
|
|
16
|
+
_getWorkingDirectory = getWdFn;
|
|
17
|
+
_getSessionId = getSidFn;
|
|
18
|
+
logger.info('State accessors initialized for git_add tool registration.');
|
|
19
|
+
}
|
|
20
|
+
const TOOL_NAME = 'git_add';
|
|
21
|
+
const TOOL_DESCRIPTION = 'Stages changes in the Git repository for the next commit by adding file contents to the index (staging area). Can stage specific files/patterns or all changes (default: \'.\'). Returns the result as a JSON object.';
|
|
22
|
+
/**
|
|
23
|
+
* Registers the git_add tool with the MCP server.
|
|
24
|
+
* Uses the high-level server.tool() method for registration, schema validation, and routing.
|
|
25
|
+
*
|
|
26
|
+
* @param {McpServer} server - The McpServer instance to register the tool with.
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
* @throws {Error} If registration fails or state accessors are not initialized.
|
|
29
|
+
*/
|
|
30
|
+
export const registerGitAddTool = async (server) => {
|
|
31
|
+
if (!_getWorkingDirectory || !_getSessionId) {
|
|
32
|
+
throw new Error('State accessors for git_add must be initialized before registration.');
|
|
33
|
+
}
|
|
34
|
+
const operation = 'registerGitAddTool';
|
|
35
|
+
const context = requestContextService.createRequestContext({ operation });
|
|
36
|
+
await ErrorHandler.tryCatch(async () => {
|
|
37
|
+
server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitAddInputSchema.shape, // Provide the Zod schema shape
|
|
38
|
+
async (validatedArgs, callContext) => {
|
|
39
|
+
const toolOperation = 'tool:git_add';
|
|
40
|
+
const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
|
|
41
|
+
const sessionId = _getSessionId(requestContext);
|
|
42
|
+
const getWorkingDirectoryForSession = () => {
|
|
43
|
+
return _getWorkingDirectory(sessionId);
|
|
44
|
+
};
|
|
45
|
+
const logicContext = {
|
|
46
|
+
...requestContext,
|
|
47
|
+
sessionId: sessionId,
|
|
48
|
+
getWorkingDirectory: getWorkingDirectoryForSession,
|
|
49
|
+
};
|
|
50
|
+
logger.info(`Executing tool: ${TOOL_NAME}`, logicContext);
|
|
51
|
+
return await ErrorHandler.tryCatch(async () => {
|
|
52
|
+
// Call the core logic function which now returns a GitAddResult object
|
|
53
|
+
const addResult = await addGitFiles(validatedArgs, logicContext);
|
|
54
|
+
// Format the successful result as a JSON string within TextContent
|
|
55
|
+
const resultContent = {
|
|
56
|
+
type: 'text',
|
|
57
|
+
// Stringify the JSON object for the response content
|
|
58
|
+
text: JSON.stringify(addResult, null, 2), // Pretty-print JSON
|
|
59
|
+
contentType: 'application/json',
|
|
60
|
+
};
|
|
61
|
+
logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, logicContext);
|
|
62
|
+
return { content: [resultContent] };
|
|
63
|
+
}, {
|
|
64
|
+
operation: toolOperation,
|
|
65
|
+
context: logicContext,
|
|
66
|
+
input: validatedArgs,
|
|
67
|
+
errorCode: BaseErrorCode.INTERNAL_ERROR, // Default error code
|
|
68
|
+
// Let the logic function throw specific errors like NOT_FOUND
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
logger.info(`Tool registered: ${TOOL_NAME}`, context);
|
|
72
|
+
}, { operation, context, critical: true });
|
|
73
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Barrel file for the git_branch tool.
|
|
3
|
+
* Exports the registration function and state accessor initialization function.
|
|
4
|
+
*/
|
|
5
|
+
export { registerGitBranchTool, initializeGitBranchStateAccessors } from './registration.js';
|
|
6
|
+
// Export types if needed elsewhere, e.g.:
|
|
7
|
+
// export type { GitBranchInput, GitBranchResult } from './logic.js';
|
|
@@ -0,0 +1,180 @@
|
|
|
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_branch tool using Zod
|
|
9
|
+
export const GitBranchBaseSchema = 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(['list', 'create', 'delete', 'rename', 'show-current']).describe("The branch operation to perform: 'list', 'create', 'delete', 'rename', 'show-current'."),
|
|
12
|
+
branchName: z.string().min(1).optional().describe("The name of the branch. Required for 'create', 'delete', 'rename' modes."),
|
|
13
|
+
newBranchName: z.string().min(1).optional().describe("The new name for the branch. Required for 'rename' mode."),
|
|
14
|
+
startPoint: z.string().min(1).optional().describe("Optional commit hash, tag, or existing branch name to start the new branch from. Used only in 'create' mode. Defaults to HEAD."),
|
|
15
|
+
force: z.boolean().default(false).describe("Force the operation. Use -D for delete, -M for rename, -f for create (if branch exists). Use with caution, as forcing operations can lead to data loss."),
|
|
16
|
+
all: z.boolean().default(false).describe("List both local and remote-tracking branches. Used only in 'list' mode."),
|
|
17
|
+
remote: z.boolean().default(false).describe("Act on remote-tracking branches. Used with 'list' (-r) or 'delete' (-r)."),
|
|
18
|
+
});
|
|
19
|
+
// Apply refinements and export the FINAL schema for validation within the handler
|
|
20
|
+
export const GitBranchInputSchema = GitBranchBaseSchema.refine(data => !(data.mode === 'create' && !data.branchName), {
|
|
21
|
+
message: "A 'branchName' is required for 'create' mode.", path: ["branchName"],
|
|
22
|
+
}).refine(data => !(data.mode === 'delete' && !data.branchName), {
|
|
23
|
+
message: "A 'branchName' is required for 'delete' mode.", path: ["branchName"],
|
|
24
|
+
}).refine(data => !(data.mode === 'rename' && (!data.branchName || !data.newBranchName)), {
|
|
25
|
+
message: "Both 'branchName' (old name) and 'newBranchName' are required for 'rename' mode.", path: ["branchName", "newBranchName"],
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Executes git branch commands based on the specified mode.
|
|
29
|
+
*
|
|
30
|
+
* @param {GitBranchInput} input - The validated input object.
|
|
31
|
+
* @param {RequestContext} context - The request context for logging and error handling.
|
|
32
|
+
* @returns {Promise<GitBranchResult>} A promise that resolves with the structured result.
|
|
33
|
+
* @throws {McpError} Throws an McpError for path resolution/validation failures or unexpected errors.
|
|
34
|
+
*/
|
|
35
|
+
export async function gitBranchLogic(input, context) {
|
|
36
|
+
const operation = `gitBranchLogic:${input.mode}`;
|
|
37
|
+
logger.debug(`Executing ${operation}`, { ...context, input });
|
|
38
|
+
let targetPath;
|
|
39
|
+
try {
|
|
40
|
+
// Resolve and sanitize the target path
|
|
41
|
+
const workingDir = context.getWorkingDirectory();
|
|
42
|
+
targetPath = (input.path && input.path !== '.')
|
|
43
|
+
? input.path
|
|
44
|
+
: workingDir ?? '.';
|
|
45
|
+
if (targetPath === '.' && !workingDir) {
|
|
46
|
+
logger.warning("Executing git branch in server's CWD as no path provided and no session WD set.", { ...context, operation });
|
|
47
|
+
targetPath = process.cwd();
|
|
48
|
+
}
|
|
49
|
+
else if (targetPath === '.' && workingDir) {
|
|
50
|
+
targetPath = workingDir;
|
|
51
|
+
logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
|
|
55
|
+
}
|
|
56
|
+
targetPath = sanitization.sanitizePath(targetPath);
|
|
57
|
+
logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
logger.error('Path resolution or sanitization failed', { ...context, operation, error });
|
|
61
|
+
if (error instanceof McpError)
|
|
62
|
+
throw error;
|
|
63
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
let command;
|
|
67
|
+
let result;
|
|
68
|
+
switch (input.mode) {
|
|
69
|
+
case 'list':
|
|
70
|
+
command = `git -C "${targetPath}" branch --list --no-color`; // Start with basic list
|
|
71
|
+
if (input.all)
|
|
72
|
+
command += ' -a'; // Add -a if requested
|
|
73
|
+
else if (input.remote)
|
|
74
|
+
command += ' -r'; // Add -r if requested (exclusive with -a)
|
|
75
|
+
command += ' --verbose'; // Add verbose for commit info
|
|
76
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
77
|
+
const { stdout: listStdout } = await execAsync(command);
|
|
78
|
+
const branches = listStdout.trim().split('\n')
|
|
79
|
+
.filter(line => line && !line.match(/^\s*->\s*/)) // Filter out HEAD pointer lines if any
|
|
80
|
+
.map(line => {
|
|
81
|
+
const isCurrent = line.startsWith('* ');
|
|
82
|
+
const trimmedLine = line.replace(/^\*?\s+/, ''); // Remove leading '*' and spaces
|
|
83
|
+
const parts = trimmedLine.split(/\s+/);
|
|
84
|
+
const name = parts[0];
|
|
85
|
+
const isRemote = name.startsWith('remotes/');
|
|
86
|
+
const commitHash = parts[1] || undefined; // Verbose gives hash
|
|
87
|
+
const commitSubject = parts.slice(2).join(' ') || undefined; // Verbose gives subject
|
|
88
|
+
return { name, isCurrent, isRemote, commitHash, commitSubject };
|
|
89
|
+
});
|
|
90
|
+
const currentBranch = branches.find(b => b.isCurrent)?.name;
|
|
91
|
+
result = { success: true, mode: 'list', branches, currentBranch };
|
|
92
|
+
break;
|
|
93
|
+
case 'create':
|
|
94
|
+
// branchName is validated by Zod refine
|
|
95
|
+
command = `git -C "${targetPath}" branch `;
|
|
96
|
+
if (input.force)
|
|
97
|
+
command += '-f ';
|
|
98
|
+
command += `"${input.branchName}"`; // branchName is guaranteed by refine
|
|
99
|
+
if (input.startPoint)
|
|
100
|
+
command += ` "${input.startPoint}"`;
|
|
101
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
102
|
+
await execAsync(command);
|
|
103
|
+
result = { success: true, mode: 'create', branchName: input.branchName, message: `Branch '${input.branchName}' created successfully.` };
|
|
104
|
+
break;
|
|
105
|
+
case 'delete':
|
|
106
|
+
// branchName is validated by Zod refine
|
|
107
|
+
command = `git -C "${targetPath}" branch `;
|
|
108
|
+
if (input.remote)
|
|
109
|
+
command += '-r ';
|
|
110
|
+
command += input.force ? '-D ' : '-d ';
|
|
111
|
+
command += `"${input.branchName}"`; // branchName is guaranteed by refine
|
|
112
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
113
|
+
const { stdout: deleteStdout } = await execAsync(command);
|
|
114
|
+
result = { success: true, mode: 'delete', branchName: input.branchName, wasRemote: input.remote, message: deleteStdout.trim() || `Branch '${input.branchName}' deleted successfully.` };
|
|
115
|
+
break;
|
|
116
|
+
case 'rename':
|
|
117
|
+
// branchName and newBranchName validated by Zod refine
|
|
118
|
+
command = `git -C "${targetPath}" branch `;
|
|
119
|
+
command += input.force ? '-M ' : '-m ';
|
|
120
|
+
command += `"${input.branchName}" "${input.newBranchName}"`;
|
|
121
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
122
|
+
await execAsync(command);
|
|
123
|
+
result = { success: true, mode: 'rename', oldBranchName: input.branchName, newBranchName: input.newBranchName, message: `Branch '${input.branchName}' renamed to '${input.newBranchName}' successfully.` };
|
|
124
|
+
break;
|
|
125
|
+
case 'show-current':
|
|
126
|
+
command = `git -C "${targetPath}" branch --show-current`;
|
|
127
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
128
|
+
try {
|
|
129
|
+
const { stdout: currentStdout } = await execAsync(command);
|
|
130
|
+
const currentBranchName = currentStdout.trim();
|
|
131
|
+
result = { success: true, mode: 'show-current', currentBranch: currentBranchName || null, message: currentBranchName ? `Current branch is '${currentBranchName}'.` : 'Currently in detached HEAD state.' };
|
|
132
|
+
}
|
|
133
|
+
catch (showError) {
|
|
134
|
+
// Handle detached HEAD state specifically if command fails
|
|
135
|
+
if (showError.stderr?.includes('HEAD detached')) {
|
|
136
|
+
result = { success: true, mode: 'show-current', currentBranch: null, message: 'Currently in detached HEAD state.' };
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
throw showError; // Re-throw other errors
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
default:
|
|
144
|
+
// Should not happen due to Zod validation
|
|
145
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
|
|
146
|
+
}
|
|
147
|
+
logger.info(`${operation} executed successfully`, { ...context, operation, path: targetPath });
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
const errorMessage = error.stderr || error.stdout || error.message || ''; // stdout might contain error messages too
|
|
152
|
+
logger.error(`Failed to execute git branch command`, { ...context, operation, path: targetPath, error: errorMessage, stderr: error.stderr, stdout: error.stdout });
|
|
153
|
+
// Specific error handling
|
|
154
|
+
if (errorMessage.toLowerCase().includes('not a git repository')) {
|
|
155
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
|
|
156
|
+
}
|
|
157
|
+
if (input.mode === 'create' && errorMessage.includes('already exists')) {
|
|
158
|
+
return { success: false, mode: 'create', message: `Failed to create branch: Branch '${input.branchName}' already exists. Use force=true to overwrite.`, error: errorMessage };
|
|
159
|
+
}
|
|
160
|
+
if (input.mode === 'delete' && errorMessage.includes('not found')) {
|
|
161
|
+
return { success: false, mode: 'delete', message: `Failed to delete branch: Branch '${input.branchName}' not found.`, error: errorMessage };
|
|
162
|
+
}
|
|
163
|
+
if (input.mode === 'delete' && errorMessage.includes('not fully merged')) {
|
|
164
|
+
return { success: false, mode: 'delete', message: `Failed to delete branch: Branch '${input.branchName}' is not fully merged. Use force=true to delete.`, error: errorMessage };
|
|
165
|
+
}
|
|
166
|
+
if (input.mode === 'rename' && errorMessage.includes('already exists')) {
|
|
167
|
+
return { success: false, mode: 'rename', message: `Failed to rename branch: Branch '${input.newBranchName}' already exists. Use force=true to overwrite.`, error: errorMessage };
|
|
168
|
+
}
|
|
169
|
+
if (input.mode === 'rename' && errorMessage.includes('not found')) {
|
|
170
|
+
return { success: false, mode: 'rename', message: `Failed to rename branch: Branch '${input.branchName}' not found.`, error: errorMessage };
|
|
171
|
+
}
|
|
172
|
+
// Return structured failure for other git errors
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
mode: input.mode,
|
|
176
|
+
message: `Git branch ${input.mode} failed for path: ${targetPath}.`,
|
|
177
|
+
error: errorMessage
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -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 { gitBranchLogic, GitBranchBaseSchema } 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_branch 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 initializeGitBranchStateAccessors(getWdFn, getSidFn) {
|
|
14
|
+
_getWorkingDirectory = getWdFn;
|
|
15
|
+
_getSessionId = getSidFn;
|
|
16
|
+
logger.info('State accessors initialized for git_branch tool registration.');
|
|
17
|
+
}
|
|
18
|
+
const TOOL_NAME = 'git_branch';
|
|
19
|
+
const TOOL_DESCRIPTION = 'Manages Git branches. Supports listing (local, remote, all), creating, deleting (with force), renaming (with force), and showing the current branch. Returns results as a JSON object.';
|
|
20
|
+
/**
|
|
21
|
+
* Registers the git_branch 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 registerGitBranchTool = async (server) => {
|
|
28
|
+
if (!_getWorkingDirectory || !_getSessionId) {
|
|
29
|
+
throw new Error('State accessors for git_branch must be initialized before registration.');
|
|
30
|
+
}
|
|
31
|
+
const operation = 'registerGitBranchTool';
|
|
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, GitBranchBaseSchema.shape,
|
|
36
|
+
// SDK validates against the full GitBranchInputSchema 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 branchResult = await gitBranchLogic(toolInput, logicContext);
|
|
51
|
+
const resultContent = {
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: JSON.stringify(branchResult, null, 2), // Pretty-print JSON
|
|
54
|
+
contentType: 'application/json',
|
|
55
|
+
};
|
|
56
|
+
if (branchResult.success) {
|
|
57
|
+
logger.info(`Tool ${TOOL_NAME} (mode: ${toolInput.mode}) executed successfully, returning JSON`, logicContext);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
logger.warning(`Tool ${TOOL_NAME} (mode: ${toolInput.mode}) failed: ${branchResult.message}`, { ...logicContext, errorDetails: branchResult.error });
|
|
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,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Barrel file for the gitCheckout tool.
|
|
3
|
+
*/
|
|
4
|
+
export { registerGitCheckoutTool, initializeGitCheckoutStateAccessors } from './registration.js';
|
|
5
|
+
// Export types if needed elsewhere, e.g.:
|
|
6
|
+
// export type { GitCheckoutInput, GitCheckoutResult } from './logic.js';
|
|
@@ -0,0 +1,165 @@
|
|
|
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_checkout tool using Zod
|
|
9
|
+
export const GitCheckoutInputSchema = 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
|
+
branchOrPath: z.string().min(1).describe("The branch name, commit hash, tag, or file path(s) to checkout."),
|
|
12
|
+
newBranch: z.string().optional().describe("Create a new branch named <new_branch> and start it at <branchOrPath>."),
|
|
13
|
+
force: z.boolean().optional().default(false).describe("Force checkout even if there are uncommitted changes (use with caution, discards local changes)."),
|
|
14
|
+
// Add other relevant git checkout options as needed (e.g., --track, -b for new branch shorthand)
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Executes the 'git checkout' command and returns structured JSON output.
|
|
18
|
+
* Handles switching branches, creating new branches, and restoring files.
|
|
19
|
+
*
|
|
20
|
+
* @param {GitCheckoutInput} input - The validated input object.
|
|
21
|
+
* @param {RequestContext} context - The request context for logging and error handling.
|
|
22
|
+
* @returns {Promise<GitCheckoutResult>} A promise that resolves with the structured checkout result.
|
|
23
|
+
* @throws {McpError} Throws an McpError if path resolution, validation, or the git command fails unexpectedly.
|
|
24
|
+
*/
|
|
25
|
+
export async function checkoutGit(input, context) {
|
|
26
|
+
const operation = 'checkoutGit';
|
|
27
|
+
logger.debug(`Executing ${operation}`, { ...context, input });
|
|
28
|
+
let targetPath;
|
|
29
|
+
try {
|
|
30
|
+
// Resolve and sanitize the target path
|
|
31
|
+
if (input.path && input.path !== '.') {
|
|
32
|
+
targetPath = input.path;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const workingDir = context.getWorkingDirectory();
|
|
36
|
+
if (!workingDir) {
|
|
37
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, "No path provided and no working directory set for the session.", { context, operation });
|
|
38
|
+
}
|
|
39
|
+
targetPath = workingDir;
|
|
40
|
+
}
|
|
41
|
+
targetPath = sanitization.sanitizePath(targetPath);
|
|
42
|
+
logger.debug('Sanitized path', { ...context, operation, sanitizedPath: targetPath });
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
logger.error('Path resolution or sanitization failed', { ...context, operation, error });
|
|
46
|
+
if (error instanceof McpError)
|
|
47
|
+
throw error;
|
|
48
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
|
|
49
|
+
}
|
|
50
|
+
// Basic sanitization for branch/path argument
|
|
51
|
+
const safeBranchOrPath = input.branchOrPath.replace(/[`$&;*()|<>]/g, ''); // Remove potentially dangerous characters
|
|
52
|
+
try {
|
|
53
|
+
// Construct the git checkout command
|
|
54
|
+
let command = `git -C "${targetPath}" checkout`;
|
|
55
|
+
if (input.force) {
|
|
56
|
+
command += ' --force';
|
|
57
|
+
}
|
|
58
|
+
if (input.newBranch) {
|
|
59
|
+
const safeNewBranch = input.newBranch.replace(/[^a-zA-Z0-9_.\-/]/g, ''); // Sanitize new branch name
|
|
60
|
+
command += ` -b ${safeNewBranch}`;
|
|
61
|
+
}
|
|
62
|
+
command += ` ${safeBranchOrPath}`; // Add the target branch/path
|
|
63
|
+
logger.debug(`Executing command: ${command}`, { ...context, operation });
|
|
64
|
+
// Execute command. Checkout often uses stderr for status messages.
|
|
65
|
+
const { stdout, stderr } = await execAsync(command);
|
|
66
|
+
logger.info(`Git checkout stdout: ${stdout}`, { ...context, operation });
|
|
67
|
+
logger.info(`Git checkout stderr: ${stderr}`, { ...context, operation }); // Log stderr as info
|
|
68
|
+
// Analyze stderr primarily, fallback to stdout
|
|
69
|
+
let message = stderr.trim() || stdout.trim();
|
|
70
|
+
let success = true;
|
|
71
|
+
let previousBranch = undefined;
|
|
72
|
+
let currentBranch = undefined;
|
|
73
|
+
let newBranchCreated = !!input.newBranch;
|
|
74
|
+
let filesRestored = undefined;
|
|
75
|
+
// Extract previous branch if available
|
|
76
|
+
const prevBranchMatch = stderr.match(/Switched to.*? from ['"]?(.*?)['"]?/);
|
|
77
|
+
if (prevBranchMatch) {
|
|
78
|
+
previousBranch = prevBranchMatch[1];
|
|
79
|
+
}
|
|
80
|
+
// Extract current branch/state
|
|
81
|
+
if (stderr.includes('Switched to branch')) {
|
|
82
|
+
const currentBranchMatch = stderr.match(/Switched to branch ['"]?(.*?)['"]?/);
|
|
83
|
+
if (currentBranchMatch)
|
|
84
|
+
currentBranch = currentBranchMatch[1];
|
|
85
|
+
message = `Switched to branch '${currentBranch || input.branchOrPath}'.`;
|
|
86
|
+
}
|
|
87
|
+
else if (stderr.includes('Switched to a new branch')) {
|
|
88
|
+
const currentBranchMatch = stderr.match(/Switched to a new branch ['"]?(.*?)['"]?/);
|
|
89
|
+
if (currentBranchMatch)
|
|
90
|
+
currentBranch = currentBranchMatch[1];
|
|
91
|
+
message = `Switched to new branch '${currentBranch || input.newBranch}'.`;
|
|
92
|
+
newBranchCreated = true; // Confirm creation
|
|
93
|
+
}
|
|
94
|
+
else if (stderr.includes('Already on')) {
|
|
95
|
+
const currentBranchMatch = stderr.match(/Already on ['"]?(.*?)['"]?/);
|
|
96
|
+
if (currentBranchMatch)
|
|
97
|
+
currentBranch = currentBranchMatch[1];
|
|
98
|
+
message = `Already on '${currentBranch || input.branchOrPath}'.`;
|
|
99
|
+
}
|
|
100
|
+
else if (stderr.includes('Updated N path') || stdout.includes('Updated N path')) { // Checking out files
|
|
101
|
+
message = `Restored path(s): ${input.branchOrPath}`;
|
|
102
|
+
// Potentially list the files if input.branchOrPath was specific enough
|
|
103
|
+
// Assume input.branchOrPath contains file paths separated by newlines
|
|
104
|
+
filesRestored = input.branchOrPath.split('\n').filter(p => p.trim().length > 0); // Split by newline and filter out empty entries
|
|
105
|
+
// Try to get current branch after file checkout
|
|
106
|
+
try {
|
|
107
|
+
const statusResult = await execAsync(`git -C "${targetPath}" branch --show-current`);
|
|
108
|
+
currentBranch = statusResult.stdout.trim();
|
|
109
|
+
}
|
|
110
|
+
catch (statusError) {
|
|
111
|
+
logger.warning('Could not determine current branch after file checkout', { ...context, operation, statusError });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (stderr.includes('Previous HEAD position was') && stderr.includes('HEAD is now at')) { // Detached HEAD
|
|
115
|
+
message = `Checked out commit ${input.branchOrPath} (Detached HEAD state).`;
|
|
116
|
+
currentBranch = 'Detached HEAD'; // Indicate detached state
|
|
117
|
+
}
|
|
118
|
+
else if (stderr.includes('Note: switching to')) { // Another detached HEAD message variant
|
|
119
|
+
message = `Checked out ${input.branchOrPath} (Detached HEAD state).`;
|
|
120
|
+
currentBranch = 'Detached HEAD';
|
|
121
|
+
}
|
|
122
|
+
else if (message.includes('fatal:')) {
|
|
123
|
+
success = false;
|
|
124
|
+
message = `Checkout failed: ${message}`;
|
|
125
|
+
logger.error(`Git checkout command indicated failure: ${message}`, { ...context, operation, stdout, stderr });
|
|
126
|
+
}
|
|
127
|
+
else if (!message && !stdout && !stderr) {
|
|
128
|
+
message = 'Checkout command executed, but no output received.';
|
|
129
|
+
logger.warning(message, { ...context, operation });
|
|
130
|
+
// Attempt to get current branch as confirmation
|
|
131
|
+
try {
|
|
132
|
+
const statusResult = await execAsync(`git -C "${targetPath}" branch --show-current`);
|
|
133
|
+
currentBranch = statusResult.stdout.trim();
|
|
134
|
+
message += ` Current branch is '${currentBranch}'.`;
|
|
135
|
+
}
|
|
136
|
+
catch (statusError) {
|
|
137
|
+
logger.warning('Could not determine current branch after silent checkout', { ...context, operation, statusError });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
logger.info(`${operation} completed`, { ...context, operation, path: targetPath, success, message });
|
|
141
|
+
return { success, message, previousBranch, currentBranch, newBranchCreated, filesRestored };
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
logger.error(`Failed to execute git checkout command`, { ...context, operation, path: targetPath, error: error.message, stderr: error.stderr, stdout: error.stdout });
|
|
145
|
+
const errorMessage = error.stderr || error.stdout || error.message || '';
|
|
146
|
+
// Handle specific error cases
|
|
147
|
+
if (errorMessage.toLowerCase().includes('not a git repository')) {
|
|
148
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
|
|
149
|
+
}
|
|
150
|
+
if (errorMessage.match(/pathspec '.*?' did not match any file\(s\) known to git/)) {
|
|
151
|
+
throw new McpError(BaseErrorCode.NOT_FOUND, `Branch or pathspec not found: ${input.branchOrPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
152
|
+
}
|
|
153
|
+
if (errorMessage.includes('already exists')) { // e.g., trying -b with existing branch name
|
|
154
|
+
throw new McpError(BaseErrorCode.CONFLICT, `Cannot create new branch '${input.newBranch}': it already exists. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
155
|
+
}
|
|
156
|
+
if (errorMessage.includes('Your local changes to the following files would be overwritten by checkout')) {
|
|
157
|
+
throw new McpError(BaseErrorCode.CONFLICT, `Checkout failed due to uncommitted local changes that would be overwritten. Please commit or stash them first, or use --force. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
158
|
+
}
|
|
159
|
+
if (errorMessage.includes('invalid reference')) {
|
|
160
|
+
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid branch name or reference: ${input.branchOrPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
161
|
+
}
|
|
162
|
+
// Generic internal error for other failures
|
|
163
|
+
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Failed to checkout for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
|
|
164
|
+
}
|
|
165
|
+
}
|