diff --git a/README.md b/README.md index 335f9ed..bb2cc0c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ This MCP server provides the following Git operations as tools: - **git_init**: Initialize a new Git repository - **git_push**: Pushes local commits to a remote repository (requires `--write-access` flag) - **git_list_repositories**: Lists all available Git repositories +- **git_apply_patch_string**: Applies a patch from a string to a git repository +- **git_apply_patch_file**: Applies a patch from a file to a git repository ## Installation diff --git a/pkg/gitops/gogit/operations.go b/pkg/gitops/gogit/operations.go index c9ef719..a6a30b5 100644 --- a/pkg/gitops/gogit/operations.go +++ b/pkg/gitops/gogit/operations.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/geropl/git-mcp-go/pkg/gitops" @@ -266,12 +267,12 @@ func (g *GoGitOperations) PushChanges(repoPath string, remote string, branch str if err != nil { return "", fmt.Errorf("failed to open repository: %w", err) } - + // Use "origin" as default remote if not specified if remote == "" { remote = "origin" } - + // Determine refspec based on branch var refspec string var branchName string @@ -290,19 +291,65 @@ func (g *GoGitOperations) PushChanges(repoPath string, remote string, branch str refspec = plumbing.NewBranchReferenceName(branch).String() branchName = branch } - + // Push to remote err = repo.Push(&git.PushOptions{ RemoteName: remote, RefSpecs: []config.RefSpec{config.RefSpec(refspec + ":" + refspec)}, }) - + if err != nil { if err == git.NoErrAlreadyUpToDate { return "Everything up-to-date", nil } return "", fmt.Errorf("failed to push: %w", err) } - + return fmt.Sprintf("Successfully pushed to %s/%s", remote, branchName), nil } + +// ApplyPatchFromFile applies a patch from a file to the repository +func (g *GoGitOperations) ApplyPatchFromFile(repoPath string, patchFilePath string) (string, error) { + // Ensure the patch file exists + if _, err := os.Stat(patchFilePath); os.IsNotExist(err) { + return "", fmt.Errorf("patch file does not exist: %s", patchFilePath) + } + + // go-git doesn't have direct support for applying patches + // We'll use git command for this operation + output, err := gitops.RunGitCommand(repoPath, "apply", patchFilePath) + if err != nil { + return "", fmt.Errorf("failed to apply patch: %w", err) + } + + return fmt.Sprintf("Patch from file '%s' applied successfully\n%s", patchFilePath, output), nil +} + +// ApplyPatchFromString applies a patch from a string to the repository +func (g *GoGitOperations) ApplyPatchFromString(repoPath string, patchString string) (string, error) { + // Create a temporary file to store the patch + tmpFile, err := os.CreateTemp("", "git-mcp-patch-*.patch") + if err != nil { + return "", fmt.Errorf("failed to create temporary file: %w", err) + } + defer os.Remove(tmpFile.Name()) // Clean up the temp file when done + + // Write the patch content to the temporary file + if _, err := tmpFile.WriteString(patchString); err != nil { + return "", fmt.Errorf("failed to write patch to temporary file: %w", err) + } + + // Close the file to ensure all data is written + if err := tmpFile.Close(); err != nil { + return "", fmt.Errorf("failed to close temporary file: %w", err) + } + + // Delegate to the file-based method + result, err := g.ApplyPatchFromFile(repoPath, tmpFile.Name()) + if err != nil { + return "", err + } + + // Modify the result to remove the file path reference since it's a temporary file + return strings.Replace(result, fmt.Sprintf("from file '%s' ", tmpFile.Name()), "", 1), nil +} diff --git a/pkg/gitops/interface.go b/pkg/gitops/interface.go index 9ebbaf0..8fc0da5 100644 --- a/pkg/gitops/interface.go +++ b/pkg/gitops/interface.go @@ -15,4 +15,6 @@ type GitOperations interface { InitRepo(repoPath string) (string, error) ShowCommit(repoPath string, revision string) (string, error) PushChanges(repoPath string, remote string, branch string) (string, error) + ApplyPatchFromString(repoPath string, patchString string) (string, error) + ApplyPatchFromFile(repoPath string, patchFilePath string) (string, error) } diff --git a/pkg/gitops/shell/operations.go b/pkg/gitops/shell/operations.go index bb08e4e..e222503 100644 --- a/pkg/gitops/shell/operations.go +++ b/pkg/gitops/shell/operations.go @@ -71,12 +71,12 @@ func (s *ShellGitOperations) GetLog(repoPath string, maxCount int) ([]string, er if maxCount > 0 { args = append(args, fmt.Sprintf("-n%d", maxCount)) } - + output, err := gitops.RunGitCommand(repoPath, args...) if err != nil { return nil, fmt.Errorf("failed to get log: %w", err) } - + // Split the output into individual commit entries logs := strings.Split(strings.TrimSpace(output), "\n\n") return logs, nil @@ -88,12 +88,12 @@ func (s *ShellGitOperations) CreateBranch(repoPath string, branchName string, ba if baseBranch != "" { args = append(args, baseBranch) } - + _, err := gitops.RunGitCommand(repoPath, args...) if err != nil { return "", fmt.Errorf("failed to create branch: %w", err) } - + baseRef := baseBranch if baseRef == "" { // Get the current branch name @@ -104,7 +104,7 @@ func (s *ShellGitOperations) CreateBranch(repoPath string, branchName string, ba baseRef = strings.TrimSpace(currentBranch) } } - + return fmt.Sprintf("Created branch '%s' from '%s'", branchName, baseRef), nil } @@ -114,7 +114,7 @@ func (s *ShellGitOperations) CheckoutBranch(repoPath string, branchName string) if err != nil { return "", fmt.Errorf("failed to checkout branch: %w", err) } - + return fmt.Sprintf("Switched to branch '%s'", branchName), nil } @@ -125,12 +125,12 @@ func (s *ShellGitOperations) InitRepo(repoPath string) (string, error) { if err != nil { return "", fmt.Errorf("failed to create directory: %w", err) } - + _, err = gitops.RunGitCommand(repoPath, "init") if err != nil { return "", fmt.Errorf("failed to initialize repository: %w", err) } - + gitDir := filepath.Join(repoPath, ".git") return fmt.Sprintf("Initialized empty Git repository in %s", gitDir), nil } @@ -149,20 +149,65 @@ func (s *ShellGitOperations) PushChanges(repoPath string, remote string, branch if branch != "" { args = append(args, branch) } - + output, err := gitops.RunGitCommand(repoPath, args...) if err != nil { return "", fmt.Errorf("failed to push changes: %w", err) } - + // Check if the output indicates that everything is up-to-date if strings.Contains(output, "up-to-date") { return output, nil } - + // Format the output to match the expected format - return fmt.Sprintf("Successfully pushed to %s/%s\n%s", - remote, - branch, + return fmt.Sprintf("Successfully pushed to %s/%s\n%s", + remote, + branch, output), nil } + +// ApplyPatchFromFile applies a patch from a file to the repository +func (s *ShellGitOperations) ApplyPatchFromFile(repoPath string, patchFilePath string) (string, error) { + // Ensure the patch file exists + if _, err := os.Stat(patchFilePath); os.IsNotExist(err) { + return "", fmt.Errorf("patch file does not exist: %s", patchFilePath) + } + + // Apply the patch using git apply + output, err := gitops.RunGitCommand(repoPath, "apply", patchFilePath) + if err != nil { + return "", fmt.Errorf("failed to apply patch: %w", err) + } + + return fmt.Sprintf("Patch from file '%s' applied successfully\n%s", patchFilePath, output), nil +} + +// ApplyPatchFromString applies a patch from a string to the repository +func (s *ShellGitOperations) ApplyPatchFromString(repoPath string, patchString string) (string, error) { + // Create a temporary file to store the patch + tmpFile, err := os.CreateTemp("", "git-mcp-patch-*.patch") + if err != nil { + return "", fmt.Errorf("failed to create temporary file: %w", err) + } + defer os.Remove(tmpFile.Name()) // Clean up the temp file when done + + // Write the patch content to the temporary file + if _, err := tmpFile.WriteString(patchString); err != nil { + return "", fmt.Errorf("failed to write patch to temporary file: %w", err) + } + + // Close the file to ensure all data is written + if err := tmpFile.Close(); err != nil { + return "", fmt.Errorf("failed to close temporary file: %w", err) + } + + // Delegate to the file-based method + result, err := s.ApplyPatchFromFile(repoPath, tmpFile.Name()) + if err != nil { + return "", err + } + + // Modify the result to remove the file path reference since it's a temporary file + return strings.Replace(result, fmt.Sprintf("from file '%s' ", tmpFile.Name()), "", 1), nil +} diff --git a/pkg/server.go b/pkg/server.go index 9046c76..25517e5 100644 --- a/pkg/server.go +++ b/pkg/server.go @@ -33,13 +33,13 @@ func NewGitServer(repoPaths []string, gitOps gitops.GitOperations, writeAccess b if path == "" { continue } - + absPath, err := filepath.Abs(path) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to resolve path %s: %v\n", path, err) continue } - + // Check if it's a git repository gitDirPath := filepath.Join(absPath, ".git") if info, err := os.Stat(gitDirPath); err == nil && info.IsDir() { @@ -127,12 +127,14 @@ func GetReadOnlyToolNames() map[string]bool { func GetLocalOnlyToolNames() map[string]bool { // local tools that alter state, complementing the read-only tools result := map[string]bool{ - "git_init": true, - "git_create_branch": true, - "git_checkout": true, - "git_commit": true, - "git_add": true, - "git_reset": true, + "git_init": true, + "git_create_branch": true, + "git_checkout": true, + "git_commit": true, + "git_add": true, + "git_reset": true, + "git_apply_patch_string": true, + "git_apply_patch_file": true, } for toolName := range GetReadOnlyToolNames() { @@ -332,6 +334,34 @@ func (s *GitServer) RegisterTools() { mcp.WithDescription("Lists all available Git repositories"), ), s.gitListRepositoriesHandler) + // Register git_apply_patch_string tool + applyPatchStringTool := mcp.NewTool("git_apply_patch_string", + mcp.WithDescription("Applies a patch from a string to a git repository"), + mcp.WithString("repo_path", + mcp.Required(), + mcp.Description("Path to Git repository"), + ), + mcp.WithString("patch_string", + mcp.Required(), + mcp.Description("Patch string to apply"), + ), + ) + s.server.AddTool(applyPatchStringTool, s.gitApplyPatchStringHandler) + + // Register git_apply_patch_file tool + applyPatchFileTool := mcp.NewTool("git_apply_patch_file", + mcp.WithDescription("Applies a patch from a file to a git repository"), + mcp.WithString("repo_path", + mcp.Required(), + mcp.Description("Path to Git repository"), + ), + mcp.WithString("patch_file", + mcp.Required(), + mcp.Description("Path to the patch file"), + ), + ) + s.server.AddTool(applyPatchFileTool, s.gitApplyPatchFileHandler) + if s.writeAccess { // Register git_push tool pushTool := mcp.NewTool("git_push", @@ -360,7 +390,7 @@ func (s *GitServer) Serve() error { func (s *GitServer) gitStatusHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -376,7 +406,7 @@ func (s *GitServer) gitStatusHandler(ctx context.Context, request mcp.CallToolRe func (s *GitServer) gitDiffUnstagedHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -392,7 +422,7 @@ func (s *GitServer) gitDiffUnstagedHandler(ctx context.Context, request mcp.Call func (s *GitServer) gitDiffStagedHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -408,7 +438,7 @@ func (s *GitServer) gitDiffStagedHandler(ctx context.Context, request mcp.CallTo func (s *GitServer) gitDiffHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -429,7 +459,7 @@ func (s *GitServer) gitDiffHandler(ctx context.Context, request mcp.CallToolRequ func (s *GitServer) gitCommitHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -450,7 +480,7 @@ func (s *GitServer) gitCommitHandler(ctx context.Context, request mcp.CallToolRe func (s *GitServer) gitAddHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -461,11 +491,21 @@ func (s *GitServer) gitAddHandler(ctx context.Context, request mcp.CallToolReque return mcp.NewToolResultError("files must be a string"), nil } - // Split the comma-separated list of files - files := strings.Split(filesStr, ",") - // Trim spaces from each file path - for i, file := range files { - files[i] = strings.TrimSpace(file) + // LLMs are inconsistent with how they interact with this + // So, support either single file, comma-separated, or space-delimited + var files []string + if strings.Contains(filesStr, ",") { + files := strings.Split(filesStr, ",") + for i, file := range files { + files[i] = strings.TrimSpace(file) + } + } else if strings.Contains(filesStr, " ") { + files := strings.Split(filesStr, " ") + for i, file := range files { + files[i] = strings.TrimSpace(file) + } + } else { + files = []string{filesStr} } result, err := s.gitOps.AddFiles(repoPath, files) @@ -478,7 +518,7 @@ func (s *GitServer) gitAddHandler(ctx context.Context, request mcp.CallToolReque func (s *GitServer) gitResetHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -494,7 +534,7 @@ func (s *GitServer) gitResetHandler(ctx context.Context, request mcp.CallToolReq func (s *GitServer) gitLogHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -517,7 +557,7 @@ func (s *GitServer) gitLogHandler(ctx context.Context, request mcp.CallToolReque func (s *GitServer) gitCreateBranchHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -545,7 +585,7 @@ func (s *GitServer) gitCreateBranchHandler(ctx context.Context, request mcp.Call func (s *GitServer) gitCheckoutHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -566,7 +606,7 @@ func (s *GitServer) gitCheckoutHandler(ctx context.Context, request mcp.CallTool func (s *GitServer) gitShowHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -587,7 +627,7 @@ func (s *GitServer) gitShowHandler(ctx context.Context, request mcp.CallToolRequ func (s *GitServer) gitInitHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + // For init, we don't validate through getRepoPathForOperation since we're creating a new repo if requestedPath == "" { return mcp.NewToolResultError("repo_path must be specified for initialization"), nil @@ -617,7 +657,7 @@ func (s *GitServer) gitPushHandler(ctx context.Context, request mcp.CallToolRequ } requestedPath, _ := request.Params.Arguments["repo_path"].(string) - + repoPath, err := s.getRepoPathForOperation(requestedPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil @@ -645,20 +685,82 @@ func (s *GitServer) gitPushHandler(ctx context.Context, request mcp.CallToolRequ return mcp.NewToolResultText(result), nil } +// gitApplyPatchStringHandler applies a patch from a string to a repository +func (s *GitServer) gitApplyPatchStringHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + requestedPath, _ := request.Params.Arguments["repo_path"].(string) + + repoPath, err := s.getRepoPathForOperation(requestedPath) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil + } + + patchString, ok := request.Params.Arguments["patch_string"].(string) + if !ok { + return mcp.NewToolResultError("patch_string must be a string"), nil + } + + if strings.TrimSpace(patchString) == "" { + return mcp.NewToolResultError("patch_string cannot be empty"), nil + } + + result, err := s.gitOps.ApplyPatchFromString(repoPath, patchString) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to apply patch: %v", err)), nil + } + + return mcp.NewToolResultText(result), nil +} + +// gitApplyPatchFileHandler applies a patch from a file to a repository +func (s *GitServer) gitApplyPatchFileHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + requestedPath, _ := request.Params.Arguments["repo_path"].(string) + + repoPath, err := s.getRepoPathForOperation(requestedPath) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Repository path error: %v", err)), nil + } + + patchFile, ok := request.Params.Arguments["patch_file"].(string) + if !ok { + return mcp.NewToolResultError("patch_file must be a string"), nil + } + + if strings.TrimSpace(patchFile) == "" { + return mcp.NewToolResultError("patch_file cannot be empty"), nil + } + + // Ensure the patch file exists + absPath, err := filepath.Abs(patchFile) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Invalid patch file path: %v", err)), nil + } + + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return mcp.NewToolResultError(fmt.Sprintf("Patch file does not exist: %s", absPath)), nil + } + + result, err := s.gitOps.ApplyPatchFromFile(repoPath, absPath) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to apply patch: %v", err)), nil + } + + return mcp.NewToolResultText(result), nil +} + // gitListRepositoriesHandler lists all available repositories func (s *GitServer) gitListRepositoriesHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { if len(s.repoPaths) == 0 { return mcp.NewToolResultText("No repositories configured"), nil } - + var result strings.Builder result.WriteString(fmt.Sprintf("Available repositories (%d):\n\n", len(s.repoPaths))) - + for i, repoPath := range s.repoPaths { // Get the repository name (last part of the path) repoName := filepath.Base(repoPath) result.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, repoName, repoPath)) } - + return mcp.NewToolResultText(result.String()), nil }