@cyanheads/git-mcp-server
Version:
An MCP (Model Context Protocol) server enabling LLMs and AI agents to interact with Git repositories. Provides tools for comprehensive Git operations including clone, commit, branch, diff, log, status, push, pull, merge, rebase, worktree, tag management,
365 lines (364 loc) • 16 kB
JavaScript
import { execFile } from "child_process";
import { promisify } from "util";
import { z } from "zod";
import { logger, sanitization } from "../../../utils/index.js";
import { BaseErrorCode, McpError } from "../../../types-global/errors.js";
const execFileAsync = promisify(execFile);
// Define the BASE input schema for the git_worktree tool using Zod
export const GitWorktreeBaseSchema = z.object({
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."),
mode: z
.enum(["list", "add", "remove", "move", "prune"])
.describe("The worktree operation to perform: 'list', 'add', 'remove', 'move', 'prune'."),
// Common optional path for operations
worktreePath: z
.string()
.min(1)
.optional()
.describe("Path of the worktree. Required for 'add', 'remove', 'move' modes."),
// 'add' mode specific
commitish: z
.string()
.min(1)
.optional()
.describe("Branch or commit to checkout in the new worktree. Used only in 'add' mode. Defaults to HEAD."),
newBranch: z
.string()
.min(1)
.optional()
.describe("Create a new branch in the worktree. Used only in 'add' mode."),
force: z
.boolean()
.default(false)
.describe("Force the operation (e.g., for 'add' if branch exists, or 'remove' if uncommitted changes)."),
detach: z
.boolean()
.default(false)
.describe("Detach HEAD in the new worktree. Used only in 'add' mode."),
// 'move' mode specific
newPath: z
.string()
.min(1)
.optional()
.describe("The new path for the worktree. Required for 'move' mode."),
// 'prune' mode specific
verbose: z
.boolean()
.default(false)
.describe("Provide more detailed output. Used in 'list' and 'prune' modes."),
dryRun: z
.boolean()
.default(false)
.describe("Show what would be done without actually doing it. Used in 'prune' mode."),
expire: z
.string()
.min(1)
.optional()
.describe("Prune entries older than this time (e.g., '1.month.ago'). Used in 'prune' mode."),
});
// Apply refinements and export the FINAL schema
export const GitWorktreeInputSchema = GitWorktreeBaseSchema.refine((data) => !(data.mode === "add" && !data.worktreePath), {
message: "A 'worktreePath' is required for 'add' mode.",
path: ["worktreePath"],
})
.refine((data) => !(data.mode === "remove" && !data.worktreePath), {
message: "A 'worktreePath' is required for 'remove' mode.",
path: ["worktreePath"],
})
.refine((data) => !(data.mode === "move" && (!data.worktreePath || !data.newPath)), {
message: "Both 'worktreePath' (old path) and 'newPath' are required for 'move' mode.",
path: ["worktreePath", "newPath"],
});
/**
* Parses the output of `git worktree list --porcelain`.
*/
function parsePorcelainWorktreeList(stdout) {
const worktrees = [];
const entries = stdout.trim().split("\n\n"); // Entries are separated by double newlines
for (const entry of entries) {
const lines = entry.trim().split("\n");
let path = "";
let head = "";
let branch;
let isBare = false;
let isLocked = false;
let isPrunable = false;
let prunableReason;
for (const line of lines) {
if (line.startsWith("worktree ")) {
path = line.substring("worktree ".length);
}
else if (line.startsWith("HEAD ")) {
head = line.substring("HEAD ".length);
}
else if (line.startsWith("branch ")) {
branch = line.substring("branch ".length);
}
else if (line.startsWith("bare")) {
isBare = true;
}
else if (line.startsWith("locked")) {
isLocked = true;
const reasonMatch = line.match(/locked(?: (.+))?/);
if (reasonMatch && reasonMatch[1]) {
prunableReason = reasonMatch[1]; // Using prunableReason for lock reason too
}
}
else if (line.startsWith("prunable")) {
isPrunable = true;
const reasonMatch = line.match(/prunable(?: (.+))?/);
if (reasonMatch && reasonMatch[1]) {
prunableReason = reasonMatch[1];
}
}
}
if (path) {
// Only add if a path was found
worktrees.push({
path,
head,
branch,
isBare,
isLocked,
isPrunable,
prunableReason,
});
}
}
return worktrees;
}
/**
* Executes git worktree commands.
*/
export async function gitWorktreeLogic(input, context) {
const operation = `gitWorktreeLogic:${input.mode}`;
logger.debug(`Executing ${operation}`, { ...context, input });
let targetPath;
try {
const workingDir = context.getWorkingDirectory();
targetPath =
input.path && input.path !== "." ? input.path : (workingDir ?? ".");
if (targetPath === "." && !workingDir) {
logger.warning("Executing git worktree in server's CWD as no path provided and no session WD set.", { ...context, operation });
targetPath = process.cwd();
}
else if (targetPath === "." && workingDir) {
targetPath = workingDir;
logger.debug(`Using session working directory: ${targetPath}`, {
...context,
operation,
sessionId: context.sessionId,
});
}
else {
logger.debug(`Using provided path: ${targetPath}`, {
...context,
operation,
});
}
targetPath = sanitization.sanitizePath(targetPath, {
allowAbsolute: true,
}).sanitizedPath;
}
catch (error) {
logger.error("Path resolution or sanitization failed", {
...context,
operation,
error,
});
if (error instanceof McpError)
throw error;
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid path: ${error instanceof Error ? error.message : String(error)}`, { context, operation, originalError: error });
}
try {
let args;
let result;
switch (input.mode) {
case "list":
args = ["-C", targetPath, "worktree", "list"];
if (input.verbose) {
args.push("--porcelain");
} // Use porcelain for structured output
logger.debug(`Executing command: git ${args.join(" ")}`, {
...context,
operation,
});
const { stdout: listStdout } = await execFileAsync("git", args);
if (input.verbose) {
const worktrees = parsePorcelainWorktreeList(listStdout);
result = { success: true, mode: "list", worktrees };
}
else {
// Simple list output parsing (less structured)
const worktrees = listStdout
.trim()
.split("\n")
.map((line) => {
const parts = line.split(/\s+/);
return {
path: parts[0],
head: parts[1],
branch: parts[2]?.replace(/[\[\]]/g, ""), // Remove brackets from branch name
isBare: false, // Cannot determine from simple list
isLocked: false, // Cannot determine
isPrunable: false, // Cannot determine
};
});
result = { success: true, mode: "list", worktrees };
}
break;
case "add":
// worktreePath is guaranteed by refine
const sanitizedWorktreePathAdd = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
args = ["-C", targetPath, "worktree", "add"];
if (input.force) {
args.push("--force");
}
if (input.detach) {
args.push("--detach");
}
if (input.newBranch) {
args.push("-b", input.newBranch);
}
args.push(sanitizedWorktreePathAdd);
if (input.commitish) {
args.push(input.commitish);
}
logger.debug(`Executing command: git ${args.join(" ")}`, {
...context,
operation,
});
await execFileAsync("git", args);
// To get the HEAD of the new worktree, we might need another command or parse output if available
// For simplicity, we'll report success. A more robust solution might `git -C new_worktree_path rev-parse HEAD`
result = {
success: true,
mode: "add",
worktreePath: sanitizedWorktreePathAdd,
branch: input.newBranch,
head: "HEAD", // Placeholder, actual SHA would require another call
message: `Worktree '${sanitizedWorktreePathAdd}' added successfully.`,
};
break;
case "remove":
// worktreePath is guaranteed by refine
const sanitizedWorktreePathRemove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
args = ["-C", targetPath, "worktree", "remove"];
if (input.force) {
args.push("--force");
}
args.push(sanitizedWorktreePathRemove);
logger.debug(`Executing command: git ${args.join(" ")}`, {
...context,
operation,
});
const { stdout: removeStdout } = await execFileAsync("git", args);
result = {
success: true,
mode: "remove",
worktreePath: sanitizedWorktreePathRemove,
message: removeStdout.trim() ||
`Worktree '${sanitizedWorktreePathRemove}' removed successfully.`,
};
break;
case "move":
// worktreePath and newPath are guaranteed by refine
const sanitizedOldPathMove = sanitization.sanitizePath(input.worktreePath, { allowAbsolute: true, rootDir: targetPath }).sanitizedPath;
const sanitizedNewPathMove = sanitization.sanitizePath(input.newPath, {
allowAbsolute: true,
rootDir: targetPath,
}).sanitizedPath;
args = [
"-C",
targetPath,
"worktree",
"move",
sanitizedOldPathMove,
sanitizedNewPathMove,
];
logger.debug(`Executing command: git ${args.join(" ")}`, {
...context,
operation,
});
await execFileAsync("git", args);
result = {
success: true,
mode: "move",
oldPath: sanitizedOldPathMove,
newPath: sanitizedNewPathMove,
message: `Worktree moved from '${sanitizedOldPathMove}' to '${sanitizedNewPathMove}' successfully.`,
};
break;
case "prune":
args = ["-C", targetPath, "worktree", "prune"];
if (input.dryRun) {
args.push("--dry-run");
}
if (input.verbose) {
args.push("--verbose");
}
if (input.expire) {
args.push(`--expire=${input.expire}`);
}
logger.debug(`Executing command: git ${args.join(" ")}`, {
...context,
operation,
});
const { stdout: pruneStdout, stderr: pruneStderr } = await execFileAsync("git", args);
// Prune often outputs to stderr even on success for verbose/dry-run
const pruneMessage = pruneStdout.trim() ||
pruneStderr.trim() ||
"Worktree prune operation completed.";
result = { success: true, mode: "prune", message: pruneMessage };
if (input.verbose && pruneStdout.trim()) {
// Attempt to parse verbose output if needed, for now just return raw message
// result.prunedItems = ...
}
break;
default:
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Invalid mode: ${input.mode}`, { context, operation });
}
logger.info(`git worktree ${input.mode} executed successfully`, {
...context,
operation,
path: targetPath,
result,
});
return result;
}
catch (error) {
const errorMessage = error.stderr || error.stdout || error.message || "";
logger.error(`Failed to execute git worktree command`, {
...context,
path: targetPath,
error: errorMessage,
stderr: error.stderr,
stdout: error.stdout,
});
if (errorMessage.toLowerCase().includes("not a git repository")) {
throw new McpError(BaseErrorCode.NOT_FOUND, `Path is not a Git repository: ${targetPath}`, { context, operation, originalError: error });
}
// Add more specific error handling based on `git worktree` messages
if (input.mode === "add" && errorMessage.includes("already exists")) {
throw new McpError(BaseErrorCode.CONFLICT, `Failed to add worktree: Path '${input.worktreePath}' already exists or is a worktree. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (input.mode === "add" && errorMessage.includes("is a submodule")) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Failed to add worktree: Path '${input.worktreePath}' is a submodule. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (input.mode === "remove" &&
errorMessage.includes("cannot remove the current worktree")) {
throw new McpError(BaseErrorCode.VALIDATION_ERROR, `Failed to remove worktree: Cannot remove the current worktree. Error: ${errorMessage}`, { context, operation, originalError: error });
}
if (input.mode === "remove" &&
errorMessage.includes("has unclean changes")) {
throw new McpError(BaseErrorCode.CONFLICT, `Failed to remove worktree: '${input.worktreePath}' has uncommitted changes. Use force=true to remove. Error: ${errorMessage}`, { context, operation, originalError: error });
}
// Throw a generic McpError for other failures
throw new McpError(BaseErrorCode.INTERNAL_ERROR, `Git worktree ${input.mode} failed for path: ${targetPath}. Error: ${errorMessage}`, { context, operation, originalError: error });
}
}