@cyanheads/git-mcp-server 2.0.11 → 2.0.14

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.
@@ -0,0 +1,239 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { z } from 'zod';
4
+ import { logger, sanitization } from '../../../utils/index.js';
5
+ import { BaseErrorCode, McpError } from '../../../types-global/errors.js';
6
+ const execAsync = promisify(exec);
7
+ // Define the BASE input schema for the git_worktree tool using Zod
8
+ export const GitWorktreeBaseSchema = z.object({
9
+ path: z.string().min(1).optional().default('.').describe("Path to the local Git repository. Defaults to the directory set via `git_set_working_dir` for the session; set 'git_set_working_dir' if not set."),
10
+ mode: z.enum(['list', 'add', 'remove', 'move', 'prune']).describe("The worktree operation to perform: 'list', 'add', 'remove', 'move', 'prune'."),
11
+ // Common optional path for operations
12
+ worktreePath: z.string().min(1).optional().describe("Path of the worktree. Required for 'add', 'remove', 'move' modes."),
13
+ // 'add' mode specific
14
+ commitish: z.string().min(1).optional().describe("Branch or commit to checkout in the new worktree. Used only in 'add' mode. Defaults to HEAD."),
15
+ newBranch: z.string().min(1).optional().describe("Create a new branch in the worktree. Used only in 'add' mode."),
16
+ force: z.boolean().default(false).describe("Force the operation (e.g., for 'add' if branch exists, or 'remove' if uncommitted changes)."),
17
+ detach: z.boolean().default(false).describe("Detach HEAD in the new worktree. Used only in 'add' mode."),
18
+ // 'move' mode specific
19
+ newPath: z.string().min(1).optional().describe("The new path for the worktree. Required for 'move' mode."),
20
+ // 'prune' mode specific
21
+ verbose: z.boolean().default(false).describe("Provide more detailed output. Used in 'list' and 'prune' modes."),
22
+ dryRun: z.boolean().default(false).describe("Show what would be done without actually doing it. Used in 'prune' mode."),
23
+ expire: z.string().min(1).optional().describe("Prune entries older than this time (e.g., '1.month.ago'). Used in 'prune' mode."),
24
+ });
25
+ // Apply refinements and export the FINAL schema
26
+ export const GitWorktreeInputSchema = GitWorktreeBaseSchema.refine(data => !(data.mode === 'add' && !data.worktreePath), {
27
+ message: "A 'worktreePath' is required for 'add' mode.", path: ["worktreePath"],
28
+ }).refine(data => !(data.mode === 'remove' && !data.worktreePath), {
29
+ message: "A 'worktreePath' is required for 'remove' mode.", path: ["worktreePath"],
30
+ }).refine(data => !(data.mode === 'move' && (!data.worktreePath || !data.newPath)), {
31
+ message: "Both 'worktreePath' (old path) and 'newPath' are required for 'move' mode.", path: ["worktreePath", "newPath"],
32
+ });
33
+ /**
34
+ * Parses the output of `git worktree list --porcelain`.
35
+ */
36
+ function parsePorcelainWorktreeList(stdout) {
37
+ const worktrees = [];
38
+ const entries = stdout.trim().split('\n\n'); // Entries are separated by double newlines
39
+ for (const entry of entries) {
40
+ const lines = entry.trim().split('\n');
41
+ let path = '';
42
+ let head = '';
43
+ let branch;
44
+ let isBare = false;
45
+ let isLocked = false;
46
+ let isPrunable = false;
47
+ let prunableReason;
48
+ for (const line of lines) {
49
+ if (line.startsWith('worktree ')) {
50
+ path = line.substring('worktree '.length);
51
+ }
52
+ else if (line.startsWith('HEAD ')) {
53
+ head = line.substring('HEAD '.length);
54
+ }
55
+ else if (line.startsWith('branch ')) {
56
+ branch = line.substring('branch '.length);
57
+ }
58
+ else if (line.startsWith('bare')) {
59
+ isBare = true;
60
+ }
61
+ else if (line.startsWith('locked')) {
62
+ isLocked = true;
63
+ const reasonMatch = line.match(/locked(?: (.+))?/);
64
+ if (reasonMatch && reasonMatch[1]) {
65
+ prunableReason = reasonMatch[1]; // Using prunableReason for lock reason too
66
+ }
67
+ }
68
+ else if (line.startsWith('prunable')) {
69
+ isPrunable = true;
70
+ const reasonMatch = line.match(/prunable(?: (.+))?/);
71
+ if (reasonMatch && reasonMatch[1]) {
72
+ prunableReason = reasonMatch[1];
73
+ }
74
+ }
75
+ }
76
+ if (path) { // Only add if a path was found
77
+ worktrees.push({ path, head, branch, isBare, isLocked, isPrunable, prunableReason });
78
+ }
79
+ }
80
+ return worktrees;
81
+ }
82
+ /**
83
+ * Executes git worktree commands.
84
+ */
85
+ export async function gitWorktreeLogic(input, context) {
86
+ const operation = `gitWorktreeLogic:${input.mode}`;
87
+ logger.debug(`Executing ${operation}`, { ...context, input });
88
+ let targetPath;
89
+ try {
90
+ const workingDir = context.getWorkingDirectory();
91
+ targetPath = (input.path && input.path !== '.')
92
+ ? input.path
93
+ : workingDir ?? '.';
94
+ if (targetPath === '.' && !workingDir) {
95
+ logger.warning("Executing git worktree in server's CWD as no path provided and no session WD set.", { ...context, operation });
96
+ targetPath = process.cwd();
97
+ }
98
+ else if (targetPath === '.' && workingDir) {
99
+ targetPath = workingDir;
100
+ logger.debug(`Using session working directory: ${targetPath}`, { ...context, operation, sessionId: context.sessionId });
101
+ }
102
+ else {
103
+ logger.debug(`Using provided path: ${targetPath}`, { ...context, operation });
104
+ }
105
+ targetPath = sanitization.sanitizePath(targetPath, { allowAbsolute: true }).sanitizedPath;
106
+ }
107
+ catch (error) {
108
+ logger.error('Path resolution or sanitization failed', { ...context, operation, error });
109
+ if (error instanceof McpError)
110
+ throw error;
111
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
112
+ }
113
+ try {
114
+ let command = `git -C "${targetPath}" worktree `;
115
+ let result;
116
+ switch (input.mode) {
117
+ case 'list':
118
+ command += 'list';
119
+ if (input.verbose)
120
+ command += ' --porcelain'; // Use porcelain for structured output
121
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
122
+ const { stdout: listStdout } = await execAsync(command);
123
+ if (input.verbose) {
124
+ const worktrees = parsePorcelainWorktreeList(listStdout);
125
+ result = { success: true, mode: 'list', worktrees };
126
+ }
127
+ else {
128
+ // Simple list output parsing (less structured)
129
+ const worktrees = listStdout.trim().split('\n').map(line => {
130
+ const parts = line.split(/\s+/);
131
+ return {
132
+ path: parts[0],
133
+ head: parts[1],
134
+ branch: parts[2]?.replace(/[\[\]]/g, ''), // Remove brackets from branch name
135
+ isBare: false, // Cannot determine from simple list
136
+ isLocked: false, // Cannot determine
137
+ isPrunable: false // Cannot determine
138
+ };
139
+ });
140
+ result = { success: true, mode: 'list', worktrees };
141
+ }
142
+ break;
143
+ case 'add':
144
+ // worktreePath is guaranteed by refine
145
+ const sanitizedWorktreePathAdd = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
146
+ command += `add `;
147
+ if (input.force)
148
+ command += '--force ';
149
+ if (input.detach)
150
+ command += '--detach ';
151
+ if (input.newBranch)
152
+ command += `-b "${input.newBranch}" `;
153
+ command += `"${sanitizedWorktreePathAdd}"`;
154
+ if (input.commitish)
155
+ command += ` "${input.commitish}"`;
156
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
157
+ await execAsync(command);
158
+ // To get the HEAD of the new worktree, we might need another command or parse output if available
159
+ // For simplicity, we'll report success. A more robust solution might `git -C new_worktree_path rev-parse HEAD`
160
+ result = {
161
+ success: true,
162
+ mode: 'add',
163
+ worktreePath: sanitizedWorktreePathAdd,
164
+ branch: input.newBranch,
165
+ head: 'HEAD', // Placeholder, actual SHA would require another call
166
+ message: `Worktree '${sanitizedWorktreePathAdd}' added successfully.`
167
+ };
168
+ break;
169
+ case 'remove':
170
+ // worktreePath is guaranteed by refine
171
+ const sanitizedWorktreePathRemove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
172
+ command += `remove `;
173
+ if (input.force)
174
+ command += '--force ';
175
+ command += `"${sanitizedWorktreePathRemove}"`;
176
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
177
+ const { stdout: removeStdout } = await execAsync(command);
178
+ result = { success: true, mode: 'remove', worktreePath: sanitizedWorktreePathRemove, message: removeStdout.trim() || `Worktree '${sanitizedWorktreePathRemove}' removed successfully.` };
179
+ break;
180
+ case 'move':
181
+ // worktreePath and newPath are guaranteed by refine
182
+ const sanitizedOldPathMove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
183
+ const sanitizedNewPathMove = sanitization.sanitizePath(input.newPath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
184
+ command += `move "${sanitizedOldPathMove}" "${sanitizedNewPathMove}"`;
185
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
186
+ await execAsync(command);
187
+ result = { success: true, mode: 'move', oldPath: sanitizedOldPathMove, newPath: sanitizedNewPathMove, message: `Worktree moved from '${sanitizedOldPathMove}' to '${sanitizedNewPathMove}' successfully.` };
188
+ break;
189
+ case 'prune':
190
+ command += 'prune ';
191
+ if (input.dryRun)
192
+ command += '--dry-run ';
193
+ if (input.verbose)
194
+ command += '--verbose ';
195
+ if (input.expire)
196
+ command += `--expire "${input.expire}" `;
197
+ logger.debug(`Executing command: ${command}`, { ...context, operation });
198
+ const { stdout: pruneStdout, stderr: pruneStderr } = await execAsync(command);
199
+ // Prune often outputs to stderr even on success for verbose/dry-run
200
+ const pruneMessage = (pruneStdout.trim() || pruneStderr.trim()) || 'Worktree prune operation completed.';
201
+ result = { success: true, mode: 'prune', message: pruneMessage };
202
+ if (input.verbose && pruneStdout.trim()) {
203
+ // Attempt to parse verbose output if needed, for now just return raw message
204
+ // result.prunedItems = ...
205
+ }
206
+ break;
207
+ default:
208
+ throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
209
+ }
210
+ logger.info(`${operation} executed successfully`, { ...context, path: targetPath });
211
+ return result;
212
+ }
213
+ catch (error) {
214
+ const errorMessage = error.stderr || error.stdout || (error.message || '');
215
+ logger.error(`Failed to execute git worktree command`, { ...context, path: targetPath, error: errorMessage, stderr: error.stderr, stdout: error.stdout });
216
+ if (errorMessage.toLowerCase().includes('not a git repository')) {
217
+ throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
218
+ }
219
+ // Add more specific error handling based on `git worktree` messages
220
+ if (input.mode === 'add' && errorMessage.includes('already exists')) {
221
+ return { success: false, mode: 'add', message: `Failed to add worktree: Path '${input.worktreePath}' already exists or is a worktree.`, error: errorMessage };
222
+ }
223
+ if (input.mode === 'add' && errorMessage.includes('is a submodule')) {
224
+ return { success: false, mode: 'add', message: `Failed to add worktree: Path '${input.worktreePath}' is a submodule.`, error: errorMessage };
225
+ }
226
+ if (input.mode === 'remove' && errorMessage.includes('cannot remove the current worktree')) {
227
+ return { success: false, mode: 'remove', message: `Failed to remove worktree: Cannot remove the current worktree.`, error: errorMessage };
228
+ }
229
+ if (input.mode === 'remove' && errorMessage.includes('has unclean changes')) {
230
+ return { success: false, mode: 'remove', message: `Failed to remove worktree: '${input.worktreePath}' has uncommitted changes. Use force=true to remove.`, error: errorMessage };
231
+ }
232
+ return {
233
+ success: false,
234
+ mode: input.mode,
235
+ message: `Git worktree ${input.mode} failed for path: ${targetPath}.`,
236
+ error: errorMessage
237
+ };
238
+ }
239
+ }
@@ -0,0 +1,67 @@
1
+ import { logger, ErrorHandler, requestContextService } from '../../../utils/index.js';
2
+ import { BaseErrorCode } from '../../../types-global/errors.js';
3
+ import { GitWorktreeBaseSchema, gitWorktreeLogic } from './logic.js';
4
+ let _getWorkingDirectory;
5
+ let _getSessionId;
6
+ /**
7
+ * Initializes the state accessors needed by the git_worktree tool registration.
8
+ * @param getWdFn - Function to get the working directory for a session.
9
+ * @param getSidFn - Function to get the session ID from context.
10
+ */
11
+ export function initializeGitWorktreeStateAccessors(getWdFn, getSidFn) {
12
+ _getWorkingDirectory = getWdFn;
13
+ _getSessionId = getSidFn;
14
+ logger.info('State accessors initialized for git_worktree tool registration.');
15
+ }
16
+ const TOOL_NAME = 'git_worktree';
17
+ const TOOL_DESCRIPTION = 'Manages Git worktrees. Supports listing, adding, removing, moving, and pruning worktrees. Returns results as a JSON object.';
18
+ /**
19
+ * Registers the git_worktree tool with the MCP server.
20
+ *
21
+ * @param {McpServer} server - The McpServer instance to register the tool with.
22
+ * @returns {Promise<void>}
23
+ * @throws {Error} If registration fails or state accessors are not initialized.
24
+ */
25
+ export const registerGitWorktreeTool = async (server) => {
26
+ if (!_getWorkingDirectory || !_getSessionId) {
27
+ throw new Error('State accessors for git_worktree must be initialized before registration.');
28
+ }
29
+ const operation = 'registerGitWorktreeTool';
30
+ const context = requestContextService.createRequestContext({ operation });
31
+ await ErrorHandler.tryCatch(async () => {
32
+ server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitWorktreeBaseSchema.shape, async (validatedArgs, callContext) => {
33
+ const toolInput = validatedArgs; // Cast for use
34
+ const toolOperation = `tool:${TOOL_NAME}:${toolInput.mode}`;
35
+ const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
36
+ const sessionId = _getSessionId(requestContext);
37
+ const getWorkingDirectoryForSession = () => _getWorkingDirectory(sessionId);
38
+ const logicContext = {
39
+ ...requestContext,
40
+ sessionId: sessionId,
41
+ getWorkingDirectory: getWorkingDirectoryForSession,
42
+ };
43
+ logger.info(`Executing tool: ${TOOL_NAME} (mode: ${toolInput.mode})`, logicContext);
44
+ return await ErrorHandler.tryCatch(async () => {
45
+ const worktreeResult = await gitWorktreeLogic(toolInput, logicContext);
46
+ const resultContent = {
47
+ type: 'text',
48
+ text: JSON.stringify(worktreeResult, null, 2), // Pretty-print JSON
49
+ contentType: 'application/json',
50
+ };
51
+ if (worktreeResult.success) {
52
+ logger.info(`Tool ${TOOL_NAME} (mode: ${toolInput.mode}) executed successfully, returning JSON`, logicContext);
53
+ }
54
+ else {
55
+ logger.warning(`Tool ${TOOL_NAME} (mode: ${toolInput.mode}) failed: ${worktreeResult.message}`, { ...logicContext, errorDetails: worktreeResult.error });
56
+ }
57
+ return { content: [resultContent] };
58
+ }, {
59
+ operation: toolOperation,
60
+ context: logicContext,
61
+ input: validatedArgs,
62
+ errorCode: BaseErrorCode.INTERNAL_ERROR,
63
+ });
64
+ });
65
+ logger.info(`Tool registered: ${TOOL_NAME}`, context);
66
+ }, { operation, context, critical: true });
67
+ };
@@ -0,0 +1,3 @@
1
+ export { registerGitWrapupInstructionsTool } from './registration.js';
2
+ // This tool does not require session-specific state accessors like getWorkingDirectory,
3
+ // so no initialize...StateAccessors function is needed or exported here.
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ // Define the input schema
3
+ export const GitWrapupInstructionsInputSchema = z.object({
4
+ acknowledgement: z.enum(['Y', 'y', 'Yes', 'yes'], {
5
+ required_error: 'Acknowledgement is required.',
6
+ description: 'Acknowledgement that you have permission (implicit allowed, explicit preferred) from the user to initiate this tool. Must be "Y" or "Yes" (case-insensitive).',
7
+ }),
8
+ updateAgentMetaFiles: z.enum(['Y', 'y', 'Yes', 'yes']).optional().describe("If set to 'Y' or 'Yes', include an extra instruction to review and update agent-specific meta files like .clinerules or claude.md if present. Only use this if the user explicitly requested it."),
9
+ });
10
+ // The predefined instructions string.
11
+ const WRAPUP_INSTRUCTIONS = `Initiate our standard git wrapup workflow. (1) First, review all changes to our repo using the git_diff tool to understand the precise nature and rationale behind each change (what changed and why did it change?). (2) For substantial code updates, review and update the README to ensure it is up to date with our current codebase (make a note to the user of any discrepancies you noticed, gathered from everything you've seen of our codebase). (3) Then, update the CHANGELOG with concise, descriptive entries detailing all modifications, clearly indicating their purpose (e.g., bug fix, feature implementation, refactoring). (4) Finally, proceed to commit all changes; group these changes into logical, atomic commits, each accompanied by a clear and descriptive message adhering to Conventional Commits standards (e.g. "docs(readme): updated readme to include xyz."). Note the 'git_commit' tool allows you to also stage the files while commiting. Ensure commit messages accurately convey the scope and impact of the changes, incorporating specific metrics or identifiers where applicable. Be sure to set 'git_set_working_dir' if not already set.`;
12
+ /**
13
+ * Core logic for the git_wrapup_instructions tool.
14
+ * This tool simply returns a predefined set of instructions, potentially augmented.
15
+ *
16
+ * @param {GitWrapupInstructionsInput} input - The validated input, may contain 'updateAgentMetaFiles'.
17
+ * @param {RequestContext} _context - The request context (included for consistency, not used in this simple logic).
18
+ * @returns {Promise<GitWrapupInstructionsResult>} A promise that resolves with the wrap-up instructions.
19
+ */
20
+ export async function getWrapupInstructions(input, _context // Included for structural consistency, not used by this simple tool
21
+ ) {
22
+ let finalInstructions = WRAPUP_INSTRUCTIONS;
23
+ if (input.updateAgentMetaFiles && ['Y', 'y', 'Yes', 'yes'].includes(input.updateAgentMetaFiles)) {
24
+ finalInstructions += ` Extra request: review and update if needed the .clinerules and claude.md files if present.`;
25
+ }
26
+ return {
27
+ instructions: finalInstructions,
28
+ };
29
+ }
@@ -0,0 +1,42 @@
1
+ import { BaseErrorCode } from '../../../types-global/errors.js';
2
+ import { ErrorHandler, logger, requestContextService } from '../../../utils/index.js';
3
+ import { getWrapupInstructions, GitWrapupInstructionsInputSchema, } from './logic.js';
4
+ const TOOL_NAME = 'git_wrapup_instructions';
5
+ const TOOL_DESCRIPTION = 'Provides a standard Git wrap-up workflow. This involves reviewing changes with `git_diff`, updating documentation (README, CHANGELOG), and making logical, descriptive commits using the `git_commit` tool.';
6
+ /**
7
+ * Registers the git_wrapup_instructions tool with the MCP server.
8
+ *
9
+ * @param {McpServer} server - The McpServer instance to register the tool with.
10
+ * @returns {Promise<void>}
11
+ * @throws {Error} If registration fails.
12
+ */
13
+ export const registerGitWrapupInstructionsTool = async (server) => {
14
+ const operation = 'registerGitWrapupInstructionsTool';
15
+ const context = requestContextService.createRequestContext({ operation });
16
+ await ErrorHandler.tryCatch(async () => {
17
+ server.tool(TOOL_NAME, TOOL_DESCRIPTION, GitWrapupInstructionsInputSchema.shape, // Empty schema shape
18
+ async (validatedArgs, callContext) => {
19
+ const toolOperation = 'tool:git_wrapup_instructions';
20
+ // Pass callContext as parentContext for consistent context chaining
21
+ const requestContext = requestContextService.createRequestContext({ operation: toolOperation, parentContext: callContext });
22
+ logger.info(`Executing tool: ${TOOL_NAME}`, requestContext);
23
+ return await ErrorHandler.tryCatch(async () => {
24
+ const result = await getWrapupInstructions(validatedArgs, requestContext // Pass the created requestContext
25
+ );
26
+ const resultContent = {
27
+ type: 'text',
28
+ text: JSON.stringify(result, null, 2),
29
+ contentType: 'application/json',
30
+ };
31
+ logger.info(`Tool ${TOOL_NAME} executed successfully, returning JSON`, requestContext);
32
+ return { content: [resultContent] };
33
+ }, {
34
+ operation: toolOperation,
35
+ context: requestContext,
36
+ input: validatedArgs,
37
+ errorCode: BaseErrorCode.INTERNAL_ERROR,
38
+ });
39
+ });
40
+ logger.info(`Tool registered: ${TOOL_NAME}`, context);
41
+ }, { operation, context, critical: true });
42
+ };