This is page 1 of 2. Use http://codebase.md/sheshiyer/git-mcp-v2?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── jest.config.js
├── package.json
├── README.md
├── src
│ ├── caching
│ │ ├── cache.ts
│ │ └── repository-cache.ts
│ ├── common
│ │ └── command-builder.ts
│ ├── errors
│ │ ├── error-handler.ts
│ │ └── error-types.ts
│ ├── git-operations.ts
│ ├── index.ts
│ ├── monitoring
│ │ ├── performance.ts
│ │ └── types.ts
│ ├── operations
│ │ ├── base
│ │ │ ├── base-operation.ts
│ │ │ └── operation-result.ts
│ │ ├── branch
│ │ │ ├── branch-operations.ts
│ │ │ └── branch-types.ts
│ │ ├── remote
│ │ │ ├── remote-operations.ts
│ │ │ └── remote-types.ts
│ │ ├── repository
│ │ │ └── repository-operations.ts
│ │ ├── sync
│ │ │ ├── sync-operations.ts
│ │ │ └── sync-types.ts
│ │ ├── tag
│ │ │ ├── tag-operations.ts
│ │ │ └── tag-types.ts
│ │ └── working-tree
│ │ ├── working-tree-operations.ts
│ │ └── working-tree-types.ts
│ ├── tool-handler.ts
│ ├── types.ts
│ └── utils
│ ├── command.ts
│ ├── logger.ts
│ ├── path.ts
│ ├── paths.ts
│ └── repository.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependencies
2 | node_modules/
3 | package-lock.json
4 |
5 | # Build output
6 | build/
7 | dist/
8 | *.tsbuildinfo
9 |
10 | # Environment variables
11 | .env
12 | .env.local
13 | .env.*.local
14 |
15 | # IDE
16 | .vscode/
17 | .idea/
18 | *.swp
19 | *.swo
20 |
21 | # Logs
22 | logs/
23 | *.log
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # System files
29 | .DS_Store
30 | Thumbs.db
31 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Git MCP Server
2 |
3 | A Model Context Protocol (MCP) server that provides enhanced Git operations through a standardized interface. This server integrates with the MCP ecosystem to provide Git functionality to AI assistants.
4 |
5 | ## Features
6 |
7 | - **Core Git Operations**: init, clone, status, add, commit, push, pull
8 | - **Branch Management**: list, create, delete, checkout
9 | - **Tag Operations**: list, create, delete
10 | - **Remote Management**: list, add, remove
11 | - **Stash Operations**: list, save, pop
12 | - **Bulk Actions**: Execute multiple Git operations in sequence
13 | - **GitHub Integration**: Built-in GitHub support via Personal Access Token
14 | - **Path Resolution**: Smart path handling with optional default path configuration
15 | - **Error Handling**: Comprehensive error handling with custom error types
16 | - **Repository Caching**: Efficient repository state management
17 | - **Performance Monitoring**: Built-in performance tracking
18 |
19 | ## Installation
20 |
21 | 1. Clone the repository:
22 | ```bash
23 | git clone https://github.com/yourusername/git-mcp-v2.git
24 | cd git-mcp-v2
25 | ```
26 |
27 | 2. Install dependencies:
28 | ```bash
29 | npm install
30 | ```
31 |
32 | 3. Build the project:
33 | ```bash
34 | npm run build
35 | ```
36 |
37 | ## Configuration
38 |
39 | Add to your MCP settings file:
40 |
41 | ```json
42 | {
43 | "mcpServers": {
44 | "git-v2": {
45 | "command": "node",
46 | "args": ["path/to/git-mcp-v2/build/index.js"],
47 | "env": {
48 | "GIT_DEFAULT_PATH": "/path/to/default/git/directory",
49 | "GITHUB_PERSONAL_ACCESS_TOKEN": "your-github-pat"
50 | }
51 | }
52 | }
53 | }
54 | ```
55 |
56 | ## Environment Variables
57 |
58 | - `GIT_DEFAULT_PATH`: (Optional) Default path for Git operations
59 | - `GITHUB_PERSONAL_ACCESS_TOKEN`: (Optional) GitHub Personal Access Token for GitHub operations
60 |
61 | ## Available Tools
62 |
63 | ### Basic Operations
64 | - `init`: Initialize a new Git repository
65 | - `clone`: Clone a repository
66 | - `status`: Get repository status
67 | - `add`: Stage files
68 | - `commit`: Create a commit
69 | - `push`: Push commits to remote
70 | - `pull`: Pull changes from remote
71 |
72 | ### Branch Operations
73 | - `branch_list`: List all branches
74 | - `branch_create`: Create a new branch
75 | - `branch_delete`: Delete a branch
76 | - `checkout`: Switch branches or restore working tree files
77 |
78 | ### Tag Operations
79 | - `tag_list`: List tags
80 | - `tag_create`: Create a tag
81 | - `tag_delete`: Delete a tag
82 |
83 | ### Remote Operations
84 | - `remote_list`: List remotes
85 | - `remote_add`: Add a remote
86 | - `remote_remove`: Remove a remote
87 |
88 | ### Stash Operations
89 | - `stash_list`: List stashes
90 | - `stash_save`: Save changes to stash
91 | - `stash_pop`: Apply and remove a stash
92 |
93 | ### Bulk Operations
94 | - `bulk_action`: Execute multiple Git operations in sequence
95 |
96 | ## Development
97 |
98 | ```bash
99 | # Run tests
100 | npm test
101 |
102 | # Run tests with coverage
103 | npm run test:coverage
104 |
105 | # Run linter
106 | npm run lint
107 |
108 | # Format code
109 | npm run format
110 | ```
111 |
112 | ## License
113 |
114 | MIT
115 |
116 | ## Contributing
117 |
118 | 1. Fork the repository
119 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
120 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
121 | 4. Push to the branch (`git push origin feature/amazing-feature`)
122 | 5. Open a Pull Request
123 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true
12 | },
13 | "include": ["src/**/*"],
14 | "exclude": ["node_modules"]
15 | }
16 |
```
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
```javascript
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | export default {
3 | preset: 'ts-jest',
4 | testEnvironment: 'node',
5 | extensionsToTreatAsEsm: ['.ts'],
6 | moduleNameMapper: {
7 | '^(\\.{1,2}/.*)\\.js$': '$1',
8 | },
9 | transform: {
10 | '^.+\\.tsx?$': [
11 | 'ts-jest',
12 | {
13 | useESM: true,
14 | },
15 | ],
16 | },
17 | coverageDirectory: 'coverage',
18 | collectCoverageFrom: [
19 | 'src/**/*.ts',
20 | '!src/**/*.d.ts',
21 | '!src/**/index.ts',
22 | '!src/**/*.types.ts'
23 | ],
24 | coverageThreshold: {
25 | global: {
26 | branches: 80,
27 | functions: 80,
28 | lines: 80,
29 | statements: 80
30 | }
31 | },
32 | testMatch: [
33 | '<rootDir>/tests/**/*.test.ts'
34 | ],
35 | setupFilesAfterEnv: [
36 | '<rootDir>/tests/setup.ts'
37 | ],
38 | testPathIgnorePatterns: [
39 | '/node_modules/',
40 | '/build/'
41 | ],
42 | verbose: true,
43 | testTimeout: 10000
44 | };
45 |
```
--------------------------------------------------------------------------------
/src/monitoring/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GitMcpError } from '../errors/error-types.js';
2 | import { ErrorCategory, ErrorSeverity } from '../errors/error-types.js';
3 | import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
4 |
5 | /**
6 | * Performance monitoring types
7 | */
8 |
9 | /**
10 | * Performance error context
11 | */
12 | export interface PerformanceErrorContext {
13 | currentUsage?: number;
14 | threshold?: number;
15 | operation?: string;
16 | details?: Record<string, any>;
17 | [key: string]: unknown; // Index signature for additional properties
18 | }
19 |
20 | /**
21 | * Performance error with context
22 | */
23 | export class PerformanceError extends GitMcpError {
24 | constructor(
25 | message: string,
26 | context: PerformanceErrorContext
27 | ) {
28 | super(
29 | ErrorCode.InternalError,
30 | message,
31 | ErrorSeverity.HIGH,
32 | ErrorCategory.SYSTEM,
33 | {
34 | operation: context.operation || 'performance',
35 | timestamp: Date.now(),
36 | severity: ErrorSeverity.HIGH,
37 | category: ErrorCategory.SYSTEM,
38 | details: context
39 | }
40 | );
41 | this.name = 'PerformanceError';
42 | }
43 | }
44 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "git-mcp-server",
3 | "version": "1.0.0",
4 | "description": "A Model Context Protocol server",
5 | "private": true,
6 | "type": "module",
7 | "bin": {
8 | "git-mcp-server": "./build/index.js"
9 | },
10 | "files": [
11 | "build"
12 | ],
13 | "scripts": {
14 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
15 | "prepare": "npm run build",
16 | "watch": "tsc --watch",
17 | "inspector": "npx @modelcontextprotocol/inspector build/index.js",
18 | "test": "jest",
19 | "test:watch": "jest --watch",
20 | "test:coverage": "jest --coverage",
21 | "lint": "eslint src --ext .ts",
22 | "format": "prettier --write \"src/**/*.ts\"",
23 | "clean": "rimraf build coverage"
24 | },
25 | "dependencies": {
26 | "@modelcontextprotocol/sdk": "1.0.4",
27 | "simple-git": "^3.27.0"
28 | },
29 | "devDependencies": {
30 | "@types/jest": "^29.5.11",
31 | "@types/node": "^20.17.10",
32 | "@typescript-eslint/eslint-plugin": "^6.19.0",
33 | "@typescript-eslint/parser": "^6.19.0",
34 | "eslint": "^8.56.0",
35 | "eslint-config-prettier": "^9.1.0",
36 | "eslint-plugin-jest": "^27.6.3",
37 | "jest": "^29.7.0",
38 | "prettier": "^3.2.4",
39 | "rimraf": "^5.0.5",
40 | "ts-jest": "^29.1.1",
41 | "typescript": "^5.3.3"
42 | }
43 | }
44 |
```
--------------------------------------------------------------------------------
/src/operations/base/operation-result.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GitToolContent } from '../../types.js';
2 | import { GitMcpError } from '../../errors/error-types.js';
3 |
4 | /**
5 | * Represents the result of a Git operation with proper type safety
6 | */
7 | export interface GitOperationResult<T = void> {
8 | /** Whether the operation was successful */
9 | success: boolean;
10 |
11 | /** Operation-specific data if successful */
12 | data?: T;
13 |
14 | /** Error information if operation failed */
15 | error?: GitMcpError;
16 |
17 | /** Standard MCP tool response content */
18 | content: GitToolContent[];
19 |
20 | /** Additional metadata about the operation */
21 | meta?: Record<string, unknown>;
22 | }
23 |
24 | /**
25 | * Base interface for all Git operation options
26 | */
27 | export interface GitOperationOptions {
28 | /** Operation path override */
29 | path?: string;
30 |
31 | /** Whether to use caching */
32 | useCache?: boolean;
33 |
34 | /** Whether to invalidate cache after operation */
35 | invalidateCache?: boolean;
36 | }
37 |
38 | /**
39 | * Common result types for Git operations
40 | */
41 | import { CommandResult as BaseCommandResult } from '../../utils/command.js';
42 |
43 | export interface CommandResult extends BaseCommandResult {
44 | // Extend the base command result with any additional fields we need
45 | }
46 |
47 | export interface ListResult {
48 | items: string[];
49 | raw: string;
50 | }
51 |
52 | export interface StatusResult {
53 | staged: string[];
54 | unstaged: string[];
55 | untracked: string[];
56 | raw: string;
57 | }
58 |
59 | export interface BranchResult {
60 | current: string;
61 | branches: string[];
62 | raw: string;
63 | }
64 |
65 | export interface TagResult {
66 | tags: string[];
67 | raw: string;
68 | }
69 |
70 | export interface RemoteResult {
71 | remotes: Array<{
72 | name: string;
73 | url: string;
74 | purpose: 'fetch' | 'push';
75 | }>;
76 | raw: string;
77 | }
78 |
79 | export interface StashResult {
80 | stashes: Array<{
81 | index: number;
82 | message: string;
83 | }>;
84 | raw: string;
85 | }
86 |
```
--------------------------------------------------------------------------------
/src/operations/tag/tag-types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GitOperationOptions } from '../base/operation-result.js';
2 |
3 | /**
4 | * Options for listing tags
5 | */
6 | export interface TagListOptions extends GitOperationOptions {
7 | /** Show tag message */
8 | showMessage?: boolean;
9 | /** Sort tags by specific key */
10 | sort?: 'version' | 'creatordate' | 'taggerdate';
11 | /** Show only tags containing the specified commit */
12 | contains?: string;
13 | /** Match tags with pattern */
14 | pattern?: string;
15 | }
16 |
17 | /**
18 | * Options for creating tags
19 | */
20 | export interface TagCreateOptions extends GitOperationOptions {
21 | /** Name of the tag to create */
22 | name: string;
23 | /** Tag message (creates annotated tag) */
24 | message?: string;
25 | /** Whether to force create even if tag exists */
26 | force?: boolean;
27 | /** Create a signed tag */
28 | sign?: boolean;
29 | /** Specific commit to tag */
30 | commit?: string;
31 | }
32 |
33 | /**
34 | * Options for deleting tags
35 | */
36 | export interface TagDeleteOptions extends GitOperationOptions {
37 | /** Name of the tag to delete */
38 | name: string;
39 | /** Whether to force delete */
40 | force?: boolean;
41 | /** Also delete the tag from remotes */
42 | remote?: boolean;
43 | }
44 |
45 | /**
46 | * Structured tag information
47 | */
48 | export interface TagInfo {
49 | /** Tag name */
50 | name: string;
51 | /** Whether this is an annotated tag */
52 | annotated: boolean;
53 | /** Tag message if annotated */
54 | message?: string;
55 | /** Tagger information if annotated */
56 | tagger?: {
57 | name: string;
58 | email: string;
59 | date: string;
60 | };
61 | /** Commit that is tagged */
62 | commit: string;
63 | /** Whether this is a signed tag */
64 | signed: boolean;
65 | }
66 |
67 | /**
68 | * Result of tag listing operation
69 | */
70 | export interface TagListResult {
71 | /** List of all tags */
72 | tags: TagInfo[];
73 | /** Raw command output */
74 | raw: string;
75 | }
76 |
77 | /**
78 | * Result of tag creation operation
79 | */
80 | export interface TagCreateResult {
81 | /** Name of created tag */
82 | name: string;
83 | /** Whether it's an annotated tag */
84 | annotated: boolean;
85 | /** Whether it's signed */
86 | signed: boolean;
87 | /** Tagged commit */
88 | commit?: string;
89 | /** Raw command output */
90 | raw: string;
91 | }
92 |
93 | /**
94 | * Result of tag deletion operation
95 | */
96 | export interface TagDeleteResult {
97 | /** Name of deleted tag */
98 | name: string;
99 | /** Whether it was force deleted */
100 | forced: boolean;
101 | /** Raw command output */
102 | raw: string;
103 | }
104 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
5 | import { ToolHandler } from './tool-handler.js';
6 | import { logger } from './utils/logger.js';
7 | import { CommandExecutor } from './utils/command.js';
8 | import { PathResolver } from './utils/paths.js';
9 |
10 | async function validateDefaultPath(): Promise<void> {
11 | const defaultPath = process.env.GIT_DEFAULT_PATH;
12 | if (!defaultPath) {
13 | logger.warn('startup', 'GIT_DEFAULT_PATH not set - absolute paths will be required for all operations');
14 | return;
15 | }
16 |
17 | try {
18 | // Validate the default path exists and is accessible
19 | PathResolver.validatePath(defaultPath, 'startup', {
20 | mustExist: true,
21 | mustBeDirectory: true,
22 | createIfMissing: true
23 | });
24 | logger.info('startup', 'Default git path validated', defaultPath);
25 | } catch (error) {
26 | logger.error('startup', 'Invalid GIT_DEFAULT_PATH', defaultPath, error as Error);
27 | throw new McpError(
28 | ErrorCode.InternalError,
29 | `Invalid GIT_DEFAULT_PATH: ${(error as Error).message}`
30 | );
31 | }
32 | }
33 |
34 | async function main() {
35 | try {
36 | // Validate git installation first
37 | await CommandExecutor.validateGitInstallation('startup');
38 | logger.info('startup', 'Git installation validated');
39 |
40 | // Validate default path if provided
41 | await validateDefaultPath();
42 |
43 | // Create and configure server
44 | const server = new Server(
45 | {
46 | name: 'git-mcp-server',
47 | version: '1.0.0',
48 | },
49 | {
50 | capabilities: {
51 | tools: {},
52 | },
53 | }
54 | );
55 |
56 | // Set up error handling
57 | server.onerror = (error) => {
58 | if (error instanceof McpError) {
59 | logger.error('server', error.message, undefined, error);
60 | } else {
61 | logger.error('server', 'Unexpected error', undefined, error as Error);
62 | }
63 | };
64 |
65 | // Initialize tool handler
66 | new ToolHandler(server);
67 |
68 | // Connect server
69 | const transport = new StdioServerTransport();
70 | await server.connect(transport);
71 | logger.info('server', 'Git MCP server running on stdio');
72 |
73 | // Handle shutdown
74 | process.on('SIGINT', async () => {
75 | logger.info('server', 'Shutting down server');
76 | await server.close();
77 | process.exit(0);
78 | });
79 |
80 | } catch (error) {
81 | logger.error('startup', 'Failed to start server', undefined, error as Error);
82 | process.exit(1);
83 | }
84 | }
85 |
86 | main().catch((error) => {
87 | console.error('Fatal error:', error);
88 | process.exit(1);
89 | });
90 |
```
--------------------------------------------------------------------------------
/src/operations/remote/remote-types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GitOperationOptions } from '../base/operation-result.js';
2 |
3 | /**
4 | * Options for listing remotes
5 | */
6 | export interface RemoteListOptions extends GitOperationOptions {
7 | /** Show remote URLs */
8 | verbose?: boolean;
9 | }
10 |
11 | /**
12 | * Options for adding remotes
13 | */
14 | export interface RemoteAddOptions extends GitOperationOptions {
15 | /** Name of the remote */
16 | name: string;
17 | /** URL of the remote */
18 | url: string;
19 | /** Whether to fetch immediately */
20 | fetch?: boolean;
21 | /** Tags to fetch (--tags, --no-tags) */
22 | tags?: boolean;
23 | /** Mirror mode (--mirror=fetch or --mirror=push) */
24 | mirror?: 'fetch' | 'push';
25 | }
26 |
27 | /**
28 | * Options for removing remotes
29 | */
30 | export interface RemoteRemoveOptions extends GitOperationOptions {
31 | /** Name of the remote */
32 | name: string;
33 | }
34 |
35 | /**
36 | * Options for updating remote URLs
37 | */
38 | export interface RemoteSetUrlOptions extends GitOperationOptions {
39 | /** Name of the remote */
40 | name: string;
41 | /** New URL for the remote */
42 | url: string;
43 | /** Whether this is a push URL */
44 | pushUrl?: boolean;
45 | /** Add URL instead of changing existing URLs */
46 | add?: boolean;
47 | /** Delete URL instead of changing it */
48 | delete?: boolean;
49 | }
50 |
51 | /**
52 | * Options for pruning remotes
53 | */
54 | export interface RemotePruneOptions extends GitOperationOptions {
55 | /** Name of the remote */
56 | name: string;
57 | /** Whether to show what would be done */
58 | dryRun?: boolean;
59 | }
60 |
61 | /**
62 | * Represents a remote configuration
63 | */
64 | export interface RemoteConfig {
65 | /** Remote name */
66 | name: string;
67 | /** Fetch URL */
68 | fetchUrl: string;
69 | /** Push URL (if different from fetch) */
70 | pushUrl?: string;
71 | /** Remote branches tracked */
72 | branches?: string[];
73 | /** Whether tags are fetched */
74 | fetchTags?: boolean;
75 | /** Mirror configuration */
76 | mirror?: 'fetch' | 'push';
77 | }
78 |
79 | /**
80 | * Result of remote listing operation
81 | */
82 | export interface RemoteListResult {
83 | /** List of remotes */
84 | remotes: RemoteConfig[];
85 | /** Raw command output */
86 | raw: string;
87 | }
88 |
89 | /**
90 | * Result of remote add operation
91 | */
92 | export interface RemoteAddResult {
93 | /** Added remote configuration */
94 | remote: RemoteConfig;
95 | /** Raw command output */
96 | raw: string;
97 | }
98 |
99 | /**
100 | * Result of remote remove operation
101 | */
102 | export interface RemoteRemoveResult {
103 | /** Name of removed remote */
104 | name: string;
105 | /** Raw command output */
106 | raw: string;
107 | }
108 |
109 | /**
110 | * Result of remote set-url operation
111 | */
112 | export interface RemoteSetUrlResult {
113 | /** Updated remote configuration */
114 | remote: RemoteConfig;
115 | /** Raw command output */
116 | raw: string;
117 | }
118 |
119 | /**
120 | * Result of remote prune operation
121 | */
122 | export interface RemotePruneResult {
123 | /** Name of pruned remote */
124 | name: string;
125 | /** Branches that were pruned */
126 | prunedBranches: string[];
127 | /** Raw command output */
128 | raw: string;
129 | }
130 |
```
--------------------------------------------------------------------------------
/src/operations/working-tree/working-tree-types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GitOperationOptions } from '../base/operation-result.js';
2 |
3 | /**
4 | * Options for adding files to staging
5 | */
6 | export interface AddOptions extends GitOperationOptions {
7 | /** Files to stage */
8 | files: string[];
9 | /** Whether to add all files (including untracked) */
10 | all?: boolean;
11 | /** Whether to add only updates to already tracked files */
12 | update?: boolean;
13 | /** Whether to ignore removal of files */
14 | ignoreRemoval?: boolean;
15 | /** Whether to add files with errors */
16 | force?: boolean;
17 | /** Whether to only show what would be added */
18 | dryRun?: boolean;
19 | }
20 |
21 | /**
22 | * Options for committing changes
23 | */
24 | export interface CommitOptions extends GitOperationOptions {
25 | /** Commit message */
26 | message: string;
27 | /** Whether to allow empty commits */
28 | allowEmpty?: boolean;
29 | /** Whether to amend the previous commit */
30 | amend?: boolean;
31 | /** Whether to skip pre-commit hooks */
32 | noVerify?: boolean;
33 | /** Author of the commit (in format: "Name <email>") */
34 | author?: string;
35 | /** Files to commit (if not specified, commits all staged changes) */
36 | files?: string[];
37 | }
38 |
39 | /**
40 | * Options for checking status
41 | */
42 | export interface StatusOptions extends GitOperationOptions {
43 | /** Whether to show untracked files */
44 | showUntracked?: boolean;
45 | /** Whether to ignore submodules */
46 | ignoreSubmodules?: boolean;
47 | /** Whether to show ignored files */
48 | showIgnored?: boolean;
49 | /** Whether to show branch info */
50 | showBranch?: boolean;
51 | }
52 |
53 | /**
54 | * Represents a file change in the working tree
55 | */
56 | export interface FileChange {
57 | /** Path of the file */
58 | path: string;
59 | /** Type of change */
60 | type: 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'untracked' | 'ignored';
61 | /** Original path for renamed files */
62 | originalPath?: string;
63 | /** Whether the change is staged */
64 | staged: boolean;
65 | /** Raw status code from Git */
66 | raw: string;
67 | }
68 |
69 | /**
70 | * Result of add operation
71 | */
72 | export interface AddResult {
73 | /** Files that were staged */
74 | staged: string[];
75 | /** Files that were not staged (with reasons) */
76 | notStaged?: Array<{
77 | path: string;
78 | reason: string;
79 | }>;
80 | /** Raw command output */
81 | raw: string;
82 | }
83 |
84 | /**
85 | * Result of commit operation
86 | */
87 | export interface CommitResult {
88 | /** Commit hash */
89 | hash: string;
90 | /** Number of files changed */
91 | filesChanged: number;
92 | /** Number of insertions */
93 | insertions: number;
94 | /** Number of deletions */
95 | deletions: number;
96 | /** Whether it was an amend */
97 | amended: boolean;
98 | /** Raw command output */
99 | raw: string;
100 | }
101 |
102 | /**
103 | * Result of status operation
104 | */
105 | export interface StatusResult {
106 | /** Current branch name */
107 | branch: string;
108 | /** Whether the working tree is clean */
109 | clean: boolean;
110 | /** Staged changes */
111 | staged: FileChange[];
112 | /** Unstaged changes */
113 | unstaged: FileChange[];
114 | /** Untracked files */
115 | untracked: FileChange[];
116 | /** Ignored files (if requested) */
117 | ignored?: FileChange[];
118 | /** Raw command output */
119 | raw: string;
120 | }
121 |
```
--------------------------------------------------------------------------------
/src/operations/branch/branch-types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GitOperationOptions } from '../base/operation-result.js';
2 |
3 | /**
4 | * Options for listing branches
5 | */
6 | export interface BranchListOptions extends GitOperationOptions {
7 | /** Show remote branches */
8 | remotes?: boolean;
9 | /** Show all branches (local and remote) */
10 | all?: boolean;
11 | /** Show only branches containing the specified commit */
12 | contains?: string;
13 | /** Show only branches merged into the specified commit */
14 | merged?: string;
15 | /** Show only branches not merged into the specified commit */
16 | noMerged?: string;
17 | }
18 |
19 | /**
20 | * Options for creating branches
21 | */
22 | export interface BranchCreateOptions extends GitOperationOptions {
23 | /** Name of the branch to create */
24 | name: string;
25 | /** Whether to force create even if branch exists */
26 | force?: boolean;
27 | /** Set up tracking mode (true = --track, false = --no-track) */
28 | track?: boolean;
29 | /** Set upstream for push/pull */
30 | setUpstream?: boolean;
31 | /** Start point (commit/branch) for the new branch */
32 | startPoint?: string;
33 | }
34 |
35 | /**
36 | * Options for deleting branches
37 | */
38 | export interface BranchDeleteOptions extends GitOperationOptions {
39 | /** Name of the branch to delete */
40 | name: string;
41 | /** Whether to force delete even if not merged */
42 | force?: boolean;
43 | /** Also delete the branch from remotes */
44 | remote?: boolean;
45 | }
46 |
47 | /**
48 | * Options for checking out branches
49 | */
50 | export interface CheckoutOptions extends GitOperationOptions {
51 | /** Branch/commit/tag to check out */
52 | target: string;
53 | /** Whether to force checkout even with local changes */
54 | force?: boolean;
55 | /** Create a new branch and check it out */
56 | newBranch?: string;
57 | /** Track the remote branch */
58 | track?: boolean;
59 | }
60 |
61 | /**
62 | * Structured branch information
63 | */
64 | export interface BranchInfo {
65 | /** Branch name */
66 | name: string;
67 | /** Whether this is the current branch */
68 | current: boolean;
69 | /** Remote tracking branch if any */
70 | tracking?: string;
71 | /** Whether the branch is ahead/behind tracking branch */
72 | status?: {
73 | ahead: number;
74 | behind: number;
75 | };
76 | /** Whether this is a remote branch */
77 | remote: boolean;
78 | /** Latest commit hash */
79 | commit?: string;
80 | /** Latest commit message */
81 | message?: string;
82 | }
83 |
84 | /**
85 | * Result of branch listing operation
86 | */
87 | export interface BranchListResult {
88 | /** Current branch name */
89 | current: string;
90 | /** List of all branches */
91 | branches: BranchInfo[];
92 | /** Raw command output */
93 | raw: string;
94 | }
95 |
96 | /**
97 | * Result of branch creation operation
98 | */
99 | export interface BranchCreateResult {
100 | /** Name of created branch */
101 | name: string;
102 | /** Starting point of the branch */
103 | startPoint?: string;
104 | /** Whether tracking was set up */
105 | tracking?: string;
106 | /** Raw command output */
107 | raw: string;
108 | }
109 |
110 | /**
111 | * Result of branch deletion operation
112 | */
113 | export interface BranchDeleteResult {
114 | /** Name of deleted branch */
115 | name: string;
116 | /** Whether it was force deleted */
117 | forced: boolean;
118 | /** Raw command output */
119 | raw: string;
120 | }
121 |
122 | /**
123 | * Result of checkout operation
124 | */
125 | export interface CheckoutResult {
126 | /** Target that was checked out */
127 | target: string;
128 | /** Whether a new branch was created */
129 | newBranch?: string;
130 | /** Previous HEAD position */
131 | previousHead?: string;
132 | /** Raw command output */
133 | raw: string;
134 | }
135 |
```
--------------------------------------------------------------------------------
/src/operations/sync/sync-types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GitOperationOptions } from '../base/operation-result.js';
2 |
3 | /**
4 | * Options for push operations
5 | */
6 | export interface PushOptions extends GitOperationOptions {
7 | /** Remote to push to */
8 | remote?: string;
9 | /** Branch to push */
10 | branch: string;
11 | /** Whether to force push */
12 | force?: boolean;
13 | /** Whether to force push with lease */
14 | forceWithLease?: boolean;
15 | /** Whether to push all branches */
16 | all?: boolean;
17 | /** Whether to push tags */
18 | tags?: boolean;
19 | /** Whether to skip pre-push hooks */
20 | noVerify?: boolean;
21 | /** Whether to set upstream for branch */
22 | setUpstream?: boolean;
23 | /** Whether to delete remote branches that were deleted locally */
24 | prune?: boolean;
25 | }
26 |
27 | /**
28 | * Options for pull operations
29 | */
30 | export interface PullOptions extends GitOperationOptions {
31 | /** Remote to pull from */
32 | remote?: string;
33 | /** Branch to pull */
34 | branch: string;
35 | /** Whether to rebase instead of merge */
36 | rebase?: boolean;
37 | /** Whether to automatically stash/unstash changes */
38 | autoStash?: boolean;
39 | /** Whether to allow unrelated histories */
40 | allowUnrelated?: boolean;
41 | /** Whether to fast-forward only */
42 | ff?: 'only' | 'no' | true;
43 | /** Strategy to use when merging */
44 | strategy?: 'recursive' | 'resolve' | 'octopus' | 'ours' | 'subtree';
45 | /** Strategy options */
46 | strategyOption?: string[];
47 | }
48 |
49 | /**
50 | * Options for fetch operations
51 | */
52 | export interface FetchOptions extends GitOperationOptions {
53 | /** Remote to fetch from */
54 | remote?: string;
55 | /** Whether to fetch all remotes */
56 | all?: boolean;
57 | /** Whether to prune remote branches */
58 | prune?: boolean;
59 | /** Whether to prune tags */
60 | pruneTags?: boolean;
61 | /** Whether to fetch tags */
62 | tags?: boolean;
63 | /** Whether to fetch only tags */
64 | tagsOnly?: boolean;
65 | /** Whether to force fetch tags */
66 | forceTags?: boolean;
67 | /** Depth of history to fetch */
68 | depth?: number;
69 | /** Whether to update submodules */
70 | recurseSubmodules?: boolean | 'on-demand';
71 | /** Whether to show progress */
72 | progress?: boolean;
73 | }
74 |
75 | /**
76 | * Result of push operation
77 | */
78 | export interface PushResult {
79 | /** Remote that was pushed to */
80 | remote: string;
81 | /** Branch that was pushed */
82 | branch: string;
83 | /** Whether force push was used */
84 | forced: boolean;
85 | /** New remote ref */
86 | newRef?: string;
87 | /** Old remote ref */
88 | oldRef?: string;
89 | /** Summary of changes */
90 | summary: {
91 | created?: string[];
92 | deleted?: string[];
93 | updated?: string[];
94 | rejected?: string[];
95 | };
96 | /** Raw command output */
97 | raw: string;
98 | }
99 |
100 | /**
101 | * Result of pull operation
102 | */
103 | export interface PullResult {
104 | /** Remote that was pulled from */
105 | remote: string;
106 | /** Branch that was pulled */
107 | branch: string;
108 | /** Whether rebase was used */
109 | rebased: boolean;
110 | /** Files changed */
111 | filesChanged: number;
112 | /** Number of insertions */
113 | insertions: number;
114 | /** Number of deletions */
115 | deletions: number;
116 | /** Summary of changes */
117 | summary: {
118 | merged?: string[];
119 | conflicts?: string[];
120 | };
121 | /** Raw command output */
122 | raw: string;
123 | }
124 |
125 | /**
126 | * Result of fetch operation
127 | */
128 | export interface FetchResult {
129 | /** Remote that was fetched from */
130 | remote?: string;
131 | /** Summary of changes */
132 | summary: {
133 | branches?: Array<{
134 | name: string;
135 | oldRef?: string;
136 | newRef: string;
137 | }>;
138 | tags?: Array<{
139 | name: string;
140 | oldRef?: string;
141 | newRef: string;
142 | }>;
143 | pruned?: string[];
144 | };
145 | /** Raw command output */
146 | raw: string;
147 | }
148 |
```
--------------------------------------------------------------------------------
/src/operations/repository/repository-operations.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { BaseGitOperation } from '../base/base-operation.js';
2 | import { GitOperationOptions, CommandResult } from '../base/operation-result.js';
3 | import { GitCommandBuilder } from '../../common/command-builder.js';
4 | import { ErrorHandler } from '../../errors/error-handler.js';
5 | import { PathValidator } from '../../utils/path.js';
6 |
7 | /**
8 | * Options for repository initialization
9 | */
10 | export interface InitOptions extends GitOperationOptions {
11 | /** Whether to create a bare repository */
12 | bare?: boolean;
13 | /** Initial branch name */
14 | initialBranch?: string;
15 | }
16 |
17 | /**
18 | * Options for repository cloning
19 | */
20 | export interface CloneOptions extends GitOperationOptions {
21 | /** Repository URL to clone from */
22 | url: string;
23 | /** Whether to create a bare repository */
24 | bare?: boolean;
25 | /** Depth of history to clone */
26 | depth?: number;
27 | /** Branch to clone */
28 | branch?: string;
29 | }
30 |
31 | /**
32 | * Handles Git repository initialization
33 | */
34 | export class InitOperation extends BaseGitOperation<InitOptions> {
35 | protected buildCommand(): GitCommandBuilder {
36 | const command = GitCommandBuilder.init();
37 |
38 | if (this.options.bare) {
39 | command.flag('bare');
40 | }
41 |
42 | if (this.options.initialBranch) {
43 | command.option('initial-branch', this.options.initialBranch);
44 | }
45 |
46 | return command;
47 | }
48 |
49 | protected parseResult(result: CommandResult): void {
50 | // Init doesn't return any structured data
51 | }
52 |
53 | protected getCacheConfig() {
54 | return {
55 | command: 'init'
56 | };
57 | }
58 |
59 | protected validateOptions(): void {
60 | const path = this.options.path || process.env.GIT_DEFAULT_PATH;
61 | if (!path) {
62 | throw ErrorHandler.handleValidationError(
63 | new Error('Path must be provided when GIT_DEFAULT_PATH is not set'),
64 | { operation: this.context.operation }
65 | );
66 | }
67 |
68 | // Validate path exists or can be created
69 | PathValidator.validatePath(path, {
70 | mustExist: false,
71 | allowDirectory: true
72 | });
73 | }
74 | }
75 |
76 | /**
77 | * Handles Git repository cloning
78 | */
79 | export class CloneOperation extends BaseGitOperation<CloneOptions> {
80 | protected buildCommand(): GitCommandBuilder {
81 | const command = GitCommandBuilder.clone()
82 | .arg(this.options.url)
83 | .arg(this.options.path || '.');
84 |
85 | if (this.options.bare) {
86 | command.flag('bare');
87 | }
88 |
89 | if (this.options.depth) {
90 | command.option('depth', this.options.depth.toString());
91 | }
92 |
93 | if (this.options.branch) {
94 | command.option('branch', this.options.branch);
95 | }
96 |
97 | return command;
98 | }
99 |
100 | protected parseResult(result: CommandResult): void {
101 | // Clone doesn't return any structured data
102 | }
103 |
104 | protected getCacheConfig() {
105 | return {
106 | command: 'clone'
107 | };
108 | }
109 |
110 | protected validateOptions(): void {
111 | if (!this.options.url) {
112 | throw ErrorHandler.handleValidationError(
113 | new Error('URL is required for clone operation'),
114 | { operation: this.context.operation }
115 | );
116 | }
117 |
118 | const path = this.options.path || process.env.GIT_DEFAULT_PATH;
119 | if (!path) {
120 | throw ErrorHandler.handleValidationError(
121 | new Error('Path must be provided when GIT_DEFAULT_PATH is not set'),
122 | { operation: this.context.operation }
123 | );
124 | }
125 |
126 | // Validate path exists or can be created
127 | PathValidator.validatePath(path, {
128 | mustExist: false,
129 | allowDirectory: true
130 | });
131 |
132 | if (this.options.depth !== undefined && this.options.depth <= 0) {
133 | throw ErrorHandler.handleValidationError(
134 | new Error('Depth must be a positive number'),
135 | { operation: this.context.operation }
136 | );
137 | }
138 | }
139 | }
140 |
```
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpError } from '@modelcontextprotocol/sdk/types.js';
2 | import { resolve, relative } from 'path';
3 |
4 | export enum LogLevel {
5 | DEBUG = 'DEBUG',
6 | INFO = 'INFO',
7 | WARN = 'WARN',
8 | ERROR = 'ERROR'
9 | }
10 |
11 | interface LogEntry {
12 | timestamp: string;
13 | level: LogLevel;
14 | operation: string;
15 | message: string;
16 | path?: string;
17 | error?: Error;
18 | context?: Record<string, any>;
19 | }
20 |
21 | export class Logger {
22 | private static instance: Logger;
23 | private entries: LogEntry[] = [];
24 | private readonly cwd: string;
25 |
26 | private constructor() {
27 | this.cwd = process.cwd();
28 | }
29 |
30 | static getInstance(): Logger {
31 | if (!Logger.instance) {
32 | Logger.instance = new Logger();
33 | }
34 | return Logger.instance;
35 | }
36 |
37 | private formatPath(path: string): string {
38 | const absolutePath = resolve(this.cwd, path);
39 | return relative(this.cwd, absolutePath);
40 | }
41 |
42 | private createEntry(
43 | level: LogLevel,
44 | operation: string,
45 | message: string,
46 | path?: string,
47 | error?: Error,
48 | context?: Record<string, any>
49 | ): LogEntry {
50 | return {
51 | timestamp: new Date().toISOString(),
52 | level,
53 | operation,
54 | message,
55 | path: path ? this.formatPath(path) : undefined,
56 | error,
57 | context,
58 | };
59 | }
60 |
61 | private log(entry: LogEntry): void {
62 | this.entries.push(entry);
63 | let logMessage = `[${entry.timestamp}] ${entry.level} - ${entry.operation}: ${entry.message}`;
64 |
65 | if (entry.path) {
66 | logMessage += `\n Path: ${entry.path}`;
67 | }
68 |
69 | if (entry.context) {
70 | logMessage += `\n Context: ${JSON.stringify(entry.context, null, 2)}`;
71 | }
72 |
73 | if (entry.error) {
74 | if (entry.error instanceof McpError) {
75 | logMessage += `\n Error: ${entry.error.message}`;
76 | } else {
77 | logMessage += `\n Error: ${entry.error.stack || entry.error.message}`;
78 | }
79 | }
80 |
81 | console.error(logMessage);
82 | }
83 |
84 | debug(operation: string, message: string, path?: string, context?: Record<string, any>): void {
85 | this.log(this.createEntry(LogLevel.DEBUG, operation, message, path, undefined, context));
86 | }
87 |
88 | info(operation: string, message: string, path?: string, context?: Record<string, any>): void {
89 | this.log(this.createEntry(LogLevel.INFO, operation, message, path, undefined, context));
90 | }
91 |
92 | warn(operation: string, message: string, path?: string, error?: Error, context?: Record<string, any>): void {
93 | this.log(this.createEntry(LogLevel.WARN, operation, message, path, error, context));
94 | }
95 |
96 | error(operation: string, message: string, path?: string, error?: Error, context?: Record<string, any>): void {
97 | this.log(this.createEntry(LogLevel.ERROR, operation, message, path, error, context));
98 | }
99 |
100 | getEntries(): LogEntry[] {
101 | return [...this.entries];
102 | }
103 |
104 | getEntriesForOperation(operation: string): LogEntry[] {
105 | return this.entries.filter(entry => entry.operation === operation);
106 | }
107 |
108 | getEntriesForPath(path: string): LogEntry[] {
109 | const searchPath = this.formatPath(path);
110 | return this.entries.filter(entry => entry.path === searchPath);
111 | }
112 |
113 | clear(): void {
114 | this.entries = [];
115 | }
116 |
117 | // Helper methods for common operations
118 | logCommand(operation: string, command: string, path?: string, context?: Record<string, any>): void {
119 | this.debug(operation, `Executing command: ${command}`, path, context);
120 | }
121 |
122 | logCommandResult(operation: string, result: string, path?: string, context?: Record<string, any>): void {
123 | this.debug(operation, `Command result: ${result}`, path, context);
124 | }
125 |
126 | logPathValidation(operation: string, path: string, context?: Record<string, any>): void {
127 | this.debug(operation, `Validating path: ${path}`, path, context);
128 | }
129 |
130 | logGitOperation(operation: string, details: string, path?: string, context?: Record<string, any>): void {
131 | this.info(operation, details, path, context);
132 | }
133 |
134 | logError(operation: string, error: Error, path?: string, context?: Record<string, any>): void {
135 | this.error(operation, 'Operation failed', path, error, context);
136 | }
137 | }
138 |
139 | // Export a singleton instance
140 | export const logger = Logger.getInstance();
141 |
```
--------------------------------------------------------------------------------
/src/common/command-builder.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Provides a fluent interface for building Git commands with proper option handling
3 | */
4 | export class GitCommandBuilder {
5 | private command: string[] = ['git'];
6 | private options: Map<string, string | boolean> = new Map();
7 |
8 | /**
9 | * Create a new GitCommandBuilder for a specific Git command
10 | */
11 | constructor(command: string) {
12 | this.command.push(command);
13 | }
14 |
15 | /**
16 | * Add a positional argument to the command
17 | */
18 | arg(value: string): this {
19 | this.command.push(this.escapeArg(value));
20 | return this;
21 | }
22 |
23 | /**
24 | * Add multiple positional arguments
25 | */
26 | args(...values: string[]): this {
27 | values.forEach(value => this.arg(value));
28 | return this;
29 | }
30 |
31 | /**
32 | * Add a flag option (--flag)
33 | */
34 | flag(name: string): this {
35 | this.options.set(name, true);
36 | return this;
37 | }
38 |
39 | /**
40 | * Add a value option (--option=value)
41 | */
42 | option(name: string, value: string): this {
43 | this.options.set(name, value);
44 | return this;
45 | }
46 |
47 | /**
48 | * Add a force flag (--force)
49 | */
50 | withForce(): this {
51 | return this.flag('force');
52 | }
53 |
54 | /**
55 | * Add a no-verify flag (--no-verify)
56 | */
57 | withNoVerify(): this {
58 | return this.flag('no-verify');
59 | }
60 |
61 | /**
62 | * Add a tags flag (--tags)
63 | */
64 | withTags(): this {
65 | return this.flag('tags');
66 | }
67 |
68 | /**
69 | * Add a track flag (--track)
70 | */
71 | withTrack(): this {
72 | return this.flag('track');
73 | }
74 |
75 | /**
76 | * Add a no-track flag (--no-track)
77 | */
78 | withNoTrack(): this {
79 | return this.flag('no-track');
80 | }
81 |
82 | /**
83 | * Add a set-upstream flag (--set-upstream)
84 | */
85 | withSetUpstream(): this {
86 | return this.flag('set-upstream');
87 | }
88 |
89 | /**
90 | * Add an annotated flag (-a)
91 | */
92 | withAnnotated(): this {
93 | return this.flag('a');
94 | }
95 |
96 | /**
97 | * Add a sign flag (-s)
98 | */
99 | withSign(): this {
100 | return this.flag('s');
101 | }
102 |
103 | /**
104 | * Add an include-untracked flag (--include-untracked)
105 | */
106 | withIncludeUntracked(): this {
107 | return this.flag('include-untracked');
108 | }
109 |
110 | /**
111 | * Add a keep-index flag (--keep-index)
112 | */
113 | withKeepIndex(): this {
114 | return this.flag('keep-index');
115 | }
116 |
117 | /**
118 | * Add an all flag (--all)
119 | */
120 | withAll(): this {
121 | return this.flag('all');
122 | }
123 |
124 | /**
125 | * Add a message option (-m "message")
126 | */
127 | withMessage(message: string): this {
128 | return this.option('m', message);
129 | }
130 |
131 | /**
132 | * Build the final command string
133 | */
134 | toString(): string {
135 | const parts = [...this.command];
136 |
137 | // Add options in sorted order for consistency
138 | Array.from(this.options.entries())
139 | .sort(([a], [b]) => a.localeCompare(b))
140 | .forEach(([name, value]) => {
141 | if (name.length === 1) {
142 | // Short option (-m "value")
143 | parts.push(`-${name}`);
144 | if (typeof value === 'string') {
145 | parts.push(this.escapeArg(value));
146 | }
147 | } else {
148 | // Long option
149 | if (value === true) {
150 | // Flag (--force)
151 | parts.push(`--${name}`);
152 | } else if (typeof value === 'string') {
153 | // Value option (--option=value)
154 | parts.push(`--${name}=${this.escapeArg(value)}`);
155 | }
156 | }
157 | });
158 |
159 | return parts.join(' ');
160 | }
161 |
162 | /**
163 | * Create common Git commands
164 | */
165 | static init(): GitCommandBuilder {
166 | return new GitCommandBuilder('init');
167 | }
168 |
169 | static clone(): GitCommandBuilder {
170 | return new GitCommandBuilder('clone');
171 | }
172 |
173 | static add(): GitCommandBuilder {
174 | return new GitCommandBuilder('add');
175 | }
176 |
177 | static commit(): GitCommandBuilder {
178 | return new GitCommandBuilder('commit');
179 | }
180 |
181 | static push(): GitCommandBuilder {
182 | return new GitCommandBuilder('push');
183 | }
184 |
185 | static pull(): GitCommandBuilder {
186 | return new GitCommandBuilder('pull');
187 | }
188 |
189 | static branch(): GitCommandBuilder {
190 | return new GitCommandBuilder('branch');
191 | }
192 |
193 | static checkout(): GitCommandBuilder {
194 | return new GitCommandBuilder('checkout');
195 | }
196 |
197 | static tag(): GitCommandBuilder {
198 | return new GitCommandBuilder('tag');
199 | }
200 |
201 | static remote(): GitCommandBuilder {
202 | return new GitCommandBuilder('remote');
203 | }
204 |
205 | static stash(): GitCommandBuilder {
206 | return new GitCommandBuilder('stash');
207 | }
208 |
209 | static status(): GitCommandBuilder {
210 | return new GitCommandBuilder('status');
211 | }
212 |
213 | static fetch(): GitCommandBuilder {
214 | return new GitCommandBuilder('fetch');
215 | }
216 |
217 | /**
218 | * Escape command arguments that contain spaces or special characters
219 | */
220 | private escapeArg(arg: string): string {
221 | if (arg.includes(' ') || arg.includes('"') || arg.includes('\'')) {
222 | // Escape quotes and wrap in quotes
223 | return `"${arg.replace(/"/g, '\\"')}"`;
224 | }
225 | return arg;
226 | }
227 | }
228 |
```
--------------------------------------------------------------------------------
/src/operations/base/base-operation.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CommandExecutor } from '../../utils/command.js';
2 | import { PathValidator } from '../../utils/path.js';
3 | import { logger } from '../../utils/logger.js';
4 | import { repositoryCache } from '../../caching/repository-cache.js';
5 | import { RepoStateType } from '../../caching/repository-cache.js';
6 | import { GitToolContext, GitToolResult } from '../../types.js';
7 | import { GitCommandBuilder } from '../../common/command-builder.js';
8 | import { GitOperationOptions, GitOperationResult, CommandResult } from './operation-result.js';
9 | import { ErrorHandler } from '../../errors/error-handler.js';
10 | import { GitMcpError } from '../../errors/error-types.js';
11 |
12 | /**
13 | * Base class for all Git operations providing common functionality
14 | */
15 | export abstract class BaseGitOperation<TOptions extends GitOperationOptions, TResult = void> {
16 | protected constructor(
17 | protected readonly context: GitToolContext,
18 | protected readonly options: TOptions
19 | ) {}
20 |
21 | /**
22 | * Execute the Git operation with proper error handling and caching
23 | */
24 | public async execute(): Promise<GitOperationResult<TResult>> {
25 | try {
26 | // Validate options before proceeding
27 | this.validateOptions();
28 |
29 | // Get resolved path
30 | const path = this.getResolvedPath();
31 |
32 | // Execute operation with caching if enabled
33 | const result = await this.executeWithCache(path);
34 |
35 | // Format the result
36 | return await this.formatResult(result);
37 | } catch (error: unknown) {
38 | return this.handleError(error);
39 | }
40 | }
41 |
42 | /**
43 | * Build the Git command for this operation
44 | */
45 | protected abstract buildCommand(): GitCommandBuilder | Promise<GitCommandBuilder>;
46 |
47 | /**
48 | * Parse the command result into operation-specific format
49 | */
50 | protected abstract parseResult(result: CommandResult): TResult | Promise<TResult>;
51 |
52 | /**
53 | * Get cache configuration for this operation
54 | */
55 | protected abstract getCacheConfig(): {
56 | command: string;
57 | stateType?: RepoStateType;
58 | };
59 |
60 | /**
61 | * Validate operation-specific options
62 | */
63 | protected abstract validateOptions(): void;
64 |
65 | /**
66 | * Execute the Git command with caching if enabled
67 | */
68 | private async executeWithCache(path: string): Promise<CommandResult> {
69 | const { command, stateType } = this.getCacheConfig();
70 | const action = () => this.executeCommand(path);
71 |
72 | if (this.options.useCache && path) {
73 | if (stateType) {
74 | // Use state cache
75 | return await repositoryCache.getState(
76 | path,
77 | stateType,
78 | command,
79 | action
80 | );
81 | } else {
82 | // Use command cache
83 | return await repositoryCache.getCommandResult(
84 | path,
85 | command,
86 | action
87 | );
88 | }
89 | }
90 |
91 | // Execute without caching
92 | return await action();
93 | }
94 |
95 | /**
96 | * Execute the Git command
97 | */
98 | private async executeCommand(path: string): Promise<CommandResult> {
99 | const builder = await Promise.resolve(this.buildCommand());
100 | const command = builder.toString();
101 | return await CommandExecutor.executeGitCommand(
102 | command,
103 | this.context.operation,
104 | path
105 | );
106 | }
107 |
108 | /**
109 | * Format the operation result into standard GitToolResult
110 | */
111 | private async formatResult(result: CommandResult): Promise<GitOperationResult<TResult>> {
112 | return {
113 | success: true,
114 | data: await Promise.resolve(this.parseResult(result)),
115 | content: [{
116 | type: 'text',
117 | text: CommandExecutor.formatOutput(result)
118 | }]
119 | };
120 | }
121 |
122 | /**
123 | * Handle operation errors
124 | */
125 | private handleError(error: unknown): GitOperationResult<TResult> {
126 | if (error instanceof GitMcpError) {
127 | return {
128 | success: false,
129 | error,
130 | content: [{
131 | type: 'text',
132 | text: error.message
133 | }]
134 | };
135 | }
136 |
137 | const wrappedError = ErrorHandler.handleOperationError(
138 | error instanceof Error ? error : new Error('Unknown error'),
139 | {
140 | operation: this.context.operation,
141 | path: this.options.path,
142 | command: this.getCacheConfig().command
143 | }
144 | );
145 |
146 | return {
147 | success: false,
148 | error: wrappedError,
149 | content: [{
150 | type: 'text',
151 | text: wrappedError.message
152 | }]
153 | };
154 | }
155 |
156 | /**
157 | * Get resolved path with proper validation
158 | */
159 | protected getResolvedPath(): string {
160 | const path = this.options.path || process.env.GIT_DEFAULT_PATH;
161 | if (!path) {
162 | throw ErrorHandler.handleValidationError(
163 | new Error('Path must be provided when GIT_DEFAULT_PATH is not set'),
164 | { operation: this.context.operation }
165 | );
166 | }
167 |
168 | const { path: repoPath } = PathValidator.validateGitRepo(path);
169 | return repoPath;
170 | }
171 |
172 | /**
173 | * Invalidate cache if needed
174 | */
175 | protected invalidateCache(path: string): void {
176 | if (this.options.invalidateCache) {
177 | const { command, stateType } = this.getCacheConfig();
178 | if (stateType) {
179 | repositoryCache.invalidateState(path, stateType);
180 | }
181 | repositoryCache.invalidateCommand(path, command);
182 | }
183 | }
184 | }
185 |
```
--------------------------------------------------------------------------------
/src/operations/tag/tag-operations.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { BaseGitOperation } from '../base/base-operation.js';
2 | import { GitCommandBuilder } from '../../common/command-builder.js';
3 | import { CommandResult } from '../base/operation-result.js';
4 | import { ErrorHandler } from '../../errors/error-handler.js';
5 | import { RepositoryValidator } from '../../utils/repository.js';
6 | import { CommandExecutor } from '../../utils/command.js';
7 | import { RepoStateType } from '../../caching/repository-cache.js';
8 | import {
9 | TagListOptions,
10 | TagCreateOptions,
11 | TagDeleteOptions,
12 | TagListResult,
13 | TagCreateResult,
14 | TagDeleteResult,
15 | TagInfo
16 | } from './tag-types.js';
17 |
18 | /**
19 | * Handles Git tag listing operations
20 | */
21 | export class TagListOperation extends BaseGitOperation<TagListOptions, TagListResult> {
22 | protected buildCommand(): GitCommandBuilder {
23 | const command = GitCommandBuilder.tag();
24 |
25 | // Add format option for parsing
26 | command.option('format', '%(refname:strip=2)|%(objecttype)|%(subject)|%(taggername)|%(taggeremail)|%(taggerdate)|%(objectname)');
27 |
28 | if (this.options.showMessage) {
29 | command.flag('n');
30 | }
31 |
32 | if (this.options.sort) {
33 | command.option('sort', this.options.sort);
34 | }
35 |
36 | if (this.options.contains) {
37 | command.option('contains', this.options.contains);
38 | }
39 |
40 | if (this.options.pattern) {
41 | command.arg(this.options.pattern);
42 | }
43 |
44 | return command;
45 | }
46 |
47 | protected parseResult(result: CommandResult): TagListResult {
48 | const tags: TagInfo[] = [];
49 |
50 | // Parse each line of output
51 | result.stdout.split('\n').filter(Boolean).forEach(line => {
52 | const [name, type, message, taggerName, taggerEmail, taggerDate, commit] = line.split('|');
53 |
54 | const tag: TagInfo = {
55 | name,
56 | annotated: type === 'tag',
57 | commit,
58 | signed: message?.includes('-----BEGIN PGP SIGNATURE-----') || false
59 | };
60 |
61 | if (tag.annotated) {
62 | tag.message = message;
63 | if (taggerName && taggerEmail && taggerDate) {
64 | tag.tagger = {
65 | name: taggerName,
66 | email: taggerEmail.replace(/[<>]/g, ''),
67 | date: taggerDate
68 | };
69 | }
70 | }
71 |
72 | tags.push(tag);
73 | });
74 |
75 | return {
76 | tags,
77 | raw: result.stdout
78 | };
79 | }
80 |
81 | protected getCacheConfig() {
82 | return {
83 | command: 'tag',
84 | stateType: RepoStateType.TAG
85 | };
86 | }
87 |
88 | protected validateOptions(): void {
89 | // No specific validation needed for listing
90 | }
91 | }
92 |
93 | /**
94 | * Handles Git tag creation operations
95 | */
96 | export class TagCreateOperation extends BaseGitOperation<TagCreateOptions, TagCreateResult> {
97 | protected buildCommand(): GitCommandBuilder {
98 | const command = GitCommandBuilder.tag();
99 |
100 | if (this.options.message) {
101 | command.withAnnotated();
102 | command.withMessage(this.options.message);
103 | }
104 |
105 | if (this.options.force) {
106 | command.withForce();
107 | }
108 |
109 | if (this.options.sign) {
110 | command.withSign();
111 | }
112 |
113 | command.arg(this.options.name);
114 |
115 | if (this.options.commit) {
116 | command.arg(this.options.commit);
117 | }
118 |
119 | return command;
120 | }
121 |
122 | protected parseResult(result: CommandResult): TagCreateResult {
123 | const signed = result.stdout.includes('-----BEGIN PGP SIGNATURE-----');
124 |
125 | return {
126 | name: this.options.name,
127 | annotated: Boolean(this.options.message),
128 | signed,
129 | commit: this.options.commit,
130 | raw: result.stdout
131 | };
132 | }
133 |
134 | protected getCacheConfig() {
135 | return {
136 | command: 'tag_create',
137 | stateType: RepoStateType.TAG
138 | };
139 | }
140 |
141 | protected validateOptions(): void {
142 | if (!this.options.name) {
143 | throw ErrorHandler.handleValidationError(
144 | new Error('Tag name is required'),
145 | { operation: this.context.operation }
146 | );
147 | }
148 | }
149 | }
150 |
151 | /**
152 | * Handles Git tag deletion operations
153 | */
154 | export class TagDeleteOperation extends BaseGitOperation<TagDeleteOptions, TagDeleteResult> {
155 | protected async buildCommand(): Promise<GitCommandBuilder> {
156 | const command = GitCommandBuilder.tag();
157 |
158 | command.flag('d');
159 | if (this.options.force) {
160 | command.withForce();
161 | }
162 |
163 | command.arg(this.options.name);
164 |
165 | if (this.options.remote) {
166 | // Get remote name from configuration
167 | const remotes = await RepositoryValidator.getRemotes(
168 | this.getResolvedPath(),
169 | this.context.operation
170 | );
171 |
172 | // Push deletion to all remotes
173 | for (const remote of remotes) {
174 | await CommandExecutor.executeGitCommand(
175 | `push ${remote} :refs/tags/${this.options.name}`,
176 | this.context.operation,
177 | this.getResolvedPath()
178 | );
179 | }
180 | }
181 |
182 | return command;
183 | }
184 |
185 | protected parseResult(result: CommandResult): TagDeleteResult {
186 | return {
187 | name: this.options.name,
188 | forced: this.options.force || false,
189 | raw: result.stdout
190 | };
191 | }
192 |
193 | protected getCacheConfig() {
194 | return {
195 | command: 'tag_delete',
196 | stateType: RepoStateType.TAG
197 | };
198 | }
199 |
200 | protected async validateOptions(): Promise<void> {
201 | if (!this.options.name) {
202 | throw ErrorHandler.handleValidationError(
203 | new Error('Tag name is required'),
204 | { operation: this.context.operation }
205 | );
206 | }
207 |
208 | // Ensure tag exists
209 | await RepositoryValidator.validateTagExists(
210 | this.getResolvedPath(),
211 | this.options.name,
212 | this.context.operation
213 | );
214 | }
215 | }
216 |
```
--------------------------------------------------------------------------------
/src/caching/repository-cache.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { RepositoryStateCache, CommandResultCache } from './cache.js';
2 | import { logger } from '../utils/logger.js';
3 | import { PerformanceMonitor } from '../monitoring/performance.js';
4 |
5 | /**
6 | * Repository state types
7 | */
8 | export enum RepoStateType {
9 | BRANCH = 'branch',
10 | STATUS = 'status',
11 | REMOTE = 'remote',
12 | TAG = 'tag',
13 | STASH = 'stash'
14 | }
15 |
16 | /**
17 | * Repository state manager with caching
18 | */
19 | export class RepositoryCacheManager {
20 | private static instance: RepositoryCacheManager;
21 | private stateCache: RepositoryStateCache;
22 | private commandCache: CommandResultCache;
23 | private performanceMonitor: PerformanceMonitor;
24 |
25 | private constructor() {
26 | this.stateCache = new RepositoryStateCache();
27 | this.commandCache = new CommandResultCache();
28 | this.performanceMonitor = PerformanceMonitor.getInstance();
29 | }
30 |
31 | /**
32 | * Get singleton instance
33 | */
34 | static getInstance(): RepositoryCacheManager {
35 | if (!RepositoryCacheManager.instance) {
36 | RepositoryCacheManager.instance = new RepositoryCacheManager();
37 | }
38 | return RepositoryCacheManager.instance;
39 | }
40 |
41 | /**
42 | * Get repository state from cache or execute command
43 | */
44 | async getState(
45 | repoPath: string,
46 | stateType: RepoStateType,
47 | command: string,
48 | executor: () => Promise<any>
49 | ): Promise<any> {
50 | const cacheKey = this.getStateKey(repoPath, stateType);
51 | const cachedState = this.stateCache.get(cacheKey);
52 |
53 | if (cachedState !== undefined) {
54 | logger.debug(
55 | 'cache',
56 | `Cache hit for repository state: ${stateType}`,
57 | repoPath,
58 | { command }
59 | );
60 | return cachedState;
61 | }
62 |
63 | // Start timing the operation
64 | const startTime = performance.now();
65 |
66 | try {
67 | const result = await executor();
68 | const duration = performance.now() - startTime;
69 |
70 | // Record performance metrics
71 | this.performanceMonitor.recordCommandExecution(command, duration, {
72 | repoPath,
73 | stateType,
74 | cached: false
75 | });
76 |
77 | // Cache the result
78 | this.stateCache.set(cacheKey, result);
79 |
80 | return result;
81 | } catch (error) {
82 | const duration = performance.now() - startTime;
83 | this.performanceMonitor.recordCommandExecution(command, duration, {
84 | repoPath,
85 | stateType,
86 | cached: false,
87 | error: true
88 | });
89 | throw error;
90 | }
91 | }
92 |
93 | /**
94 | * Get command result from cache or execute command
95 | */
96 | async getCommandResult(
97 | repoPath: string,
98 | command: string,
99 | executor: () => Promise<any>
100 | ): Promise<any> {
101 | const cacheKey = CommandResultCache.generateKey(command, repoPath);
102 | const cachedResult = this.commandCache.get(cacheKey);
103 |
104 | if (cachedResult !== undefined) {
105 | logger.debug(
106 | 'cache',
107 | `Cache hit for command result`,
108 | repoPath,
109 | { command }
110 | );
111 | return cachedResult;
112 | }
113 |
114 | // Start timing the operation
115 | const startTime = performance.now();
116 |
117 | try {
118 | const result = await executor();
119 | const duration = performance.now() - startTime;
120 |
121 | // Record performance metrics
122 | this.performanceMonitor.recordCommandExecution(command, duration, {
123 | repoPath,
124 | cached: false
125 | });
126 |
127 | // Cache the result
128 | this.commandCache.set(cacheKey, result);
129 |
130 | return result;
131 | } catch (error) {
132 | const duration = performance.now() - startTime;
133 | this.performanceMonitor.recordCommandExecution(command, duration, {
134 | repoPath,
135 | cached: false,
136 | error: true
137 | });
138 | throw error;
139 | }
140 | }
141 |
142 | /**
143 | * Invalidate repository state cache
144 | */
145 | invalidateState(repoPath: string, stateType?: RepoStateType): void {
146 | if (stateType) {
147 | const cacheKey = this.getStateKey(repoPath, stateType);
148 | this.stateCache.delete(cacheKey);
149 | logger.debug(
150 | 'cache',
151 | `Invalidated repository state cache`,
152 | repoPath,
153 | { stateType }
154 | );
155 | } else {
156 | // Invalidate all state types for this repository
157 | Object.values(RepoStateType).forEach(type => {
158 | const cacheKey = this.getStateKey(repoPath, type);
159 | this.stateCache.delete(cacheKey);
160 | });
161 | logger.debug(
162 | 'cache',
163 | `Invalidated all repository state cache`,
164 | repoPath
165 | );
166 | }
167 | }
168 |
169 | /**
170 | * Invalidate command result cache
171 | */
172 | invalidateCommand(repoPath: string, command?: string): void {
173 | if (command) {
174 | const cacheKey = CommandResultCache.generateKey(command, repoPath);
175 | this.commandCache.delete(cacheKey);
176 | logger.debug(
177 | 'cache',
178 | `Invalidated command result cache`,
179 | repoPath,
180 | { command }
181 | );
182 | } else {
183 | // Clear all command results for this repository
184 | // Note: This is a bit inefficient as it clears all commands for all repos
185 | // A better solution would be to store repo-specific commands separately
186 | this.commandCache.clear();
187 | logger.debug(
188 | 'cache',
189 | `Invalidated all command result cache`,
190 | repoPath
191 | );
192 | }
193 | }
194 |
195 | /**
196 | * Get cache statistics
197 | */
198 | getStats(): Record<string, any> {
199 | return {
200 | state: this.stateCache.getStats(),
201 | command: this.commandCache.getStats()
202 | };
203 | }
204 |
205 | /**
206 | * Generate cache key for repository state
207 | */
208 | private getStateKey(repoPath: string, stateType: RepoStateType): string {
209 | return `${repoPath}:${stateType}`;
210 | }
211 | }
212 |
213 | // Export singleton instance
214 | export const repositoryCache = RepositoryCacheManager.getInstance();
215 |
```
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ExecOptions } from 'child_process';
2 |
3 | export enum ErrorCode {
4 | InvalidParams = 'InvalidParams',
5 | InternalError = 'InternalError',
6 | InvalidState = 'InvalidState'
7 | }
8 |
9 | export interface GitOptions {
10 | /**
11 | * Absolute path to the working directory
12 | * Example: /Users/username/projects/my-repo
13 | */
14 | cwd?: string;
15 | execOptions?: ExecOptions;
16 | }
17 |
18 | export interface GitToolContent {
19 | type: string;
20 | text: string;
21 | }
22 |
23 | export interface GitToolResult {
24 | content: GitToolContent[];
25 | _meta?: Record<string, unknown>;
26 | }
27 |
28 | export interface GitToolContext {
29 | operation: string;
30 | path?: string;
31 | options?: GitOptions;
32 | }
33 |
34 | // Base interface for operations that require a path
35 | export interface BasePathOptions {
36 | /**
37 | * MUST be an absolute path to the repository
38 | * Example: /Users/username/projects/my-repo
39 | * If not provided, will use GIT_DEFAULT_PATH from environment
40 | */
41 | path?: string;
42 | }
43 |
44 | // Tool-specific interfaces
45 | export interface InitOptions extends GitOptions, BasePathOptions {}
46 |
47 | export interface CloneOptions extends GitOptions, BasePathOptions {
48 | /**
49 | * URL of the repository to clone
50 | */
51 | url: string;
52 | }
53 |
54 | export interface AddOptions extends GitOptions, BasePathOptions {
55 | /**
56 | * Array of absolute paths to files to stage
57 | * Example: /Users/username/projects/my-repo/src/file.js
58 | */
59 | files: string[];
60 | }
61 |
62 | export interface CommitOptions extends GitOptions, BasePathOptions {
63 | message: string;
64 | }
65 |
66 | export interface PushPullOptions extends GitOptions, BasePathOptions {
67 | remote?: string;
68 | branch: string;
69 | force?: boolean; // Allow force push/pull
70 | noVerify?: boolean; // Skip pre-push/pre-pull hooks
71 | tags?: boolean; // Include tags
72 | }
73 |
74 | export interface BranchOptions extends GitOptions, BasePathOptions {
75 | name: string;
76 | force?: boolean; // Allow force operations
77 | track?: boolean; // Set up tracking mode
78 | setUpstream?: boolean; // Set upstream for push/pull
79 | }
80 |
81 | export interface CheckoutOptions extends GitOptions, BasePathOptions {
82 | target: string;
83 | }
84 |
85 | export interface TagOptions extends GitOptions, BasePathOptions {
86 | name: string;
87 | message?: string;
88 | force?: boolean; // Allow force operations
89 | annotated?: boolean; // Create an annotated tag
90 | sign?: boolean; // Create a signed tag
91 | }
92 |
93 | export interface RemoteOptions extends GitOptions, BasePathOptions {
94 | name: string;
95 | url?: string;
96 | force?: boolean; // Allow force operations
97 | mirror?: boolean; // Mirror all refs
98 | tags?: boolean; // Include tags
99 | }
100 |
101 | export interface StashOptions extends GitOptions, BasePathOptions {
102 | message?: string;
103 | index?: number;
104 | includeUntracked?: boolean; // Include untracked files
105 | keepIndex?: boolean; // Keep staged changes
106 | all?: boolean; // Include ignored files
107 | }
108 |
109 | // New bulk action interfaces
110 | export interface BulkActionStage {
111 | type: 'stage';
112 | files?: string[]; // If not provided, stages all files
113 | }
114 |
115 | export interface BulkActionCommit {
116 | type: 'commit';
117 | message: string;
118 | }
119 |
120 | export interface BulkActionPush {
121 | type: 'push';
122 | remote?: string;
123 | branch: string;
124 | }
125 |
126 | export type BulkAction = BulkActionStage | BulkActionCommit | BulkActionPush;
127 |
128 | export interface BulkActionOptions extends GitOptions, BasePathOptions {
129 | actions: BulkAction[];
130 | }
131 |
132 | // Type guard functions
133 | export function isAbsolutePath(path: string): boolean {
134 | return path.startsWith('/');
135 | }
136 |
137 | export function validatePath(path?: string): boolean {
138 | return !path || isAbsolutePath(path);
139 | }
140 |
141 | export function isInitOptions(obj: any): obj is InitOptions {
142 | return obj && validatePath(obj.path);
143 | }
144 |
145 | export function isCloneOptions(obj: any): obj is CloneOptions {
146 | return obj &&
147 | typeof obj.url === 'string' &&
148 | validatePath(obj.path);
149 | }
150 |
151 | export function isAddOptions(obj: any): obj is AddOptions {
152 | return obj &&
153 | validatePath(obj.path) &&
154 | Array.isArray(obj.files) &&
155 | obj.files.every((f: any) => typeof f === 'string' && isAbsolutePath(f));
156 | }
157 |
158 | export function isCommitOptions(obj: any): obj is CommitOptions {
159 | return obj &&
160 | validatePath(obj.path) &&
161 | typeof obj.message === 'string';
162 | }
163 |
164 | export function isPushPullOptions(obj: any): obj is PushPullOptions {
165 | return obj &&
166 | validatePath(obj.path) &&
167 | typeof obj.branch === 'string';
168 | }
169 |
170 | export function isBranchOptions(obj: any): obj is BranchOptions {
171 | return obj &&
172 | validatePath(obj.path) &&
173 | typeof obj.name === 'string';
174 | }
175 |
176 | export function isCheckoutOptions(obj: any): obj is CheckoutOptions {
177 | return obj &&
178 | validatePath(obj.path) &&
179 | typeof obj.target === 'string';
180 | }
181 |
182 | export function isTagOptions(obj: any): obj is TagOptions {
183 | return obj &&
184 | validatePath(obj.path) &&
185 | typeof obj.name === 'string';
186 | }
187 |
188 | export function isRemoteOptions(obj: any): obj is RemoteOptions {
189 | return obj &&
190 | validatePath(obj.path) &&
191 | typeof obj.name === 'string';
192 | }
193 |
194 | export function isStashOptions(obj: any): obj is StashOptions {
195 | return obj && validatePath(obj.path);
196 | }
197 |
198 | export function isPathOnly(obj: any): obj is BasePathOptions {
199 | return obj && validatePath(obj.path);
200 | }
201 |
202 | export function isBulkActionOptions(obj: any): obj is BulkActionOptions {
203 | if (!obj || !validatePath(obj.path) || !Array.isArray(obj.actions)) {
204 | return false;
205 | }
206 |
207 | return obj.actions.every((action: any) => {
208 | if (!action || typeof action.type !== 'string') {
209 | return false;
210 | }
211 |
212 | switch (action.type) {
213 | case 'stage':
214 | return !action.files || (Array.isArray(action.files) &&
215 | action.files.every((f: any) => typeof f === 'string' && isAbsolutePath(f)));
216 | case 'commit':
217 | return typeof action.message === 'string';
218 | case 'push':
219 | return typeof action.branch === 'string';
220 | default:
221 | return false;
222 | }
223 | });
224 | }
225 |
```
--------------------------------------------------------------------------------
/src/utils/paths.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { resolve, isAbsolute, normalize, relative, join, dirname } from 'path';
2 | import { existsSync, statSync, mkdirSync } from 'fs';
3 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
4 | import { logger } from './logger.js';
5 |
6 | export interface PathInfo {
7 | original: string;
8 | absolute: string;
9 | relative: string;
10 | exists: boolean;
11 | isDirectory?: boolean;
12 | isFile?: boolean;
13 | isGitRepo?: boolean;
14 | parent: string;
15 | }
16 |
17 | export class PathResolver {
18 | private static readonly CWD = process.cwd();
19 |
20 | private static createDirectory(path: string, operation: string): void {
21 | try {
22 | mkdirSync(path, { recursive: true });
23 | logger.info(operation, `Created directory: ${path}`);
24 | } catch (error) {
25 | logger.error(operation, `Failed to create directory: ${path}`, path, error as Error);
26 | throw new McpError(
27 | ErrorCode.InternalError,
28 | `Failed to create directory: ${(error as Error).message}`
29 | );
30 | }
31 | }
32 |
33 | private static getStats(path: string): { exists: boolean; isDirectory?: boolean; isFile?: boolean } {
34 | if (!existsSync(path)) {
35 | return { exists: false };
36 | }
37 |
38 | try {
39 | const stats = statSync(path);
40 | return {
41 | exists: true,
42 | isDirectory: stats.isDirectory(),
43 | isFile: stats.isFile(),
44 | };
45 | } catch {
46 | return { exists: true };
47 | }
48 | }
49 |
50 | private static validateAbsolutePath(path: string, operation: string): void {
51 | if (!isAbsolute(path)) {
52 | const error = new McpError(
53 | ErrorCode.InvalidParams,
54 | `Path must be absolute. Received: ${path}\nExample: /Users/username/projects/my-repo`
55 | );
56 | logger.error(operation, 'Invalid path format', path, error);
57 | throw error;
58 | }
59 | }
60 |
61 | static getPathInfo(path: string, operation: string): PathInfo {
62 | logger.debug(operation, 'Resolving path info', path);
63 |
64 | // Validate absolute path
65 | this.validateAbsolutePath(path, operation);
66 |
67 | // Normalize the path
68 | const absolutePath = normalize(path);
69 | const relativePath = relative(this.CWD, absolutePath);
70 | const parentPath = dirname(absolutePath);
71 |
72 | // Get path stats
73 | const stats = this.getStats(absolutePath);
74 | const isGitRepo = stats.isDirectory ? existsSync(join(absolutePath, '.git')) : false;
75 |
76 | const pathInfo: PathInfo = {
77 | original: path,
78 | absolute: absolutePath,
79 | relative: relativePath,
80 | exists: stats.exists,
81 | isDirectory: stats.isDirectory,
82 | isFile: stats.isFile,
83 | isGitRepo,
84 | parent: parentPath,
85 | };
86 |
87 | logger.debug(operation, 'Path info resolved', path, pathInfo);
88 | return pathInfo;
89 | }
90 |
91 | static validatePath(path: string, operation: string, options: {
92 | mustExist?: boolean;
93 | mustBeDirectory?: boolean;
94 | mustBeFile?: boolean;
95 | mustBeGitRepo?: boolean;
96 | createIfMissing?: boolean;
97 | } = {}): PathInfo {
98 | const {
99 | mustExist = false,
100 | mustBeDirectory = false,
101 | mustBeFile = false,
102 | mustBeGitRepo = false,
103 | createIfMissing = false,
104 | } = options;
105 |
106 | logger.debug(operation, 'Validating path with options', path, options);
107 |
108 | // Get path info (includes absolute path validation)
109 | const pathInfo = this.getPathInfo(path, operation);
110 |
111 | // Create directory if needed
112 | if (!pathInfo.exists && (createIfMissing || mustBeDirectory)) {
113 | this.createDirectory(pathInfo.absolute, operation);
114 | return this.getPathInfo(path, operation);
115 | }
116 |
117 | // Handle existence requirements
118 | if (mustExist && !pathInfo.exists) {
119 | const error = new McpError(
120 | ErrorCode.InvalidParams,
121 | `Path does not exist: ${pathInfo.absolute}`
122 | );
123 | logger.error(operation, 'Path validation failed', path, error);
124 | throw error;
125 | }
126 |
127 | // Validate directory requirement
128 | if (mustBeDirectory && !pathInfo.isDirectory) {
129 | const error = new McpError(
130 | ErrorCode.InvalidParams,
131 | `Path is not a directory: ${pathInfo.absolute}`
132 | );
133 | logger.error(operation, 'Path validation failed', path, error);
134 | throw error;
135 | }
136 |
137 | // Validate file requirement
138 | if (mustBeFile && !pathInfo.isFile) {
139 | const error = new McpError(
140 | ErrorCode.InvalidParams,
141 | `Path is not a file: ${pathInfo.absolute}`
142 | );
143 | logger.error(operation, 'Path validation failed', path, error);
144 | throw error;
145 | }
146 |
147 | // Validate git repo requirement
148 | if (mustBeGitRepo && !pathInfo.isGitRepo) {
149 | const error = new McpError(
150 | ErrorCode.InvalidParams,
151 | `Path is not a git repository: ${pathInfo.absolute}`
152 | );
153 | logger.error(operation, 'Path validation failed', path, error);
154 | throw error;
155 | }
156 |
157 | logger.debug(operation, 'Path validation successful', path, pathInfo);
158 | return pathInfo;
159 | }
160 |
161 | static validateFilePaths(paths: string[], operation: string): PathInfo[] {
162 | logger.debug(operation, 'Validating multiple file paths', undefined, { paths });
163 |
164 | return paths.map(path => {
165 | // Validate absolute path
166 | this.validateAbsolutePath(path, operation);
167 |
168 | const pathInfo = this.validatePath(path, operation, {
169 | mustExist: true,
170 | mustBeFile: true,
171 | });
172 | return pathInfo;
173 | });
174 | }
175 |
176 | static validateGitRepo(path: string, operation: string): PathInfo {
177 | // Validate absolute path
178 | this.validateAbsolutePath(path, operation);
179 |
180 | return this.validatePath(path, operation, {
181 | mustExist: true,
182 | mustBeDirectory: true,
183 | mustBeGitRepo: true,
184 | });
185 | }
186 |
187 | static ensureDirectory(path: string, operation: string): PathInfo {
188 | // Validate absolute path
189 | this.validateAbsolutePath(path, operation);
190 |
191 | return this.validatePath(path, operation, {
192 | createIfMissing: true,
193 | mustBeDirectory: true,
194 | });
195 | }
196 | }
197 |
```
--------------------------------------------------------------------------------
/src/utils/command.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ExecException, exec, ExecOptions } from 'child_process';
2 | import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
3 | import { logger } from './logger.js';
4 | import { PathResolver } from './paths.js';
5 | import { ErrorHandler } from '../errors/error-handler.js';
6 | import { ErrorCategory, ErrorSeverity, GitMcpError } from '../errors/error-types.js';
7 |
8 | export interface CommandResult {
9 | stdout: string;
10 | stderr: string;
11 | command: string;
12 | workingDir?: string;
13 | }
14 |
15 | /**
16 | * Formats a command error message with detailed context
17 | */
18 | function formatCommandError(error: ExecException, result: Partial<CommandResult>): string {
19 | let message = `Command failed with exit code ${error.code}`;
20 |
21 | if (result.command) {
22 | message += `\nCommand: ${result.command}`;
23 | }
24 |
25 | if (result.workingDir) {
26 | message += `\nWorking Directory: ${result.workingDir}`;
27 | }
28 |
29 | if (result.stdout) {
30 | message += `\nOutput: ${result.stdout}`;
31 | }
32 |
33 | if (result.stderr) {
34 | message += `\nError: ${result.stderr}`;
35 | }
36 |
37 | return message;
38 | }
39 |
40 | /**
41 | * Creates a command error with appropriate category and severity
42 | */
43 | function createCommandError(
44 | error: ExecException,
45 | result: Partial<CommandResult>,
46 | operation: string
47 | ): GitMcpError {
48 | const message = formatCommandError(error, result);
49 | const context = {
50 | operation,
51 | path: result.workingDir,
52 | command: result.command,
53 | details: {
54 | exitCode: error.code,
55 | stdout: result.stdout,
56 | stderr: result.stderr
57 | }
58 | };
59 |
60 | // Determine error category and severity based on error code and context
61 | const errorCode = error.code?.toString() || '';
62 |
63 | // System errors
64 | if (errorCode === 'ENOENT') {
65 | return ErrorHandler.handleSystemError(error, context);
66 | }
67 |
68 | // Security errors
69 | if (errorCode === 'EACCES') {
70 | return ErrorHandler.handleSecurityError(error, context);
71 | }
72 |
73 | // Validation errors
74 | if (errorCode === 'ENOTDIR' || errorCode === 'ENOTEMPTY') {
75 | return ErrorHandler.handleValidationError(error, context);
76 | }
77 |
78 | // Git-specific error codes
79 | const numericCode = typeof error.code === 'number' ? error.code :
80 | typeof error.code === 'string' ? parseInt(error.code, 10) :
81 | null;
82 |
83 | if (numericCode !== null) {
84 | switch (numericCode) {
85 | case 128: // Repository not found or invalid
86 | return ErrorHandler.handleRepositoryError(error, context);
87 | case 129: // Invalid command or argument
88 | return ErrorHandler.handleValidationError(error, context);
89 | case 130: // User interrupt
90 | return ErrorHandler.handleOperationError(error, context);
91 | default:
92 | return ErrorHandler.handleOperationError(error, context);
93 | }
94 | }
95 |
96 | // Default to operation error for unknown cases
97 | return ErrorHandler.handleOperationError(error, context);
98 | }
99 |
100 | export class CommandExecutor {
101 | static async execute(
102 | command: string,
103 | operation: string,
104 | workingDir?: string,
105 | options: ExecOptions = {}
106 | ): Promise<CommandResult> {
107 | // Validate and resolve working directory if provided
108 | if (workingDir) {
109 | const pathInfo = PathResolver.validatePath(workingDir, operation, {
110 | mustExist: true,
111 | mustBeDirectory: true,
112 | });
113 | workingDir = pathInfo.absolute;
114 | }
115 |
116 | // Log command execution
117 | logger.logCommand(operation, command, workingDir);
118 |
119 | // Prepare execution options
120 | const execOptions: ExecOptions = {
121 | ...options,
122 | cwd: workingDir,
123 | maxBuffer: 10 * 1024 * 1024, // 10MB buffer
124 | };
125 |
126 | return new Promise((resolve, reject) => {
127 | exec(command, execOptions, (error, stdout, stderr) => {
128 | const result: CommandResult = {
129 | command,
130 | workingDir,
131 | stdout: stdout.trim(),
132 | stderr: stderr.trim(),
133 | };
134 |
135 | // Log command result
136 | if (error) {
137 | reject(createCommandError(error, result, operation));
138 | return;
139 | }
140 |
141 | logger.logCommandResult(operation, result.stdout, workingDir, {
142 | stderr: result.stderr,
143 | });
144 | resolve(result);
145 | });
146 | });
147 | }
148 |
149 | static formatOutput(result: CommandResult): string {
150 | let output = '';
151 |
152 | if (result.stdout) {
153 | output += result.stdout;
154 | }
155 |
156 | if (result.stderr) {
157 | if (output) output += '\n';
158 | output += result.stderr;
159 | }
160 |
161 | return output.trim();
162 | }
163 |
164 | static async executeGitCommand(
165 | command: string,
166 | operation: string,
167 | workingDir?: string,
168 | options: ExecOptions = {}
169 | ): Promise<CommandResult> {
170 | // Add git environment variables
171 | const gitOptions: ExecOptions = {
172 | ...options,
173 | env: {
174 | ...process.env,
175 | ...options.env,
176 | GIT_TERMINAL_PROMPT: '0', // Disable git terminal prompts
177 | GIT_ASKPASS: 'echo', // Prevent password prompts
178 | },
179 | };
180 |
181 | try {
182 | return await this.execute(`git ${command}`, operation, workingDir, gitOptions);
183 | } catch (error) {
184 | if (error instanceof GitMcpError) {
185 | // Add git-specific context to error
186 | logger.error(operation, 'Git command failed', workingDir, error, {
187 | command: `git ${command}`,
188 | gitConfig: await this.execute('git config --list', operation, workingDir)
189 | .then(result => result.stdout)
190 | .catch(() => 'Unable to get git config'),
191 | });
192 | }
193 | throw error;
194 | }
195 | }
196 |
197 | static async validateGitInstallation(operation: string): Promise<void> {
198 | try {
199 | const result = await this.execute('git --version', operation);
200 | logger.info(operation, 'Git installation validated', undefined, {
201 | version: result.stdout,
202 | });
203 | } catch (error) {
204 | const mcpError = new McpError(
205 | ErrorCode.InternalError,
206 | 'Git is not installed or not accessible'
207 | );
208 | logger.error(operation, 'Git installation validation failed', undefined, mcpError);
209 | throw mcpError;
210 | }
211 | }
212 | }
213 |
```
--------------------------------------------------------------------------------
/src/caching/cache.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { logger } from '../utils/logger.js';
2 | import { PerformanceMonitor } from '../monitoring/performance.js';
3 |
4 | /**
5 | * Cache entry with metadata
6 | */
7 | interface CacheEntry<T> {
8 | value: T;
9 | timestamp: number;
10 | ttl: number;
11 | hits: number;
12 | lastAccess: number;
13 | }
14 |
15 | /**
16 | * Cache configuration
17 | */
18 | interface CacheConfig {
19 | defaultTTL: number; // Default time-to-live in milliseconds
20 | maxSize: number; // Maximum number of entries
21 | cleanupInterval: number; // Cleanup interval in milliseconds
22 | }
23 |
24 | /**
25 | * Default cache configuration
26 | */
27 | const DEFAULT_CONFIG: CacheConfig = {
28 | defaultTTL: 5 * 60 * 1000, // 5 minutes
29 | maxSize: 1000, // 1000 entries
30 | cleanupInterval: 60 * 1000 // 1 minute
31 | };
32 |
33 | /**
34 | * Generic cache implementation with performance monitoring
35 | */
36 | export class Cache<T> {
37 | private entries: Map<string, CacheEntry<T>> = new Map();
38 | private config: CacheConfig;
39 | private performanceMonitor: PerformanceMonitor;
40 | private readonly cacheType: string;
41 |
42 | constructor(cacheType: string, config: Partial<CacheConfig> = {}) {
43 | this.config = { ...DEFAULT_CONFIG, ...config };
44 | this.cacheType = cacheType;
45 | this.performanceMonitor = PerformanceMonitor.getInstance();
46 | this.startCleanup();
47 | }
48 |
49 | /**
50 | * Set a cache entry
51 | */
52 | set(key: string, value: T, ttl: number = this.config.defaultTTL): void {
53 | // Check cache size limit
54 | if (this.entries.size >= this.config.maxSize) {
55 | this.evictOldest();
56 | }
57 |
58 | this.entries.set(key, {
59 | value,
60 | timestamp: Date.now(),
61 | ttl,
62 | hits: 0,
63 | lastAccess: Date.now()
64 | });
65 |
66 | logger.debug(
67 | 'cache',
68 | `Set cache entry: ${key}`,
69 | undefined,
70 | { cacheType: this.cacheType }
71 | );
72 | }
73 |
74 | /**
75 | * Get a cache entry
76 | */
77 | get(key: string): T | undefined {
78 | const entry = this.entries.get(key);
79 |
80 | if (!entry) {
81 | this.performanceMonitor.recordCacheAccess(false, this.cacheType);
82 | return undefined;
83 | }
84 |
85 | // Check if entry is expired
86 | if (this.isExpired(entry)) {
87 | this.entries.delete(key);
88 | this.performanceMonitor.recordCacheAccess(false, this.cacheType);
89 | return undefined;
90 | }
91 |
92 | // Update entry metadata
93 | entry.hits++;
94 | entry.lastAccess = Date.now();
95 | this.performanceMonitor.recordCacheAccess(true, this.cacheType);
96 |
97 | logger.debug(
98 | 'cache',
99 | `Cache hit: ${key}`,
100 | undefined,
101 | { cacheType: this.cacheType, hits: entry.hits }
102 | );
103 |
104 | return entry.value;
105 | }
106 |
107 | /**
108 | * Delete a cache entry
109 | */
110 | delete(key: string): void {
111 | this.entries.delete(key);
112 | logger.debug(
113 | 'cache',
114 | `Deleted cache entry: ${key}`,
115 | undefined,
116 | { cacheType: this.cacheType }
117 | );
118 | }
119 |
120 | /**
121 | * Clear all cache entries
122 | */
123 | clear(): void {
124 | this.entries.clear();
125 | logger.info(
126 | 'cache',
127 | 'Cleared cache',
128 | undefined,
129 | { cacheType: this.cacheType }
130 | );
131 | }
132 |
133 | /**
134 | * Get cache statistics
135 | */
136 | getStats(): Record<string, any> {
137 | const now = Date.now();
138 | let totalHits = 0;
139 | let totalSize = 0;
140 | let oldestTimestamp = now;
141 | let newestTimestamp = 0;
142 |
143 | this.entries.forEach(entry => {
144 | totalHits += entry.hits;
145 | totalSize++;
146 | oldestTimestamp = Math.min(oldestTimestamp, entry.timestamp);
147 | newestTimestamp = Math.max(newestTimestamp, entry.timestamp);
148 | });
149 |
150 | return {
151 | size: totalSize,
152 | maxSize: this.config.maxSize,
153 | totalHits,
154 | oldestEntry: oldestTimestamp === now ? null : oldestTimestamp,
155 | newestEntry: newestTimestamp === 0 ? null : newestTimestamp,
156 | hitRate: this.performanceMonitor.getCacheHitRate(this.cacheType)
157 | };
158 | }
159 |
160 | /**
161 | * Check if a cache entry exists and is valid
162 | */
163 | has(key: string): boolean {
164 | const entry = this.entries.get(key);
165 | if (!entry) return false;
166 | if (this.isExpired(entry)) {
167 | this.entries.delete(key);
168 | return false;
169 | }
170 | return true;
171 | }
172 |
173 | /**
174 | * Update cache configuration
175 | */
176 | updateConfig(config: Partial<CacheConfig>): void {
177 | this.config = {
178 | ...this.config,
179 | ...config
180 | };
181 | logger.info(
182 | 'cache',
183 | 'Updated cache configuration',
184 | undefined,
185 | { cacheType: this.cacheType, config: this.config }
186 | );
187 | }
188 |
189 | /**
190 | * Get current configuration
191 | */
192 | getConfig(): CacheConfig {
193 | return { ...this.config };
194 | }
195 |
196 | /**
197 | * Check if a cache entry is expired
198 | */
199 | private isExpired(entry: CacheEntry<T>): boolean {
200 | return Date.now() - entry.timestamp > entry.ttl;
201 | }
202 |
203 | /**
204 | * Evict the least recently used entry
205 | */
206 | private evictOldest(): void {
207 | let oldestKey: string | undefined;
208 | let oldestAccess = Date.now();
209 |
210 | this.entries.forEach((entry, key) => {
211 | if (entry.lastAccess < oldestAccess) {
212 | oldestAccess = entry.lastAccess;
213 | oldestKey = key;
214 | }
215 | });
216 |
217 | if (oldestKey) {
218 | this.entries.delete(oldestKey);
219 | logger.debug(
220 | 'cache',
221 | `Evicted oldest entry: ${oldestKey}`,
222 | undefined,
223 | { cacheType: this.cacheType }
224 | );
225 | }
226 | }
227 |
228 | /**
229 | * Start periodic cache cleanup
230 | */
231 | private startCleanup(): void {
232 | setInterval(() => {
233 | const now = Date.now();
234 | let expiredCount = 0;
235 |
236 | this.entries.forEach((entry, key) => {
237 | if (now - entry.timestamp > entry.ttl) {
238 | this.entries.delete(key);
239 | expiredCount++;
240 | }
241 | });
242 |
243 | if (expiredCount > 0) {
244 | logger.debug(
245 | 'cache',
246 | `Cleaned up ${expiredCount} expired entries`,
247 | undefined,
248 | { cacheType: this.cacheType }
249 | );
250 | }
251 | }, this.config.cleanupInterval);
252 | }
253 | }
254 |
255 | /**
256 | * Repository state cache
257 | */
258 | export class RepositoryStateCache extends Cache<any> {
259 | constructor() {
260 | super('repository_state', {
261 | defaultTTL: 30 * 1000, // 30 seconds
262 | maxSize: 100 // 100 entries
263 | });
264 | }
265 | }
266 |
267 | /**
268 | * Command result cache
269 | */
270 | export class CommandResultCache extends Cache<any> {
271 | constructor() {
272 | super('command_result', {
273 | defaultTTL: 5 * 60 * 1000, // 5 minutes
274 | maxSize: 500 // 500 entries
275 | });
276 | }
277 |
278 | /**
279 | * Generate cache key for a command
280 | */
281 | static generateKey(command: string, workingDir?: string): string {
282 | return `${workingDir || ''}:${command}`;
283 | }
284 | }
285 |
```
--------------------------------------------------------------------------------
/src/errors/error-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2 | import {
3 | ErrorCategory,
4 | ErrorSeverity,
5 | ErrorCategoryType,
6 | ErrorSeverityType,
7 | GitMcpError,
8 | ErrorContext
9 | } from './error-types.js';
10 | import { logger } from '../utils/logger.js';
11 |
12 | /**
13 | * Maps error categories to appropriate MCP error codes
14 | */
15 | function getMcpErrorCode(category: ErrorCategoryType, severity: ErrorSeverityType): ErrorCode {
16 | switch (category) {
17 | case ErrorCategory.VALIDATION:
18 | return ErrorCode.InvalidParams;
19 | case ErrorCategory.SYSTEM:
20 | case ErrorCategory.OPERATION:
21 | case ErrorCategory.NETWORK:
22 | case ErrorCategory.SECURITY:
23 | return ErrorCode.InternalError;
24 | case ErrorCategory.REPOSITORY:
25 | // For repository state errors, use InvalidParams if it's a configuration issue,
26 | // otherwise use InternalError
27 | return severity === ErrorSeverity.MEDIUM ? ErrorCode.InvalidParams : ErrorCode.InternalError;
28 | case ErrorCategory.CONFIGURATION:
29 | return ErrorCode.InvalidParams;
30 | default:
31 | return ErrorCode.InternalError;
32 | }
33 | }
34 |
35 | /**
36 | * Handles and logs errors with appropriate context and recovery steps
37 | */
38 | export class ErrorHandler {
39 | /**
40 | * Creates a GitMcpError with appropriate context and logs it
41 | */
42 | static handleError(
43 | error: Error | GitMcpError,
44 | category: ErrorCategoryType,
45 | severity: ErrorSeverityType,
46 | context: Partial<ErrorContext>
47 | ): GitMcpError {
48 | // If it's already a GitMcpError, just log and return it
49 | if (error instanceof GitMcpError) {
50 | this.logError(error);
51 | return error;
52 | }
53 |
54 | // Create new GitMcpError with context
55 | const errorContext: Partial<ErrorContext> = {
56 | ...context,
57 | stackTrace: error.stack,
58 | timestamp: Date.now()
59 | };
60 |
61 | const gitError = new GitMcpError(
62 | getMcpErrorCode(category, severity),
63 | error.message,
64 | severity,
65 | category,
66 | errorContext
67 | );
68 |
69 | this.logError(gitError);
70 | return gitError;
71 | }
72 |
73 | /**
74 | * Logs error with full context and recovery steps
75 | */
76 | private static logError(error: GitMcpError): void {
77 | const errorInfo = {
78 | name: error.name,
79 | message: error.message,
80 | severity: error.severity,
81 | category: error.category,
82 | context: error.context,
83 | recoverySteps: error.getRecoverySteps()
84 | };
85 |
86 | // Log based on severity
87 | switch (error.severity) {
88 | case ErrorSeverity.CRITICAL:
89 | logger.error(
90 | error.context.operation,
91 | `CRITICAL: ${error.message}`,
92 | error.context.path,
93 | error,
94 | errorInfo
95 | );
96 | break;
97 | case ErrorSeverity.HIGH:
98 | logger.error(
99 | error.context.operation,
100 | `HIGH: ${error.message}`,
101 | error.context.path,
102 | error,
103 | errorInfo
104 | );
105 | break;
106 | case ErrorSeverity.MEDIUM:
107 | logger.warn(
108 | error.context.operation,
109 | `MEDIUM: ${error.message}`,
110 | error.context.path,
111 | error,
112 | errorInfo
113 | );
114 | break;
115 | case ErrorSeverity.LOW:
116 | logger.warn(
117 | error.context.operation,
118 | `LOW: ${error.message}`,
119 | error.context.path,
120 | error,
121 | errorInfo
122 | );
123 | break;
124 | }
125 | }
126 |
127 | /**
128 | * Creates and handles a system error
129 | */
130 | static handleSystemError(error: Error, context: Partial<ErrorContext>): GitMcpError {
131 | return this.handleError(error, ErrorCategory.SYSTEM, ErrorSeverity.CRITICAL, context);
132 | }
133 |
134 | /**
135 | * Creates and handles a validation error
136 | */
137 | static handleValidationError(error: Error, context: Partial<ErrorContext>): GitMcpError {
138 | return this.handleError(error, ErrorCategory.VALIDATION, ErrorSeverity.HIGH, context);
139 | }
140 |
141 | /**
142 | * Creates and handles an operation error
143 | */
144 | static handleOperationError(error: Error, context: Partial<ErrorContext>): GitMcpError {
145 | return this.handleError(error, ErrorCategory.OPERATION, ErrorSeverity.HIGH, context);
146 | }
147 |
148 | /**
149 | * Creates and handles a repository error
150 | */
151 | static handleRepositoryError(error: Error, context: Partial<ErrorContext>): GitMcpError {
152 | return this.handleError(error, ErrorCategory.REPOSITORY, ErrorSeverity.HIGH, context);
153 | }
154 |
155 | /**
156 | * Creates and handles a network error
157 | */
158 | static handleNetworkError(error: Error, context: Partial<ErrorContext>): GitMcpError {
159 | return this.handleError(error, ErrorCategory.NETWORK, ErrorSeverity.HIGH, context);
160 | }
161 |
162 | /**
163 | * Creates and handles a configuration error
164 | */
165 | static handleConfigError(error: Error, context: Partial<ErrorContext>): GitMcpError {
166 | return this.handleError(error, ErrorCategory.CONFIGURATION, ErrorSeverity.MEDIUM, context);
167 | }
168 |
169 | /**
170 | * Creates and handles a security error
171 | */
172 | static handleSecurityError(error: Error, context: Partial<ErrorContext>): GitMcpError {
173 | return this.handleError(error, ErrorCategory.SECURITY, ErrorSeverity.CRITICAL, context);
174 | }
175 |
176 | /**
177 | * Determines if an error is retryable based on its category and severity
178 | */
179 | static isRetryable(error: GitMcpError): boolean {
180 | // Never retry validation or security errors
181 | if (
182 | error.category === ErrorCategory.VALIDATION ||
183 | error.category === ErrorCategory.SECURITY ||
184 | error.severity === ErrorSeverity.CRITICAL
185 | ) {
186 | return false;
187 | }
188 |
189 | // Network errors are usually retryable
190 | if (error.category === ErrorCategory.NETWORK) {
191 | return true;
192 | }
193 |
194 | // Repository and operation errors are retryable for non-critical severities
195 | if (
196 | (error.category === ErrorCategory.REPOSITORY ||
197 | error.category === ErrorCategory.OPERATION) &&
198 | [ErrorSeverity.HIGH, ErrorSeverity.MEDIUM, ErrorSeverity.LOW].includes(error.severity as any)
199 | ) {
200 | return true;
201 | }
202 |
203 | return false;
204 | }
205 |
206 | /**
207 | * Gets suggested retry delay in milliseconds based on error type
208 | */
209 | static getRetryDelay(error: GitMcpError): number {
210 | if (!this.isRetryable(error)) {
211 | return 0;
212 | }
213 |
214 | switch (error.category) {
215 | case ErrorCategory.NETWORK:
216 | return 1000; // 1 second for network issues
217 | case ErrorCategory.REPOSITORY:
218 | return 500; // 500ms for repository issues
219 | case ErrorCategory.OPERATION:
220 | return 200; // 200ms for operation issues
221 | default:
222 | return 1000; // Default 1 second
223 | }
224 | }
225 | }
226 |
```
--------------------------------------------------------------------------------
/src/operations/branch/branch-operations.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { BaseGitOperation } from '../base/base-operation.js';
2 | import { GitCommandBuilder } from '../../common/command-builder.js';
3 | import { CommandResult } from '../base/operation-result.js';
4 | import { ErrorHandler } from '../../errors/error-handler.js';
5 | import { RepositoryValidator } from '../../utils/repository.js';
6 | import { RepoStateType } from '../../caching/repository-cache.js';
7 | import {
8 | BranchListOptions,
9 | BranchCreateOptions,
10 | BranchDeleteOptions,
11 | CheckoutOptions,
12 | BranchListResult,
13 | BranchCreateResult,
14 | BranchDeleteResult,
15 | CheckoutResult,
16 | BranchInfo
17 | } from './branch-types.js';
18 |
19 | /**
20 | * Handles Git branch listing operations
21 | */
22 | export class BranchListOperation extends BaseGitOperation<BranchListOptions, BranchListResult> {
23 | protected buildCommand(): GitCommandBuilder {
24 | const command = GitCommandBuilder.branch();
25 |
26 | // Add format option for parsing
27 | command.option('format', '%(refname:short)|%(upstream:short)|%(objectname:short)|%(subject)');
28 |
29 | if (this.options.remotes) {
30 | command.flag('remotes');
31 | }
32 | if (this.options.all) {
33 | command.flag('all');
34 | }
35 | if (this.options.contains) {
36 | command.option('contains', this.options.contains);
37 | }
38 | if (this.options.merged) {
39 | command.option('merged', this.options.merged);
40 | }
41 | if (this.options.noMerged) {
42 | command.option('no-merged', this.options.noMerged);
43 | }
44 |
45 | return command;
46 | }
47 |
48 | protected parseResult(result: CommandResult): BranchListResult {
49 | const branches: BranchInfo[] = [];
50 | let current = '';
51 |
52 | // Parse each line of output
53 | result.stdout.split('\n').filter(Boolean).forEach(line => {
54 | const [name, tracking, commit, message] = line.split('|');
55 | const isCurrent = name.startsWith('* ');
56 | const cleanName = name.replace('* ', '');
57 |
58 | const branch: BranchInfo = {
59 | name: cleanName,
60 | current: isCurrent,
61 | tracking: tracking || undefined,
62 | remote: cleanName.includes('origin/'),
63 | commit: commit || undefined,
64 | message: message || undefined
65 | };
66 |
67 | if (isCurrent) {
68 | current = cleanName;
69 | }
70 |
71 | branches.push(branch);
72 | });
73 |
74 | return {
75 | current,
76 | branches,
77 | raw: result.stdout
78 | };
79 | }
80 |
81 | protected getCacheConfig() {
82 | return {
83 | command: 'branch',
84 | stateType: RepoStateType.BRANCH
85 | };
86 | }
87 |
88 | protected validateOptions(): void {
89 | // No specific validation needed for listing
90 | }
91 | }
92 |
93 | /**
94 | * Handles Git branch creation operations
95 | */
96 | export class BranchCreateOperation extends BaseGitOperation<BranchCreateOptions, BranchCreateResult> {
97 | protected buildCommand(): GitCommandBuilder {
98 | const command = GitCommandBuilder.branch()
99 | .arg(this.options.name);
100 |
101 | if (this.options.startPoint) {
102 | command.arg(this.options.startPoint);
103 | }
104 |
105 | if (this.options.force) {
106 | command.withForce();
107 | }
108 |
109 | if (this.options.track) {
110 | command.withTrack();
111 | } else {
112 | command.withNoTrack();
113 | }
114 |
115 | if (this.options.setUpstream) {
116 | command.withSetUpstream();
117 | }
118 |
119 | return command;
120 | }
121 |
122 | protected parseResult(result: CommandResult): BranchCreateResult {
123 | return {
124 | name: this.options.name,
125 | startPoint: this.options.startPoint,
126 | tracking: result.stdout.includes('set up to track') ?
127 | result.stdout.match(/track\s+([^\s]+)/)?.[1] : undefined,
128 | raw: result.stdout
129 | };
130 | }
131 |
132 | protected getCacheConfig() {
133 | return {
134 | command: 'branch_create',
135 | stateType: RepoStateType.BRANCH
136 | };
137 | }
138 |
139 | protected validateOptions(): void {
140 | if (!this.options.name) {
141 | throw ErrorHandler.handleValidationError(
142 | new Error('Branch name is required'),
143 | { operation: this.context.operation }
144 | );
145 | }
146 | }
147 | }
148 |
149 | /**
150 | * Handles Git branch deletion operations
151 | */
152 | export class BranchDeleteOperation extends BaseGitOperation<BranchDeleteOptions, BranchDeleteResult> {
153 | protected async buildCommand(): Promise<GitCommandBuilder> {
154 | const command = GitCommandBuilder.branch();
155 |
156 | // Use -D for force delete, -d for safe delete
157 | command.flag(this.options.force ? 'D' : 'd')
158 | .arg(this.options.name);
159 |
160 | if (this.options.remote) {
161 | // Get remote name from branch if it's a remote branch
162 | const remoteName = this.options.name.split('/')[0];
163 | if (remoteName) {
164 | await RepositoryValidator.validateRemoteConfig(
165 | this.getResolvedPath(),
166 | remoteName,
167 | this.context.operation
168 | );
169 | }
170 | command.flag('r');
171 | }
172 |
173 | return command;
174 | }
175 |
176 | protected parseResult(result: CommandResult): BranchDeleteResult {
177 | return {
178 | name: this.options.name,
179 | forced: this.options.force || false,
180 | raw: result.stdout
181 | };
182 | }
183 |
184 | protected getCacheConfig() {
185 | return {
186 | command: 'branch_delete',
187 | stateType: RepoStateType.BRANCH
188 | };
189 | }
190 |
191 | protected async validateOptions(): Promise<void> {
192 | if (!this.options.name) {
193 | throw ErrorHandler.handleValidationError(
194 | new Error('Branch name is required'),
195 | { operation: this.context.operation }
196 | );
197 | }
198 |
199 | // Ensure branch exists
200 | await RepositoryValidator.validateBranchExists(
201 | this.getResolvedPath(),
202 | this.options.name,
203 | this.context.operation
204 | );
205 |
206 | // Cannot delete current branch
207 | const currentBranch = await RepositoryValidator.getCurrentBranch(
208 | this.getResolvedPath(),
209 | this.context.operation
210 | );
211 | if (currentBranch === this.options.name) {
212 | throw ErrorHandler.handleValidationError(
213 | new Error(`Cannot delete the currently checked out branch: ${this.options.name}`),
214 | { operation: this.context.operation }
215 | );
216 | }
217 | }
218 |
219 | }
220 |
221 | /**
222 | * Handles Git checkout operations
223 | */
224 | export class CheckoutOperation extends BaseGitOperation<CheckoutOptions, CheckoutResult> {
225 | protected async buildCommand(): Promise<GitCommandBuilder> {
226 | const command = GitCommandBuilder.checkout();
227 |
228 | if (this.options.newBranch) {
229 | command.flag('b').arg(this.options.newBranch);
230 | if (this.options.track) {
231 | command.withTrack();
232 | }
233 | }
234 |
235 | command.arg(this.options.target);
236 |
237 | if (this.options.force) {
238 | command.withForce();
239 | }
240 |
241 | return command;
242 | }
243 |
244 | protected parseResult(result: CommandResult): CheckoutResult {
245 | const previousHead = result.stdout.match(/HEAD is now at ([a-f0-9]+)/)?.[1];
246 | const newBranch = result.stdout.includes('Switched to a new branch') ?
247 | this.options.newBranch : undefined;
248 |
249 | return {
250 | target: this.options.target,
251 | newBranch,
252 | previousHead,
253 | raw: result.stdout
254 | };
255 | }
256 |
257 | protected getCacheConfig() {
258 | return {
259 | command: 'checkout',
260 | stateType: RepoStateType.BRANCH
261 | };
262 | }
263 |
264 | protected async validateOptions(): Promise<void> {
265 | if (!this.options.target) {
266 | throw ErrorHandler.handleValidationError(
267 | new Error('Checkout target is required'),
268 | { operation: this.context.operation }
269 | );
270 | }
271 |
272 | // Ensure working tree is clean unless force is specified
273 | if (!this.options.force) {
274 | await RepositoryValidator.ensureClean(
275 | this.getResolvedPath(),
276 | this.context.operation
277 | );
278 | }
279 | }
280 |
281 | }
282 |
```
--------------------------------------------------------------------------------
/src/errors/error-types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2 |
3 | /**
4 | * Error severity levels for categorizing errors and determining appropriate responses
5 | */
6 | export const ErrorSeverity = {
7 | CRITICAL: 'CRITICAL', // System-level failures requiring immediate attention
8 | HIGH: 'HIGH', // Operation-blocking errors that need urgent handling
9 | MEDIUM: 'MEDIUM', // Non-blocking errors that should be addressed
10 | LOW: 'LOW' // Minor issues that can be handled gracefully
11 | } as const;
12 |
13 | export type ErrorSeverityType = typeof ErrorSeverity[keyof typeof ErrorSeverity];
14 |
15 | /**
16 | * Error categories for better error handling and reporting
17 | */
18 | export const ErrorCategory = {
19 | SYSTEM: 'SYSTEM', // System-level errors (file system, process, etc.)
20 | VALIDATION: 'VALIDATION', // Input validation errors
21 | OPERATION: 'OPERATION', // Git operation errors
22 | REPOSITORY: 'REPOSITORY', // Repository state errors
23 | NETWORK: 'NETWORK', // Network-related errors
24 | CONFIGURATION: 'CONFIG', // Configuration errors
25 | SECURITY: 'SECURITY' // Security-related errors
26 | } as const;
27 |
28 | export type ErrorCategoryType = typeof ErrorCategory[keyof typeof ErrorCategory];
29 |
30 | /**
31 | * Extended error context for better error tracking and debugging
32 | */
33 | export interface ErrorContext {
34 | operation: string; // Operation being performed
35 | path?: string; // Path being operated on
36 | command?: string; // Git command being executed
37 | timestamp: number; // Error occurrence timestamp
38 | severity: ErrorSeverityType; // Error severity level
39 | category: ErrorCategoryType; // Error category
40 | details?: {
41 | currentUsage?: number;
42 | threshold?: number;
43 | command?: string;
44 | exitCode?: number | string;
45 | stdout?: string;
46 | stderr?: string;
47 | config?: string;
48 | tool?: string;
49 | args?: unknown;
50 | [key: string]: unknown;
51 | }; // Additional error-specific details
52 | recoverySteps?: string[]; // Suggested recovery steps
53 | stackTrace?: string; // Error stack trace
54 | }
55 |
56 | /**
57 | * Base class for all Git MCP server errors
58 | */
59 | export class GitMcpError extends McpError {
60 | readonly severity: ErrorSeverityType;
61 | readonly category: ErrorCategoryType;
62 | readonly context: ErrorContext;
63 |
64 | constructor(
65 | code: ErrorCode,
66 | message: string,
67 | severity: ErrorSeverityType,
68 | category: ErrorCategoryType,
69 | context: Partial<ErrorContext>
70 | ) {
71 | super(code, message);
72 | this.name = 'GitMcpError';
73 | this.severity = severity;
74 | this.category = category;
75 | this.context = {
76 | operation: context.operation || 'unknown',
77 | timestamp: Date.now(),
78 | severity,
79 | category,
80 | ...context
81 | };
82 | }
83 |
84 | /**
85 | * Get recovery steps based on error type and context
86 | */
87 | getRecoverySteps(): string[] {
88 | return this.context.recoverySteps || this.getDefaultRecoverySteps();
89 | }
90 |
91 | /**
92 | * Get default recovery steps based on error category
93 | */
94 | private getDefaultRecoverySteps(): string[] {
95 | switch (this.category) {
96 | case ErrorCategory.SYSTEM:
97 | return [
98 | 'Check system permissions and access rights',
99 | 'Verify file system access',
100 | 'Check available disk space',
101 | 'Ensure required dependencies are installed'
102 | ];
103 | case ErrorCategory.VALIDATION:
104 | return [
105 | 'Verify input parameters are correct',
106 | 'Check path formatting and permissions',
107 | 'Ensure all required fields are provided'
108 | ];
109 | case ErrorCategory.OPERATION:
110 | return [
111 | 'Verify Git command syntax',
112 | 'Check repository state',
113 | 'Ensure working directory is clean',
114 | 'Try running git status for more information'
115 | ];
116 | case ErrorCategory.REPOSITORY:
117 | return [
118 | 'Verify repository exists and is accessible',
119 | 'Check repository permissions',
120 | 'Ensure .git directory is intact',
121 | 'Try reinitializing the repository'
122 | ];
123 | case ErrorCategory.NETWORK:
124 | return [
125 | 'Check network connectivity',
126 | 'Verify remote repository access',
127 | 'Check authentication credentials',
128 | 'Try using git remote -v to verify remote configuration'
129 | ];
130 | case ErrorCategory.CONFIGURATION:
131 | return [
132 | 'Check Git configuration',
133 | 'Verify environment variables',
134 | 'Ensure required settings are configured',
135 | 'Try git config --list to view current configuration'
136 | ];
137 | case ErrorCategory.SECURITY:
138 | return [
139 | 'Check file and directory permissions',
140 | 'Verify authentication credentials',
141 | 'Ensure secure connection to remote',
142 | 'Review security settings'
143 | ];
144 | default:
145 | return [
146 | 'Check operation parameters',
147 | 'Verify system state',
148 | 'Review error message details',
149 | 'Contact support if issue persists'
150 | ];
151 | }
152 | }
153 |
154 | /**
155 | * Format error for logging
156 | */
157 | toJSON(): Record<string, any> {
158 | return {
159 | name: this.name,
160 | message: this.message,
161 | code: this.code,
162 | severity: this.severity,
163 | category: this.category,
164 | context: this.context,
165 | recoverySteps: this.getRecoverySteps(),
166 | stack: this.stack
167 | };
168 | }
169 | }
170 |
171 | /**
172 | * System-level errors
173 | */
174 | export class SystemError extends GitMcpError {
175 | constructor(message: string, context: Partial<ErrorContext>) {
176 | super(
177 | ErrorCode.InternalError,
178 | message,
179 | ErrorSeverity.CRITICAL,
180 | ErrorCategory.SYSTEM,
181 | context
182 | );
183 | this.name = 'SystemError';
184 | }
185 | }
186 |
187 | /**
188 | * Validation errors
189 | */
190 | export class ValidationError extends GitMcpError {
191 | constructor(message: string, context: Partial<ErrorContext>) {
192 | super(
193 | ErrorCode.InvalidParams,
194 | message,
195 | ErrorSeverity.HIGH,
196 | ErrorCategory.VALIDATION,
197 | context
198 | );
199 | this.name = 'ValidationError';
200 | }
201 | }
202 |
203 | /**
204 | * Git operation errors
205 | */
206 | export class OperationError extends GitMcpError {
207 | constructor(message: string, context: Partial<ErrorContext>) {
208 | super(
209 | ErrorCode.InternalError,
210 | message,
211 | ErrorSeverity.HIGH,
212 | ErrorCategory.OPERATION,
213 | context
214 | );
215 | this.name = 'OperationError';
216 | }
217 | }
218 |
219 | /**
220 | * Repository state errors
221 | */
222 | export class RepositoryError extends GitMcpError {
223 | constructor(message: string, context: Partial<ErrorContext>) {
224 | super(
225 | ErrorCode.InternalError,
226 | message,
227 | ErrorSeverity.HIGH,
228 | ErrorCategory.REPOSITORY,
229 | context
230 | );
231 | this.name = 'RepositoryError';
232 | }
233 | }
234 |
235 | /**
236 | * Network-related errors
237 | */
238 | export class NetworkError extends GitMcpError {
239 | constructor(message: string, context: Partial<ErrorContext>) {
240 | super(
241 | ErrorCode.InternalError,
242 | message,
243 | ErrorSeverity.HIGH,
244 | ErrorCategory.NETWORK,
245 | context
246 | );
247 | this.name = 'NetworkError';
248 | }
249 | }
250 |
251 | /**
252 | * Configuration errors
253 | */
254 | export class ConfigurationError extends GitMcpError {
255 | constructor(message: string, context: Partial<ErrorContext>) {
256 | super(
257 | ErrorCode.InvalidParams,
258 | message,
259 | ErrorSeverity.MEDIUM,
260 | ErrorCategory.CONFIGURATION,
261 | context
262 | );
263 | this.name = 'ConfigurationError';
264 | }
265 | }
266 |
267 | /**
268 | * Security-related errors
269 | */
270 | export class SecurityError extends GitMcpError {
271 | constructor(message: string, context: Partial<ErrorContext>) {
272 | super(
273 | ErrorCode.InternalError,
274 | message,
275 | ErrorSeverity.CRITICAL,
276 | ErrorCategory.SECURITY,
277 | context
278 | );
279 | this.name = 'SecurityError';
280 | }
281 | }
282 |
```
--------------------------------------------------------------------------------
/src/operations/working-tree/working-tree-operations.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { BaseGitOperation } from '../base/base-operation.js';
2 | import { GitCommandBuilder } from '../../common/command-builder.js';
3 | import { CommandResult } from '../base/operation-result.js';
4 | import { ErrorHandler } from '../../errors/error-handler.js';
5 | import { RepositoryValidator } from '../../utils/repository.js';
6 | import { CommandExecutor } from '../../utils/command.js';
7 | import { RepoStateType } from '../../caching/repository-cache.js';
8 | import {
9 | AddOptions,
10 | CommitOptions,
11 | StatusOptions,
12 | AddResult,
13 | CommitResult,
14 | StatusResult,
15 | FileChange
16 | } from './working-tree-types.js';
17 |
18 | /**
19 | * Handles Git add operations
20 | */
21 | export class AddOperation extends BaseGitOperation<AddOptions, AddResult> {
22 | protected buildCommand(): GitCommandBuilder {
23 | const command = GitCommandBuilder.add();
24 |
25 | if (this.options.all) {
26 | command.flag('all');
27 | }
28 |
29 | if (this.options.update) {
30 | command.flag('update');
31 | }
32 |
33 | if (this.options.ignoreRemoval) {
34 | command.flag('no-all');
35 | }
36 |
37 | if (this.options.force) {
38 | command.withForce();
39 | }
40 |
41 | if (this.options.dryRun) {
42 | command.flag('dry-run');
43 | }
44 |
45 | // Add files
46 | this.options.files.forEach(file => command.arg(file));
47 |
48 | return command;
49 | }
50 |
51 | protected parseResult(result: CommandResult): AddResult {
52 | const staged: string[] = [];
53 | const notStaged: Array<{ path: string; reason: string }> = [];
54 |
55 | // Parse output to determine which files were staged
56 | result.stdout.split('\n').forEach(line => {
57 | const match = line.match(/^add '(.+)'$/);
58 | if (match) {
59 | staged.push(match[1]);
60 | } else if (line.includes('error:')) {
61 | const errorMatch = line.match(/error: (.+?) '(.+?)'/);
62 | if (errorMatch) {
63 | notStaged.push({
64 | path: errorMatch[2],
65 | reason: errorMatch[1]
66 | });
67 | }
68 | }
69 | });
70 |
71 | return {
72 | staged,
73 | notStaged: notStaged.length > 0 ? notStaged : undefined,
74 | raw: result.stdout
75 | };
76 | }
77 |
78 | protected getCacheConfig() {
79 | return {
80 | command: 'add',
81 | stateType: RepoStateType.STATUS
82 | };
83 | }
84 |
85 | protected validateOptions(): void {
86 | if (!this.options.files || this.options.files.length === 0) {
87 | throw ErrorHandler.handleValidationError(
88 | new Error('At least one file must be specified'),
89 | { operation: this.context.operation }
90 | );
91 | }
92 | }
93 | }
94 |
95 | /**
96 | * Handles Git commit operations
97 | */
98 | export class CommitOperation extends BaseGitOperation<CommitOptions, CommitResult> {
99 | protected buildCommand(): GitCommandBuilder {
100 | const command = GitCommandBuilder.commit();
101 |
102 | command.withMessage(this.options.message);
103 |
104 | if (this.options.allowEmpty) {
105 | command.flag('allow-empty');
106 | }
107 |
108 | if (this.options.amend) {
109 | command.flag('amend');
110 | }
111 |
112 | if (this.options.noVerify) {
113 | command.withNoVerify();
114 | }
115 |
116 | if (this.options.author) {
117 | command.option('author', this.options.author);
118 | }
119 |
120 | // Add specific files if provided
121 | if (this.options.files) {
122 | this.options.files.forEach(file => command.arg(file));
123 | }
124 |
125 | return command;
126 | }
127 |
128 | protected parseResult(result: CommandResult): CommitResult {
129 | const hash = result.stdout.match(/\[.+?(\w+)\]/)?.[1] || '';
130 | const stats = result.stdout.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
131 |
132 | return {
133 | hash,
134 | filesChanged: stats ? parseInt(stats[1], 10) : 0,
135 | insertions: stats && stats[2] ? parseInt(stats[2], 10) : 0,
136 | deletions: stats && stats[3] ? parseInt(stats[3], 10) : 0,
137 | amended: this.options.amend || false,
138 | raw: result.stdout
139 | };
140 | }
141 |
142 | protected getCacheConfig() {
143 | return {
144 | command: 'commit',
145 | stateType: RepoStateType.STATUS
146 | };
147 | }
148 |
149 | protected async validateOptions(): Promise<void> {
150 | if (!this.options.message && !this.options.amend) {
151 | throw ErrorHandler.handleValidationError(
152 | new Error('Commit message is required unless amending'),
153 | { operation: this.context.operation }
154 | );
155 | }
156 |
157 | // Verify there are staged changes unless allowing empty commits
158 | if (!this.options.allowEmpty) {
159 | const statusResult = await CommandExecutor.executeGitCommand(
160 | 'status --porcelain',
161 | this.context.operation,
162 | this.getResolvedPath()
163 | );
164 |
165 | if (!statusResult.stdout.trim()) {
166 | throw ErrorHandler.handleValidationError(
167 | new Error('No changes to commit'),
168 | { operation: this.context.operation }
169 | );
170 | }
171 | }
172 | }
173 | }
174 |
175 | /**
176 | * Handles Git status operations
177 | */
178 | export class StatusOperation extends BaseGitOperation<StatusOptions, StatusResult> {
179 | protected buildCommand(): GitCommandBuilder {
180 | const command = GitCommandBuilder.status();
181 |
182 | // Use porcelain format for consistent parsing
183 | command.flag('porcelain');
184 | command.flag('z'); // Use NUL character as separator
185 |
186 | if (this.options.showUntracked) {
187 | command.flag('untracked-files');
188 | }
189 |
190 | if (this.options.ignoreSubmodules) {
191 | command.option('ignore-submodules', 'all');
192 | }
193 |
194 | if (this.options.showIgnored) {
195 | command.flag('ignored');
196 | }
197 |
198 | if (this.options.showBranch) {
199 | command.flag('branch');
200 | }
201 |
202 | return command;
203 | }
204 |
205 | protected async parseResult(result: CommandResult): Promise<StatusResult> {
206 | const staged: FileChange[] = [];
207 | const unstaged: FileChange[] = [];
208 | const untracked: FileChange[] = [];
209 | const ignored: FileChange[] = [];
210 |
211 | // Get current branch
212 | const branchResult = await CommandExecutor.executeGitCommand(
213 | 'rev-parse --abbrev-ref HEAD',
214 | this.context.operation,
215 | this.getResolvedPath()
216 | );
217 | const branch = branchResult.stdout.trim();
218 |
219 | // Parse status output
220 | const entries = result.stdout.split('\0').filter(Boolean);
221 | for (const entry of entries) {
222 | const [status, ...pathParts] = entry.split(' ');
223 | const path = pathParts.join(' ');
224 |
225 | const change: FileChange = {
226 | path,
227 | type: this.parseChangeType(status),
228 | staged: status[0] !== ' ' && status[0] !== '?',
229 | raw: status
230 | };
231 |
232 | // Handle renamed files
233 | if (change.type === 'renamed') {
234 | const [oldPath, newPath] = path.split(' -> ');
235 | change.path = newPath;
236 | change.originalPath = oldPath;
237 | }
238 |
239 | // Categorize the change
240 | if (status === '??') {
241 | untracked.push(change);
242 | } else if (status === '!!') {
243 | ignored.push(change);
244 | } else if (change.staged) {
245 | staged.push(change);
246 | } else {
247 | unstaged.push(change);
248 | }
249 | }
250 |
251 | return {
252 | branch,
253 | clean: staged.length === 0 && unstaged.length === 0 && untracked.length === 0,
254 | staged,
255 | unstaged,
256 | untracked,
257 | ignored: this.options.showIgnored ? ignored : undefined,
258 | raw: result.stdout
259 | };
260 | }
261 |
262 | protected getCacheConfig() {
263 | return {
264 | command: 'status',
265 | stateType: RepoStateType.STATUS
266 | };
267 | }
268 |
269 | protected validateOptions(): void {
270 | // No specific validation needed for status
271 | }
272 |
273 | private parseChangeType(status: string): FileChange['type'] {
274 | const index = status[0];
275 | const worktree = status[1];
276 |
277 | if (status === '??') return 'untracked';
278 | if (status === '!!') return 'ignored';
279 | if (index === 'R' || worktree === 'R') return 'renamed';
280 | if (index === 'C' || worktree === 'C') return 'copied';
281 | if (index === 'A' || worktree === 'A') return 'added';
282 | if (index === 'D' || worktree === 'D') return 'deleted';
283 | return 'modified';
284 | }
285 | }
286 |
```
--------------------------------------------------------------------------------
/src/utils/path.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { resolve, isAbsolute, normalize } from 'path';
2 | import { existsSync, statSync, readdirSync } from 'fs';
3 | import { ErrorHandler } from '../errors/error-handler.js';
4 | import { GitMcpError } from '../errors/error-types.js';
5 |
6 | export interface PathValidationOptions {
7 | mustExist?: boolean;
8 | allowDirectory?: boolean;
9 | allowPattern?: boolean;
10 | cwd?: string;
11 | operation?: string;
12 | }
13 |
14 | export class PathValidator {
15 | static validatePath(path: string, options: PathValidationOptions = {}): string {
16 | const {
17 | mustExist = true,
18 | allowDirectory = true,
19 | cwd = process.cwd(),
20 | operation = 'path_validation'
21 | } = options;
22 |
23 | try {
24 | if (!path || typeof path !== 'string') {
25 | throw new Error('Path must be a non-empty string');
26 | }
27 |
28 | // Convert to absolute path if relative
29 | const absolutePath = isAbsolute(path) ? normalize(path) : resolve(cwd, path);
30 |
31 | // Check existence if required
32 | if (mustExist && !existsSync(absolutePath)) {
33 | throw new Error(`Path does not exist: ${path}`);
34 | }
35 |
36 | // If path exists and is not a pattern, validate type
37 | if (existsSync(absolutePath)) {
38 | const stats = statSync(absolutePath);
39 | if (!allowDirectory && stats.isDirectory()) {
40 | throw new Error(`Path is a directory when file expected: ${path}`);
41 | }
42 | }
43 |
44 | return absolutePath;
45 | } catch (error: unknown) {
46 | throw ErrorHandler.handleValidationError(error instanceof Error ? error : new Error('Unknown error'), {
47 | operation,
48 | path,
49 | details: { options }
50 | });
51 | }
52 | }
53 |
54 | static validateGitRepo(path: string, operation = 'validate_repo'): { path: string; hasEmbeddedRepo: boolean } {
55 | try {
56 | const absolutePath = this.validatePath(path, { allowDirectory: true, operation });
57 | const gitPath = resolve(absolutePath, '.git');
58 |
59 | if (!existsSync(gitPath)) {
60 | throw new Error(`Not a git repository: ${path}`);
61 | }
62 |
63 | if (!statSync(gitPath).isDirectory()) {
64 | throw new Error(`Invalid git repository: ${path}`);
65 | }
66 |
67 | // Check for embedded repositories
68 | let hasEmbeddedRepo = false;
69 | const checkEmbeddedRepos = (dir: string) => {
70 | const entries = readdirSync(dir, { withFileTypes: true });
71 | for (const entry of entries) {
72 | if (entry.isDirectory()) {
73 | const fullPath = resolve(dir, entry.name);
74 | if (entry.name === '.git' && fullPath !== gitPath) {
75 | hasEmbeddedRepo = true;
76 | break;
77 | }
78 | if (entry.name !== '.git' && entry.name !== 'node_modules') {
79 | checkEmbeddedRepos(fullPath);
80 | }
81 | }
82 | }
83 | };
84 | checkEmbeddedRepos(absolutePath);
85 |
86 | return { path: absolutePath, hasEmbeddedRepo };
87 | } catch (error: unknown) {
88 | if (error instanceof GitMcpError) throw error;
89 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
90 | operation,
91 | path,
92 | details: { gitPath: resolve(path, '.git') }
93 | });
94 | }
95 | }
96 |
97 | static validatePaths(paths: string[], options: PathValidationOptions = {}): string[] {
98 | const {
99 | allowPattern = false,
100 | cwd = process.cwd(),
101 | operation = 'validate_paths'
102 | } = options;
103 |
104 | try {
105 | if (!Array.isArray(paths)) {
106 | throw new Error('Paths must be an array');
107 | }
108 |
109 | return paths.map(path => {
110 | if (!path || typeof path !== 'string') {
111 | throw new Error('Each path must be a non-empty string');
112 | }
113 |
114 | // If patterns are allowed and path contains wildcards, return as-is
115 | if (allowPattern && /[*?[\]]/.test(path)) {
116 | return path;
117 | }
118 |
119 | // For relative paths starting with '.', make them relative to the repository root
120 | if (path.startsWith('.')) {
121 | // Just return the path as-is to let Git handle it relative to the repo root
122 | return path;
123 | }
124 |
125 | // Convert to absolute path if relative
126 | return isAbsolute(path) ? normalize(path) : resolve(cwd, path);
127 | });
128 | } catch (error: unknown) {
129 | throw ErrorHandler.handleValidationError(error instanceof Error ? error : new Error('Unknown error'), {
130 | operation,
131 | details: { paths, options }
132 | });
133 | }
134 | }
135 |
136 | static validateBranchName(branch: string, operation = 'validate_branch'): void {
137 | try {
138 | if (!branch || typeof branch !== 'string') {
139 | throw new Error('Branch name must be a non-empty string');
140 | }
141 |
142 | // Git branch naming rules
143 | if (!/^(?!\/|\.|\.\.|@|\{|\}|\[|\]|\\)[\x21-\x7E]+(?<!\.lock|[/.])$/.test(branch)) {
144 | throw new Error('Invalid branch name format');
145 | }
146 | } catch (error: unknown) {
147 | throw ErrorHandler.handleValidationError(error instanceof Error ? error : new Error('Unknown error'), {
148 | operation,
149 | details: { branch }
150 | });
151 | }
152 | }
153 |
154 | static validateRemoteName(name: string, operation = 'validate_remote'): void {
155 | try {
156 | if (!name || typeof name !== 'string') {
157 | throw new Error('Remote name must be a non-empty string');
158 | }
159 |
160 | // Git remote naming rules
161 | if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(name)) {
162 | throw new Error('Invalid remote name format');
163 | }
164 | } catch (error: unknown) {
165 | throw ErrorHandler.handleValidationError(error instanceof Error ? error : new Error('Unknown error'), {
166 | operation,
167 | details: { remoteName: name }
168 | });
169 | }
170 | }
171 |
172 | static validateRemoteUrl(url: string, operation = 'validate_remote_url'): void {
173 | try {
174 | if (!url || typeof url !== 'string') {
175 | throw new Error('Remote URL must be a non-empty string');
176 | }
177 |
178 | // Basic URL format validation for git URLs
179 | const gitUrlPattern = /^(git|https?|ssh):\/\/|^git@|^[a-zA-Z0-9_-]+:/;
180 | if (!gitUrlPattern.test(url)) {
181 | throw new Error('Invalid git remote URL format');
182 | }
183 |
184 | // Additional security checks for URLs
185 | const securityPattern = /[<>'";&|]/;
186 | if (securityPattern.test(url)) {
187 | throw new Error('Remote URL contains invalid characters');
188 | }
189 | } catch (error: unknown) {
190 | throw ErrorHandler.handleValidationError(error instanceof Error ? error : new Error('Unknown error'), {
191 | operation,
192 | details: {
193 | url,
194 | allowedProtocols: ['git', 'https', 'ssh']
195 | }
196 | });
197 | }
198 | }
199 |
200 | static validateTagName(tag: string, operation = 'validate_tag'): void {
201 | try {
202 | if (!tag || typeof tag !== 'string') {
203 | throw new Error('Tag name must be a non-empty string');
204 | }
205 |
206 | // Git tag naming rules
207 | if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(tag)) {
208 | throw new Error('Invalid tag name format');
209 | }
210 |
211 | // Additional validation for semantic versioning tags
212 | if (tag.startsWith('v') && !/^v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(tag)) {
213 | throw new Error('Invalid semantic version tag format');
214 | }
215 | } catch (error: unknown) {
216 | throw ErrorHandler.handleValidationError(error instanceof Error ? error : new Error('Unknown error'), {
217 | operation,
218 | details: {
219 | tag,
220 | semanticVersioning: tag.startsWith('v')
221 | }
222 | });
223 | }
224 | }
225 |
226 | /**
227 | * Validates a commit message format
228 | */
229 | static validateCommitMessage(message: string, operation = 'validate_commit'): void {
230 | try {
231 | if (!message || typeof message !== 'string') {
232 | throw new Error('Commit message must be a non-empty string');
233 | }
234 |
235 | // Basic commit message format validation
236 | if (message.length > 72) {
237 | throw new Error('Commit message exceeds maximum length of 72 characters');
238 | }
239 |
240 | // Check for conventional commit format if it appears to be one
241 | const conventionalPattern = /^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\(.+\))?: .+/;
242 | if (message.match(/^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)/) && !conventionalPattern.test(message)) {
243 | throw new Error('Invalid conventional commit format');
244 | }
245 | } catch (error: unknown) {
246 | throw ErrorHandler.handleValidationError(error instanceof Error ? error : new Error('Unknown error'), {
247 | operation,
248 | details: {
249 | message,
250 | isConventionalCommit: message.match(/^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)/) !== null
251 | }
252 | });
253 | }
254 | }
255 | }
256 |
```
--------------------------------------------------------------------------------
/src/utils/repository.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { existsSync } from 'fs';
2 | import { join } from 'path';
3 | import { CommandExecutor } from './command.js';
4 | import { ErrorHandler } from '../errors/error-handler.js';
5 | import { GitMcpError } from '../errors/error-types.js';
6 |
7 | export class RepositoryValidator {
8 | /**
9 | * Get list of configured remotes
10 | */
11 | static async getRemotes(path: string, operation: string): Promise<string[]> {
12 | const result = await CommandExecutor.executeGitCommand(
13 | 'remote',
14 | operation,
15 | path
16 | );
17 | return result.stdout.split('\n').filter(Boolean);
18 | }
19 |
20 | static async validateLocalRepo(path: string, operation: string): Promise<void> {
21 | try {
22 | const gitDir = join(path, '.git');
23 | if (!existsSync(gitDir)) {
24 | throw new Error('Not a git repository');
25 | }
26 | } catch (error: unknown) {
27 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
28 | operation,
29 | path,
30 | details: { gitDir: join(path, '.git') }
31 | });
32 | }
33 | }
34 |
35 | static async validateRemoteRepo(remote: string, operation: string): Promise<void> {
36 | try {
37 | await CommandExecutor.execute(`git ls-remote ${remote}`, operation);
38 | } catch (error: unknown) {
39 | if (error instanceof GitMcpError) throw error;
40 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
41 | operation,
42 | details: {
43 | remote,
44 | action: 'validate_remote_repo'
45 | }
46 | });
47 | }
48 | }
49 |
50 | static async validateBranchExists(path: string, branch: string, operation: string): Promise<void> {
51 | try {
52 | await CommandExecutor.execute(
53 | `git show-ref --verify --quiet refs/heads/${branch}`,
54 | operation,
55 | path
56 | );
57 | } catch (error: unknown) {
58 | if (error instanceof GitMcpError) throw error;
59 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
60 | operation,
61 | path,
62 | details: {
63 | branch,
64 | action: 'validate_branch_exists'
65 | }
66 | });
67 | }
68 | }
69 |
70 | static async validateRemoteBranchExists(path: string, remote: string, branch: string, operation: string): Promise<void> {
71 | try {
72 | await CommandExecutor.execute(
73 | `git show-ref --verify --quiet refs/remotes/${remote}/${branch}`,
74 | operation,
75 | path
76 | );
77 | } catch (error: unknown) {
78 | if (error instanceof GitMcpError) throw error;
79 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
80 | operation,
81 | path,
82 | details: {
83 | remote,
84 | branch,
85 | action: 'validate_remote_branch_exists'
86 | }
87 | });
88 | }
89 | }
90 |
91 | static async getCurrentBranch(path: string, operation: string): Promise<string> {
92 | try {
93 | const result = await CommandExecutor.execute('git rev-parse --abbrev-ref HEAD', operation, path);
94 | return result.stdout.trim();
95 | } catch (error: unknown) {
96 | if (error instanceof GitMcpError) throw error;
97 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
98 | operation,
99 | path,
100 | details: {
101 | action: 'get_current_branch',
102 | command: 'git rev-parse --abbrev-ref HEAD'
103 | }
104 | });
105 | }
106 | }
107 |
108 | static async ensureClean(path: string, operation: string): Promise<void> {
109 | let statusResult;
110 | try {
111 | statusResult = await CommandExecutor.execute('git status --porcelain', operation, path);
112 | if (statusResult.stdout.trim()) {
113 | throw new Error('Working directory is not clean. Please commit or stash your changes.');
114 | }
115 | } catch (error: unknown) {
116 | if (error instanceof GitMcpError) throw error;
117 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
118 | operation,
119 | path,
120 | details: {
121 | action: 'ensure_clean',
122 | status: statusResult?.stdout || 'unknown'
123 | }
124 | });
125 | }
126 | }
127 |
128 | static async validateRemoteConfig(path: string, remote: string, operation: string): Promise<void> {
129 | let remoteResult;
130 | try {
131 | remoteResult = await CommandExecutor.execute(`git remote get-url ${remote}`, operation, path);
132 | if (!remoteResult.stdout.trim()) {
133 | throw new Error(`Remote ${remote} is not configured`);
134 | }
135 | } catch (error: unknown) {
136 | if (error instanceof GitMcpError) throw error;
137 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
138 | operation,
139 | path,
140 | details: {
141 | remote,
142 | action: 'validate_remote_config',
143 | remoteUrl: remoteResult?.stdout || 'unknown'
144 | }
145 | });
146 | }
147 | }
148 |
149 | static async validateCommitExists(path: string, commit: string, operation: string): Promise<void> {
150 | try {
151 | await CommandExecutor.execute(`git cat-file -e ${commit}^{commit}`, operation, path);
152 | } catch (error: unknown) {
153 | if (error instanceof GitMcpError) throw error;
154 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
155 | operation,
156 | path,
157 | details: {
158 | commit,
159 | action: 'validate_commit_exists'
160 | }
161 | });
162 | }
163 | }
164 |
165 | static async validateTagExists(path: string, tag: string, operation: string): Promise<void> {
166 | try {
167 | await CommandExecutor.execute(`git show-ref --tags --quiet refs/tags/${tag}`, operation, path);
168 | } catch (error: unknown) {
169 | if (error instanceof GitMcpError) throw error;
170 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
171 | operation,
172 | path,
173 | details: {
174 | tag,
175 | action: 'validate_tag_exists'
176 | }
177 | });
178 | }
179 | }
180 |
181 | /**
182 | * Validates repository configuration
183 | */
184 | static async validateRepositoryConfig(path: string, operation: string): Promise<void> {
185 | let configResult;
186 | try {
187 | // Check core configuration
188 | configResult = await CommandExecutor.execute('git config --list', operation, path);
189 | const config = new Map<string, string>(
190 | configResult.stdout
191 | .split('\n')
192 | .filter(line => line)
193 | .map(line => {
194 | const [key, ...values] = line.split('=');
195 | return [key, values.join('=')] as [string, string];
196 | })
197 | );
198 |
199 | // Required configurations
200 | const requiredConfigs = [
201 | ['core.repositoryformatversion', '0'],
202 | ['core.filemode', 'true'],
203 | ['core.bare', 'false']
204 | ];
205 |
206 | for (const [key, value] of requiredConfigs) {
207 | if (config.get(key) !== value) {
208 | throw new Error(`Invalid repository configuration: ${key}=${config.get(key) || 'undefined'}`);
209 | }
210 | }
211 |
212 | // Check repository integrity
213 | await CommandExecutor.execute('git fsck --full', operation, path);
214 |
215 | } catch (error: unknown) {
216 | if (error instanceof GitMcpError) throw error;
217 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
218 | operation,
219 | path,
220 | details: {
221 | action: 'validate_repository_config',
222 | config: configResult?.stdout || 'unknown'
223 | }
224 | });
225 | }
226 | }
227 |
228 | /**
229 | * Checks if a repository has any uncommitted changes
230 | */
231 | static async hasUncommittedChanges(path: string, operation: string): Promise<boolean> {
232 | try {
233 | const result = await CommandExecutor.execute('git status --porcelain', operation, path);
234 | return result.stdout.trim().length > 0;
235 | } catch (error: unknown) {
236 | if (error instanceof GitMcpError) throw error;
237 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
238 | operation,
239 | path,
240 | details: {
241 | action: 'check_uncommitted_changes'
242 | }
243 | });
244 | }
245 | }
246 |
247 | /**
248 | * Gets the repository's current state information
249 | */
250 | static async getRepositoryState(path: string, operation: string): Promise<{
251 | branch: string;
252 | isClean: boolean;
253 | hasStashed: boolean;
254 | remotes: string[];
255 | lastCommit: string;
256 | }> {
257 | try {
258 | const [branch, isClean, stashList, remoteList, lastCommit] = await Promise.all([
259 | this.getCurrentBranch(path, operation),
260 | this.hasUncommittedChanges(path, operation).then(changes => !changes),
261 | CommandExecutor.execute('git stash list', operation, path),
262 | CommandExecutor.execute('git remote', operation, path),
263 | CommandExecutor.execute('git log -1 --format=%H', operation, path)
264 | ]);
265 |
266 | return {
267 | branch,
268 | isClean,
269 | hasStashed: stashList.stdout.trim().length > 0,
270 | remotes: remoteList.stdout.trim().split('\n').filter(Boolean),
271 | lastCommit: lastCommit.stdout.trim()
272 | };
273 | } catch (error: unknown) {
274 | if (error instanceof GitMcpError) throw error;
275 | throw ErrorHandler.handleRepositoryError(error instanceof Error ? error : new Error('Unknown error'), {
276 | operation,
277 | path,
278 | details: {
279 | action: 'get_repository_state'
280 | }
281 | });
282 | }
283 | }
284 | }
285 |
```
--------------------------------------------------------------------------------
/src/operations/remote/remote-operations.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { BaseGitOperation } from '../base/base-operation.js';
2 | import { GitCommandBuilder } from '../../common/command-builder.js';
3 | import { CommandResult } from '../base/operation-result.js';
4 | import { ErrorHandler } from '../../errors/error-handler.js';
5 | import { RepositoryValidator } from '../../utils/repository.js';
6 | import { CommandExecutor } from '../../utils/command.js';
7 | import { RepoStateType } from '../../caching/repository-cache.js';
8 | import {
9 | RemoteListOptions,
10 | RemoteAddOptions,
11 | RemoteRemoveOptions,
12 | RemoteSetUrlOptions,
13 | RemotePruneOptions,
14 | RemoteListResult,
15 | RemoteAddResult,
16 | RemoteRemoveResult,
17 | RemoteSetUrlResult,
18 | RemotePruneResult,
19 | RemoteConfig
20 | } from './remote-types.js';
21 |
22 | /**
23 | * Handles Git remote listing operations
24 | */
25 | export class RemoteListOperation extends BaseGitOperation<RemoteListOptions, RemoteListResult> {
26 | protected buildCommand(): GitCommandBuilder {
27 | const command = GitCommandBuilder.remote();
28 |
29 | if (this.options.verbose) {
30 | command.flag('verbose');
31 | }
32 |
33 | return command;
34 | }
35 |
36 | protected async parseResult(result: CommandResult): Promise<RemoteListResult> {
37 | const remotes: RemoteConfig[] = [];
38 | const lines = result.stdout.split('\n').filter(Boolean);
39 |
40 | for (const line of lines) {
41 | const [name, url, purpose] = line.split(/\s+/);
42 |
43 | // Find or create remote config
44 | let remote = remotes.find(r => r.name === name);
45 | if (!remote) {
46 | remote = {
47 | name,
48 | fetchUrl: url
49 | };
50 | remotes.push(remote);
51 | }
52 |
53 | // Set URL based on purpose
54 | if (purpose === '(push)') {
55 | remote.pushUrl = url;
56 | }
57 |
58 | // Get additional configuration if verbose
59 | if (this.options.verbose) {
60 | const configResult = await CommandExecutor.executeGitCommand(
61 | `config --get-regexp ^remote\\.${name}\\.`,
62 | this.context.operation,
63 | this.getResolvedPath()
64 | );
65 |
66 | configResult.stdout.split('\n').filter(Boolean).forEach(configLine => {
67 | const [key, value] = configLine.split(' ');
68 | const configKey = key.split('.')[2];
69 |
70 | switch (configKey) {
71 | case 'tagopt':
72 | remote!.fetchTags = value === '--tags';
73 | break;
74 | case 'mirror':
75 | remote!.mirror = value as 'fetch' | 'push';
76 | break;
77 | case 'fetch':
78 | if (!remote!.branches) remote!.branches = [];
79 | const branch = value.match(/refs\/heads\/(.+):refs\/remotes\/.+/)?.[1];
80 | if (branch) remote!.branches.push(branch);
81 | break;
82 | }
83 | });
84 | }
85 | }
86 |
87 | return {
88 | remotes,
89 | raw: result.stdout
90 | };
91 | }
92 |
93 | protected getCacheConfig() {
94 | return {
95 | command: 'remote',
96 | stateType: RepoStateType.REMOTE
97 | };
98 | }
99 |
100 | protected validateOptions(): void {
101 | // No specific validation needed for listing
102 | }
103 | }
104 |
105 | /**
106 | * Handles Git remote add operations
107 | */
108 | export class RemoteAddOperation extends BaseGitOperation<RemoteAddOptions, RemoteAddResult> {
109 | protected buildCommand(): GitCommandBuilder {
110 | const command = GitCommandBuilder.remote()
111 | .arg('add');
112 |
113 | if (this.options.fetch) {
114 | command.flag('fetch');
115 | }
116 |
117 | if (typeof this.options.tags === 'boolean') {
118 | command.flag(this.options.tags ? 'tags' : 'no-tags');
119 | }
120 |
121 | if (this.options.mirror) {
122 | command.option('mirror', this.options.mirror);
123 | }
124 |
125 | command.arg(this.options.name)
126 | .arg(this.options.url);
127 |
128 | return command;
129 | }
130 |
131 | protected async parseResult(result: CommandResult): Promise<RemoteAddResult> {
132 | // Get full remote configuration
133 | const listOperation = new RemoteListOperation(this.context, { verbose: true });
134 | const listResult = await listOperation.execute();
135 | const remotes = listResult.data?.remotes;
136 | if (!remotes) {
137 | throw ErrorHandler.handleOperationError(
138 | new Error('Failed to get remote list'),
139 | { operation: this.context.operation }
140 | );
141 | }
142 | const remote = remotes.find(r => r.name === this.options.name);
143 |
144 | if (!remote) {
145 | throw ErrorHandler.handleOperationError(
146 | new Error(`Failed to get configuration for remote ${this.options.name}`),
147 | { operation: this.context.operation }
148 | );
149 | }
150 |
151 | return {
152 | remote,
153 | raw: result.stdout
154 | };
155 | }
156 |
157 | protected getCacheConfig() {
158 | return {
159 | command: 'remote_add',
160 | stateType: RepoStateType.REMOTE
161 | };
162 | }
163 |
164 | protected validateOptions(): void {
165 | if (!this.options.name) {
166 | throw ErrorHandler.handleValidationError(
167 | new Error('Remote name is required'),
168 | { operation: this.context.operation }
169 | );
170 | }
171 |
172 | if (!this.options.url) {
173 | throw ErrorHandler.handleValidationError(
174 | new Error('Remote URL is required'),
175 | { operation: this.context.operation }
176 | );
177 | }
178 | }
179 | }
180 |
181 | /**
182 | * Handles Git remote remove operations
183 | */
184 | export class RemoteRemoveOperation extends BaseGitOperation<RemoteRemoveOptions, RemoteRemoveResult> {
185 | protected buildCommand(): GitCommandBuilder {
186 | return GitCommandBuilder.remote()
187 | .arg('remove')
188 | .arg(this.options.name);
189 | }
190 |
191 | protected parseResult(result: CommandResult): RemoteRemoveResult {
192 | return {
193 | name: this.options.name,
194 | raw: result.stdout
195 | };
196 | }
197 |
198 | protected getCacheConfig() {
199 | return {
200 | command: 'remote_remove',
201 | stateType: RepoStateType.REMOTE
202 | };
203 | }
204 |
205 | protected async validateOptions(): Promise<void> {
206 | if (!this.options.name) {
207 | throw ErrorHandler.handleValidationError(
208 | new Error('Remote name is required'),
209 | { operation: this.context.operation }
210 | );
211 | }
212 |
213 | // Ensure remote exists
214 | await RepositoryValidator.validateRemoteConfig(
215 | this.getResolvedPath(),
216 | this.options.name,
217 | this.context.operation
218 | );
219 | }
220 | }
221 |
222 | /**
223 | * Handles Git remote set-url operations
224 | */
225 | export class RemoteSetUrlOperation extends BaseGitOperation<RemoteSetUrlOptions, RemoteSetUrlResult> {
226 | protected buildCommand(): GitCommandBuilder {
227 | const command = GitCommandBuilder.remote()
228 | .arg('set-url');
229 |
230 | if (this.options.pushUrl) {
231 | command.flag('push');
232 | }
233 |
234 | if (this.options.add) {
235 | command.flag('add');
236 | }
237 |
238 | if (this.options.delete) {
239 | command.flag('delete');
240 | }
241 |
242 | command.arg(this.options.name)
243 | .arg(this.options.url);
244 |
245 | return command;
246 | }
247 |
248 | protected async parseResult(result: CommandResult): Promise<RemoteSetUrlResult> {
249 | // Get full remote configuration
250 | const listOperation = new RemoteListOperation(this.context, { verbose: true });
251 | const listResult = await listOperation.execute();
252 | const remotes = listResult.data?.remotes;
253 | if (!remotes) {
254 | throw ErrorHandler.handleOperationError(
255 | new Error('Failed to get remote list'),
256 | { operation: this.context.operation }
257 | );
258 | }
259 | const remote = remotes.find(r => r.name === this.options.name);
260 |
261 | if (!remote) {
262 | throw ErrorHandler.handleOperationError(
263 | new Error(`Failed to get configuration for remote ${this.options.name}`),
264 | { operation: this.context.operation }
265 | );
266 | }
267 |
268 | return {
269 | remote,
270 | raw: result.stdout
271 | };
272 | }
273 |
274 | protected getCacheConfig() {
275 | return {
276 | command: 'remote_set_url',
277 | stateType: RepoStateType.REMOTE
278 | };
279 | }
280 |
281 | protected async validateOptions(): Promise<void> {
282 | if (!this.options.name) {
283 | throw ErrorHandler.handleValidationError(
284 | new Error('Remote name is required'),
285 | { operation: this.context.operation }
286 | );
287 | }
288 |
289 | if (!this.options.url) {
290 | throw ErrorHandler.handleValidationError(
291 | new Error('Remote URL is required'),
292 | { operation: this.context.operation }
293 | );
294 | }
295 |
296 | // Ensure remote exists
297 | await RepositoryValidator.validateRemoteConfig(
298 | this.getResolvedPath(),
299 | this.options.name,
300 | this.context.operation
301 | );
302 | }
303 | }
304 |
305 | /**
306 | * Handles Git remote prune operations
307 | */
308 | export class RemotePruneOperation extends BaseGitOperation<RemotePruneOptions, RemotePruneResult> {
309 | protected buildCommand(): GitCommandBuilder {
310 | const command = GitCommandBuilder.remote()
311 | .arg('prune');
312 |
313 | if (this.options.dryRun) {
314 | command.flag('dry-run');
315 | }
316 |
317 | command.arg(this.options.name);
318 |
319 | return command;
320 | }
321 |
322 | protected parseResult(result: CommandResult): RemotePruneResult {
323 | const prunedBranches = result.stdout
324 | .split('\n')
325 | .filter(line => line.includes('* [pruned] '))
326 | .map(line => line.match(/\* \[pruned\] (.+)/)?.[1] || '');
327 |
328 | return {
329 | name: this.options.name,
330 | prunedBranches,
331 | raw: result.stdout
332 | };
333 | }
334 |
335 | protected getCacheConfig() {
336 | return {
337 | command: 'remote_prune',
338 | stateType: RepoStateType.REMOTE
339 | };
340 | }
341 |
342 | protected async validateOptions(): Promise<void> {
343 | if (!this.options.name) {
344 | throw ErrorHandler.handleValidationError(
345 | new Error('Remote name is required'),
346 | { operation: this.context.operation }
347 | );
348 | }
349 |
350 | // Ensure remote exists
351 | await RepositoryValidator.validateRemoteConfig(
352 | this.getResolvedPath(),
353 | this.options.name,
354 | this.context.operation
355 | );
356 | }
357 | }
358 |
```
--------------------------------------------------------------------------------
/src/monitoring/performance.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { logger } from '../utils/logger.js';
2 | import { ErrorHandler } from '../errors/error-handler.js';
3 | import { PerformanceError } from './types.js';
4 | import { ErrorCategory, ErrorSeverity } from '../errors/error-types.js';
5 |
6 | /**
7 | * Performance metric types
8 | */
9 | export enum MetricType {
10 | OPERATION_DURATION = 'operation_duration',
11 | MEMORY_USAGE = 'memory_usage',
12 | COMMAND_EXECUTION = 'command_execution',
13 | CACHE_HIT = 'cache_hit',
14 | CACHE_MISS = 'cache_miss',
15 | RESOURCE_USAGE = 'resource_usage'
16 | }
17 |
18 | /**
19 | * Performance metric data structure
20 | */
21 | export interface Metric {
22 | type: MetricType;
23 | value: number;
24 | timestamp: number;
25 | labels: Record<string, string>;
26 | context?: Record<string, any>;
27 | }
28 |
29 | /**
30 | * Resource usage thresholds
31 | */
32 | export interface ResourceThresholds {
33 | memory: {
34 | warning: number; // MB
35 | critical: number; // MB
36 | };
37 | cpu: {
38 | warning: number; // Percentage
39 | critical: number; // Percentage
40 | };
41 | operations: {
42 | warning: number; // Operations per second
43 | critical: number; // Operations per second
44 | };
45 | }
46 |
47 | /**
48 | * Default resource thresholds
49 | */
50 | const DEFAULT_THRESHOLDS: ResourceThresholds = {
51 | memory: {
52 | warning: 1024, // 1GB
53 | critical: 2048 // 2GB
54 | },
55 | cpu: {
56 | warning: 70, // 70%
57 | critical: 90 // 90%
58 | },
59 | operations: {
60 | warning: 100, // 100 ops/sec
61 | critical: 200 // 200 ops/sec
62 | }
63 | };
64 |
65 | /**
66 | * Performance monitoring system
67 | */
68 | export class PerformanceMonitor {
69 | private static instance: PerformanceMonitor;
70 | private metrics: Metric[] = [];
71 | private thresholds: ResourceThresholds;
72 | private operationTimers: Map<string, number> = new Map();
73 | private readonly METRICS_RETENTION = 3600; // 1 hour in seconds
74 | private readonly METRICS_CLEANUP_INTERVAL = 300; // 5 minutes in seconds
75 |
76 | private constructor() {
77 | this.thresholds = DEFAULT_THRESHOLDS;
78 | this.startMetricsCleanup();
79 | }
80 |
81 | /**
82 | * Get singleton instance
83 | */
84 | static getInstance(): PerformanceMonitor {
85 | if (!PerformanceMonitor.instance) {
86 | PerformanceMonitor.instance = new PerformanceMonitor();
87 | }
88 | return PerformanceMonitor.instance;
89 | }
90 |
91 | /**
92 | * Start operation timing
93 | */
94 | startOperation(operation: string): void {
95 | this.operationTimers.set(operation, performance.now());
96 | }
97 |
98 | /**
99 | * End operation timing and record metric
100 | */
101 | endOperation(operation: string, context?: Record<string, any>): void {
102 | const startTime = this.operationTimers.get(operation);
103 | if (!startTime) {
104 | logger.warn(operation, 'No start time found for operation timing', undefined, new Error('Missing operation start time'));
105 | return;
106 | }
107 |
108 | const duration = performance.now() - startTime;
109 | this.operationTimers.delete(operation);
110 |
111 | this.recordMetric({
112 | type: MetricType.OPERATION_DURATION,
113 | value: duration,
114 | timestamp: Date.now(),
115 | labels: { operation },
116 | context
117 | });
118 | }
119 |
120 | /**
121 | * Record command execution metric
122 | */
123 | recordCommandExecution(command: string, duration: number, context?: Record<string, any>): void {
124 | this.recordMetric({
125 | type: MetricType.COMMAND_EXECUTION,
126 | value: duration,
127 | timestamp: Date.now(),
128 | labels: { command },
129 | context
130 | });
131 | }
132 |
133 | /**
134 | * Record memory usage metric
135 | */
136 | recordMemoryUsage(context?: Record<string, any>): void {
137 | const memoryUsage = process.memoryUsage();
138 | const memoryUsageMB = memoryUsage.heapUsed / 1024 / 1024; // Convert to MB
139 |
140 | // Record heap usage
141 | this.recordMetric({
142 | type: MetricType.MEMORY_USAGE,
143 | value: memoryUsageMB,
144 | timestamp: Date.now(),
145 | labels: { type: 'heap' },
146 | context: {
147 | ...context,
148 | heapTotal: memoryUsage.heapTotal / 1024 / 1024,
149 | external: memoryUsage.external / 1024 / 1024,
150 | rss: memoryUsage.rss / 1024 / 1024
151 | }
152 | });
153 |
154 | // Check thresholds
155 | this.checkMemoryThresholds(memoryUsageMB);
156 | }
157 |
158 | /**
159 | * Record resource usage metric
160 | */
161 | recordResourceUsage(
162 | resource: string,
163 | value: number,
164 | context?: Record<string, any>
165 | ): void {
166 | this.recordMetric({
167 | type: MetricType.RESOURCE_USAGE,
168 | value,
169 | timestamp: Date.now(),
170 | labels: { resource },
171 | context
172 | });
173 | }
174 |
175 | /**
176 | * Record cache hit/miss
177 | */
178 | recordCacheAccess(hit: boolean, cacheType: string, context?: Record<string, any>): void {
179 | this.recordMetric({
180 | type: hit ? MetricType.CACHE_HIT : MetricType.CACHE_MISS,
181 | value: 1,
182 | timestamp: Date.now(),
183 | labels: { cacheType },
184 | context
185 | });
186 | }
187 |
188 | /**
189 | * Get metrics for a specific type and time range
190 | */
191 | getMetrics(
192 | type: MetricType,
193 | startTime: number,
194 | endTime: number = Date.now()
195 | ): Metric[] {
196 | return this.metrics.filter(metric =>
197 | metric.type === type &&
198 | metric.timestamp >= startTime &&
199 | metric.timestamp <= endTime
200 | );
201 | }
202 |
203 | /**
204 | * Calculate operation rate (operations per second)
205 | */
206 | getOperationRate(operation: string, windowSeconds: number = 60): number {
207 | const now = Date.now();
208 | const startTime = now - (windowSeconds * 1000);
209 |
210 | const operationMetrics = this.getMetrics(
211 | MetricType.OPERATION_DURATION,
212 | startTime,
213 | now
214 | ).filter(metric => metric.labels.operation === operation);
215 |
216 | return operationMetrics.length / windowSeconds;
217 | }
218 |
219 | /**
220 | * Get average operation duration
221 | */
222 | getAverageOperationDuration(
223 | operation: string,
224 | windowSeconds: number = 60
225 | ): number {
226 | const now = Date.now();
227 | const startTime = now - (windowSeconds * 1000);
228 |
229 | const operationMetrics = this.getMetrics(
230 | MetricType.OPERATION_DURATION,
231 | startTime,
232 | now
233 | ).filter(metric => metric.labels.operation === operation);
234 |
235 | if (operationMetrics.length === 0) return 0;
236 |
237 | const totalDuration = operationMetrics.reduce(
238 | (sum, metric) => sum + metric.value,
239 | 0
240 | );
241 | return totalDuration / operationMetrics.length;
242 | }
243 |
244 | /**
245 | * Get cache hit rate
246 | */
247 | getCacheHitRate(cacheType: string, windowSeconds: number = 60): number {
248 | const now = Date.now();
249 | const startTime = now - (windowSeconds * 1000);
250 |
251 | const hits = this.getMetrics(MetricType.CACHE_HIT, startTime, now)
252 | .filter(metric => metric.labels.cacheType === cacheType).length;
253 |
254 | const misses = this.getMetrics(MetricType.CACHE_MISS, startTime, now)
255 | .filter(metric => metric.labels.cacheType === cacheType).length;
256 |
257 | const total = hits + misses;
258 | return total === 0 ? 0 : hits / total;
259 | }
260 |
261 | /**
262 | * Update resource thresholds
263 | */
264 | updateThresholds(thresholds: Partial<ResourceThresholds>): void {
265 | this.thresholds = {
266 | ...this.thresholds,
267 | ...thresholds
268 | };
269 | }
270 |
271 | /**
272 | * Get current thresholds
273 | */
274 | getThresholds(): ResourceThresholds {
275 | return { ...this.thresholds };
276 | }
277 |
278 | /**
279 | * Private helper to record a metric
280 | */
281 | private recordMetric(metric: Metric): void {
282 | this.metrics.push(metric);
283 |
284 | // Log high severity metrics
285 | if (
286 | metric.type === MetricType.MEMORY_USAGE ||
287 | metric.type === MetricType.RESOURCE_USAGE
288 | ) {
289 | const metricError = new PerformanceError(
290 | `Recorded ${metric.type} metric`,
291 | {
292 | details: {
293 | value: metric.value,
294 | labels: metric.labels,
295 | context: metric.context
296 | },
297 | operation: metric.labels.operation || 'performance'
298 | }
299 | );
300 | logger.info(
301 | metric.labels.operation || 'performance',
302 | `Recorded ${metric.type} metric`,
303 | undefined,
304 | metricError
305 | );
306 | }
307 | }
308 |
309 | /**
310 | * Check memory usage against thresholds
311 | */
312 | private checkMemoryThresholds(memoryUsageMB: number): void {
313 | if (memoryUsageMB >= this.thresholds.memory.critical) {
314 | const error = new PerformanceError(
315 | `Critical memory usage: ${memoryUsageMB.toFixed(2)}MB`,
316 | {
317 | details: {
318 | currentUsage: memoryUsageMB,
319 | threshold: this.thresholds.memory.critical
320 | },
321 | operation: 'memory_monitor',
322 | severity: ErrorSeverity.CRITICAL,
323 | category: ErrorCategory.SYSTEM
324 | }
325 | );
326 | ErrorHandler.handleSystemError(error, {
327 | operation: 'memory_monitor',
328 | severity: ErrorSeverity.CRITICAL,
329 | category: ErrorCategory.SYSTEM
330 | });
331 | } else if (memoryUsageMB >= this.thresholds.memory.warning) {
332 | const warningError = new PerformanceError(
333 | `High memory usage: ${memoryUsageMB.toFixed(2)}MB`,
334 | {
335 | details: {
336 | currentUsage: memoryUsageMB,
337 | threshold: this.thresholds.memory.warning
338 | },
339 | operation: 'memory_monitor'
340 | }
341 | );
342 | logger.warn(
343 | 'memory_monitor',
344 | `High memory usage: ${memoryUsageMB.toFixed(2)}MB`,
345 | undefined,
346 | warningError
347 | );
348 | }
349 | }
350 |
351 | /**
352 | * Start periodic metrics cleanup
353 | */
354 | private startMetricsCleanup(): void {
355 | setInterval(() => {
356 | const cutoffTime = Date.now() - (this.METRICS_RETENTION * 1000);
357 | this.metrics = this.metrics.filter(metric => metric.timestamp >= cutoffTime);
358 | }, this.METRICS_CLEANUP_INTERVAL * 1000);
359 | }
360 |
361 | /**
362 | * Get current performance statistics
363 | */
364 | getStatistics(): Record<string, any> {
365 | const now = Date.now();
366 | const oneMinuteAgo = now - 60000;
367 | const fiveMinutesAgo = now - 300000;
368 |
369 | return {
370 | memory: {
371 | current: process.memoryUsage().heapUsed / 1024 / 1024,
372 | trend: this.getMetrics(MetricType.MEMORY_USAGE, fiveMinutesAgo)
373 | .map(m => ({ timestamp: m.timestamp, value: m.value }))
374 | },
375 | operations: {
376 | last1m: this.metrics
377 | .filter(m =>
378 | m.type === MetricType.OPERATION_DURATION &&
379 | m.timestamp >= oneMinuteAgo
380 | ).length,
381 | last5m: this.metrics
382 | .filter(m =>
383 | m.type === MetricType.OPERATION_DURATION &&
384 | m.timestamp >= fiveMinutesAgo
385 | ).length
386 | },
387 | cache: {
388 | hitRate1m: this.getCacheHitRate('all', 60),
389 | hitRate5m: this.getCacheHitRate('all', 300)
390 | },
391 | commandExecutions: {
392 | last1m: this.metrics
393 | .filter(m =>
394 | m.type === MetricType.COMMAND_EXECUTION &&
395 | m.timestamp >= oneMinuteAgo
396 | ).length,
397 | last5m: this.metrics
398 | .filter(m =>
399 | m.type === MetricType.COMMAND_EXECUTION &&
400 | m.timestamp >= fiveMinutesAgo
401 | ).length
402 | }
403 | };
404 | }
405 | }
406 |
```