# Directory Structure
```
├── .clinerules
├── .github
│ └── workflows
│ └── npm-publish.yml
├── .gitignore
├── bun.lock
├── LICENSE
├── memory-bank
│ ├── activeContext.md
│ ├── productContext.md
│ ├── progress.md
│ ├── projectbrief.md
│ ├── systemPatterns.md
│ └── techContext.md
├── package-lock.json
├── package.json
├── readme.md
└── src
├── handlers
│ ├── advanced-operations.js
│ ├── branch-operations.js
│ ├── commit-operations.js
│ ├── common.js
│ ├── config-operations.js
│ ├── directory-operations.js
│ ├── index.js
│ ├── other-operations.js
│ ├── remote-operations.js
│ ├── stash-operations.js
│ └── tag-operations.js
├── index.js
├── server.js
└── utils
└── git.js
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependency directories
2 | node_modules/
3 | jspm_packages/
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # TypeScript cache
42 | *.tsbuildinfo
43 |
44 | # Optional npm cache directory
45 | .npm
46 |
47 | # Optional eslint cache
48 | .eslintcache
49 |
50 | # Optional REPL history
51 | .node_repl_history
52 |
53 | # Output of 'npm pack'
54 | *.tgz
55 |
56 | # Yarn Integrity file
57 | .yarn-integrity
58 |
59 | # dotenv environment variables file
60 | .env
61 | .env.test
62 |
63 | # parcel-bundler cache (https://parceljs.org/)
64 | .cache
65 |
66 | # Temporary folders
67 | tmp/
68 | temp/
69 |
70 | # IDE folders
71 | .idea/
72 | .vscode/
73 | *.swp
74 | *.swo
75 |
```
--------------------------------------------------------------------------------
/.clinerules:
--------------------------------------------------------------------------------
```
1 | # Git Commands MCP Project Rules
2 |
3 | ## Package Version Management
4 |
5 | 1. **Increment Version Before Pushing**:
6 | - Always increment the version number in `package.json` before pushing to the repository
7 | - This is critical as pushes to the repository trigger an npm package release through the CI/CD pipeline
8 | - Current version format is semantic versioning (major.minor.patch)
9 |
10 | 2. **Version Update Workflow**:
11 | - Check current version in package.json
12 | - Increment appropriate segment based on changes:
13 | - Patch (0.1.x): Bug fixes and minor changes
14 | - Minor (0.x.0): New features, backward compatible
15 | - Major (x.0.0): Breaking changes
16 | - Stage and commit version change separately
17 | - Sample commit message: "Bump version to X.Y.Z for npm release"
18 |
19 | ## Repository Configuration
20 |
21 | 1. **Git Remote Setup**:
22 | - Repository uses SSH for authentication: `[email protected]:bsreeram08/git-commands-mcp.git`
23 | - SSH keys must be properly configured for push access
24 |
25 | 2. **Branch Structure**:
26 | - Main development happens on `master` branch
27 | - Use feature branches for new development
28 |
29 | ## CI/CD Pipeline
30 |
31 | 1. **GitHub Actions**:
32 | - The repository has an npm-publish workflow in `.github/workflows/npm-publish.yml`
33 | - This workflow triggers on pushes to the repository
34 | - It builds and publishes the package to npm registry automatically
35 |
36 | 2. **Release Checklist**:
37 | - Update version in package.json
38 | - Ensure all changes are committed
39 | - Push to repository
40 | - Verify GitHub Actions workflow completes successfully
41 | - Check npm registry for the updated package
42 |
43 | ## Development Patterns
44 |
45 | 1. **Tool Handler Registration**:
46 | - When adding new Git handlers, ensure they are added to both:
47 | - `this.handlersMap` for functional registration
48 | - `this.toolsList` for exposure through the MCP interface
49 | - Update appropriate handler category in `this.handlerCategories`
50 |
51 | 2. **Code Organization**:
52 | - Handlers are organized in `src/handlers/index.js`
53 | - Server setup is in `src/server.js`
54 | - Main entry point is `src/index.js`
55 |
56 | 3. **Naming Conventions**:
57 | - Git tool handlers follow pattern: `git_[action]_[resource]`
58 | - Handler implementations follow pattern: `handleGit[Action][Resource]`
59 |
```
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
```markdown
1 | # MCP Git Repo Browser (Node.js)
2 |
3 | A Node.js implementation of a Git repository browser using the Model Context Protocol (MCP).
4 |
5 | [](https://github.com/bsreeram08/git-commands-mcp)
6 | [](https://www.npmjs.com/package/git-commands-mcp)
7 |
8 | ## Installation
9 |
10 | ### NPM (Recommended)
11 |
12 | ```bash
13 | npm install -g git-commands-mcp
14 | ```
15 |
16 | ### Manual Installation
17 |
18 | ```bash
19 | git clone https://github.com/bsreeram08/git-commands-mcp.git
20 | cd git-commands-mcp
21 | npm install
22 | ```
23 |
24 | ## Configuration
25 |
26 | Add this to your MCP settings configuration file:
27 |
28 | ```json
29 | {
30 | "mcpServers": {
31 | "git-commands-mcp": {
32 | "command": "git-commands-mcp"
33 | }
34 | }
35 | }
36 | ```
37 |
38 | For manual installation, use:
39 |
40 | ```json
41 | {
42 | "mcpServers": {
43 | "git-commands-mcp": {
44 | "command": "node",
45 | "args": ["/path/to/git-commands-mcp/src/index.js"]
46 | }
47 | }
48 | }
49 | ```
50 |
51 | ## Features
52 |
53 | The server provides the following tools:
54 |
55 | ### Basic Repository Operations
56 |
57 | 1. `git_directory_structure`: Returns a tree-like representation of a repository's directory structure
58 |
59 | - Input: Repository URL
60 | - Output: ASCII tree representation of the repository structure
61 |
62 | 2. `git_read_files`: Reads and returns the contents of specified files in a repository
63 |
64 | - Input: Repository URL and list of file paths
65 | - Output: Dictionary mapping file paths to their contents
66 |
67 | 3. `git_search_code`: Searches for patterns in repository code
68 | - Input: Repository URL, search pattern, optional file patterns, case sensitivity, and context lines
69 | - Output: JSON with search results including matching lines and context
70 |
71 | ### Branch Operations
72 |
73 | 4. `git_branch_diff`: Compare two branches and show files changed between them
74 | - Input: Repository URL, source branch, target branch, and optional show_patch flag
75 | - Output: JSON with commit count and diff summary
76 |
77 | ### Commit Operations
78 |
79 | 5. `git_commit_history`: Get commit history for a branch with optional filtering
80 |
81 | - Input: Repository URL, branch name, max count, author filter, since date, until date, and message grep
82 | - Output: JSON with commit details
83 |
84 | 6. `git_commits_details`: Get detailed information about commits including full messages and diffs
85 |
86 | - Input: Repository URL, branch name, max count, include_diff flag, author filter, since date, until date, and message grep
87 | - Output: JSON with detailed commit information
88 |
89 | 7. `git_local_changes`: Get uncommitted changes in the working directory
90 | - Input: Local repository path
91 | - Output: JSON with status information and diffs
92 |
93 | ## Project Structure
94 |
95 | ```
96 | git-commands-mcp/
97 | ├── src/
98 | │ ├── index.js # Entry point
99 | │ ├── server.js # Main server implementation
100 | │ ├── handlers/ # Tool handlers
101 | │ │ └── index.js # Tool implementation functions
102 | │ └── utils/ # Utility functions
103 | │ └── git.js # Git-related helper functions
104 | ├── package.json
105 | └── readme.md
106 | ```
107 |
108 | ## Implementation Details
109 |
110 | - Uses Node.js native modules (crypto, path, os) for core functionality
111 | - Leverages fs-extra for enhanced file operations
112 | - Uses simple-git for Git repository operations
113 | - Implements clean error handling and resource cleanup
114 | - Creates deterministic temporary directories based on repository URL hashes
115 | - Reuses cloned repositories when possible for efficiency
116 | - Modular code structure for better maintainability
117 |
118 | ## Requirements
119 |
120 | - Node.js 14.x or higher
121 | - Git installed on the system
122 |
123 | ## Usage
124 |
125 | If installed globally via npm:
126 |
127 | ```bash
128 | git-commands-mcp
129 | ```
130 |
131 | If installed manually:
132 |
133 | ```bash
134 | node src/index.js
135 | ```
136 |
137 | The server runs on stdio, making it compatible with MCP clients.
138 |
139 | ## CI/CD
140 |
141 | This project uses GitHub Actions for continuous integration and deployment:
142 |
143 | ### Automatic NPM Publishing
144 |
145 | The repository is configured with a GitHub Actions workflow that automatically publishes the package to npm when changes are pushed to the master branch.
146 |
147 | #### Setting up NPM_AUTOMATION_TOKEN
148 |
149 | To enable automatic publishing, you need to add an npm Automation token as a GitHub secret (this works even with accounts that have 2FA enabled):
150 |
151 | 1. Generate an npm Automation token:
152 |
153 | - Log in to your npm account on [npmjs.com](https://www.npmjs.com/)
154 | - Go to your profile settings
155 | - Select "Access Tokens"
156 | - Click "Generate New Token"
157 | - Select "Automation" token type
158 | - Set the appropriate permissions (needs "Read and write" for packages)
159 | - Copy the generated token
160 |
161 | 2. Add the token to your GitHub repository:
162 | - Go to your GitHub repository
163 | - Navigate to "Settings" > "Secrets and variables" > "Actions"
164 | - Click "New repository secret"
165 | - Name: `NPM_AUTOMATION_TOKEN`
166 | - Value: Paste your npm Automation token
167 | - Click "Add secret"
168 |
169 | Once configured, any push to the master branch will trigger the workflow to publish the package to npm.
170 |
171 | ## License
172 |
173 | MIT License - see the [LICENSE](LICENSE) file for details.
174 |
175 | ## Links
176 |
177 | - [GitHub Repository](https://github.com/bsreeram08/git-commands-mcp)
178 | - [NPM Package](https://www.npmjs.com/package/git-commands-mcp)
179 | - [Report Issues](https://github.com/bsreeram08/git-commands-mcp/issues)
180 |
```
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 | import { GitRepoBrowserServer } from "./server.js";
3 |
4 | const server = new GitRepoBrowserServer();
5 | server.run().catch(console.error);
6 |
```
--------------------------------------------------------------------------------
/src/handlers/common.js:
--------------------------------------------------------------------------------
```javascript
1 | import path from "path";
2 | import fs from "fs-extra";
3 | import { simpleGit } from "simple-git";
4 | import { exec } from "child_process";
5 | import { promisify } from "util";
6 | import { cloneRepo, getDirectoryTree } from "../utils/git.js";
7 |
8 | const execPromise = promisify(exec);
9 |
10 | export { path, fs, simpleGit, execPromise, cloneRepo, getDirectoryTree };
11 |
```
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: NPM Publish
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v3
14 |
15 | - name: Setup Node.js
16 | uses: actions/setup-node@v3
17 | with:
18 | node-version: "16.x"
19 | registry-url: "https://registry.npmjs.org"
20 |
21 | - name: Install dependencies
22 | run: npm ci
23 |
24 | - name: Publish to npm
25 | # Use --access=public if it's a scoped package (@username/package-name)
26 | run: npm publish --provenance
27 | env:
28 | # Use an Automation token to avoid 2FA issues
29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}
30 |
```
--------------------------------------------------------------------------------
/memory-bank/projectbrief.md:
--------------------------------------------------------------------------------
```markdown
1 | # Project Brief: Git Commands MCP Server
2 |
3 | ## Core Goal
4 |
5 | To provide a Model Context Protocol (MCP) server that exposes common and advanced Git commands as tools. This allows users (or AI agents) to interact with Git repositories programmatically through the MCP interface.
6 |
7 | ## Key Features
8 |
9 | - Expose a range of Git operations (cloning, committing, branching, merging, diffing, etc.) as distinct MCP tools.
10 | - Operate on both remote repositories (via URL) and local repositories (via path).
11 | - Return structured information from Git commands.
12 |
13 | ## Current Scope
14 |
15 | The server currently implements a variety of Git commands using the `simple-git` library, which wraps the local Git executable.
16 |
17 | ## Project Status
18 |
19 | Actively developed. A recent Pull Request proposes adding Docker and Smithery configuration for deployment, but there are concerns about compatibility due to the server's reliance on local Git execution via `simple-git`.
20 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "git-commands-mcp",
3 | "version": "0.1.4",
4 | "description": "A Node.js implementation of a Git repository browser using the Model Context Protocol (MCP)",
5 | "main": "src/index.js",
6 | "type": "module",
7 | "bin": {
8 | "git-commands-mcp": "src/index.js"
9 | },
10 | "scripts": {
11 | "start": "node src/index.js",
12 | "prepublishOnly": "npm ci && echo \"No tests specified - publishing anyway\""
13 | },
14 | "author": "sreeram balamurugan",
15 | "license": "MIT",
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/bsreeram08/git-commands-mcp.git"
19 | },
20 | "bugs": {
21 | "url": "https://github.com/bsreeram08/git-commands-mcp/issues"
22 | },
23 | "homepage": "https://github.com/bsreeram08/git-commands-mcp#readme",
24 | "keywords": [
25 | "git",
26 | "mcp",
27 | "model-context-protocol",
28 | "repository",
29 | "browser"
30 | ],
31 | "dependencies": {
32 | "@modelcontextprotocol/sdk": "1.5.0",
33 | "@surfai/docs-mcp": "^1.0.1",
34 | "fs-extra": "^11.3.0",
35 | "simple-git": "^3.27.0"
36 | },
37 | "engines": {
38 | "node": ">=14.0.0"
39 | }
40 | }
41 |
```
--------------------------------------------------------------------------------
/src/handlers/config-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import { simpleGit } from "./common.js";
2 |
3 | /**
4 | * Configures git settings for the repository
5 | * @param {string} repoPath - Path to the local repository
6 | * @param {string} scope - Configuration scope (local, global, system)
7 | * @param {string} key - Configuration key
8 | * @param {string} value - Configuration value
9 | * @returns {Object} - Configuration result
10 | */
11 | export async function handleGitConfig({
12 | repo_path,
13 | scope = "local",
14 | key,
15 | value,
16 | }) {
17 | try {
18 | const git = simpleGit(repo_path);
19 |
20 | // Set the configuration
21 | await git.addConfig(key, value, false, scope);
22 |
23 | return {
24 | content: [
25 | {
26 | type: "text",
27 | text: JSON.stringify(
28 | {
29 | success: true,
30 | message: `Set ${scope} config ${key}=${value}`,
31 | key: key,
32 | value: value,
33 | scope: scope,
34 | },
35 | null,
36 | 2
37 | ),
38 | },
39 | ],
40 | };
41 | } catch (error) {
42 | return {
43 | content: [
44 | {
45 | type: "text",
46 | text: JSON.stringify(
47 | { error: `Failed to set git config: ${error.message}` },
48 | null,
49 | 2
50 | ),
51 | },
52 | ],
53 | isError: true,
54 | };
55 | }
56 | }
57 |
```
--------------------------------------------------------------------------------
/src/handlers/tag-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import { simpleGit } from "./common.js";
2 |
3 | /**
4 | * Creates a tag
5 | * @param {string} repoPath - Path to the local repository
6 | * @param {string} tagName - Name of the tag
7 | * @param {string} message - Tag message (for annotated tags)
8 | * @param {boolean} annotated - Whether to create an annotated tag
9 | * @returns {Object} - Tag creation result
10 | */
11 | export async function handleGitCreateTag({
12 | repo_path,
13 | tag_name,
14 | message = "",
15 | annotated = true,
16 | }) {
17 | try {
18 | const git = simpleGit(repo_path);
19 |
20 | if (annotated) {
21 | await git.addAnnotatedTag(tag_name, message);
22 | } else {
23 | await git.addTag(tag_name);
24 | }
25 |
26 | return {
27 | content: [
28 | {
29 | type: "text",
30 | text: JSON.stringify(
31 | {
32 | success: true,
33 | message: `Created ${
34 | annotated ? "annotated " : ""
35 | }tag: ${tag_name}`,
36 | tag: tag_name,
37 | },
38 | null,
39 | 2
40 | ),
41 | },
42 | ],
43 | };
44 | } catch (error) {
45 | return {
46 | content: [
47 | {
48 | type: "text",
49 | text: JSON.stringify(
50 | { error: `Failed to create tag: ${error.message}` },
51 | null,
52 | 2
53 | ),
54 | },
55 | ],
56 | isError: true,
57 | };
58 | }
59 | }
60 |
```
--------------------------------------------------------------------------------
/memory-bank/techContext.md:
--------------------------------------------------------------------------------
```markdown
1 | # Tech Context: Git Commands MCP Server
2 |
3 | ## Core Technologies
4 |
5 | - **Language:** Node.js (JavaScript)
6 | - **Package Manager:** Likely npm (presence of `package.json`, `package-lock.json`) or potentially Bun (presence of `bun.lock`). Requires clarification if both are used or if one is primary.
7 | - **Core Library:** `simple-git` - A Node.js wrapper for the Git command-line interface. This is the primary mechanism for interacting with Git.
8 | - **MCP Framework:** Relies on an underlying MCP server implementation (details likely in `src/server.js` or dependencies) to handle MCP communication.
9 |
10 | ## Development Setup
11 |
12 | - Requires Node.js runtime installed.
13 | - Requires Git executable installed and accessible in the system PATH.
14 | - Dependencies are managed via `package.json` (and potentially `bun.lock`). Installation likely via `npm install` or `bun install`.
15 | - Server is typically run using a command like `node src/index.js` or a script defined in `package.json`.
16 |
17 | ## Technical Constraints
18 |
19 | - **Dependency on Local Git:** The use of `simple-git` inherently ties the server's execution environment to having a functional Git installation.
20 | - **Filesystem Access Requirement:** Tools operating on `repo_path` require direct access to the host filesystem, which is problematic in isolated environments like Docker containers.
21 | - **Authentication:** Handling authentication for remote private repositories relies on the Git configuration (e.g., SSH keys, credential helpers) available in the execution environment. This is difficult to replicate securely and consistently within a generic container.
22 |
23 | ## Tool Usage Patterns
24 |
25 | - MCP tools are defined with JSON schemas for input validation.
26 | - Handlers parse inputs and construct `simple-git` commands.
27 | - Error handling wraps potential exceptions from `simple-git`.
28 |
```
--------------------------------------------------------------------------------
/src/handlers/index.js:
--------------------------------------------------------------------------------
```javascript
1 | // Import handlers from individual files
2 | import {
3 | handleGitDirectoryStructure,
4 | handleGitReadFiles,
5 | handleGitSearchCode,
6 | handleGitLocalChanges,
7 | } from "./directory-operations.js";
8 | import {
9 | handleGitCommitHistory,
10 | handleGitCommitsDetails,
11 | handleGitCommit,
12 | handleGitTrack,
13 | } from "./commit-operations.js";
14 | import {
15 | handleGitBranchDiff,
16 | handleGitCheckoutBranch,
17 | handleGitDeleteBranch,
18 | handleGitMergeBranch,
19 | } from "./branch-operations.js";
20 | import {
21 | handleGitPush,
22 | handleGitPull,
23 | handleGitRemote,
24 | } from "./remote-operations.js";
25 | import { handleGitStash } from "./stash-operations.js";
26 | import { handleGitCreateTag } from "./tag-operations.js";
27 | import { handleGitRebase, handleGitReset } from "./advanced-operations.js";
28 | import { handleGitConfig } from "./config-operations.js";
29 | import {
30 | handleGitArchive,
31 | handleGitAttributes,
32 | handleGitBlame,
33 | handleGitClean,
34 | handleGitHooks,
35 | handleGitLFS,
36 | handleGitLFSFetch,
37 | handleGitRevert,
38 | } from "./other-operations.js";
39 |
40 | // Re-export all handlers
41 | export {
42 | // Directory operations
43 | handleGitDirectoryStructure,
44 | handleGitReadFiles,
45 | handleGitSearchCode,
46 | handleGitLocalChanges,
47 |
48 | // Commit operations
49 | handleGitCommitHistory,
50 | handleGitCommitsDetails,
51 | handleGitCommit,
52 | handleGitTrack,
53 |
54 | // Branch operations
55 | handleGitBranchDiff,
56 | handleGitCheckoutBranch,
57 | handleGitDeleteBranch,
58 | handleGitMergeBranch,
59 |
60 | // Remote operations
61 | handleGitPush,
62 | handleGitPull,
63 | handleGitRemote,
64 |
65 | // Stash operations
66 | handleGitStash,
67 |
68 | // Tag operations
69 | handleGitCreateTag,
70 |
71 | // Advanced operations
72 | handleGitRebase,
73 | handleGitReset,
74 |
75 | // Config operations
76 | handleGitConfig,
77 |
78 | // Other operations
79 | handleGitArchive,
80 | handleGitAttributes,
81 | handleGitBlame,
82 | handleGitClean,
83 | handleGitHooks,
84 | handleGitLFS,
85 | handleGitLFSFetch,
86 | handleGitRevert,
87 | };
88 |
```
--------------------------------------------------------------------------------
/src/utils/git.js:
--------------------------------------------------------------------------------
```javascript
1 | import { simpleGit } from "simple-git";
2 | import fs from "fs-extra";
3 | import path from "path";
4 | import os from "os";
5 | import crypto from "crypto";
6 |
7 | /**
8 | * Clones a Git repository or reuses an existing clone
9 | * @param {string} repoUrl - The URL of the Git repository to clone
10 | * @returns {Promise<string>} - Path to the cloned repository
11 | */
12 | export async function cloneRepo(repoUrl) {
13 | // Create deterministic directory name based on repo URL
14 | const repoHash = crypto
15 | .createHash("sha256")
16 | .update(repoUrl)
17 | .digest("hex")
18 | .slice(0, 12);
19 | const tempDir = path.join(os.tmpdir(), `github_tools_${repoHash}`);
20 |
21 | // Check if directory exists and is a valid git repo
22 | if (await fs.pathExists(tempDir)) {
23 | try {
24 | const git = simpleGit(tempDir);
25 | const remotes = await git.getRemotes(true);
26 | if (remotes.length > 0 && remotes[0].refs.fetch === repoUrl) {
27 | // Pull latest changes
28 | await git.pull();
29 | return tempDir;
30 | }
31 | } catch (error) {
32 | // If there's any error with existing repo, clean it up
33 | await fs.remove(tempDir);
34 | }
35 | }
36 |
37 | // Create directory and clone repository
38 | await fs.ensureDir(tempDir);
39 | try {
40 | await simpleGit().clone(repoUrl, tempDir);
41 | return tempDir;
42 | } catch (error) {
43 | // Clean up on error
44 | await fs.remove(tempDir);
45 | throw new Error(`Failed to clone repository: ${error.message}`);
46 | }
47 | }
48 |
49 | /**
50 | * Generates a tree representation of a directory structure
51 | * @param {string} dirPath - Path to the directory
52 | * @param {string} prefix - Prefix for the current line (used for recursion)
53 | * @returns {Promise<string>} - ASCII tree representation of the directory
54 | */
55 | export async function getDirectoryTree(dirPath, prefix = "") {
56 | let output = "";
57 | const entries = await fs.readdir(dirPath);
58 | entries.sort();
59 |
60 | for (let i = 0; i < entries.length; i++) {
61 | const entry = entries[i];
62 | if (entry.startsWith(".git")) continue;
63 |
64 | const isLast = i === entries.length - 1;
65 | const currentPrefix = isLast ? "└── " : "├── ";
66 | const nextPrefix = isLast ? " " : "│ ";
67 | const entryPath = path.join(dirPath, entry);
68 |
69 | output += prefix + currentPrefix + entry + "\n";
70 |
71 | const stats = await fs.stat(entryPath);
72 | if (stats.isDirectory()) {
73 | output += await getDirectoryTree(entryPath, prefix + nextPrefix);
74 | }
75 | }
76 |
77 | return output;
78 | }
79 |
```
--------------------------------------------------------------------------------
/memory-bank/activeContext.md:
--------------------------------------------------------------------------------
```markdown
1 | # Active Context: Git Commands MCP Server (2025-05-02)
2 |
3 | ## Current Focus
4 |
5 | Completed a full code review of the `git-commands-mcp` server (`src/` directory) to understand its implementation details before evaluating the Smithery deployment PR. The review confirms the initial concern regarding containerization compatibility.
6 |
7 | ## Recent Changes
8 |
9 | - Memory Bank initialized with core documentation files.
10 | - Reviewed all source files in `src/`: `index.js`, `server.js`, `utils/git.js`, and all handler files in `src/handlers/`.
11 |
12 | ## Next Steps
13 |
14 | 1. **Update Memory Bank:** Refine `productContext.md`, `systemPatterns.md`, and `progress.md` based on the detailed code review findings.
15 | 2. **Present Findings:** Communicate the detailed analysis of `repo_url` vs. `repo_path` tool implementation and the resulting container incompatibility to the user.
16 | 3. **Discuss Smithery PR:** Re-engage on the Smithery PR, specifically asking for the `Dockerfile` and config file contents, now with the full context of the server's limitations.
17 | 4. **Evaluate Options:** Discuss potential paths forward regarding the Smithery deployment (e.g., deploying a limited subset of tools, modifying the server, or concluding it's unsuitable for this deployment model).
18 |
19 | ## Active Decisions & Considerations
20 |
21 | - **Confirmation of Incompatibility:** The code review confirms that tools using `repo_path` directly interact with the local filesystem path provided, making them incompatible with standard container isolation.
22 | - **`repo_url` Tool Feasibility:** Tools using `repo_url` operate on temporary clones within the server's environment. These _could_ work in a container if prerequisites (Git, network, permissions, auth) are met, but auth remains a major hurdle.
23 | - **Deployment Scope:** Any potential Smithery deployment would likely be limited to the `repo_url`-based tools, significantly reducing the server's advertised functionality.
24 |
25 | ## Key Learnings/Insights
26 |
27 | - **Explicit Design Distinction:** The codebase clearly separates `repo_url` tools (using `cloneRepo` for temporary local copies) from `repo_path` tools (using `simpleGit` or `fs` directly on the provided path).
28 | - **Filesystem Dependency:** Many tools, including hooks and attributes management, rely on direct `fs` access within the target `repo_path`, further cementing the local execution dependency.
29 | - **`execPromise` Usage:** Some tools (`git_search_code`, `git_lfs`, `git_lfs_fetch`) use direct command execution (`execPromise`), adding another layer of dependency on the execution environment's PATH and installed tools (like `git lfs`).
30 |
```
--------------------------------------------------------------------------------
/memory-bank/productContext.md:
--------------------------------------------------------------------------------
```markdown
1 | # Product Context: Git Commands MCP Server
2 |
3 | ## Problem Solved
4 |
5 | Interacting with Git repositories often requires manual command-line usage or complex scripting. This MCP server aims to simplify Git interactions by providing a standardized, programmatic interface via MCP tools. This is particularly useful for:
6 |
7 | - **AI Agents:** Enabling AI agents like Cline to perform Git operations as part of development tasks.
8 | - **Automation:** Facilitating automated workflows that involve Git (e.g., CI/CD-like tasks, repository management).
9 | - **Integration:** Providing a consistent way to integrate Git functionality into other applications or services that understand MCP.
10 |
11 | ## How It Should Work
12 |
13 | - Users (or agents) invoke specific Git tools provided by the server (e.g., `git_directory_structure`, `git_commit`, `git_branch_diff`).
14 | - The server receives the request, validates parameters, and routes to the appropriate handler.
15 | - **`repo_url`-based Tools:** For tools operating on remote repositories (identified by `repo_url` parameter), the server uses a utility (`cloneRepo` in `src/utils/git.js`) to clone the repository into a temporary directory within the server's own execution environment. Subsequent operations for that request (e.g., reading files, getting history, diffing branches) are performed on this temporary local clone using `simple-git`, `fs`, or direct `git` commands (`execPromise`). This _might_ work in a container if prerequisites (Git installed, network, permissions, auth) are met.
16 | - **`repo_path`-based Tools:** For tools operating on local repositories (identified by `repo_path` parameter), the server initializes `simple-git` directly with the provided path or uses `fs`/`execPromise` to interact with files/commands within that path. This requires the server process to have direct read/write access to the specified filesystem path. **This mode is fundamentally incompatible with standard containerized deployment (like Docker/Smithery) due to filesystem isolation.**
17 | - The server processes the output from the underlying Git operation (via `simple-git`, `fs`, or `execPromise`) and returns a structured JSON response to the caller.
18 |
19 | ## User Experience Goals
20 |
21 | - **Simplicity:** Abstract away the complexities of Git command-line syntax.
22 | - **Reliability:** Execute Git commands accurately and handle errors gracefully.
23 | - **Discoverability:** Clearly define available tools and their parameters through the MCP schema.
24 | - **Flexibility:** Support a wide range of common Git operations for both local and remote workflows.
25 |
26 | ## Target Users
27 |
28 | - AI Development Agents (like Cline)
29 | - Developers building automation scripts
30 | - Platform engineers integrating Git operations
31 |
```
--------------------------------------------------------------------------------
/memory-bank/progress.md:
--------------------------------------------------------------------------------
```markdown
1 | # Progress & Status: Git Commands MCP Server (2025-05-02)
2 |
3 | ## What Works
4 |
5 | - The MCP server successfully exposes a wide range of Git commands as tools, defined in `src/server.js`.
6 | - **`repo_url`-based Tools:** These tools (e.g., `git_directory_structure`, `git_read_files`, `git_commit_history`) function by cloning the remote repo into a temporary directory within the server's environment (`os.tmpdir()`) and operating on that clone. This works reliably when the server runs locally with network access and appropriate credentials (if needed).
7 | - **`repo_path`-based Tools:** These tools (e.g., `git_commit`, `git_push`, `git_local_changes`, `git_checkout_branch`) function correctly _only when the server process has direct filesystem access_ to the specified `repo_path`.
8 |
9 | ## What's Left to Build / Current Tasks
10 |
11 | - **Evaluate Smithery Deployment PR:** Analyze the feasibility of the proposed Docker/Smithery deployment in light of the confirmed incompatibility of `repo_path`-based tools with containerization. This requires reviewing the PR's `Dockerfile` and Smithery config file.
12 | - **Address Container Compatibility:** Decide how to handle the incompatibility issue. Options include:
13 | - Deploying only the `repo_url`-based tools.
14 | - Modifying the server architecture (significant effort).
15 | - Rejecting the containerized deployment approach for this server.
16 |
17 | ## Current Status
18 |
19 | - **Code Review Complete:** Full review of `src/` directory completed.
20 | - **Memory Bank Updated:** Core memory bank files created and refined based on code review.
21 | - **Blocked:** Further action on the Smithery PR is blocked pending review of its specific files (`Dockerfile`, config) and a decision on how to handle the `repo_path` tool incompatibility.
22 |
23 | ## Known Issues
24 |
25 | - **Fundamental Container Incompatibility (`repo_path` tools):** Tools requiring `repo_path` cannot function in a standard isolated container (like Docker/Smithery) because the container lacks access to the user-specified host filesystem paths.
26 | - **Container Prerequisites (`repo_url` tools):** For `repo_url` tools to work in a container, the container needs:
27 | - Git installed.
28 | - Network access.
29 | - Write permissions to its temporary directory.
30 | - A mechanism to handle authentication for private repositories (major challenge).
31 | - **Dependency on Local Tools:** Some handlers rely on `git lfs` being installed locally.
32 |
33 | ## Evolution of Decisions
34 |
35 | - The initial design leveraging `simple-git` and direct filesystem access (`repo_path`) is effective for local use but unsuitable for standard containerized deployment.
36 | - The `cloneRepo` utility for `repo_url` tools provides a potential (but limited) path for containerization, focusing only on remote repository interactions.
37 | - The Smithery PR necessitates a decision on whether to adapt the server, limit its deployed scope, or abandon containerization for this specific MCP.
38 |
```
--------------------------------------------------------------------------------
/src/handlers/advanced-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import { simpleGit } from "./common.js";
2 |
3 | /**
4 | * Handles git rebase operations
5 | * @param {string} repoPath - Path to the local repository
6 | * @param {string} onto - Branch or commit to rebase onto
7 | * @param {boolean} interactive - Whether to perform an interactive rebase
8 | * @returns {Object} - Rebase result
9 | */
10 | export async function handleGitRebase({
11 | repo_path,
12 | onto,
13 | interactive = false,
14 | }) {
15 | try {
16 | // For interactive rebase, we need to use exec as simple-git doesn't support it well
17 | if (interactive) {
18 | return {
19 | content: [
20 | {
21 | type: "text",
22 | text: JSON.stringify(
23 | { error: "Interactive rebase not supported through API" },
24 | null,
25 | 2
26 | ),
27 | },
28 | ],
29 | isError: true,
30 | };
31 | }
32 |
33 | const git = simpleGit(repo_path);
34 | const rebaseResult = await git.rebase([onto]);
35 |
36 | return {
37 | content: [
38 | {
39 | type: "text",
40 | text: JSON.stringify(
41 | {
42 | success: true,
43 | message: `Rebased onto ${onto}`,
44 | result: rebaseResult,
45 | },
46 | null,
47 | 2
48 | ),
49 | },
50 | ],
51 | };
52 | } catch (error) {
53 | return {
54 | content: [
55 | {
56 | type: "text",
57 | text: JSON.stringify(
58 | {
59 | error: `Failed to rebase: ${error.message}`,
60 | conflicts: error.git ? error.git.conflicts : null,
61 | },
62 | null,
63 | 2
64 | ),
65 | },
66 | ],
67 | isError: true,
68 | };
69 | }
70 | }
71 |
72 | /**
73 | * Resets repository to specified commit or state
74 | * @param {string} repoPath - Path to the local repository
75 | * @param {string} mode - Reset mode (soft, mixed, hard)
76 | * @param {string} to - Commit or reference to reset to
77 | * @returns {Object} - Reset result
78 | */
79 | export async function handleGitReset({
80 | repo_path,
81 | mode = "mixed",
82 | to = "HEAD",
83 | }) {
84 | try {
85 | const git = simpleGit(repo_path);
86 |
87 | // Check valid mode
88 | if (!["soft", "mixed", "hard"].includes(mode)) {
89 | return {
90 | content: [
91 | {
92 | type: "text",
93 | text: JSON.stringify(
94 | {
95 | error: `Invalid reset mode: ${mode}. Use 'soft', 'mixed', or 'hard'.`,
96 | },
97 | null,
98 | 2
99 | ),
100 | },
101 | ],
102 | isError: true,
103 | };
104 | }
105 |
106 | // Perform the reset
107 | await git.reset([`--${mode}`, to]);
108 |
109 | return {
110 | content: [
111 | {
112 | type: "text",
113 | text: JSON.stringify(
114 | {
115 | success: true,
116 | message: `Reset (${mode}) to ${to}`,
117 | mode: mode,
118 | target: to,
119 | },
120 | null,
121 | 2
122 | ),
123 | },
124 | ],
125 | };
126 | } catch (error) {
127 | return {
128 | content: [
129 | {
130 | type: "text",
131 | text: JSON.stringify(
132 | { error: `Failed to reset repository: ${error.message}` },
133 | null,
134 | 2
135 | ),
136 | },
137 | ],
138 | isError: true,
139 | };
140 | }
141 | }
142 |
```
--------------------------------------------------------------------------------
/src/handlers/stash-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import { simpleGit } from "./common.js";
2 |
3 | /**
4 | * Creates or applies a stash
5 | * @param {string} repoPath - Path to the local repository
6 | * @param {string} action - Stash action (save, pop, apply, list, drop)
7 | * @param {string} message - Stash message (for save action)
8 | * @param {number} index - Stash index (for pop, apply, drop actions)
9 | * @returns {Object} - Stash operation result
10 | */
11 | export async function handleGitStash({
12 | repo_path,
13 | action = "save",
14 | message = "",
15 | index = 0,
16 | }) {
17 | try {
18 | const git = simpleGit(repo_path);
19 |
20 | let result;
21 | switch (action) {
22 | case "save":
23 | result = await git.stash(["save", message]);
24 | return {
25 | content: [
26 | {
27 | type: "text",
28 | text: JSON.stringify(
29 | {
30 | success: true,
31 | message: "Changes stashed successfully",
32 | stash_message: message,
33 | },
34 | null,
35 | 2
36 | ),
37 | },
38 | ],
39 | };
40 |
41 | case "pop":
42 | result = await git.stash(["pop", index.toString()]);
43 | return {
44 | content: [
45 | {
46 | type: "text",
47 | text: JSON.stringify(
48 | {
49 | success: true,
50 | message: `Applied and dropped stash@{${index}}`,
51 | },
52 | null,
53 | 2
54 | ),
55 | },
56 | ],
57 | };
58 |
59 | case "apply":
60 | result = await git.stash(["apply", index.toString()]);
61 | return {
62 | content: [
63 | {
64 | type: "text",
65 | text: JSON.stringify(
66 | {
67 | success: true,
68 | message: `Applied stash@{${index}}`,
69 | },
70 | null,
71 | 2
72 | ),
73 | },
74 | ],
75 | };
76 |
77 | case "list":
78 | result = await git.stash(["list"]);
79 | // Parse the stash list
80 | const stashList = result
81 | .trim()
82 | .split("\n")
83 | .filter((line) => line.trim() !== "")
84 | .map((line) => {
85 | const match = line.match(/stash@\{(\d+)\}: (.*)/);
86 | if (match) {
87 | return {
88 | index: parseInt(match[1]),
89 | description: match[2],
90 | };
91 | }
92 | return null;
93 | })
94 | .filter((item) => item !== null);
95 |
96 | return {
97 | content: [
98 | {
99 | type: "text",
100 | text: JSON.stringify(
101 | {
102 | success: true,
103 | stashes: stashList,
104 | },
105 | null,
106 | 2
107 | ),
108 | },
109 | ],
110 | };
111 |
112 | case "drop":
113 | result = await git.stash(["drop", index.toString()]);
114 | return {
115 | content: [
116 | {
117 | type: "text",
118 | text: JSON.stringify(
119 | {
120 | success: true,
121 | message: `Dropped stash@{${index}}`,
122 | },
123 | null,
124 | 2
125 | ),
126 | },
127 | ],
128 | };
129 |
130 | default:
131 | return {
132 | content: [
133 | {
134 | type: "text",
135 | text: JSON.stringify(
136 | { error: `Unknown stash action: ${action}` },
137 | null,
138 | 2
139 | ),
140 | },
141 | ],
142 | isError: true,
143 | };
144 | }
145 | } catch (error) {
146 | return {
147 | content: [
148 | {
149 | type: "text",
150 | text: JSON.stringify(
151 | { error: `Failed to perform stash operation: ${error.message}` },
152 | null,
153 | 2
154 | ),
155 | },
156 | ],
157 | isError: true,
158 | };
159 | }
160 | }
161 |
```
--------------------------------------------------------------------------------
/memory-bank/systemPatterns.md:
--------------------------------------------------------------------------------
```markdown
1 | # System Patterns: Git Commands MCP Server
2 |
3 | ## Core Architecture
4 |
5 | The server follows a standard MCP server pattern:
6 |
7 | 1. **Initialization:** Sets up an MCP server instance (`src/server.js`).
8 | 2. **Tool Registration:** Defines and registers available Git tools with their input schemas (`src/handlers/index.js`). Each tool corresponds to a specific Git operation.
9 | 3. **Request Handling:** Listens for incoming MCP requests. When a tool is invoked, the server routes the request to the appropriate handler function.
10 | 4. **Git Interaction:** Handler functions utilize the `simple-git` library (`src/utils/git.js`) to interact with the Git command-line executable.
11 | 5. **Response Formatting:** Handler functions process the output from `simple-git` (or handle errors) and return a structured JSON response conforming to the MCP standard.
12 |
13 | ## Key Design Patterns
14 |
15 | - **Handler Mapping:** A map (`this.handlersMap` in `src/handlers/index.js`) associates tool names (e.g., `git_clone`) with their corresponding implementation functions (e.g., `handleGitClone`).
16 | - **Tool Listing:** A separate list (`this.toolsList` in `src/handlers/index.js`) defines the tools exposed via the MCP interface, including their schemas. This ensures separation between internal implementation and external interface definition.
17 | - **Categorization:** Handlers are grouped into categories (`this.handlerCategories` in `src/handlers/index.js`) for organization, although this is primarily for internal code structure.
18 | - **Wrapper Library:** Abstraction of direct Git command execution through the `simple-git` library. This simplifies handler logic but introduces a dependency on the local Git environment.
19 |
20 | ## Critical Implementation Paths
21 |
22 | - **`repo_path`-based Operations:** Tools accepting a `repo_path` parameter (e.g., `git_commit`, `git_push`, `git_local_changes`, `git_checkout_branch`) initialize `simpleGit` directly with this path or use `fs`/`execPromise` within this path. This requires the server process to have direct read/write access to the specified local filesystem path. **This path is incompatible with standard container isolation.**
23 | - **`repo_url`-based Operations:** Tools accepting a `repo_url` parameter (e.g., `git_directory_structure`, `git_read_files`, `git_commit_history`) use the `cloneRepo` utility (`src/utils/git.js`). This clones the remote repo into a temporary directory within the server's execution environment (`os.tmpdir()`) and performs operations on that temporary clone. **This path _might_ be adaptable to containerization if prerequisites are met.**
24 | - **Direct Command Execution:** Some tools (`git_search_code`, `git_lfs`, `git_lfs_fetch`) use `execPromise` to run `git` or `git lfs` commands directly, relying on these being available in the server environment's PATH.
25 |
26 | ## Dependencies
27 |
28 | - **Local Git Installation:** `simple-git` and direct `git` commands require a functional Git executable available in the system's PATH where the server runs.
29 | - **Node.js `fs` Module:** Used for direct file operations in some handlers (e.g., `handleGitHooks`, `handleGitAttributes`, reading files from temporary clones).
30 | - **Node.js `os` Module:** Used by `cloneRepo` to determine the temporary directory location.
31 | - **Node.js `crypto` Module:** Used by `cloneRepo` to generate deterministic temporary directory names.
32 | - **Filesystem Access (`repo_path` tools):** Require direct read/write access to the user-specified local repository paths.
33 | - **Filesystem Access (`repo_url` tools):** Require write access to the server's temporary directory (`os.tmpdir()`).
34 | - **Network Access (`repo_url` tools):** Require network connectivity to clone remote Git repositories.
35 | - **Authentication (`repo_url` tools):** Cloning private remote repositories requires credentials (e.g., SSH keys, HTTPS tokens) to be configured and accessible within the server's execution environment. This is a major challenge for containerized deployments.
36 | - **Optional Tools:** `git lfs` commands require the `git-lfs` extension to be installed in the server's environment.
37 |
```
--------------------------------------------------------------------------------
/src/handlers/branch-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import { simpleGit, cloneRepo } from "./common.js";
2 |
3 | /**
4 | * Handles the git_branch_diff tool request
5 | * @param {Object} params - Tool parameters
6 | * @param {string} params.repo_url - Repository URL
7 | * @param {string} params.source_branch - Source branch name
8 | * @param {string} params.target_branch - Target branch name
9 | * @param {boolean} params.show_patch - Whether to include diff patches
10 | * @returns {Object} - Tool response
11 | */
12 | export async function handleGitBranchDiff({
13 | repo_url,
14 | source_branch,
15 | target_branch,
16 | show_patch = false,
17 | }) {
18 | try {
19 | const repoPath = await cloneRepo(repo_url);
20 | const git = simpleGit(repoPath);
21 |
22 | // Make sure both branches exist locally
23 | const branches = await git.branch();
24 | if (!branches.all.includes(source_branch)) {
25 | await git.fetch("origin", source_branch);
26 | await git.checkout(source_branch);
27 | }
28 |
29 | if (!branches.all.includes(target_branch)) {
30 | await git.fetch("origin", target_branch);
31 | }
32 |
33 | // Get the diff between branches
34 | const diffOptions = ["--name-status"];
35 | if (show_patch) {
36 | diffOptions.push("--patch");
37 | }
38 |
39 | const diff = await git.diff([
40 | ...diffOptions,
41 | `${target_branch}...${source_branch}`,
42 | ]);
43 |
44 | // Get commit range information
45 | const logSummary = await git.log({
46 | from: target_branch,
47 | to: source_branch,
48 | });
49 |
50 | const result = {
51 | commits_count: logSummary.total,
52 | diff_summary: diff,
53 | };
54 |
55 | return {
56 | content: [
57 | {
58 | type: "text",
59 | text: JSON.stringify(result, null, 2),
60 | },
61 | ],
62 | };
63 | } catch (error) {
64 | return {
65 | content: [
66 | {
67 | type: "text",
68 | text: JSON.stringify(
69 | { error: `Failed to get branch diff: ${error.message}` },
70 | null,
71 | 2
72 | ),
73 | },
74 | ],
75 | isError: true,
76 | };
77 | }
78 | }
79 |
80 | /**
81 | * Creates and checks out a new branch
82 | * @param {string} repoPath - Path to the local repository
83 | * @param {string} branchName - Name of the new branch
84 | * @param {string} startPoint - Starting point for the branch (optional)
85 | * @returns {Object} - Branch creation result
86 | */
87 | export async function handleGitCheckoutBranch({
88 | repo_path,
89 | branch_name,
90 | start_point = null,
91 | create = false,
92 | }) {
93 | try {
94 | const git = simpleGit(repo_path);
95 |
96 | if (create) {
97 | // Create and checkout a new branch
98 | if (start_point) {
99 | await git.checkoutBranch(branch_name, start_point);
100 | } else {
101 | await git.checkoutLocalBranch(branch_name);
102 | }
103 |
104 | return {
105 | content: [
106 | {
107 | type: "text",
108 | text: JSON.stringify(
109 | {
110 | success: true,
111 | message: `Created and checked out new branch: ${branch_name}`,
112 | branch: branch_name,
113 | },
114 | null,
115 | 2
116 | ),
117 | },
118 | ],
119 | };
120 | } else {
121 | // Just checkout an existing branch
122 | await git.checkout(branch_name);
123 |
124 | return {
125 | content: [
126 | {
127 | type: "text",
128 | text: JSON.stringify(
129 | {
130 | success: true,
131 | message: `Checked out branch: ${branch_name}`,
132 | branch: branch_name,
133 | },
134 | null,
135 | 2
136 | ),
137 | },
138 | ],
139 | };
140 | }
141 | } catch (error) {
142 | return {
143 | content: [
144 | {
145 | type: "text",
146 | text: JSON.stringify(
147 | { error: `Failed to checkout branch: ${error.message}` },
148 | null,
149 | 2
150 | ),
151 | },
152 | ],
153 | isError: true,
154 | };
155 | }
156 | }
157 |
158 | /**
159 | * Deletes a branch
160 | * @param {string} repoPath - Path to the local repository
161 | * @param {string} branchName - Name of the branch to delete
162 | * @param {boolean} force - Whether to force deletion
163 | * @returns {Object} - Branch deletion result
164 | */
165 | export async function handleGitDeleteBranch({
166 | repo_path,
167 | branch_name,
168 | force = false,
169 | }) {
170 | try {
171 | const git = simpleGit(repo_path);
172 |
173 | // Get current branch to prevent deleting the active branch
174 | const currentBranch = await git.branch();
175 | if (currentBranch.current === branch_name) {
176 | return {
177 | content: [
178 | {
179 | type: "text",
180 | text: JSON.stringify(
181 | { error: "Cannot delete the currently checked out branch" },
182 | null,
183 | 2
184 | ),
185 | },
186 | ],
187 | isError: true,
188 | };
189 | }
190 |
191 | // Delete the branch
192 | if (force) {
193 | await git.deleteLocalBranch(branch_name, true);
194 | } else {
195 | await git.deleteLocalBranch(branch_name);
196 | }
197 |
198 | return {
199 | content: [
200 | {
201 | type: "text",
202 | text: JSON.stringify(
203 | {
204 | success: true,
205 | message: `Deleted branch: ${branch_name}`,
206 | branch: branch_name,
207 | },
208 | null,
209 | 2
210 | ),
211 | },
212 | ],
213 | };
214 | } catch (error) {
215 | return {
216 | content: [
217 | {
218 | type: "text",
219 | text: JSON.stringify(
220 | { error: `Failed to delete branch: ${error.message}` },
221 | null,
222 | 2
223 | ),
224 | },
225 | ],
226 | isError: true,
227 | };
228 | }
229 | }
230 |
231 | /**
232 | * Merges a source branch into the current branch
233 | * @param {string} repoPath - Path to the local repository
234 | * @param {string} sourceBranch - Branch to merge from
235 | * @param {string} targetBranch - Branch to merge into (optional, uses current branch if not provided)
236 | * @param {boolean} noFastForward - Whether to create a merge commit even if fast-forward is possible
237 | * @returns {Object} - Merge result
238 | */
239 | export async function handleGitMergeBranch({
240 | repo_path,
241 | source_branch,
242 | target_branch = null,
243 | no_fast_forward = false,
244 | }) {
245 | try {
246 | const git = simpleGit(repo_path);
247 |
248 | // If target branch is specified, checkout to it first
249 | if (target_branch) {
250 | await git.checkout(target_branch);
251 | }
252 |
253 | // Perform the merge
254 | let mergeOptions = [];
255 | if (no_fast_forward) {
256 | mergeOptions = ["--no-ff"];
257 | }
258 |
259 | const mergeResult = await git.merge([...mergeOptions, source_branch]);
260 |
261 | return {
262 | content: [
263 | {
264 | type: "text",
265 | text: JSON.stringify(
266 | {
267 | success: true,
268 | result: mergeResult,
269 | message: `Merged ${source_branch} into ${
270 | target_branch || "current branch"
271 | }`,
272 | },
273 | null,
274 | 2
275 | ),
276 | },
277 | ],
278 | };
279 | } catch (error) {
280 | return {
281 | content: [
282 | {
283 | type: "text",
284 | text: JSON.stringify(
285 | {
286 | error: `Failed to merge branches: ${error.message}`,
287 | conflicts: error.git ? error.git.conflicts : null,
288 | },
289 | null,
290 | 2
291 | ),
292 | },
293 | ],
294 | isError: true,
295 | };
296 | }
297 | }
298 |
```
--------------------------------------------------------------------------------
/src/handlers/directory-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import {
2 | execPromise,
3 | cloneRepo,
4 | getDirectoryTree,
5 | simpleGit,
6 | path,
7 | fs,
8 | } from "./common.js";
9 |
10 | /**
11 | * Handles the git_directory_structure tool request
12 | * @param {Object} params - Tool parameters
13 | * @param {string} params.repo_url - Repository URL
14 | * @returns {Object} - Tool response
15 | */
16 | export async function handleGitDirectoryStructure({ repo_url }) {
17 | try {
18 | const repoPath = await cloneRepo(repo_url);
19 | const tree = await getDirectoryTree(repoPath);
20 | return {
21 | content: [
22 | {
23 | type: "text",
24 | text: tree,
25 | },
26 | ],
27 | };
28 | } catch (error) {
29 | return {
30 | content: [
31 | {
32 | type: "text",
33 | text: `Error: ${error.message}`,
34 | },
35 | ],
36 | isError: true,
37 | };
38 | }
39 | }
40 |
41 | /**
42 | * Handles the git_read_files tool request
43 | * @param {Object} params - Tool parameters
44 | * @param {string} params.repo_url - Repository URL
45 | * @param {string[]} params.file_paths - File paths to read
46 | * @returns {Object} - Tool response
47 | */
48 | export async function handleGitReadFiles({ repo_url, file_paths }) {
49 | try {
50 | const repoPath = await cloneRepo(repo_url);
51 | const results = {};
52 |
53 | for (const filePath of file_paths) {
54 | const fullPath = path.join(repoPath, filePath);
55 | try {
56 | if (await fs.pathExists(fullPath)) {
57 | results[filePath] = await fs.readFile(fullPath, "utf8");
58 | } else {
59 | results[filePath] = "Error: File not found";
60 | }
61 | } catch (error) {
62 | results[filePath] = `Error reading file: ${error.message}`;
63 | }
64 | }
65 |
66 | return {
67 | content: [
68 | {
69 | type: "text",
70 | text: JSON.stringify(results, null, 2),
71 | },
72 | ],
73 | };
74 | } catch (error) {
75 | return {
76 | content: [
77 | {
78 | type: "text",
79 | text: JSON.stringify(
80 | { error: `Failed to process repository: ${error.message}` },
81 | null,
82 | 2
83 | ),
84 | },
85 | ],
86 | isError: true,
87 | };
88 | }
89 | }
90 |
91 | /**
92 | * Handles the git_search_code tool request
93 | * @param {Object} params - Tool parameters
94 | * @param {string} params.repo_url - Repository URL
95 | * @param {string} params.pattern - Search pattern (regex or string)
96 | * @param {string[]} params.file_patterns - Optional file patterns to filter (e.g., "*.js")
97 | * @param {boolean} params.case_sensitive - Whether the search is case sensitive
98 | * @param {number} params.context_lines - Number of context lines to include
99 | * @returns {Object} - Tool response
100 | */
101 | export async function handleGitSearchCode({
102 | repo_url,
103 | pattern,
104 | file_patterns = [],
105 | case_sensitive = false,
106 | context_lines = 2,
107 | }) {
108 | try {
109 | const repoPath = await cloneRepo(repo_url);
110 |
111 | // Build the grep command
112 | let grepCommand = `cd "${repoPath}" && git grep`;
113 |
114 | // Add options
115 | if (!case_sensitive) {
116 | grepCommand += " -i";
117 | }
118 |
119 | // Add context lines
120 | grepCommand += ` -n -C${context_lines}`;
121 |
122 | // Add pattern (escape quotes in the pattern)
123 | const escapedPattern = pattern.replace(/"/g, '\\"');
124 | grepCommand += ` "${escapedPattern}"`;
125 |
126 | // Add file patterns if provided
127 | if (file_patterns && file_patterns.length > 0) {
128 | grepCommand += ` -- ${file_patterns.join(" ")}`;
129 | }
130 |
131 | // Execute the command
132 | const { stdout, stderr } = await execPromise(grepCommand);
133 |
134 | if (stderr) {
135 | console.error(`Search error: ${stderr}`);
136 | }
137 |
138 | // Process the results
139 | const results = [];
140 | if (stdout) {
141 | // Split by file sections (git grep output format)
142 | const fileMatches = stdout.split(/^(?=\S[^:]*:)/m);
143 |
144 | for (const fileMatch of fileMatches) {
145 | if (!fileMatch.trim()) continue;
146 |
147 | // Extract file name and matches
148 | const lines = fileMatch.split("\n");
149 | const firstLine = lines[0];
150 | const fileNameMatch = firstLine.match(/^([^:]+):/);
151 |
152 | if (fileNameMatch) {
153 | const fileName = fileNameMatch[1];
154 | const matches = [];
155 |
156 | // Process each line
157 | let currentMatch = null;
158 | let contextLines = [];
159 |
160 | for (let i = 0; i < lines.length; i++) {
161 | const line = lines[i];
162 | // Skip empty lines
163 | if (!line.trim()) continue;
164 |
165 | // Check if this is a line number indicator
166 | const lineNumberMatch = line.match(/^([^-][^:]+):(\d+):(.*)/);
167 |
168 | if (lineNumberMatch) {
169 | // If we have a previous match, add it to the results
170 | if (currentMatch) {
171 | currentMatch.context_after = contextLines;
172 | matches.push(currentMatch);
173 | contextLines = [];
174 | }
175 |
176 | // Start a new match
177 | currentMatch = {
178 | file: fileName,
179 | line_number: parseInt(lineNumberMatch[2]),
180 | content: lineNumberMatch[3],
181 | context_before: contextLines,
182 | context_after: [],
183 | };
184 | contextLines = [];
185 | } else {
186 | // This is a context line
187 | const contextMatch = line.match(/^([^:]+)-(\d+)-(.*)/);
188 | if (contextMatch) {
189 | contextLines.push({
190 | line_number: parseInt(contextMatch[2]),
191 | content: contextMatch[3],
192 | });
193 | }
194 | }
195 | }
196 |
197 | // Add the last match if there is one
198 | if (currentMatch) {
199 | currentMatch.context_after = contextLines;
200 | matches.push(currentMatch);
201 | }
202 |
203 | if (matches.length > 0) {
204 | results.push({
205 | file: fileName,
206 | matches: matches,
207 | });
208 | }
209 | }
210 | }
211 | }
212 |
213 | return {
214 | content: [
215 | {
216 | type: "text",
217 | text: JSON.stringify(
218 | {
219 | pattern: pattern,
220 | case_sensitive: case_sensitive,
221 | context_lines: context_lines,
222 | file_patterns: file_patterns,
223 | results: results,
224 | total_matches: results.reduce(
225 | (sum, file) => sum + file.matches.length,
226 | 0
227 | ),
228 | total_files: results.length,
229 | },
230 | null,
231 | 2
232 | ),
233 | },
234 | ],
235 | };
236 | } catch (error) {
237 | return {
238 | content: [
239 | {
240 | type: "text",
241 | text: JSON.stringify(
242 | { error: `Failed to search repository: ${error.message}` },
243 | null,
244 | 2
245 | ),
246 | },
247 | ],
248 | isError: true,
249 | };
250 | }
251 | }
252 |
253 | /**
254 | * Handles the git_local_changes tool request
255 | * @param {Object} params - Tool parameters
256 | * @param {string} params.repo_path - Local repository path
257 | * @returns {Object} - Tool response
258 | */
259 | export async function handleGitLocalChanges({ repo_path }) {
260 | try {
261 | // Use the provided local repo path
262 | const git = simpleGit(repo_path);
263 |
264 | // Get status information
265 | const status = await git.status();
266 |
267 | // Get detailed diff for modified files
268 | let diffs = {};
269 | for (const file of status.modified) {
270 | diffs[file] = await git.diff([file]);
271 | }
272 |
273 | return {
274 | content: [
275 | {
276 | type: "text",
277 | text: JSON.stringify(
278 | {
279 | branch: status.current,
280 | staged_files: status.staged,
281 | modified_files: status.modified,
282 | new_files: status.not_added,
283 | deleted_files: status.deleted,
284 | conflicted_files: status.conflicted,
285 | diffs: diffs,
286 | },
287 | null,
288 | 2
289 | ),
290 | },
291 | ],
292 | };
293 | } catch (error) {
294 | return {
295 | content: [
296 | {
297 | type: "text",
298 | text: JSON.stringify(
299 | { error: `Failed to get local changes: ${error.message}` },
300 | null,
301 | 2
302 | ),
303 | },
304 | ],
305 | isError: true,
306 | };
307 | }
308 | }
309 |
```
--------------------------------------------------------------------------------
/src/handlers/commit-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import { path, fs, simpleGit, cloneRepo } from "./common.js";
2 |
3 | /**
4 | * Handles the git_commit_history tool request
5 | * @param {Object} params - Tool parameters
6 | * @param {string} params.repo_url - Repository URL
7 | * @param {string} params.branch - Branch name
8 | * @param {number} params.max_count - Maximum number of commits
9 | * @param {string} params.author - Author filter
10 | * @param {string} params.since - Date filter (after)
11 | * @param {string} params.until - Date filter (before)
12 | * @param {string} params.grep - Message content filter
13 | * @returns {Object} - Tool response
14 | */
15 | export async function handleGitCommitHistory({
16 | repo_url,
17 | branch = "main",
18 | max_count = 10,
19 | author,
20 | since,
21 | until,
22 | grep,
23 | }) {
24 | try {
25 | const repoPath = await cloneRepo(repo_url);
26 | const git = simpleGit(repoPath);
27 |
28 | // Prepare log options
29 | const logOptions = {
30 | maxCount: max_count,
31 | };
32 |
33 | if (author) {
34 | logOptions["--author"] = author;
35 | }
36 |
37 | if (since) {
38 | logOptions["--since"] = since;
39 | }
40 |
41 | if (until) {
42 | logOptions["--until"] = until;
43 | }
44 |
45 | if (grep) {
46 | logOptions["--grep"] = grep;
47 | }
48 |
49 | // Make sure branch exists locally
50 | const branches = await git.branch();
51 | if (!branches.all.includes(branch)) {
52 | await git.fetch("origin", branch);
53 | }
54 |
55 | // Get commit history
56 | const log = await git.log(logOptions, branch);
57 |
58 | // Format the commits
59 | const commits = log.all.map((commit) => ({
60 | hash: commit.hash,
61 | author: commit.author_name,
62 | email: commit.author_email,
63 | date: commit.date,
64 | message: commit.message,
65 | body: commit.body || "",
66 | }));
67 |
68 | return {
69 | content: [
70 | {
71 | type: "text",
72 | text: JSON.stringify({ commits }, null, 2),
73 | },
74 | ],
75 | };
76 | } catch (error) {
77 | return {
78 | content: [
79 | {
80 | type: "text",
81 | text: JSON.stringify(
82 | { error: `Failed to get commit history: ${error.message}` },
83 | null,
84 | 2
85 | ),
86 | },
87 | ],
88 | isError: true,
89 | };
90 | }
91 | }
92 |
93 | /**
94 | * Handles the git_commits_details tool request
95 | * @param {Object} params - Tool parameters
96 | * @param {string} params.repo_url - Repository URL
97 | * @param {string} params.branch - Branch name
98 | * @param {number} params.max_count - Maximum number of commits
99 | * @param {boolean} params.include_diff - Whether to include diffs
100 | * @param {string} params.author - Author filter
101 | * @param {string} params.since - Date filter (after)
102 | * @param {string} params.until - Date filter (before)
103 | * @param {string} params.grep - Message content filter
104 | * @returns {Object} - Tool response
105 | */
106 | export async function handleGitCommitsDetails({
107 | repo_url,
108 | branch = "main",
109 | max_count = 10,
110 | include_diff = false,
111 | author,
112 | since,
113 | until,
114 | grep,
115 | }) {
116 | try {
117 | const repoPath = await cloneRepo(repo_url);
118 | const git = simpleGit(repoPath);
119 |
120 | // Ensure branch exists locally
121 | const branches = await git.branch();
122 | if (!branches.all.includes(branch)) {
123 | await git.fetch("origin", branch);
124 | }
125 |
126 | // Prepare log options with full details
127 | const logOptions = {
128 | maxCount: max_count,
129 | "--format": "fuller", // Get more detailed commit info
130 | };
131 |
132 | if (author) {
133 | logOptions["--author"] = author;
134 | }
135 |
136 | if (since) {
137 | logOptions["--since"] = since;
138 | }
139 |
140 | if (until) {
141 | logOptions["--until"] = until;
142 | }
143 |
144 | if (grep) {
145 | logOptions["--grep"] = grep;
146 | }
147 |
148 | // Get commit history with full details
149 | const log = await git.log(logOptions, branch);
150 |
151 | // Enhance with additional details
152 | const commitsDetails = [];
153 |
154 | for (const commit of log.all) {
155 | const commitDetails = {
156 | hash: commit.hash,
157 | author: commit.author_name,
158 | author_email: commit.author_email,
159 | committer: commit.committer_name,
160 | committer_email: commit.committer_email,
161 | date: commit.date,
162 | message: commit.message,
163 | body: commit.body || "",
164 | refs: commit.refs,
165 | };
166 |
167 | // Get the commit diff if requested
168 | if (include_diff) {
169 | if (commit.parents && commit.parents.length > 0) {
170 | // For normal commits with parents
171 | const diff = await git.diff([`${commit.hash}^..${commit.hash}`]);
172 | commitDetails.diff = diff;
173 | } else {
174 | // For initial commits with no parents
175 | const diff = await git.diff([
176 | "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
177 | commit.hash,
178 | ]);
179 | commitDetails.diff = diff;
180 | }
181 |
182 | // Get list of changed files
183 | const showResult = await git.show([
184 | "--name-status",
185 | "--oneline",
186 | commit.hash,
187 | ]);
188 |
189 | // Parse the changed files from the result
190 | const fileLines = showResult
191 | .split("\n")
192 | .slice(1) // Skip the first line (commit summary)
193 | .filter(Boolean); // Remove empty lines
194 |
195 | commitDetails.changed_files = fileLines
196 | .map((line) => {
197 | const match = line.match(/^([AMDTRC])\s+(.+)$/);
198 | if (match) {
199 | return {
200 | status: match[1],
201 | file: match[2],
202 | };
203 | }
204 | return null;
205 | })
206 | .filter(Boolean);
207 | }
208 |
209 | commitsDetails.push(commitDetails);
210 | }
211 |
212 | return {
213 | content: [
214 | {
215 | type: "text",
216 | text: JSON.stringify(
217 | {
218 | commits: commitsDetails,
219 | },
220 | null,
221 | 2
222 | ),
223 | },
224 | ],
225 | };
226 | } catch (error) {
227 | return {
228 | content: [
229 | {
230 | type: "text",
231 | text: JSON.stringify(
232 | { error: `Failed to get commit details: ${error.message}` },
233 | null,
234 | 2
235 | ),
236 | },
237 | ],
238 | isError: true,
239 | };
240 | }
241 | }
242 |
243 | /**
244 | * Creates a commit with the specified message
245 | * @param {string} repoPath - Path to the local repository
246 | * @param {string} message - Commit message
247 | * @returns {Object} - Commit result
248 | */
249 | export async function handleGitCommit({ repo_path, message }) {
250 | try {
251 | const git = simpleGit(repo_path);
252 |
253 | // Create the commit (only commit what's in the staging area)
254 | const commitResult = await git.commit(message);
255 |
256 | return {
257 | content: [
258 | {
259 | type: "text",
260 | text: JSON.stringify(
261 | {
262 | success: true,
263 | commit_hash: commitResult.commit,
264 | commit_message: message,
265 | summary: commitResult.summary,
266 | },
267 | null,
268 | 2
269 | ),
270 | },
271 | ],
272 | };
273 | } catch (error) {
274 | return {
275 | content: [
276 | {
277 | type: "text",
278 | text: JSON.stringify(
279 | { error: `Failed to create commit: ${error.message}` },
280 | null,
281 | 2
282 | ),
283 | },
284 | ],
285 | isError: true,
286 | };
287 | }
288 | }
289 |
290 | /**
291 | * Tracks (stages) specific files or all files
292 | * @param {string} repoPath - Path to the local repository
293 | * @param {string[]} files - Array of file paths to track/stage (use ["."] for all files)
294 | * @returns {Object} - Tracking result
295 | */
296 | export async function handleGitTrack({ repo_path, files = ["."] }) {
297 | try {
298 | const git = simpleGit(repo_path);
299 |
300 | // Add the specified files to the staging area
301 | await git.add(files);
302 |
303 | // Get status to show what files were tracked
304 | const status = await git.status();
305 |
306 | return {
307 | content: [
308 | {
309 | type: "text",
310 | text: JSON.stringify(
311 | {
312 | success: true,
313 | message: `Tracked ${
314 | files.length === 1 && files[0] === "."
315 | ? "all files"
316 | : files.length + " files"
317 | }`,
318 | staged: status.staged,
319 | not_staged: status.not_added,
320 | modified: status.modified,
321 | },
322 | null,
323 | 2
324 | ),
325 | },
326 | ],
327 | };
328 | } catch (error) {
329 | return {
330 | content: [
331 | {
332 | type: "text",
333 | text: JSON.stringify(
334 | { error: `Failed to track files: ${error.message}` },
335 | null,
336 | 2
337 | ),
338 | },
339 | ],
340 | isError: true,
341 | };
342 | }
343 | }
344 |
```
--------------------------------------------------------------------------------
/src/handlers/remote-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import { simpleGit } from "./common.js";
2 |
3 | /**
4 | * Pushes changes to a remote repository
5 | * @param {string} repoPath - Path to the local repository
6 | * @param {string} remote - Remote name (default: origin)
7 | * @param {string} branch - Branch to push (default: current branch)
8 | * @param {boolean} force - Whether to force push
9 | * @returns {Object} - Push result
10 | */
11 | export async function handleGitPush({
12 | repo_path,
13 | remote = "origin",
14 | branch = null,
15 | force = false,
16 | }) {
17 | try {
18 | const git = simpleGit(repo_path);
19 |
20 | // If no branch specified, get the current branch
21 | if (!branch) {
22 | const branchInfo = await git.branch();
23 | branch = branchInfo.current;
24 | }
25 |
26 | // Perform the push
27 | let pushOptions = [];
28 | if (force) {
29 | pushOptions.push("--force");
30 | }
31 |
32 | const pushResult = await git.push(remote, branch, pushOptions);
33 |
34 | return {
35 | content: [
36 | {
37 | type: "text",
38 | text: JSON.stringify(
39 | {
40 | success: true,
41 | result: pushResult,
42 | message: `Pushed ${branch} to ${remote}`,
43 | },
44 | null,
45 | 2
46 | ),
47 | },
48 | ],
49 | };
50 | } catch (error) {
51 | return {
52 | content: [
53 | {
54 | type: "text",
55 | text: JSON.stringify(
56 | { error: `Failed to push changes: ${error.message}` },
57 | null,
58 | 2
59 | ),
60 | },
61 | ],
62 | isError: true,
63 | };
64 | }
65 | }
66 |
67 | /**
68 | * Pulls changes from a remote repository
69 | * @param {string} repoPath - Path to the local repository
70 | * @param {string} remote - Remote name (default: origin)
71 | * @param {string} branch - Branch to pull (default: current branch)
72 | * @param {boolean} rebase - Whether to rebase instead of merge
73 | * @returns {Object} - Pull result
74 | */
75 | export async function handleGitPull({
76 | repo_path,
77 | remote = "origin",
78 | branch = null,
79 | rebase = false,
80 | }) {
81 | try {
82 | const git = simpleGit(repo_path);
83 |
84 | // If no branch specified, use current branch
85 | if (!branch) {
86 | const branchInfo = await git.branch();
87 | branch = branchInfo.current;
88 | }
89 |
90 | // Set up pull options
91 | const pullOptions = {};
92 | if (rebase) {
93 | pullOptions["--rebase"] = null;
94 | }
95 |
96 | // Perform the pull
97 | const pullResult = await git.pull(remote, branch, pullOptions);
98 |
99 | return {
100 | content: [
101 | {
102 | type: "text",
103 | text: JSON.stringify(
104 | {
105 | success: true,
106 | result: pullResult,
107 | message: `Pulled from ${remote}/${branch}`,
108 | },
109 | null,
110 | 2
111 | ),
112 | },
113 | ],
114 | };
115 | } catch (error) {
116 | return {
117 | content: [
118 | {
119 | type: "text",
120 | text: JSON.stringify(
121 | {
122 | error: `Failed to pull changes: ${error.message}`,
123 | conflicts: error.git ? error.git.conflicts : null,
124 | },
125 | null,
126 | 2
127 | ),
128 | },
129 | ],
130 | isError: true,
131 | };
132 | }
133 | }
134 |
135 | /**
136 | * Manages Git remotes
137 | * @param {string} repoPath - Path to the local repository
138 | * @param {string} action - Remote action (list, add, remove, set-url, prune, get-url, rename, show)
139 | * @param {string} name - Remote name
140 | * @param {string} url - Remote URL (for add and set-url)
141 | * @param {string} newName - New remote name (for rename)
142 | * @param {boolean} pushUrl - Whether to set push URL instead of fetch URL (for set-url)
143 | * @returns {Object} - Operation result
144 | */
145 | export async function handleGitRemote({
146 | repo_path,
147 | action,
148 | name = "",
149 | url = "",
150 | new_name = "",
151 | push_url = false,
152 | }) {
153 | try {
154 | const git = simpleGit(repo_path);
155 |
156 | switch (action) {
157 | case "list":
158 | // Get all remotes with their URLs
159 | const remotes = await git.remote(["-v"]);
160 |
161 | // Parse the output
162 | const remotesList = [];
163 | const lines = remotes.trim().split("\n");
164 |
165 | for (const line of lines) {
166 | const match = line.match(/^([^\s]+)\s+([^\s]+)\s+\(([^)]+)\)$/);
167 | if (match) {
168 | const remoteName = match[1];
169 | const remoteUrl = match[2];
170 | const purpose = match[3];
171 |
172 | // Check if this remote is already in our list
173 | const existingRemote = remotesList.find(
174 | (r) => r.name === remoteName
175 | );
176 |
177 | if (existingRemote) {
178 | if (purpose === "fetch") {
179 | existingRemote.fetch_url = remoteUrl;
180 | } else if (purpose === "push") {
181 | existingRemote.push_url = remoteUrl;
182 | }
183 | } else {
184 | const remote = { name: remoteName };
185 |
186 | if (purpose === "fetch") {
187 | remote.fetch_url = remoteUrl;
188 | } else if (purpose === "push") {
189 | remote.push_url = remoteUrl;
190 | }
191 |
192 | remotesList.push(remote);
193 | }
194 | }
195 | }
196 |
197 | return {
198 | content: [
199 | {
200 | type: "text",
201 | text: JSON.stringify(
202 | {
203 | success: true,
204 | remotes: remotesList,
205 | },
206 | null,
207 | 2
208 | ),
209 | },
210 | ],
211 | };
212 |
213 | case "add":
214 | if (!name) {
215 | return {
216 | content: [
217 | {
218 | type: "text",
219 | text: JSON.stringify(
220 | { error: "Remote name is required for add action" },
221 | null,
222 | 2
223 | ),
224 | },
225 | ],
226 | isError: true,
227 | };
228 | }
229 |
230 | if (!url) {
231 | return {
232 | content: [
233 | {
234 | type: "text",
235 | text: JSON.stringify(
236 | { error: "Remote URL is required for add action" },
237 | null,
238 | 2
239 | ),
240 | },
241 | ],
242 | isError: true,
243 | };
244 | }
245 |
246 | // Add the remote
247 | await git.remote(["add", name, url]);
248 |
249 | return {
250 | content: [
251 | {
252 | type: "text",
253 | text: JSON.stringify(
254 | {
255 | success: true,
256 | message: `Added remote '${name}' with URL '${url}'`,
257 | name: name,
258 | url: url,
259 | },
260 | null,
261 | 2
262 | ),
263 | },
264 | ],
265 | };
266 |
267 | case "remove":
268 | if (!name) {
269 | return {
270 | content: [
271 | {
272 | type: "text",
273 | text: JSON.stringify(
274 | { error: "Remote name is required for remove action" },
275 | null,
276 | 2
277 | ),
278 | },
279 | ],
280 | isError: true,
281 | };
282 | }
283 |
284 | // Remove the remote
285 | await git.remote(["remove", name]);
286 |
287 | return {
288 | content: [
289 | {
290 | type: "text",
291 | text: JSON.stringify(
292 | {
293 | success: true,
294 | message: `Removed remote '${name}'`,
295 | name: name,
296 | },
297 | null,
298 | 2
299 | ),
300 | },
301 | ],
302 | };
303 |
304 | case "set-url":
305 | if (!name) {
306 | return {
307 | content: [
308 | {
309 | type: "text",
310 | text: JSON.stringify(
311 | { error: "Remote name is required for set-url action" },
312 | null,
313 | 2
314 | ),
315 | },
316 | ],
317 | isError: true,
318 | };
319 | }
320 |
321 | if (!url) {
322 | return {
323 | content: [
324 | {
325 | type: "text",
326 | text: JSON.stringify(
327 | { error: "Remote URL is required for set-url action" },
328 | null,
329 | 2
330 | ),
331 | },
332 | ],
333 | isError: true,
334 | };
335 | }
336 |
337 | // Set the remote URL (fetch or push)
338 | const args = ["set-url"];
339 | if (push_url) {
340 | args.push("--push");
341 | }
342 | args.push(name, url);
343 |
344 | await git.remote(args);
345 |
346 | return {
347 | content: [
348 | {
349 | type: "text",
350 | text: JSON.stringify(
351 | {
352 | success: true,
353 | message: `Updated ${
354 | push_url ? "push" : "fetch"
355 | } URL for remote '${name}' to '${url}'`,
356 | name: name,
357 | url: url,
358 | type: push_url ? "push" : "fetch",
359 | },
360 | null,
361 | 2
362 | ),
363 | },
364 | ],
365 | };
366 |
367 | case "get-url":
368 | if (!name) {
369 | return {
370 | content: [
371 | {
372 | type: "text",
373 | text: JSON.stringify(
374 | { error: "Remote name is required for get-url action" },
375 | null,
376 | 2
377 | ),
378 | },
379 | ],
380 | isError: true,
381 | };
382 | }
383 |
384 | // Get the remote URL(s)
385 | const getUrlArgs = ["get-url"];
386 | if (push_url) {
387 | getUrlArgs.push("--push");
388 | }
389 | getUrlArgs.push(name);
390 |
391 | const remoteUrl = await git.remote(getUrlArgs);
392 | const urls = remoteUrl.trim().split("\n");
393 |
394 | return {
395 | content: [
396 | {
397 | type: "text",
398 | text: JSON.stringify(
399 | {
400 | success: true,
401 | name: name,
402 | urls: urls,
403 | type: push_url ? "push" : "fetch",
404 | },
405 | null,
406 | 2
407 | ),
408 | },
409 | ],
410 | };
411 |
412 | case "rename":
413 | if (!name) {
414 | return {
415 | content: [
416 | {
417 | type: "text",
418 | text: JSON.stringify(
419 | { error: "Remote name is required for rename action" },
420 | null,
421 | 2
422 | ),
423 | },
424 | ],
425 | isError: true,
426 | };
427 | }
428 |
429 | if (!new_name) {
430 | return {
431 | content: [
432 | {
433 | type: "text",
434 | text: JSON.stringify(
435 | { error: "New remote name is required for rename action" },
436 | null,
437 | 2
438 | ),
439 | },
440 | ],
441 | isError: true,
442 | };
443 | }
444 |
445 | // Rename the remote
446 | await git.remote(["rename", name, new_name]);
447 |
448 | return {
449 | content: [
450 | {
451 | type: "text",
452 | text: JSON.stringify(
453 | {
454 | success: true,
455 | message: `Renamed remote '${name}' to '${new_name}'`,
456 | old_name: name,
457 | new_name: new_name,
458 | },
459 | null,
460 | 2
461 | ),
462 | },
463 | ],
464 | };
465 |
466 | case "prune":
467 | if (!name) {
468 | return {
469 | content: [
470 | {
471 | type: "text",
472 | text: JSON.stringify(
473 | { error: "Remote name is required for prune action" },
474 | null,
475 | 2
476 | ),
477 | },
478 | ],
479 | isError: true,
480 | };
481 | }
482 |
483 | // Prune the remote
484 | await git.remote(["prune", name]);
485 |
486 | return {
487 | content: [
488 | {
489 | type: "text",
490 | text: JSON.stringify(
491 | {
492 | success: true,
493 | message: `Pruned remote '${name}'`,
494 | name: name,
495 | },
496 | null,
497 | 2
498 | ),
499 | },
500 | ],
501 | };
502 |
503 | case "show":
504 | if (!name) {
505 | return {
506 | content: [
507 | {
508 | type: "text",
509 | text: JSON.stringify(
510 | { error: "Remote name is required for show action" },
511 | null,
512 | 2
513 | ),
514 | },
515 | ],
516 | isError: true,
517 | };
518 | }
519 |
520 | // Show remote details
521 | const showOutput = await git.raw(["remote", "show", name]);
522 |
523 | // Parse the output to extract useful information
524 | const remoteLines = showOutput.trim().split("\n");
525 | const remoteInfo = {
526 | name: name,
527 | fetch_url: "",
528 | push_url: "",
529 | head_branch: "",
530 | remote_branches: [],
531 | local_branches: [],
532 | };
533 |
534 | for (const line of remoteLines) {
535 | const trimmed = line.trim();
536 |
537 | if (trimmed.startsWith("Fetch URL:")) {
538 | remoteInfo.fetch_url = trimmed
539 | .substring("Fetch URL:".length)
540 | .trim();
541 | } else if (trimmed.startsWith("Push URL:")) {
542 | remoteInfo.push_url = trimmed.substring("Push URL:".length).trim();
543 | } else if (trimmed.startsWith("HEAD branch:")) {
544 | remoteInfo.head_branch = trimmed
545 | .substring("HEAD branch:".length)
546 | .trim();
547 | } else if (trimmed.startsWith("Remote branch")) {
548 | // Skip the "Remote branches:" line
549 | } else if (trimmed.startsWith("Local branch")) {
550 | // Skip the "Local branches:" line
551 | } else if (trimmed.includes("merges with remote")) {
552 | const parts = trimmed.split("merges with remote");
553 | if (parts.length === 2) {
554 | const localBranch = parts[0].trim();
555 | const remoteBranch = parts[1].trim();
556 | remoteInfo.local_branches.push({
557 | local: localBranch,
558 | remote: remoteBranch,
559 | });
560 | }
561 | } else if (trimmed.includes("tracked")) {
562 | const branch = trimmed.split(" ")[0].trim();
563 | if (branch) {
564 | remoteInfo.remote_branches.push(branch);
565 | }
566 | }
567 | }
568 |
569 | return {
570 | content: [
571 | {
572 | type: "text",
573 | text: JSON.stringify(
574 | {
575 | success: true,
576 | remote: remoteInfo,
577 | raw_output: showOutput,
578 | },
579 | null,
580 | 2
581 | ),
582 | },
583 | ],
584 | };
585 |
586 | default:
587 | return {
588 | content: [
589 | {
590 | type: "text",
591 | text: JSON.stringify(
592 | { error: `Unknown remote action: ${action}` },
593 | null,
594 | 2
595 | ),
596 | },
597 | ],
598 | isError: true,
599 | };
600 | }
601 | } catch (error) {
602 | return {
603 | content: [
604 | {
605 | type: "text",
606 | text: JSON.stringify(
607 | { error: `Failed to manage remote: ${error.message}` },
608 | null,
609 | 2
610 | ),
611 | },
612 | ],
613 | isError: true,
614 | };
615 | }
616 | }
617 |
```
--------------------------------------------------------------------------------
/src/handlers/other-operations.js:
--------------------------------------------------------------------------------
```javascript
1 | import { path, fs, simpleGit, execPromise } from "./common.js";
2 |
3 | /**
4 | * Manages Git hooks in the repository
5 | * @param {string} repoPath - Path to the local repository
6 | * @param {string} action - Hook action (list, get, create, enable, disable)
7 | * @param {string} hookName - Name of the hook (e.g., "pre-commit", "post-merge")
8 | * @param {string} script - Script content for the hook (for create action)
9 | * @returns {Object} - Hook operation result
10 | */
11 | export async function handleGitHooks({
12 | repo_path,
13 | action,
14 | hook_name = "",
15 | script = "",
16 | }) {
17 | try {
18 | // Path to the hooks directory
19 | const hooksDir = path.join(repo_path, ".git", "hooks");
20 |
21 | switch (action) {
22 | case "list":
23 | // Get all available hooks
24 | const files = await fs.readdir(hooksDir);
25 | const hooks = [];
26 |
27 | for (const file of files) {
28 | // Filter out sample hooks
29 | if (!file.endsWith(".sample")) {
30 | const hookPath = path.join(hooksDir, file);
31 | const stats = await fs.stat(hookPath);
32 |
33 | hooks.push({
34 | name: file,
35 | path: hookPath,
36 | size: stats.size,
37 | executable: (stats.mode & 0o111) !== 0, // Check if executable
38 | });
39 | }
40 | }
41 |
42 | return {
43 | content: [
44 | {
45 | type: "text",
46 | text: JSON.stringify(
47 | {
48 | success: true,
49 | hooks: hooks,
50 | },
51 | null,
52 | 2
53 | ),
54 | },
55 | ],
56 | };
57 |
58 | case "get":
59 | if (!hook_name) {
60 | return {
61 | content: [
62 | {
63 | type: "text",
64 | text: JSON.stringify(
65 | { error: "Hook name is required for get action" },
66 | null,
67 | 2
68 | ),
69 | },
70 | ],
71 | isError: true,
72 | };
73 | }
74 |
75 | const hookPath = path.join(hooksDir, hook_name);
76 |
77 | // Check if hook exists
78 | if (!(await fs.pathExists(hookPath))) {
79 | return {
80 | content: [
81 | {
82 | type: "text",
83 | text: JSON.stringify(
84 | { error: `Hook '${hook_name}' does not exist` },
85 | null,
86 | 2
87 | ),
88 | },
89 | ],
90 | isError: true,
91 | };
92 | }
93 |
94 | // Read hook content
95 | const hookContent = await fs.readFile(hookPath, "utf8");
96 | const stats = await fs.stat(hookPath);
97 |
98 | return {
99 | content: [
100 | {
101 | type: "text",
102 | text: JSON.stringify(
103 | {
104 | success: true,
105 | name: hook_name,
106 | content: hookContent,
107 | executable: (stats.mode & 0o111) !== 0,
108 | },
109 | null,
110 | 2
111 | ),
112 | },
113 | ],
114 | };
115 |
116 | case "create":
117 | if (!hook_name) {
118 | return {
119 | content: [
120 | {
121 | type: "text",
122 | text: JSON.stringify(
123 | { error: "Hook name is required for create action" },
124 | null,
125 | 2
126 | ),
127 | },
128 | ],
129 | isError: true,
130 | };
131 | }
132 |
133 | if (!script) {
134 | return {
135 | content: [
136 | {
137 | type: "text",
138 | text: JSON.stringify(
139 | { error: "Script content is required for create action" },
140 | null,
141 | 2
142 | ),
143 | },
144 | ],
145 | isError: true,
146 | };
147 | }
148 |
149 | const createHookPath = path.join(hooksDir, hook_name);
150 |
151 | // Write hook content
152 | await fs.writeFile(createHookPath, script);
153 |
154 | // Make hook executable
155 | await fs.chmod(createHookPath, 0o755);
156 |
157 | return {
158 | content: [
159 | {
160 | type: "text",
161 | text: JSON.stringify(
162 | {
163 | success: true,
164 | message: `Created hook '${hook_name}'`,
165 | name: hook_name,
166 | executable: true,
167 | },
168 | null,
169 | 2
170 | ),
171 | },
172 | ],
173 | };
174 |
175 | default:
176 | return {
177 | content: [
178 | {
179 | type: "text",
180 | text: JSON.stringify(
181 | { error: `Unknown hook action: ${action}` },
182 | null,
183 | 2
184 | ),
185 | },
186 | ],
187 | isError: true,
188 | };
189 | }
190 | } catch (error) {
191 | return {
192 | content: [
193 | {
194 | type: "text",
195 | text: JSON.stringify(
196 | { error: `Failed to manage hook: ${error.message}` },
197 | null,
198 | 2
199 | ),
200 | },
201 | ],
202 | isError: true,
203 | };
204 | }
205 | }
206 |
207 | /**
208 | * Reverts a commit
209 | * @param {string} repoPath - Path to the local repository
210 | * @param {string} commit - Commit hash or reference to revert
211 | * @param {boolean} noCommit - Whether to stage changes without committing
212 | * @returns {Object} - Revert result
213 | */
214 | export async function handleGitRevert({
215 | repo_path,
216 | commit,
217 | no_commit = false,
218 | }) {
219 | try {
220 | const git = simpleGit(repo_path);
221 |
222 | if (!commit) {
223 | return {
224 | content: [
225 | {
226 | type: "text",
227 | text: JSON.stringify(
228 | { error: "Commit reference is required" },
229 | null,
230 | 2
231 | ),
232 | },
233 | ],
234 | isError: true,
235 | };
236 | }
237 |
238 | // Build the revert command
239 | const revertOptions = [];
240 | if (no_commit) {
241 | revertOptions.push("--no-commit");
242 | }
243 |
244 | // Perform the revert
245 | const result = await git.raw(["revert", ...revertOptions, commit]);
246 |
247 | return {
248 | content: [
249 | {
250 | type: "text",
251 | text: JSON.stringify(
252 | {
253 | success: true,
254 | message: `Reverted commit ${commit}`,
255 | commit: commit,
256 | result: result,
257 | },
258 | null,
259 | 2
260 | ),
261 | },
262 | ],
263 | };
264 | } catch (error) {
265 | return {
266 | content: [
267 | {
268 | type: "text",
269 | text: JSON.stringify(
270 | {
271 | error: `Failed to revert commit: ${error.message}`,
272 | conflicts: error.git ? error.git.conflicts : null,
273 | },
274 | null,
275 | 2
276 | ),
277 | },
278 | ],
279 | isError: true,
280 | };
281 | }
282 | }
283 |
284 | /**
285 | * Performs Git clean operations
286 | * @param {string} repoPath - Path to the local repository
287 | * @param {boolean} directories - Whether to remove directories as well
288 | * @param {boolean} force - Whether to force clean
289 | * @param {boolean} dryRun - Whether to perform a dry run (show what would be done)
290 | * @returns {Object} - Clean result
291 | */
292 | export async function handleGitClean({
293 | repo_path,
294 | directories = false,
295 | force = false,
296 | dry_run = true,
297 | }) {
298 | try {
299 | const git = simpleGit(repo_path);
300 |
301 | // At least one of force or dry_run must be true for safety
302 | if (!force && !dry_run) {
303 | return {
304 | content: [
305 | {
306 | type: "text",
307 | text: JSON.stringify(
308 | { error: "For safety, either force or dry_run must be true" },
309 | null,
310 | 2
311 | ),
312 | },
313 | ],
314 | isError: true,
315 | };
316 | }
317 |
318 | // Build the clean command
319 | const cleanOptions = [];
320 |
321 | if (directories) {
322 | cleanOptions.push("-d");
323 | }
324 |
325 | if (force) {
326 | cleanOptions.push("-f");
327 | }
328 |
329 | if (dry_run) {
330 | cleanOptions.push("-n");
331 | }
332 |
333 | // Get the files that would be removed
334 | const preview = await git.clean([
335 | "--dry-run",
336 | ...(directories ? ["-d"] : []),
337 | ]);
338 | const filesToRemove = preview
339 | .split("\n")
340 | .filter((line) => line.startsWith("Would remove"))
341 | .map((line) => line.replace("Would remove ", "").trim());
342 |
343 | if (!dry_run) {
344 | // Perform the actual clean
345 | await git.clean(cleanOptions);
346 | }
347 |
348 | return {
349 | content: [
350 | {
351 | type: "text",
352 | text: JSON.stringify(
353 | {
354 | success: true,
355 | message: dry_run
356 | ? `Would remove ${filesToRemove.length} files/directories`
357 | : `Removed ${filesToRemove.length} files/directories`,
358 | files: filesToRemove,
359 | dry_run: dry_run,
360 | },
361 | null,
362 | 2
363 | ),
364 | },
365 | ],
366 | };
367 | } catch (error) {
368 | return {
369 | content: [
370 | {
371 | type: "text",
372 | text: JSON.stringify(
373 | { error: `Failed to clean repository: ${error.message}` },
374 | null,
375 | 2
376 | ),
377 | },
378 | ],
379 | isError: true,
380 | };
381 | }
382 | }
383 |
384 | /**
385 | * Updates Git LFS objects
386 | * @param {string} repoPath - Path to the local repository
387 | * @param {boolean} dryRun - Whether to perform a dry run
388 | * @param {boolean} pointers - Whether to convert pointers to objects
389 | * @returns {Object} - LFS objects update result
390 | */
391 | export async function handleGitLFSFetch({
392 | repo_path,
393 | dry_run = false,
394 | pointers = false,
395 | }) {
396 | try {
397 | // Build the command
398 | let command = `cd "${repo_path}" && git lfs fetch`;
399 |
400 | if (dry_run) {
401 | command += " --dry-run";
402 | }
403 |
404 | if (pointers) {
405 | command += " --pointers";
406 | }
407 |
408 | // Execute the command
409 | const { stdout, stderr } = await execPromise(command);
410 |
411 | // Parse the output
412 | const output = stdout.trim();
413 | const errors = stderr.trim();
414 |
415 | if (errors && !output) {
416 | return {
417 | content: [
418 | {
419 | type: "text",
420 | text: JSON.stringify({ error: errors }, null, 2),
421 | },
422 | ],
423 | isError: true,
424 | };
425 | }
426 |
427 | return {
428 | content: [
429 | {
430 | type: "text",
431 | text: JSON.stringify(
432 | {
433 | success: true,
434 | message: "Git LFS fetch completed",
435 | output: output,
436 | dry_run: dry_run,
437 | },
438 | null,
439 | 2
440 | ),
441 | },
442 | ],
443 | };
444 | } catch (error) {
445 | // Special handling for "git lfs not installed" error
446 | if (error.message.includes("git: lfs is not a git command")) {
447 | return {
448 | content: [
449 | {
450 | type: "text",
451 | text: JSON.stringify(
452 | { error: "Git LFS is not installed on the system" },
453 | null,
454 | 2
455 | ),
456 | },
457 | ],
458 | isError: true,
459 | };
460 | }
461 |
462 | return {
463 | content: [
464 | {
465 | type: "text",
466 | text: JSON.stringify(
467 | { error: `Failed to fetch LFS objects: ${error.message}` },
468 | null,
469 | 2
470 | ),
471 | },
472 | ],
473 | isError: true,
474 | };
475 | }
476 | }
477 |
478 | /**
479 | * Gets blame information for a file
480 | * @param {string} repoPath - Path to the local repository
481 | * @param {string} filePath - Path to the file
482 | * @param {string} rev - Revision to blame (default: HEAD)
483 | * @returns {Object} - Blame result
484 | */
485 | export async function handleGitBlame({ repo_path, file_path, rev = "HEAD" }) {
486 | try {
487 | const git = simpleGit(repo_path);
488 |
489 | // Run git blame
490 | const blameResult = await git.raw([
491 | "blame",
492 | "--line-porcelain",
493 | rev,
494 | "--",
495 | file_path,
496 | ]);
497 |
498 | // Parse the output
499 | const lines = blameResult.split("\n");
500 | const blameInfo = [];
501 |
502 | let currentCommit = null;
503 |
504 | for (let i = 0; i < lines.length; i++) {
505 | const line = lines[i];
506 |
507 | // Start of a new blame entry
508 | if (line.match(/^[0-9a-f]{40}/)) {
509 | if (currentCommit) {
510 | blameInfo.push(currentCommit);
511 | }
512 |
513 | const parts = line.split(" ");
514 | currentCommit = {
515 | hash: parts[0],
516 | originalLine: parseInt(parts[1]),
517 | finalLine: parseInt(parts[2]),
518 | lineCount: parseInt(parts[3] || 1),
519 | author: "",
520 | authorMail: "",
521 | authorTime: 0,
522 | subject: "",
523 | content: "",
524 | };
525 | } else if (line.startsWith("author ") && currentCommit) {
526 | currentCommit.author = line.substring(7);
527 | } else if (line.startsWith("author-mail ") && currentCommit) {
528 | currentCommit.authorMail = line.substring(12).replace(/[<>]/g, "");
529 | } else if (line.startsWith("author-time ") && currentCommit) {
530 | currentCommit.authorTime = parseInt(line.substring(12));
531 | } else if (line.startsWith("summary ") && currentCommit) {
532 | currentCommit.subject = line.substring(8);
533 | } else if (line.startsWith("\t") && currentCommit) {
534 | // This is the content line
535 | currentCommit.content = line.substring(1);
536 | blameInfo.push(currentCommit);
537 | currentCommit = null;
538 | }
539 | }
540 |
541 | // Add the last commit if there is one
542 | if (currentCommit) {
543 | blameInfo.push(currentCommit);
544 | }
545 |
546 | return {
547 | content: [
548 | {
549 | type: "text",
550 | text: JSON.stringify(
551 | {
552 | success: true,
553 | file: file_path,
554 | blame: blameInfo,
555 | },
556 | null,
557 | 2
558 | ),
559 | },
560 | ],
561 | };
562 | } catch (error) {
563 | return {
564 | content: [
565 | {
566 | type: "text",
567 | text: JSON.stringify(
568 | { error: `Failed to get blame information: ${error.message}` },
569 | null,
570 | 2
571 | ),
572 | },
573 | ],
574 | isError: true,
575 | };
576 | }
577 | }
578 |
579 | /**
580 | * Manages git attributes for files
581 | * @param {string} repoPath - Path to the local repository
582 | * @param {string} action - Action (get, set, list)
583 | * @param {string} pattern - File pattern
584 | * @param {string} attribute - Attribute to set
585 | * @returns {Object} - Operation result
586 | */
587 | export async function handleGitAttributes({
588 | repo_path,
589 | action,
590 | pattern = "",
591 | attribute = "",
592 | }) {
593 | try {
594 | const attributesPath = path.join(repo_path, ".gitattributes");
595 |
596 | switch (action) {
597 | case "list":
598 | // Check if .gitattributes exists
599 | if (!(await fs.pathExists(attributesPath))) {
600 | return {
601 | content: [
602 | {
603 | type: "text",
604 | text: JSON.stringify(
605 | {
606 | success: true,
607 | attributes: [],
608 | message: ".gitattributes file does not exist",
609 | },
610 | null,
611 | 2
612 | ),
613 | },
614 | ],
615 | };
616 | }
617 |
618 | // Read and parse .gitattributes
619 | const content = await fs.readFile(attributesPath, "utf8");
620 | const lines = content
621 | .split("\n")
622 | .filter((line) => line.trim() && !line.startsWith("#"));
623 |
624 | const attributes = lines.map((line) => {
625 | const parts = line.trim().split(/\s+/);
626 | return {
627 | pattern: parts[0],
628 | attributes: parts.slice(1),
629 | };
630 | });
631 |
632 | return {
633 | content: [
634 | {
635 | type: "text",
636 | text: JSON.stringify(
637 | {
638 | success: true,
639 | attributes: attributes,
640 | },
641 | null,
642 | 2
643 | ),
644 | },
645 | ],
646 | };
647 |
648 | case "get":
649 | if (!pattern) {
650 | return {
651 | content: [
652 | {
653 | type: "text",
654 | text: JSON.stringify(
655 | { error: "Pattern is required for get action" },
656 | null,
657 | 2
658 | ),
659 | },
660 | ],
661 | isError: true,
662 | };
663 | }
664 |
665 | // Check if .gitattributes exists
666 | if (!(await fs.pathExists(attributesPath))) {
667 | return {
668 | content: [
669 | {
670 | type: "text",
671 | text: JSON.stringify(
672 | {
673 | success: true,
674 | pattern: pattern,
675 | attributes: [],
676 | message: ".gitattributes file does not exist",
677 | },
678 | null,
679 | 2
680 | ),
681 | },
682 | ],
683 | };
684 | }
685 |
686 | // Read and find pattern
687 | const getContent = await fs.readFile(attributesPath, "utf8");
688 | const getLines = getContent.split("\n");
689 |
690 | const matchingLines = getLines.filter((line) => {
691 | const parts = line.trim().split(/\s+/);
692 | return parts[0] === pattern;
693 | });
694 |
695 | if (matchingLines.length === 0) {
696 | return {
697 | content: [
698 | {
699 | type: "text",
700 | text: JSON.stringify(
701 | {
702 | success: true,
703 | pattern: pattern,
704 | attributes: [],
705 | message: `No attributes found for pattern '${pattern}'`,
706 | },
707 | null,
708 | 2
709 | ),
710 | },
711 | ],
712 | };
713 | }
714 |
715 | // Parse attributes
716 | const patternAttributes = matchingLines
717 | .map((line) => {
718 | const parts = line.trim().split(/\s+/);
719 | return parts.slice(1);
720 | })
721 | .flat();
722 |
723 | return {
724 | content: [
725 | {
726 | type: "text",
727 | text: JSON.stringify(
728 | {
729 | success: true,
730 | pattern: pattern,
731 | attributes: patternAttributes,
732 | },
733 | null,
734 | 2
735 | ),
736 | },
737 | ],
738 | };
739 |
740 | case "set":
741 | if (!pattern) {
742 | return {
743 | content: [
744 | {
745 | type: "text",
746 | text: JSON.stringify(
747 | { error: "Pattern is required for set action" },
748 | null,
749 | 2
750 | ),
751 | },
752 | ],
753 | isError: true,
754 | };
755 | }
756 |
757 | if (!attribute) {
758 | return {
759 | content: [
760 | {
761 | type: "text",
762 | text: JSON.stringify(
763 | { error: "Attribute is required for set action" },
764 | null,
765 | 2
766 | ),
767 | },
768 | ],
769 | isError: true,
770 | };
771 | }
772 |
773 | // Check if .gitattributes exists, create if not
774 | if (!(await fs.pathExists(attributesPath))) {
775 | await fs.writeFile(attributesPath, "");
776 | }
777 |
778 | // Read current content
779 | const setContent = await fs.readFile(attributesPath, "utf8");
780 | const setLines = setContent.split("\n");
781 |
782 | // Check if pattern already exists
783 | const patternIndex = setLines.findIndex((line) => {
784 | const parts = line.trim().split(/\s+/);
785 | return parts[0] === pattern;
786 | });
787 |
788 | if (patternIndex !== -1) {
789 | // Update existing pattern
790 | const parts = setLines[patternIndex].trim().split(/\s+/);
791 |
792 | // Check if attribute already exists
793 | if (!parts.includes(attribute)) {
794 | parts.push(attribute);
795 | setLines[patternIndex] = parts.join(" ");
796 | }
797 | } else {
798 | // Add new pattern
799 | setLines.push(`${pattern} ${attribute}`);
800 | }
801 |
802 | // Write back to file
803 | await fs.writeFile(
804 | attributesPath,
805 | setLines.filter(Boolean).join("\n") + "\n"
806 | );
807 |
808 | return {
809 | content: [
810 | {
811 | type: "text",
812 | text: JSON.stringify(
813 | {
814 | success: true,
815 | message: `Set attribute '${attribute}' for pattern '${pattern}'`,
816 | pattern: pattern,
817 | attribute: attribute,
818 | },
819 | null,
820 | 2
821 | ),
822 | },
823 | ],
824 | };
825 |
826 | default:
827 | return {
828 | content: [
829 | {
830 | type: "text",
831 | text: JSON.stringify(
832 | { error: `Unknown attributes action: ${action}` },
833 | null,
834 | 2
835 | ),
836 | },
837 | ],
838 | isError: true,
839 | };
840 | }
841 | } catch (error) {
842 | return {
843 | content: [
844 | {
845 | type: "text",
846 | text: JSON.stringify(
847 | { error: `Failed to manage git attributes: ${error.message}` },
848 | null,
849 | 2
850 | ),
851 | },
852 | ],
853 | isError: true,
854 | };
855 | }
856 | }
857 |
858 | /**
859 | * Creates a git archive (zip or tar)
860 | * @param {string} repoPath - Path to the local repository
861 | * @param {string} outputPath - Output path for the archive
862 | * @param {string} format - Archive format (zip or tar)
863 | * @param {string} prefix - Prefix for files in the archive
864 | * @param {string} treeish - Tree-ish to archive (default: HEAD)
865 | * @returns {Object} - Archive result
866 | */
867 | export async function handleGitArchive({
868 | repo_path,
869 | output_path,
870 | format = "zip",
871 | prefix = "",
872 | treeish = "HEAD",
873 | }) {
874 | try {
875 | const git = simpleGit(repo_path);
876 |
877 | // Validate format
878 | if (!["zip", "tar"].includes(format)) {
879 | return {
880 | content: [
881 | {
882 | type: "text",
883 | text: JSON.stringify(
884 | {
885 | error: `Invalid archive format: ${format}. Use 'zip' or 'tar'.`,
886 | },
887 | null,
888 | 2
889 | ),
890 | },
891 | ],
892 | isError: true,
893 | };
894 | }
895 |
896 | // Build archive command
897 | const archiveArgs = ["archive", `--format=${format}`];
898 |
899 | if (prefix) {
900 | archiveArgs.push(`--prefix=${prefix}/`);
901 | }
902 |
903 | archiveArgs.push("-o", output_path, treeish);
904 |
905 | // Create archive
906 | await git.raw(archiveArgs);
907 |
908 | // Check if archive was created
909 | if (!(await fs.pathExists(output_path))) {
910 | return {
911 | content: [
912 | {
913 | type: "text",
914 | text: JSON.stringify(
915 | { error: "Failed to create archive: output file not found" },
916 | null,
917 | 2
918 | ),
919 | },
920 | ],
921 | isError: true,
922 | };
923 | }
924 |
925 | // Get file size
926 | const stats = await fs.stat(output_path);
927 |
928 | return {
929 | content: [
930 | {
931 | type: "text",
932 | text: JSON.stringify(
933 | {
934 | success: true,
935 | message: `Created ${format} archive at ${output_path}`,
936 | format: format,
937 | output_path: output_path,
938 | size_bytes: stats.size,
939 | treeish: treeish,
940 | },
941 | null,
942 | 2
943 | ),
944 | },
945 | ],
946 | };
947 | } catch (error) {
948 | return {
949 | content: [
950 | {
951 | type: "text",
952 | text: JSON.stringify(
953 | { error: `Failed to create archive: ${error.message}` },
954 | null,
955 | 2
956 | ),
957 | },
958 | ],
959 | isError: true,
960 | };
961 | }
962 | }
963 |
964 | /**
965 | * Manages Git LFS (Large File Storage)
966 | * @param {string} repoPath - Path to the local repository
967 | * @param {string} action - LFS action (install, track, untrack, list)
968 | * @param {string|string[]} patterns - File patterns for track/untrack
969 | * @returns {Object} - Operation result
970 | */
971 | export async function handleGitLFS({ repo_path, action, patterns = [] }) {
972 | try {
973 | // Make sure patterns is an array
974 | const patternsArray = Array.isArray(patterns) ? patterns : [patterns];
975 |
976 | switch (action) {
977 | case "install":
978 | // Install Git LFS in the repository
979 | const { stdout: installOutput } = await execPromise(
980 | `cd "${repo_path}" && git lfs install`
981 | );
982 |
983 | return {
984 | content: [
985 | {
986 | type: "text",
987 | text: JSON.stringify(
988 | {
989 | success: true,
990 | message: "Git LFS installed successfully",
991 | output: installOutput.trim(),
992 | },
993 | null,
994 | 2
995 | ),
996 | },
997 | ],
998 | };
999 |
1000 | case "track":
1001 | if (patternsArray.length === 0) {
1002 | return {
1003 | content: [
1004 | {
1005 | type: "text",
1006 | text: JSON.stringify(
1007 | {
1008 | error: "At least one pattern is required for track action",
1009 | },
1010 | null,
1011 | 2
1012 | ),
1013 | },
1014 | ],
1015 | isError: true,
1016 | };
1017 | }
1018 |
1019 | // Track files with LFS
1020 | const trackResults = [];
1021 |
1022 | for (const pattern of patternsArray) {
1023 | const { stdout: trackOutput } = await execPromise(
1024 | `cd "${repo_path}" && git lfs track "${pattern}"`
1025 | );
1026 | trackResults.push({
1027 | pattern: pattern,
1028 | output: trackOutput.trim(),
1029 | });
1030 | }
1031 |
1032 | return {
1033 | content: [
1034 | {
1035 | type: "text",
1036 | text: JSON.stringify(
1037 | {
1038 | success: true,
1039 | message: `Tracked ${patternsArray.length} pattern(s) with Git LFS`,
1040 | patterns: patternsArray,
1041 | results: trackResults,
1042 | },
1043 | null,
1044 | 2
1045 | ),
1046 | },
1047 | ],
1048 | };
1049 |
1050 | case "untrack":
1051 | if (patternsArray.length === 0) {
1052 | return {
1053 | content: [
1054 | {
1055 | type: "text",
1056 | text: JSON.stringify(
1057 | {
1058 | error:
1059 | "At least one pattern is required for untrack action",
1060 | },
1061 | null,
1062 | 2
1063 | ),
1064 | },
1065 | ],
1066 | isError: true,
1067 | };
1068 | }
1069 |
1070 | // Untrack files from LFS
1071 | const untrackResults = [];
1072 |
1073 | for (const pattern of patternsArray) {
1074 | const { stdout: untrackOutput } = await execPromise(
1075 | `cd "${repo_path}" && git lfs untrack "${pattern}"`
1076 | );
1077 | untrackResults.push({
1078 | pattern: pattern,
1079 | output: untrackOutput.trim(),
1080 | });
1081 | }
1082 |
1083 | return {
1084 | content: [
1085 | {
1086 | type: "text",
1087 | text: JSON.stringify(
1088 | {
1089 | success: true,
1090 | message: `Untracked ${patternsArray.length} pattern(s) from Git LFS`,
1091 | patterns: patternsArray,
1092 | results: untrackResults,
1093 | },
1094 | null,
1095 | 2
1096 | ),
1097 | },
1098 | ],
1099 | };
1100 |
1101 | case "list":
1102 | // List tracked patterns
1103 | const { stdout: listOutput } = await execPromise(
1104 | `cd "${repo_path}" && git lfs track`
1105 | );
1106 |
1107 | // Parse the output to extract patterns
1108 | const trackedPatterns = listOutput
1109 | .split("\n")
1110 | .filter((line) => line.includes("("))
1111 | .map((line) => {
1112 | const match = line.match(/Tracking "([^"]+)"/);
1113 | return match ? match[1] : null;
1114 | })
1115 | .filter(Boolean);
1116 |
1117 | return {
1118 | content: [
1119 | {
1120 | type: "text",
1121 | text: JSON.stringify(
1122 | {
1123 | success: true,
1124 | tracked_patterns: trackedPatterns,
1125 | },
1126 | null,
1127 | 2
1128 | ),
1129 | },
1130 | ],
1131 | };
1132 |
1133 | default:
1134 | return {
1135 | content: [
1136 | {
1137 | type: "text",
1138 | text: JSON.stringify(
1139 | { error: `Unknown LFS action: ${action}` },
1140 | null,
1141 | 2
1142 | ),
1143 | },
1144 | ],
1145 | isError: true,
1146 | };
1147 | }
1148 | } catch (error) {
1149 | // Special handling for "git lfs not installed" error
1150 | if (error.message.includes("git: lfs is not a git command")) {
1151 | return {
1152 | content: [
1153 | {
1154 | type: "text",
1155 | text: JSON.stringify(
1156 | { error: "Git LFS is not installed on the system" },
1157 | null,
1158 | 2
1159 | ),
1160 | },
1161 | ],
1162 | isError: true,
1163 | };
1164 | }
1165 |
1166 | return {
1167 | content: [
1168 | {
1169 | type: "text",
1170 | text: JSON.stringify(
1171 | { error: `Failed to perform LFS operation: ${error.message}` },
1172 | null,
1173 | 2
1174 | ),
1175 | },
1176 | ],
1177 | isError: true,
1178 | };
1179 | }
1180 | }
1181 |
```
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
```javascript
1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2 | import {
3 | CallToolRequestSchema,
4 | ErrorCode,
5 | ListToolsRequestSchema,
6 | McpError,
7 | } from "@modelcontextprotocol/sdk/types.js";
8 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9 |
10 | import {
11 | handleGitDirectoryStructure,
12 | handleGitReadFiles,
13 | handleGitBranchDiff,
14 | handleGitCommitHistory,
15 | handleGitCommitsDetails,
16 | handleGitLocalChanges,
17 | handleGitSearchCode,
18 | handleGitCommit,
19 | handleGitTrack,
20 | handleGitCheckoutBranch,
21 | handleGitDeleteBranch,
22 | handleGitMergeBranch,
23 | handleGitPush,
24 | handleGitPull,
25 | handleGitStash,
26 | handleGitCreateTag,
27 | handleGitRebase,
28 | handleGitConfig,
29 | handleGitReset,
30 | handleGitArchive,
31 | handleGitAttributes,
32 | handleGitBlame,
33 | handleGitClean,
34 | handleGitHooks,
35 | handleGitLFS,
36 | handleGitLFSFetch,
37 | handleGitRevert,
38 | } from "./handlers/index.js";
39 |
40 | /**
41 | * Main server class for the Git Repository Browser MCP server
42 | */
43 | export class GitRepoBrowserServer {
44 | /**
45 | * Initialize the server
46 | */
47 | constructor() {
48 | this.server = new Server(
49 | {
50 | name: "mcp-git-repo-browser",
51 | version: "0.1.0",
52 | },
53 | {
54 | capabilities: {
55 | tools: {},
56 | },
57 | }
58 | );
59 |
60 | this.setupToolHandlers();
61 |
62 | // Error handling
63 | this.server.onerror = (error) => console.error("[MCP Error]", error);
64 | process.on("SIGINT", async () => {
65 | await this.server.close();
66 | process.exit(0);
67 | });
68 | }
69 |
70 | /**
71 | * Get all registered handler names
72 | * @returns {string[]} Array of handler names
73 | */
74 | getHandlerNames() {
75 | return Object.keys(this.handlersMap || {});
76 | }
77 |
78 | /**
79 | * Check if a handler exists
80 | * @param {string} name - Handler name to check
81 | * @returns {boolean} True if handler exists
82 | */
83 | hasHandler(name) {
84 | return Boolean(this.handlersMap && this.handlersMap[name]);
85 | }
86 |
87 | /**
88 | * Set up tool handlers for the server
89 | */
90 | setupToolHandlers() {
91 | // Store tools list for dynamic updates
92 | this.toolsList = [
93 | // Basic Repository Operations
94 | {
95 | name: "git_directory_structure",
96 | description:
97 | "Clone a Git repository and return its directory structure in a tree format.",
98 | inputSchema: {
99 | type: "object",
100 | properties: {
101 | repo_url: {
102 | type: "string",
103 | description: "The URL of the Git repository",
104 | },
105 | },
106 | required: ["repo_url"],
107 | },
108 | },
109 | {
110 | name: "git_read_files",
111 | description:
112 | "Read the contents of specified files in a given git repository.",
113 | inputSchema: {
114 | type: "object",
115 | properties: {
116 | repo_url: {
117 | type: "string",
118 | description: "The URL of the Git repository",
119 | },
120 | file_paths: {
121 | type: "array",
122 | items: { type: "string" },
123 | description:
124 | "List of file paths to read (relative to repository root)",
125 | },
126 | },
127 | required: ["repo_url", "file_paths"],
128 | },
129 | },
130 |
131 | // Branch Operations
132 | {
133 | name: "git_branch_diff",
134 | description:
135 | "Compare two branches and show files changed between them.",
136 | inputSchema: {
137 | type: "object",
138 | properties: {
139 | repo_url: {
140 | type: "string",
141 | description: "The URL of the Git repository",
142 | },
143 | source_branch: {
144 | type: "string",
145 | description: "The source branch name",
146 | },
147 | target_branch: {
148 | type: "string",
149 | description: "The target branch name",
150 | },
151 | show_patch: {
152 | type: "boolean",
153 | description: "Whether to include the actual diff patches",
154 | default: false,
155 | },
156 | },
157 | required: ["repo_url", "source_branch", "target_branch"],
158 | },
159 | },
160 | {
161 | name: "git_checkout_branch",
162 | description: "Create and/or checkout a branch.",
163 | inputSchema: {
164 | type: "object",
165 | properties: {
166 | repo_path: {
167 | type: "string",
168 | description: "The path to the local Git repository",
169 | },
170 | branch_name: {
171 | type: "string",
172 | description: "The name of the branch to checkout",
173 | },
174 | start_point: {
175 | type: "string",
176 | description: "Starting point for the branch (optional)",
177 | },
178 | create: {
179 | type: "boolean",
180 | description: "Whether to create a new branch",
181 | default: false,
182 | },
183 | },
184 | required: ["repo_path", "branch_name"],
185 | },
186 | },
187 | {
188 | name: "git_delete_branch",
189 | description: "Delete a branch from the repository.",
190 | inputSchema: {
191 | type: "object",
192 | properties: {
193 | repo_path: {
194 | type: "string",
195 | description: "The path to the local Git repository",
196 | },
197 | branch_name: {
198 | type: "string",
199 | description: "The name of the branch to delete",
200 | },
201 | force: {
202 | type: "boolean",
203 | description: "Whether to force deletion",
204 | default: false,
205 | },
206 | },
207 | required: ["repo_path", "branch_name"],
208 | },
209 | },
210 | {
211 | name: "git_merge_branch",
212 | description: "Merge a source branch into the current or target branch.",
213 | inputSchema: {
214 | type: "object",
215 | properties: {
216 | repo_path: {
217 | type: "string",
218 | description: "The path to the local Git repository",
219 | },
220 | source_branch: {
221 | type: "string",
222 | description: "Branch to merge from",
223 | },
224 | target_branch: {
225 | type: "string",
226 | description:
227 | "Branch to merge into (optional, uses current branch if not provided)",
228 | },
229 | no_fast_forward: {
230 | type: "boolean",
231 | description:
232 | "Whether to create a merge commit even if fast-forward is possible",
233 | default: false,
234 | },
235 | },
236 | required: ["repo_path", "source_branch"],
237 | },
238 | },
239 |
240 | // Commit Operations
241 | {
242 | name: "git_commit_history",
243 | description: "Get commit history for a branch with optional filtering.",
244 | inputSchema: {
245 | type: "object",
246 | properties: {
247 | repo_url: {
248 | type: "string",
249 | description: "The URL of the Git repository",
250 | },
251 | branch: {
252 | type: "string",
253 | description: "The branch to get history from",
254 | default: "main",
255 | },
256 | max_count: {
257 | type: "integer",
258 | description: "Maximum number of commits to retrieve",
259 | default: 10,
260 | },
261 | author: {
262 | type: "string",
263 | description: "Filter by author (optional)",
264 | },
265 | since: {
266 | type: "string",
267 | description:
268 | 'Get commits after this date (e.g., "1 week ago", "2023-01-01")',
269 | },
270 | until: {
271 | type: "string",
272 | description:
273 | 'Get commits before this date (e.g., "yesterday", "2023-12-31")',
274 | },
275 | grep: {
276 | type: "string",
277 | description: "Filter commits by message content (optional)",
278 | },
279 | },
280 | required: ["repo_url"],
281 | },
282 | },
283 | {
284 | name: "git_commits_details",
285 | description:
286 | "Get detailed information about commits including full messages and diffs.",
287 | inputSchema: {
288 | type: "object",
289 | properties: {
290 | repo_url: {
291 | type: "string",
292 | description: "The URL of the Git repository",
293 | },
294 | branch: {
295 | type: "string",
296 | description: "The branch to get commits from",
297 | default: "main",
298 | },
299 | max_count: {
300 | type: "integer",
301 | description: "Maximum number of commits to retrieve",
302 | default: 10,
303 | },
304 | include_diff: {
305 | type: "boolean",
306 | description: "Whether to include the commit diffs",
307 | default: false,
308 | },
309 | since: {
310 | type: "string",
311 | description:
312 | 'Get commits after this date (e.g., "1 week ago", "2023-01-01")',
313 | },
314 | until: {
315 | type: "string",
316 | description:
317 | 'Get commits before this date (e.g., "yesterday", "2023-12-31")',
318 | },
319 | author: {
320 | type: "string",
321 | description: "Filter by author (optional)",
322 | },
323 | grep: {
324 | type: "string",
325 | description: "Filter commits by message content (optional)",
326 | },
327 | },
328 | required: ["repo_url"],
329 | },
330 | },
331 | {
332 | name: "git_commit",
333 | description: "Create a commit with the specified message.",
334 | inputSchema: {
335 | type: "object",
336 | properties: {
337 | repo_path: {
338 | type: "string",
339 | description: "The path to the local Git repository",
340 | },
341 | message: {
342 | type: "string",
343 | description: "The commit message",
344 | },
345 | },
346 | required: ["repo_path", "message"],
347 | },
348 | },
349 | {
350 | name: "git_track",
351 | description: "Track (stage) specific files or all files.",
352 | inputSchema: {
353 | type: "object",
354 | properties: {
355 | repo_path: {
356 | type: "string",
357 | description: "The path to the local Git repository",
358 | },
359 | files: {
360 | type: "array",
361 | items: { type: "string" },
362 | description:
363 | 'Array of file paths to track/stage (use ["."] for all files)',
364 | default: ["."],
365 | },
366 | },
367 | required: ["repo_path"],
368 | },
369 | },
370 | {
371 | name: "git_local_changes",
372 | description: "Get uncommitted changes in the working directory.",
373 | inputSchema: {
374 | type: "object",
375 | properties: {
376 | repo_path: {
377 | type: "string",
378 | description: "The path to the local Git repository",
379 | },
380 | },
381 | required: ["repo_path"],
382 | },
383 | },
384 | {
385 | name: "git_search_code",
386 | description: "Search for patterns in repository code.",
387 | inputSchema: {
388 | type: "object",
389 | properties: {
390 | repo_url: {
391 | type: "string",
392 | description: "The URL of the Git repository",
393 | },
394 | pattern: {
395 | type: "string",
396 | description: "Search pattern (regex or string)",
397 | },
398 | file_patterns: {
399 | type: "array",
400 | items: { type: "string" },
401 | description: 'Optional file patterns to filter (e.g., "*.js")',
402 | },
403 | case_sensitive: {
404 | type: "boolean",
405 | description: "Whether the search is case sensitive",
406 | default: false,
407 | },
408 | context_lines: {
409 | type: "integer",
410 | description: "Number of context lines to include",
411 | default: 2,
412 | },
413 | },
414 | required: ["repo_url", "pattern"],
415 | },
416 | },
417 |
418 | // Remote Operations
419 | {
420 | name: "git_push",
421 | description: "Push changes to a remote repository.",
422 | inputSchema: {
423 | type: "object",
424 | properties: {
425 | repo_path: {
426 | type: "string",
427 | description: "The path to the local Git repository",
428 | },
429 | remote: {
430 | type: "string",
431 | description: "Remote name",
432 | default: "origin",
433 | },
434 | branch: {
435 | type: "string",
436 | description: "Branch to push (default: current branch)",
437 | },
438 | force: {
439 | type: "boolean",
440 | description: "Whether to force push",
441 | default: false,
442 | },
443 | },
444 | required: ["repo_path"],
445 | },
446 | },
447 | {
448 | name: "git_pull",
449 | description: "Pull changes from a remote repository.",
450 | inputSchema: {
451 | type: "object",
452 | properties: {
453 | repo_path: {
454 | type: "string",
455 | description: "The path to the local Git repository",
456 | },
457 | remote: {
458 | type: "string",
459 | description: "Remote name",
460 | default: "origin",
461 | },
462 | branch: {
463 | type: "string",
464 | description: "Branch to pull (default: current branch)",
465 | },
466 | rebase: {
467 | type: "boolean",
468 | description: "Whether to rebase instead of merge",
469 | default: false,
470 | },
471 | },
472 | required: ["repo_path"],
473 | },
474 | },
475 |
476 | // Stash Operations
477 | {
478 | name: "git_stash",
479 | description: "Create or apply a stash.",
480 | inputSchema: {
481 | type: "object",
482 | properties: {
483 | repo_path: {
484 | type: "string",
485 | description: "The path to the local Git repository",
486 | },
487 | action: {
488 | type: "string",
489 | description: "Stash action (save, pop, apply, list, drop)",
490 | default: "save",
491 | enum: ["save", "pop", "apply", "list", "drop"],
492 | },
493 | message: {
494 | type: "string",
495 | description: "Stash message (for save action)",
496 | default: "",
497 | },
498 | index: {
499 | type: "integer",
500 | description: "Stash index (for pop, apply, drop actions)",
501 | default: 0,
502 | },
503 | },
504 | required: ["repo_path"],
505 | },
506 | },
507 |
508 | // Tag Operations
509 | {
510 | name: "git_create_tag",
511 | description: "Create a tag.",
512 | inputSchema: {
513 | type: "object",
514 | properties: {
515 | repo_path: {
516 | type: "string",
517 | description: "The path to the local Git repository",
518 | },
519 | tag_name: {
520 | type: "string",
521 | description: "Name of the tag",
522 | },
523 | message: {
524 | type: "string",
525 | description: "Tag message (for annotated tags)",
526 | default: "",
527 | },
528 | annotated: {
529 | type: "boolean",
530 | description: "Whether to create an annotated tag",
531 | default: true,
532 | },
533 | },
534 | required: ["repo_path", "tag_name"],
535 | },
536 | },
537 |
538 | // Advanced Operations
539 | {
540 | name: "git_rebase",
541 | description: "Rebase the current branch onto another branch or commit.",
542 | inputSchema: {
543 | type: "object",
544 | properties: {
545 | repo_path: {
546 | type: "string",
547 | description: "The path to the local Git repository",
548 | },
549 | onto: {
550 | type: "string",
551 | description: "Branch or commit to rebase onto",
552 | },
553 | interactive: {
554 | type: "boolean",
555 | description: "Whether to perform an interactive rebase",
556 | default: false,
557 | },
558 | },
559 | required: ["repo_path", "onto"],
560 | },
561 | },
562 |
563 | // Configuration
564 | {
565 | name: "git_config",
566 | description: "Configure git settings for the repository.",
567 | inputSchema: {
568 | type: "object",
569 | properties: {
570 | repo_path: {
571 | type: "string",
572 | description: "The path to the local Git repository",
573 | },
574 | scope: {
575 | type: "string",
576 | description: "Configuration scope (local, global, system)",
577 | default: "local",
578 | enum: ["local", "global", "system"],
579 | },
580 | key: {
581 | type: "string",
582 | description: "Configuration key",
583 | },
584 | value: {
585 | type: "string",
586 | description: "Configuration value",
587 | },
588 | },
589 | required: ["repo_path", "key", "value"],
590 | },
591 | },
592 |
593 | // Repo Management
594 | {
595 | name: "git_reset",
596 | description: "Reset repository to specified commit or state.",
597 | inputSchema: {
598 | type: "object",
599 | properties: {
600 | repo_path: {
601 | type: "string",
602 | description: "The path to the local Git repository",
603 | },
604 | mode: {
605 | type: "string",
606 | description: "Reset mode (soft, mixed, hard)",
607 | default: "mixed",
608 | enum: ["soft", "mixed", "hard"],
609 | },
610 | to: {
611 | type: "string",
612 | description: "Commit or reference to reset to",
613 | default: "HEAD",
614 | },
615 | },
616 | required: ["repo_path"],
617 | },
618 | },
619 |
620 | // Archive Operations
621 | {
622 | name: "git_archive",
623 | description: "Create a git archive (zip or tar).",
624 | inputSchema: {
625 | type: "object",
626 | properties: {
627 | repo_path: {
628 | type: "string",
629 | description: "The path to the local Git repository",
630 | },
631 | output_path: {
632 | type: "string",
633 | description: "Output path for the archive",
634 | },
635 | format: {
636 | type: "string",
637 | description: "Archive format (zip or tar)",
638 | default: "zip",
639 | enum: ["zip", "tar"],
640 | },
641 | prefix: {
642 | type: "string",
643 | description: "Prefix for files in the archive",
644 | },
645 | treeish: {
646 | type: "string",
647 | description: "Tree-ish to archive (default: HEAD)",
648 | default: "HEAD",
649 | },
650 | },
651 | required: ["repo_path", "output_path"],
652 | },
653 | },
654 |
655 | // Attributes Operations
656 | {
657 | name: "git_attributes",
658 | description: "Manage git attributes for files.",
659 | inputSchema: {
660 | type: "object",
661 | properties: {
662 | repo_path: {
663 | type: "string",
664 | description: "The path to the local Git repository",
665 | },
666 | action: {
667 | type: "string",
668 | description: "Action (get, set, list)",
669 | default: "list",
670 | enum: ["get", "set", "list"],
671 | },
672 | pattern: {
673 | type: "string",
674 | description: "File pattern",
675 | },
676 | attribute: {
677 | type: "string",
678 | description: "Attribute to set",
679 | },
680 | },
681 | required: ["repo_path", "action"],
682 | },
683 | },
684 |
685 | // Blame Operations
686 | {
687 | name: "git_blame",
688 | description: "Get blame information for a file.",
689 | inputSchema: {
690 | type: "object",
691 | properties: {
692 | repo_path: {
693 | type: "string",
694 | description: "The path to the local Git repository",
695 | },
696 | file_path: {
697 | type: "string",
698 | description: "Path to the file",
699 | },
700 | rev: {
701 | type: "string",
702 | description: "Revision to blame (default: HEAD)",
703 | default: "HEAD",
704 | },
705 | },
706 | required: ["repo_path", "file_path"],
707 | },
708 | },
709 |
710 | // Clean Operations
711 | {
712 | name: "git_clean",
713 | description: "Perform git clean operations.",
714 | inputSchema: {
715 | type: "object",
716 | properties: {
717 | repo_path: {
718 | type: "string",
719 | description: "The path to the local Git repository",
720 | },
721 | directories: {
722 | type: "boolean",
723 | description: "Whether to remove directories as well",
724 | default: false,
725 | },
726 | force: {
727 | type: "boolean",
728 | description: "Whether to force clean",
729 | default: false,
730 | },
731 | dry_run: {
732 | type: "boolean",
733 | description: "Whether to perform a dry run",
734 | default: true,
735 | },
736 | },
737 | required: ["repo_path"],
738 | },
739 | },
740 |
741 | // Hooks Operations
742 | {
743 | name: "git_hooks",
744 | description: "Manage git hooks in the repository.",
745 | inputSchema: {
746 | type: "object",
747 | properties: {
748 | repo_path: {
749 | type: "string",
750 | description: "The path to the local Git repository",
751 | },
752 | action: {
753 | type: "string",
754 | description: "Hook action (list, get, create, enable, disable)",
755 | default: "list",
756 | enum: ["list", "get", "create", "enable", "disable"],
757 | },
758 | hook_name: {
759 | type: "string",
760 | description:
761 | "Name of the hook (e.g., 'pre-commit', 'post-merge')",
762 | },
763 | script: {
764 | type: "string",
765 | description: "Script content for the hook (for create action)",
766 | },
767 | },
768 | required: ["repo_path", "action"],
769 | },
770 | },
771 |
772 | // LFS Operations
773 | {
774 | name: "git_lfs",
775 | description: "Manage Git LFS (Large File Storage).",
776 | inputSchema: {
777 | type: "object",
778 | properties: {
779 | repo_path: {
780 | type: "string",
781 | description: "The path to the local Git repository",
782 | },
783 | action: {
784 | type: "string",
785 | description: "LFS action (install, track, untrack, list)",
786 | default: "list",
787 | enum: ["install", "track", "untrack", "list"],
788 | },
789 | patterns: {
790 | type: "array",
791 | description: "File patterns for track/untrack",
792 | items: { type: "string" },
793 | },
794 | },
795 | required: ["repo_path", "action"],
796 | },
797 | },
798 |
799 | // LFS Fetch Operations
800 | {
801 | name: "git_lfs_fetch",
802 | description: "Fetch LFS objects from the remote repository.",
803 | inputSchema: {
804 | type: "object",
805 | properties: {
806 | repo_path: {
807 | type: "string",
808 | description: "The path to the local Git repository",
809 | },
810 | dry_run: {
811 | type: "boolean",
812 | description: "Whether to perform a dry run",
813 | default: false,
814 | },
815 | pointers: {
816 | type: "boolean",
817 | description: "Whether to convert pointers to objects",
818 | default: false,
819 | },
820 | },
821 | required: ["repo_path"],
822 | },
823 | },
824 |
825 | // Revert Operations
826 | {
827 | name: "git_revert",
828 | description: "Revert the current branch to a commit or state.",
829 | inputSchema: {
830 | type: "object",
831 | properties: {
832 | repo_path: {
833 | type: "string",
834 | description: "The path to the local Git repository",
835 | },
836 | commit: {
837 | type: "string",
838 | description: "Commit hash or reference to revert",
839 | },
840 | no_commit: {
841 | type: "boolean",
842 | description: "Whether to stage changes without committing",
843 | default: false,
844 | },
845 | },
846 | required: ["repo_path"],
847 | },
848 | },
849 | ];
850 |
851 | // Set up dynamic tool listing handler
852 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
853 | tools: this.toolsList,
854 | }));
855 |
856 | // Handler categories for organization and improved discoverability
857 | this.handlerCategories = {
858 | read: [
859 | "git_directory_structure",
860 | "git_read_files",
861 | "git_branch_diff",
862 | "git_commit_history",
863 | "git_commits_details",
864 | "git_local_changes",
865 | "git_search_code",
866 | ],
867 | write: ["git_commit", "git_track", "git_reset"],
868 | branch: [
869 | "git_checkout_branch",
870 | "git_delete_branch",
871 | "git_merge_branch",
872 | "git_branch_diff",
873 | ],
874 | remote: ["git_push", "git_pull"],
875 | stash: ["git_stash"],
876 | config: ["git_config"],
877 | tag: ["git_create_tag"],
878 | advanced: ["git_rebase"],
879 | };
880 |
881 | // Create handler aliases for improved usability
882 | this.handlerAliases = {
883 | git_ls: "git_directory_structure",
884 | git_show: "git_read_files",
885 | git_diff: "git_branch_diff",
886 | git_log: "git_commit_history",
887 | git_status: "git_local_changes",
888 | git_grep: "git_search_code",
889 | git_add: "git_track",
890 | git_checkout: "git_checkout_branch",
891 | git_fetch: "git_pull",
892 | };
893 |
894 | // Initialize statistics tracking
895 | this.handlerStats = new Map();
896 |
897 | // Create a handlers mapping for O(1) lookup time
898 | this.handlersMap = {
899 | // Primary handlers
900 | git_directory_structure: handleGitDirectoryStructure,
901 | git_read_files: handleGitReadFiles,
902 | git_branch_diff: handleGitBranchDiff,
903 | git_commit_history: handleGitCommitHistory,
904 | git_commits_details: handleGitCommitsDetails,
905 | git_local_changes: handleGitLocalChanges,
906 | git_search_code: handleGitSearchCode,
907 | git_commit: handleGitCommit,
908 | git_track: handleGitTrack,
909 | git_checkout_branch: handleGitCheckoutBranch,
910 | git_delete_branch: handleGitDeleteBranch,
911 | git_merge_branch: handleGitMergeBranch,
912 | git_push: handleGitPush,
913 | git_pull: handleGitPull,
914 | git_stash: handleGitStash,
915 | git_create_tag: handleGitCreateTag,
916 | git_rebase: handleGitRebase,
917 | git_config: handleGitConfig,
918 | git_reset: handleGitReset,
919 | git_archive: handleGitArchive,
920 | git_attributes: handleGitAttributes,
921 | git_blame: handleGitBlame,
922 | git_clean: handleGitClean,
923 | git_hooks: handleGitHooks,
924 | git_lfs: handleGitLFS,
925 | git_lfs_fetch: handleGitLFSFetch,
926 | git_revert: handleGitRevert,
927 | };
928 |
929 | // Register aliases for O(1) lookup
930 | Object.entries(this.handlerAliases).forEach(([alias, target]) => {
931 | if (this.handlersMap[target]) {
932 | this.handlersMap[alias] = this.handlersMap[target];
933 | }
934 | });
935 |
936 | // Log registered handlers
937 | console.error(
938 | `[INFO] Registered ${
939 | Object.keys(this.handlersMap).length
940 | } Git tool handlers`
941 | );
942 |
943 | // Add method to get handlers by category
944 | this.getHandlersByCategory = (category) => {
945 | return this.handlerCategories[category] || [];
946 | };
947 |
948 | // Add method to execute multiple Git operations in sequence
949 | this.executeSequence = async (operations) => {
950 | const results = [];
951 | for (const op of operations) {
952 | const { name, arguments: args } = op;
953 | const handler = this.handlersMap[name];
954 | if (!handler) {
955 | throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
956 | }
957 | results.push(await handler(args));
958 | }
959 | return results;
960 | };
961 |
962 | // Add method to check if a repository is valid
963 | this.validateRepository = async (repoPath) => {
964 | try {
965 | // Implementation would verify if the path is a valid git repository
966 | return true;
967 | } catch (error) {
968 | return false;
969 | }
970 | };
971 |
972 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
973 | const { name, arguments: args } = request.params;
974 | const startTime = Date.now();
975 |
976 | // Handle batch operations as a special case
977 | if (name === "git_batch") {
978 | if (!Array.isArray(args.operations)) {
979 | throw new McpError(
980 | ErrorCode.InvalidParams,
981 | "Operations must be an array"
982 | );
983 | }
984 | return await this.executeSequence(args.operations);
985 | }
986 |
987 | try {
988 | // Resolve handler via direct match or alias
989 | const handler = this.handlersMap[name];
990 | if (handler) {
991 | // Track usage statistics
992 | const stats = this.handlerStats.get(name) || {
993 | count: 0,
994 | totalTime: 0,
995 | };
996 | stats.count++;
997 | this.handlerStats.set(name, stats);
998 |
999 | console.error(`[INFO] Executing Git tool: ${name}`);
1000 | const result = await handler(args);
1001 |
1002 | const executionTime = Date.now() - startTime;
1003 | stats.totalTime += executionTime;
1004 | console.error(`[INFO] Completed ${name} in ${executionTime}ms`);
1005 |
1006 | return result;
1007 | }
1008 |
1009 | // Suggest similar commands if not found
1010 | const similarCommands = Object.keys(this.handlersMap)
1011 | .filter((cmd) => cmd.includes(name.replace(/^git_/, "")))
1012 | .slice(0, 3);
1013 |
1014 | const suggestion =
1015 | similarCommands.length > 0
1016 | ? `. Did you mean: ${similarCommands.join(", ")}?`
1017 | : "";
1018 |
1019 | throw new McpError(
1020 | ErrorCode.MethodNotFound,
1021 | `Unknown tool: ${name}${suggestion}`
1022 | );
1023 | } catch (error) {
1024 | // Enhanced error handling
1025 | if (error instanceof McpError) {
1026 | throw error;
1027 | }
1028 | console.error(`[ERROR] Failed to execute ${name}: ${error.message}`);
1029 | throw new McpError(
1030 | ErrorCode.InternalError,
1031 | `Failed to execute ${name}: ${error.message}`
1032 | );
1033 | }
1034 | });
1035 |
1036 | /**
1037 | * Register a new handler at runtime
1038 | * @param {string} name - The name of the handler
1039 | * @param {Function} handler - The handler function
1040 | * @param {Object} [toolInfo] - Optional tool information for ListToolsRequestSchema
1041 | * @returns {boolean} True if registration was successful
1042 | */
1043 | this.registerHandler = (name, handler, toolInfo) => {
1044 | if (typeof handler !== "function") {
1045 | throw new Error(`Handler for ${name} must be a function`);
1046 | }
1047 |
1048 | // Add to handlers map
1049 | this.handlersMap[name] = handler;
1050 |
1051 | // Update tools list if toolInfo is provided
1052 | if (toolInfo && typeof toolInfo === "object") {
1053 | // Get current tools
1054 | const currentTools = this.toolsList || [];
1055 |
1056 | // Add new tool info if not already present
1057 | const exists = currentTools.some((tool) => tool.name === name);
1058 | if (!exists) {
1059 | this.toolsList = [...currentTools, { name, ...toolInfo }];
1060 | }
1061 | }
1062 |
1063 | console.error(`[INFO] Dynamically registered new handler: ${name}`);
1064 | return true;
1065 | };
1066 |
1067 | /**
1068 | * Remove a handler
1069 | * @param {string} name - The name of the handler to remove
1070 | * @returns {boolean} True if removal was successful
1071 | */
1072 | this.unregisterHandler = (name) => {
1073 | if (!this.handlersMap[name]) {
1074 | return false;
1075 | }
1076 |
1077 | delete this.handlersMap[name];
1078 | console.error(`[INFO] Unregistered handler: ${name}`);
1079 | return true;
1080 | };
1081 | }
1082 |
1083 | /**
1084 | * Start the server
1085 | */
1086 | async run() {
1087 | const transport = new StdioServerTransport();
1088 | await this.server.connect(transport);
1089 | console.error("Git Repo Browser MCP server running on stdio");
1090 | }
1091 | }
1092 |
```