This is page 2 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
--------------------------------------------------------------------------------
/src/operations/sync/sync-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 | PushOptions,
10 | PullOptions,
11 | FetchOptions,
12 | PushResult,
13 | PullResult,
14 | FetchResult
15 | } from './sync-types.js';
16 |
17 | /**
18 | * Handles Git push operations
19 | */
20 | export class PushOperation extends BaseGitOperation<PushOptions, PushResult> {
21 | protected buildCommand(): GitCommandBuilder {
22 | const command = GitCommandBuilder.push();
23 |
24 | if (this.options.remote) {
25 | command.arg(this.options.remote);
26 | }
27 |
28 | if (this.options.branch) {
29 | command.arg(this.options.branch);
30 | }
31 |
32 | if (this.options.force) {
33 | command.withForce();
34 | }
35 |
36 | if (this.options.forceWithLease) {
37 | command.flag('force-with-lease');
38 | }
39 |
40 | if (this.options.all) {
41 | command.flag('all');
42 | }
43 |
44 | if (this.options.tags) {
45 | command.flag('tags');
46 | }
47 |
48 | if (this.options.noVerify) {
49 | command.withNoVerify();
50 | }
51 |
52 | if (this.options.setUpstream) {
53 | command.withSetUpstream();
54 | }
55 |
56 | if (this.options.prune) {
57 | command.flag('prune');
58 | }
59 |
60 | return command;
61 | }
62 |
63 | protected parseResult(result: CommandResult): PushResult {
64 | const summary = {
65 | created: [] as string[],
66 | deleted: [] as string[],
67 | updated: [] as string[],
68 | rejected: [] as string[]
69 | };
70 |
71 | // Parse push output
72 | result.stdout.split('\n').forEach(line => {
73 | if (line.startsWith('To ')) return; // Skip remote URL line
74 |
75 | const match = line.match(/^\s*([a-f0-9]+)\.\.([a-f0-9]+)\s+(\S+)\s+->\s+(\S+)/);
76 | if (match) {
77 | const [, oldRef, newRef, localRef, remoteRef] = match;
78 | summary.updated.push(remoteRef);
79 | } else if (line.includes('[new branch]')) {
80 | const branchMatch = line.match(/\[new branch\]\s+(\S+)\s+->\s+(\S+)/);
81 | if (branchMatch) {
82 | summary.created.push(branchMatch[2]);
83 | }
84 | } else if (line.includes('[deleted]')) {
85 | const deleteMatch = line.match(/\[deleted\]\s+(\S+)/);
86 | if (deleteMatch) {
87 | summary.deleted.push(deleteMatch[1]);
88 | }
89 | } else if (line.includes('! [rejected]')) {
90 | const rejectMatch = line.match(/\! \[rejected\]\s+(\S+)/);
91 | if (rejectMatch) {
92 | summary.rejected.push(rejectMatch[1]);
93 | }
94 | }
95 | });
96 |
97 | return {
98 | remote: this.options.remote || 'origin',
99 | branch: this.options.branch,
100 | forced: this.options.force || false,
101 | summary: {
102 | created: summary.created.length > 0 ? summary.created : undefined,
103 | deleted: summary.deleted.length > 0 ? summary.deleted : undefined,
104 | updated: summary.updated.length > 0 ? summary.updated : undefined,
105 | rejected: summary.rejected.length > 0 ? summary.rejected : undefined
106 | },
107 | raw: result.stdout
108 | };
109 | }
110 |
111 | protected getCacheConfig() {
112 | return {
113 | command: 'push',
114 | stateType: RepoStateType.REMOTE
115 | };
116 | }
117 |
118 | protected async validateOptions(): Promise<void> {
119 | if (!this.options.branch && !this.options.all) {
120 | throw ErrorHandler.handleValidationError(
121 | new Error('Either branch or --all must be specified'),
122 | { operation: this.context.operation }
123 | );
124 | }
125 |
126 | if (this.options.remote) {
127 | await RepositoryValidator.validateRemoteConfig(
128 | this.getResolvedPath(),
129 | this.options.remote,
130 | this.context.operation
131 | );
132 | }
133 |
134 | if (this.options.branch) {
135 | await RepositoryValidator.validateBranchExists(
136 | this.getResolvedPath(),
137 | this.options.branch,
138 | this.context.operation
139 | );
140 | }
141 | }
142 | }
143 |
144 | /**
145 | * Handles Git pull operations
146 | */
147 | export class PullOperation extends BaseGitOperation<PullOptions, PullResult> {
148 | protected buildCommand(): GitCommandBuilder {
149 | const command = GitCommandBuilder.pull();
150 |
151 | if (this.options.remote) {
152 | command.arg(this.options.remote);
153 | }
154 |
155 | if (this.options.branch) {
156 | command.arg(this.options.branch);
157 | }
158 |
159 | if (this.options.rebase) {
160 | command.flag('rebase');
161 | }
162 |
163 | if (this.options.autoStash) {
164 | command.flag('autostash');
165 | }
166 |
167 | if (this.options.allowUnrelated) {
168 | command.flag('allow-unrelated-histories');
169 | }
170 |
171 | if (this.options.ff === 'only') {
172 | command.flag('ff-only');
173 | } else if (this.options.ff === 'no') {
174 | command.flag('no-ff');
175 | }
176 |
177 | if (this.options.strategy) {
178 | command.option('strategy', this.options.strategy);
179 | }
180 |
181 | if (this.options.strategyOption) {
182 | this.options.strategyOption.forEach(opt => {
183 | command.option('strategy-option', opt);
184 | });
185 | }
186 |
187 | return command;
188 | }
189 |
190 | protected parseResult(result: CommandResult): PullResult {
191 | const summary = {
192 | merged: [] as string[],
193 | conflicts: [] as string[]
194 | };
195 |
196 | let filesChanged = 0;
197 | let insertions = 0;
198 | let deletions = 0;
199 |
200 | // Parse pull output
201 | result.stdout.split('\n').forEach(line => {
202 | if (line.includes('|')) {
203 | // Parse merge stats
204 | const statsMatch = line.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
205 | if (statsMatch) {
206 | filesChanged = parseInt(statsMatch[1], 10);
207 | insertions = statsMatch[2] ? parseInt(statsMatch[2], 10) : 0;
208 | deletions = statsMatch[3] ? parseInt(statsMatch[3], 10) : 0;
209 | }
210 | } else if (line.includes('Fast-forward') || line.includes('Merge made by')) {
211 | // Track merged files
212 | const mergeMatch = line.match(/([^/]+)$/);
213 | if (mergeMatch) {
214 | summary.merged.push(mergeMatch[1]);
215 | }
216 | } else if (line.includes('CONFLICT')) {
217 | // Track conflicts
218 | const conflictMatch = line.match(/CONFLICT \(.+?\): (.+)/);
219 | if (conflictMatch) {
220 | summary.conflicts.push(conflictMatch[1]);
221 | }
222 | }
223 | });
224 |
225 | return {
226 | remote: this.options.remote || 'origin',
227 | branch: this.options.branch,
228 | rebased: this.options.rebase || false,
229 | filesChanged,
230 | insertions,
231 | deletions,
232 | summary: {
233 | merged: summary.merged.length > 0 ? summary.merged : undefined,
234 | conflicts: summary.conflicts.length > 0 ? summary.conflicts : undefined
235 | },
236 | raw: result.stdout
237 | };
238 | }
239 |
240 | protected getCacheConfig() {
241 | return {
242 | command: 'pull',
243 | stateType: RepoStateType.REMOTE
244 | };
245 | }
246 |
247 | protected async validateOptions(): Promise<void> {
248 | if (!this.options.branch) {
249 | throw ErrorHandler.handleValidationError(
250 | new Error('Branch must be specified'),
251 | { operation: this.context.operation }
252 | );
253 | }
254 |
255 | if (this.options.remote) {
256 | await RepositoryValidator.validateRemoteConfig(
257 | this.getResolvedPath(),
258 | this.options.remote,
259 | this.context.operation
260 | );
261 | }
262 |
263 | // Ensure working tree is clean unless autostash is enabled
264 | if (!this.options.autoStash) {
265 | await RepositoryValidator.ensureClean(
266 | this.getResolvedPath(),
267 | this.context.operation
268 | );
269 | }
270 | }
271 | }
272 |
273 | /**
274 | * Handles Git fetch operations
275 | */
276 | export class FetchOperation extends BaseGitOperation<FetchOptions, FetchResult> {
277 | protected buildCommand(): GitCommandBuilder {
278 | const command = GitCommandBuilder.fetch();
279 |
280 | if (this.options.remote && !this.options.all) {
281 | command.arg(this.options.remote);
282 | }
283 |
284 | if (this.options.all) {
285 | command.flag('all');
286 | }
287 |
288 | if (this.options.prune) {
289 | command.flag('prune');
290 | }
291 |
292 | if (this.options.pruneTags) {
293 | command.flag('prune-tags');
294 | }
295 |
296 | if (this.options.tags) {
297 | command.flag('tags');
298 | }
299 |
300 | if (this.options.tagsOnly) {
301 | command.flag('tags').flag('no-recurse-submodules');
302 | }
303 |
304 | if (this.options.forceTags) {
305 | command.flag('force').flag('tags');
306 | }
307 |
308 | if (this.options.depth) {
309 | command.option('depth', this.options.depth.toString());
310 | }
311 |
312 | if (typeof this.options.recurseSubmodules !== 'undefined') {
313 | if (typeof this.options.recurseSubmodules === 'boolean') {
314 | command.flag(this.options.recurseSubmodules ? 'recurse-submodules' : 'no-recurse-submodules');
315 | } else {
316 | command.option('recurse-submodules', this.options.recurseSubmodules);
317 | }
318 | }
319 |
320 | if (this.options.progress) {
321 | command.flag('progress');
322 | }
323 |
324 | return command;
325 | }
326 |
327 | protected parseResult(result: CommandResult): FetchResult {
328 | const summary = {
329 | branches: [] as Array<{ name: string; oldRef?: string; newRef: string }>,
330 | tags: [] as Array<{ name: string; oldRef?: string; newRef: string }>,
331 | pruned: [] as string[]
332 | };
333 |
334 | // Parse fetch output
335 | result.stdout.split('\n').forEach(line => {
336 | if (line.includes('->')) {
337 | // Parse branch/tag updates
338 | const match = line.match(/([a-f0-9]+)\.\.([a-f0-9]+)\s+(\S+)\s+->\s+(\S+)/);
339 | if (match) {
340 | const [, oldRef, newRef, localRef, remoteRef] = match;
341 | if (remoteRef.includes('refs/tags/')) {
342 | summary.tags.push({
343 | name: remoteRef.replace('refs/tags/', ''),
344 | oldRef,
345 | newRef
346 | });
347 | } else {
348 | summary.branches.push({
349 | name: remoteRef.replace('refs/remotes/', ''),
350 | oldRef,
351 | newRef
352 | });
353 | }
354 | }
355 | } else if (line.includes('[pruned]')) {
356 | // Parse pruned refs
357 | const pruneMatch = line.match(/\[pruned\] (.+)/);
358 | if (pruneMatch) {
359 | summary.pruned.push(pruneMatch[1]);
360 | }
361 | }
362 | });
363 |
364 | return {
365 | remote: this.options.remote,
366 | summary: {
367 | branches: summary.branches.length > 0 ? summary.branches : undefined,
368 | tags: summary.tags.length > 0 ? summary.tags : undefined,
369 | pruned: summary.pruned.length > 0 ? summary.pruned : undefined
370 | },
371 | raw: result.stdout
372 | };
373 | }
374 |
375 | protected getCacheConfig() {
376 | return {
377 | command: 'fetch',
378 | stateType: RepoStateType.REMOTE
379 | };
380 | }
381 |
382 | protected async validateOptions(): Promise<void> {
383 | if (this.options.remote && !this.options.all) {
384 | await RepositoryValidator.validateRemoteConfig(
385 | this.getResolvedPath(),
386 | this.options.remote,
387 | this.context.operation
388 | );
389 | }
390 |
391 | if (this.options.depth !== undefined && this.options.depth <= 0) {
392 | throw ErrorHandler.handleValidationError(
393 | new Error('Depth must be a positive number'),
394 | { operation: this.context.operation }
395 | );
396 | }
397 | }
398 | }
399 |
```
--------------------------------------------------------------------------------
/src/tool-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | CallToolRequestSchema,
3 | ErrorCode,
4 | ListToolsRequestSchema,
5 | McpError,
6 | } from '@modelcontextprotocol/sdk/types.js';
7 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8 | import { GitOperations } from './git-operations.js';
9 | import { logger } from './utils/logger.js';
10 | import { ErrorHandler } from './errors/error-handler.js';
11 | import { GitMcpError } from './errors/error-types.js';
12 | import {
13 | isInitOptions,
14 | isCloneOptions,
15 | isAddOptions,
16 | isCommitOptions,
17 | isPushPullOptions,
18 | isBranchOptions,
19 | isCheckoutOptions,
20 | isTagOptions,
21 | isRemoteOptions,
22 | isStashOptions,
23 | isPathOnly,
24 | isBulkActionOptions,
25 | BasePathOptions,
26 | } from './types.js';
27 |
28 | const PATH_DESCRIPTION = `MUST be an absolute path (e.g., /Users/username/projects/my-repo)`;
29 | const FILE_PATH_DESCRIPTION = `MUST be an absolute path (e.g., /Users/username/projects/my-repo/src/file.js)`;
30 |
31 | export class ToolHandler {
32 | private static readonly TOOL_PREFIX = 'git_mcp_server';
33 |
34 | constructor(private server: Server) {
35 | this.setupHandlers();
36 | }
37 |
38 | private getOperationName(toolName: string): string {
39 | return `${ToolHandler.TOOL_PREFIX}.${toolName}`;
40 | }
41 |
42 | private validateArguments<T extends BasePathOptions>(operation: string, args: unknown, validator: (obj: any) => obj is T): T {
43 | if (!args || !validator(args)) {
44 | throw ErrorHandler.handleValidationError(
45 | new Error(`Invalid arguments for operation: ${operation}`),
46 | {
47 | operation,
48 | details: { args }
49 | }
50 | );
51 | }
52 |
53 | // If path is not provided, use default path from environment
54 | if (!args.path && process.env.GIT_DEFAULT_PATH) {
55 | args.path = process.env.GIT_DEFAULT_PATH;
56 | logger.info(operation, 'Using default git path', args.path);
57 | }
58 |
59 | return args;
60 | }
61 |
62 | private setupHandlers(): void {
63 | this.setupToolDefinitions();
64 | this.setupToolExecutor();
65 | }
66 |
67 | private setupToolDefinitions(): void {
68 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
69 | tools: [
70 | {
71 | name: 'init',
72 | description: 'Initialize a new Git repository',
73 | inputSchema: {
74 | type: 'object',
75 | properties: {
76 | path: {
77 | type: 'string',
78 | description: `Path to initialize the repository in. ${PATH_DESCRIPTION}`,
79 | },
80 | },
81 | required: [],
82 | },
83 | },
84 | {
85 | name: 'clone',
86 | description: 'Clone a repository',
87 | inputSchema: {
88 | type: 'object',
89 | properties: {
90 | url: {
91 | type: 'string',
92 | description: 'URL of the repository to clone',
93 | },
94 | path: {
95 | type: 'string',
96 | description: `Path to clone into. ${PATH_DESCRIPTION}`,
97 | },
98 | },
99 | required: ['url'],
100 | },
101 | },
102 | {
103 | name: 'status',
104 | description: 'Get repository status',
105 | inputSchema: {
106 | type: 'object',
107 | properties: {
108 | path: {
109 | type: 'string',
110 | description: `Path to repository. ${PATH_DESCRIPTION}`,
111 | },
112 | },
113 | required: [],
114 | },
115 | },
116 | {
117 | name: 'add',
118 | description: 'Stage files',
119 | inputSchema: {
120 | type: 'object',
121 | properties: {
122 | path: {
123 | type: 'string',
124 | description: `Path to repository. ${PATH_DESCRIPTION}`,
125 | },
126 | files: {
127 | type: 'array',
128 | items: {
129 | type: 'string',
130 | description: FILE_PATH_DESCRIPTION,
131 | },
132 | description: 'Files to stage',
133 | },
134 | },
135 | required: ['files'],
136 | },
137 | },
138 | {
139 | name: 'commit',
140 | description: 'Create a commit',
141 | inputSchema: {
142 | type: 'object',
143 | properties: {
144 | path: {
145 | type: 'string',
146 | description: `Path to repository. ${PATH_DESCRIPTION}`,
147 | },
148 | message: {
149 | type: 'string',
150 | description: 'Commit message',
151 | },
152 | },
153 | required: ['message'],
154 | },
155 | },
156 | {
157 | name: 'push',
158 | description: 'Push commits to remote',
159 | inputSchema: {
160 | type: 'object',
161 | properties: {
162 | path: {
163 | type: 'string',
164 | description: `Path to repository. ${PATH_DESCRIPTION}`,
165 | },
166 | remote: {
167 | type: 'string',
168 | description: 'Remote name',
169 | default: 'origin',
170 | },
171 | branch: {
172 | type: 'string',
173 | description: 'Branch name',
174 | },
175 | force: {
176 | type: 'boolean',
177 | description: 'Force push changes',
178 | default: false
179 | },
180 | noVerify: {
181 | type: 'boolean',
182 | description: 'Skip pre-push hooks',
183 | default: false
184 | },
185 | tags: {
186 | type: 'boolean',
187 | description: 'Push all tags',
188 | default: false
189 | }
190 | },
191 | required: ['branch'],
192 | },
193 | },
194 | {
195 | name: 'pull',
196 | description: 'Pull changes from remote',
197 | inputSchema: {
198 | type: 'object',
199 | properties: {
200 | path: {
201 | type: 'string',
202 | description: `Path to repository. ${PATH_DESCRIPTION}`,
203 | },
204 | remote: {
205 | type: 'string',
206 | description: 'Remote name',
207 | default: 'origin',
208 | },
209 | branch: {
210 | type: 'string',
211 | description: 'Branch name',
212 | },
213 | },
214 | required: ['branch'],
215 | },
216 | },
217 | {
218 | name: 'branch_list',
219 | description: 'List all branches',
220 | inputSchema: {
221 | type: 'object',
222 | properties: {
223 | path: {
224 | type: 'string',
225 | description: `Path to repository. ${PATH_DESCRIPTION}`,
226 | },
227 | },
228 | required: [],
229 | },
230 | },
231 | {
232 | name: 'branch_create',
233 | description: 'Create a new branch',
234 | inputSchema: {
235 | type: 'object',
236 | properties: {
237 | path: {
238 | type: 'string',
239 | description: `Path to repository. ${PATH_DESCRIPTION}`,
240 | },
241 | name: {
242 | type: 'string',
243 | description: 'Branch name',
244 | },
245 | force: {
246 | type: 'boolean',
247 | description: 'Force create branch even if it exists',
248 | default: false
249 | },
250 | track: {
251 | type: 'boolean',
252 | description: 'Set up tracking mode',
253 | default: true
254 | },
255 | setUpstream: {
256 | type: 'boolean',
257 | description: 'Set upstream for push/pull',
258 | default: false
259 | }
260 | },
261 | required: ['name'],
262 | },
263 | },
264 | {
265 | name: 'branch_delete',
266 | description: 'Delete a branch',
267 | inputSchema: {
268 | type: 'object',
269 | properties: {
270 | path: {
271 | type: 'string',
272 | description: `Path to repository. ${PATH_DESCRIPTION}`,
273 | },
274 | name: {
275 | type: 'string',
276 | description: 'Branch name',
277 | },
278 | },
279 | required: ['name'],
280 | },
281 | },
282 | {
283 | name: 'checkout',
284 | description: 'Switch branches or restore working tree files',
285 | inputSchema: {
286 | type: 'object',
287 | properties: {
288 | path: {
289 | type: 'string',
290 | description: `Path to repository. ${PATH_DESCRIPTION}`,
291 | },
292 | target: {
293 | type: 'string',
294 | description: 'Branch name, commit hash, or file path',
295 | },
296 | },
297 | required: ['target'],
298 | },
299 | },
300 | {
301 | name: 'tag_list',
302 | description: 'List tags',
303 | inputSchema: {
304 | type: 'object',
305 | properties: {
306 | path: {
307 | type: 'string',
308 | description: `Path to repository. ${PATH_DESCRIPTION}`,
309 | },
310 | },
311 | required: [],
312 | },
313 | },
314 | {
315 | name: 'tag_create',
316 | description: 'Create a tag',
317 | inputSchema: {
318 | type: 'object',
319 | properties: {
320 | path: {
321 | type: 'string',
322 | description: `Path to repository. ${PATH_DESCRIPTION}`,
323 | },
324 | name: {
325 | type: 'string',
326 | description: 'Tag name',
327 | },
328 | message: {
329 | type: 'string',
330 | description: 'Tag message',
331 | },
332 | force: {
333 | type: 'boolean',
334 | description: 'Force create tag even if it exists',
335 | default: false
336 | },
337 | annotated: {
338 | type: 'boolean',
339 | description: 'Create an annotated tag',
340 | default: true
341 | },
342 | sign: {
343 | type: 'boolean',
344 | description: 'Create a signed tag',
345 | default: false
346 | }
347 | },
348 | required: ['name'],
349 | },
350 | },
351 | {
352 | name: 'tag_delete',
353 | description: 'Delete a tag',
354 | inputSchema: {
355 | type: 'object',
356 | properties: {
357 | path: {
358 | type: 'string',
359 | description: `Path to repository. ${PATH_DESCRIPTION}`,
360 | },
361 | name: {
362 | type: 'string',
363 | description: 'Tag name',
364 | },
365 | },
366 | required: ['name'],
367 | },
368 | },
369 | {
370 | name: 'remote_list',
371 | description: 'List remotes',
372 | inputSchema: {
373 | type: 'object',
374 | properties: {
375 | path: {
376 | type: 'string',
377 | description: `Path to repository. ${PATH_DESCRIPTION}`,
378 | },
379 | },
380 | required: [],
381 | },
382 | },
383 | {
384 | name: 'remote_add',
385 | description: 'Add a remote',
386 | inputSchema: {
387 | type: 'object',
388 | properties: {
389 | path: {
390 | type: 'string',
391 | description: `Path to repository. ${PATH_DESCRIPTION}`,
392 | },
393 | name: {
394 | type: 'string',
395 | description: 'Remote name',
396 | },
397 | url: {
398 | type: 'string',
399 | description: 'Remote URL',
400 | },
401 | },
402 | required: ['name', 'url'],
403 | },
404 | },
405 | {
406 | name: 'remote_remove',
407 | description: 'Remove a remote',
408 | inputSchema: {
409 | type: 'object',
410 | properties: {
411 | path: {
412 | type: 'string',
413 | description: `Path to repository. ${PATH_DESCRIPTION}`,
414 | },
415 | name: {
416 | type: 'string',
417 | description: 'Remote name',
418 | },
419 | },
420 | required: ['name'],
421 | },
422 | },
423 | {
424 | name: 'stash_list',
425 | description: 'List stashes',
426 | inputSchema: {
427 | type: 'object',
428 | properties: {
429 | path: {
430 | type: 'string',
431 | description: `Path to repository. ${PATH_DESCRIPTION}`,
432 | },
433 | },
434 | required: [],
435 | },
436 | },
437 | {
438 | name: 'stash_save',
439 | description: 'Save changes to stash',
440 | inputSchema: {
441 | type: 'object',
442 | properties: {
443 | path: {
444 | type: 'string',
445 | description: `Path to repository. ${PATH_DESCRIPTION}`,
446 | },
447 | message: {
448 | type: 'string',
449 | description: 'Stash message',
450 | },
451 | includeUntracked: {
452 | type: 'boolean',
453 | description: 'Include untracked files',
454 | default: false
455 | },
456 | keepIndex: {
457 | type: 'boolean',
458 | description: 'Keep staged changes',
459 | default: false
460 | },
461 | all: {
462 | type: 'boolean',
463 | description: 'Include ignored files',
464 | default: false
465 | }
466 | },
467 | required: [],
468 | },
469 | },
470 | {
471 | name: 'stash_pop',
472 | description: 'Apply and remove a stash',
473 | inputSchema: {
474 | type: 'object',
475 | properties: {
476 | path: {
477 | type: 'string',
478 | description: `Path to repository. ${PATH_DESCRIPTION}`,
479 | },
480 | index: {
481 | type: 'number',
482 | description: 'Stash index',
483 | default: 0,
484 | },
485 | },
486 | required: [],
487 | },
488 | },
489 | // New bulk action tool
490 | {
491 | name: 'bulk_action',
492 | description: 'Execute multiple Git operations in sequence. This is the preferred way to execute multiple operations.',
493 | inputSchema: {
494 | type: 'object',
495 | properties: {
496 | path: {
497 | type: 'string',
498 | description: `Path to repository. ${PATH_DESCRIPTION}`,
499 | },
500 | actions: {
501 | type: 'array',
502 | description: 'Array of Git operations to execute in sequence',
503 | items: {
504 | type: 'object',
505 | oneOf: [
506 | {
507 | type: 'object',
508 | properties: {
509 | type: { const: 'stage' },
510 | files: {
511 | type: 'array',
512 | items: {
513 | type: 'string',
514 | description: FILE_PATH_DESCRIPTION,
515 | },
516 | description: 'Files to stage. If not provided, stages all changes.',
517 | },
518 | },
519 | required: ['type'],
520 | },
521 | {
522 | type: 'object',
523 | properties: {
524 | type: { const: 'commit' },
525 | message: {
526 | type: 'string',
527 | description: 'Commit message',
528 | },
529 | },
530 | required: ['type', 'message'],
531 | },
532 | {
533 | type: 'object',
534 | properties: {
535 | type: { const: 'push' },
536 | remote: {
537 | type: 'string',
538 | description: 'Remote name',
539 | default: 'origin',
540 | },
541 | branch: {
542 | type: 'string',
543 | description: 'Branch name',
544 | },
545 | },
546 | required: ['type', 'branch'],
547 | },
548 | ],
549 | },
550 | minItems: 1,
551 | },
552 | },
553 | required: ['actions'],
554 | },
555 | },
556 | ],
557 | }));
558 | }
559 |
560 | private setupToolExecutor(): void {
561 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
562 | const operation = this.getOperationName(request.params.name);
563 | const args = request.params.arguments;
564 | const context = { operation, path: args?.path as string | undefined };
565 |
566 | try {
567 | switch (request.params.name) {
568 | case 'init': {
569 | const validArgs = this.validateArguments(operation, args, isInitOptions);
570 | return await GitOperations.init(validArgs, context);
571 | }
572 |
573 | case 'clone': {
574 | const validArgs = this.validateArguments(operation, args, isCloneOptions);
575 | return await GitOperations.clone(validArgs, context);
576 | }
577 |
578 | case 'status': {
579 | const validArgs = this.validateArguments(operation, args, isPathOnly);
580 | return await GitOperations.status(validArgs, context);
581 | }
582 |
583 | case 'add': {
584 | const validArgs = this.validateArguments(operation, args, isAddOptions);
585 | return await GitOperations.add(validArgs, context);
586 | }
587 |
588 | case 'commit': {
589 | const validArgs = this.validateArguments(operation, args, isCommitOptions);
590 | return await GitOperations.commit(validArgs, context);
591 | }
592 |
593 | case 'push': {
594 | const validArgs = this.validateArguments(operation, args, isPushPullOptions);
595 | return await GitOperations.push(validArgs, context);
596 | }
597 |
598 | case 'pull': {
599 | const validArgs = this.validateArguments(operation, args, isPushPullOptions);
600 | return await GitOperations.pull(validArgs, context);
601 | }
602 |
603 | case 'branch_list': {
604 | const validArgs = this.validateArguments(operation, args, isPathOnly);
605 | return await GitOperations.branchList(validArgs, context);
606 | }
607 |
608 | case 'branch_create': {
609 | const validArgs = this.validateArguments(operation, args, isBranchOptions);
610 | return await GitOperations.branchCreate(validArgs, context);
611 | }
612 |
613 | case 'branch_delete': {
614 | const validArgs = this.validateArguments(operation, args, isBranchOptions);
615 | return await GitOperations.branchDelete(validArgs, context);
616 | }
617 |
618 | case 'checkout': {
619 | const validArgs = this.validateArguments(operation, args, isCheckoutOptions);
620 | return await GitOperations.checkout(validArgs, context);
621 | }
622 |
623 | case 'tag_list': {
624 | const validArgs = this.validateArguments(operation, args, isPathOnly);
625 | return await GitOperations.tagList(validArgs, context);
626 | }
627 |
628 | case 'tag_create': {
629 | const validArgs = this.validateArguments(operation, args, isTagOptions);
630 | return await GitOperations.tagCreate(validArgs, context);
631 | }
632 |
633 | case 'tag_delete': {
634 | const validArgs = this.validateArguments(operation, args, isTagOptions);
635 | return await GitOperations.tagDelete(validArgs, context);
636 | }
637 |
638 | case 'remote_list': {
639 | const validArgs = this.validateArguments(operation, args, isPathOnly);
640 | return await GitOperations.remoteList(validArgs, context);
641 | }
642 |
643 | case 'remote_add': {
644 | const validArgs = this.validateArguments(operation, args, isRemoteOptions);
645 | return await GitOperations.remoteAdd(validArgs, context);
646 | }
647 |
648 | case 'remote_remove': {
649 | const validArgs = this.validateArguments(operation, args, isRemoteOptions);
650 | return await GitOperations.remoteRemove(validArgs, context);
651 | }
652 |
653 | case 'stash_list': {
654 | const validArgs = this.validateArguments(operation, args, isPathOnly);
655 | return await GitOperations.stashList(validArgs, context);
656 | }
657 |
658 | case 'stash_save': {
659 | const validArgs = this.validateArguments(operation, args, isStashOptions);
660 | return await GitOperations.stashSave(validArgs, context);
661 | }
662 |
663 | case 'stash_pop': {
664 | const validArgs = this.validateArguments(operation, args, isStashOptions);
665 | return await GitOperations.stashPop(validArgs, context);
666 | }
667 |
668 | case 'bulk_action': {
669 | const validArgs = this.validateArguments(operation, args, isBulkActionOptions);
670 | return await GitOperations.executeBulkActions(validArgs, context);
671 | }
672 |
673 | default:
674 | throw ErrorHandler.handleValidationError(
675 | new Error(`Unknown tool: ${request.params.name}`),
676 | { operation }
677 | );
678 | }
679 | } catch (error: unknown) {
680 | // If it's already a GitMcpError or McpError, rethrow it
681 | if (error instanceof GitMcpError || error instanceof McpError) {
682 | throw error;
683 | }
684 |
685 | // Otherwise, wrap it in an appropriate error type
686 | throw ErrorHandler.handleOperationError(
687 | error instanceof Error ? error : new Error('Unknown error'),
688 | {
689 | operation,
690 | path: context.path,
691 | details: { tool: request.params.name }
692 | }
693 | );
694 | }
695 | });
696 | }
697 | }
698 |
```
--------------------------------------------------------------------------------
/src/git-operations.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CommandExecutor } from './utils/command.js';
2 | import { PathValidator } from './utils/path.js';
3 | import { RepositoryValidator } from './utils/repository.js';
4 | import { logger } from './utils/logger.js';
5 | import { repositoryCache } from './caching/repository-cache.js';
6 | import { RepoStateType } from './caching/repository-cache.js';
7 | import {
8 | GitToolResult,
9 | GitToolContext,
10 | InitOptions,
11 | CloneOptions,
12 | AddOptions,
13 | CommitOptions,
14 | PushPullOptions,
15 | BranchOptions,
16 | CheckoutOptions,
17 | TagOptions,
18 | RemoteOptions,
19 | StashOptions,
20 | BasePathOptions,
21 | BulkActionOptions,
22 | BulkAction,
23 | } from './types.js';
24 | import { resolve } from 'path';
25 | import { existsSync } from 'fs';
26 | import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
27 | import { ErrorHandler } from './errors/error-handler.js';
28 | import { GitMcpError } from './errors/error-types.js';
29 |
30 | export class GitOperations {
31 | private static async executeOperation<T>(
32 | operation: string,
33 | path: string | undefined,
34 | action: () => Promise<T>,
35 | options: {
36 | useCache?: boolean;
37 | stateType?: RepoStateType;
38 | command?: string;
39 | invalidateCache?: boolean;
40 | } = {}
41 | ): Promise<T> {
42 | try {
43 | logger.info(operation, 'Starting git operation', path);
44 |
45 | let result: T;
46 | if (options.useCache && path && options.stateType && options.command) {
47 | // Use cache for repository state operations
48 | result = await repositoryCache.getState(
49 | path,
50 | options.stateType,
51 | options.command,
52 | action
53 | );
54 | } else if (options.useCache && path && options.command) {
55 | // Use cache for command results
56 | result = await repositoryCache.getCommandResult(
57 | path,
58 | options.command,
59 | action
60 | );
61 | } else {
62 | // Execute without caching
63 | result = await action();
64 | }
65 |
66 | // Invalidate cache if needed
67 | if (options.invalidateCache && path) {
68 | if (options.stateType) {
69 | repositoryCache.invalidateState(path, options.stateType);
70 | }
71 | if (options.command) {
72 | repositoryCache.invalidateCommand(path, options.command);
73 | }
74 | }
75 |
76 | logger.info(operation, 'Operation completed successfully', path);
77 | return result;
78 | } catch (error: unknown) {
79 | if (error instanceof GitMcpError) throw error;
80 | throw ErrorHandler.handleOperationError(error instanceof Error ? error : new Error('Unknown error'), {
81 | operation,
82 | path,
83 | command: options.command || 'git operation'
84 | });
85 | }
86 | }
87 |
88 | private static getPath(options: BasePathOptions): string {
89 | if (!options.path && !process.env.GIT_DEFAULT_PATH) {
90 | throw ErrorHandler.handleValidationError(
91 | new Error('Path must be provided when GIT_DEFAULT_PATH is not set'),
92 | { operation: 'get_path' }
93 | );
94 | }
95 | return options.path || process.env.GIT_DEFAULT_PATH!;
96 | }
97 |
98 | static async init(options: InitOptions, context: GitToolContext): Promise<GitToolResult> {
99 | const path = this.getPath(options);
100 | return await this.executeOperation(
101 | context.operation,
102 | path,
103 | async () => {
104 | const pathInfo = PathValidator.validatePath(path, { mustExist: false, allowDirectory: true });
105 | const result = await CommandExecutor.executeGitCommand(
106 | 'init',
107 | context.operation,
108 | pathInfo
109 | );
110 |
111 | return {
112 | content: [{
113 | type: 'text',
114 | text: `Repository initialized successfully\n${CommandExecutor.formatOutput(result)}`
115 | }]
116 | };
117 | },
118 | {
119 | command: 'init',
120 | invalidateCache: true // Invalidate all caches for this repo
121 | }
122 | );
123 | }
124 |
125 | static async clone(options: CloneOptions, context: GitToolContext): Promise<GitToolResult> {
126 | const path = this.getPath(options);
127 | return await this.executeOperation(
128 | context.operation,
129 | path,
130 | async () => {
131 | const pathInfo = PathValidator.validatePath(path, { mustExist: false, allowDirectory: true });
132 | const result = await CommandExecutor.executeGitCommand(
133 | `clone ${options.url} ${pathInfo}`,
134 | context.operation
135 | );
136 |
137 | return {
138 | content: [{
139 | type: 'text',
140 | text: `Repository cloned successfully\n${CommandExecutor.formatOutput(result)}`
141 | }]
142 | };
143 | },
144 | {
145 | command: 'clone',
146 | invalidateCache: true // Invalidate all caches for this repo
147 | }
148 | );
149 | }
150 |
151 | static async status(options: BasePathOptions, context: GitToolContext): Promise<GitToolResult> {
152 | const path = this.getPath(options);
153 | return await this.executeOperation(
154 | context.operation,
155 | path,
156 | async () => {
157 | const { path: repoPath } = PathValidator.validateGitRepo(path);
158 | const result = await CommandExecutor.executeGitCommand(
159 | 'status',
160 | context.operation,
161 | repoPath
162 | );
163 |
164 | return {
165 | content: [{
166 | type: 'text',
167 | text: CommandExecutor.formatOutput(result)
168 | }]
169 | };
170 | },
171 | {
172 | useCache: true,
173 | stateType: RepoStateType.STATUS,
174 | command: 'status'
175 | }
176 | );
177 | }
178 |
179 | static async add({ path, files }: AddOptions, context: GitToolContext): Promise<GitToolResult> {
180 | const resolvedPath = this.getPath({ path });
181 | return await this.executeOperation(
182 | context.operation,
183 | resolvedPath,
184 | async () => {
185 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
186 |
187 | // Handle each file individually to avoid path issues
188 | for (const file of files) {
189 | await CommandExecutor.executeGitCommand(
190 | `add "${file}"`,
191 | context.operation,
192 | repoPath
193 | );
194 | }
195 |
196 | return {
197 | content: [{
198 | type: 'text',
199 | text: 'Files staged successfully'
200 | }]
201 | };
202 | },
203 | {
204 | command: 'add',
205 | invalidateCache: true, // Invalidate status cache
206 | stateType: RepoStateType.STATUS
207 | }
208 | );
209 | }
210 |
211 | static async commit({ path, message }: CommitOptions, context: GitToolContext): Promise<GitToolResult> {
212 | const resolvedPath = this.getPath({ path });
213 | return await this.executeOperation(
214 | context.operation,
215 | resolvedPath,
216 | async () => {
217 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
218 |
219 | // Verify there are staged changes
220 | const statusResult = await CommandExecutor.executeGitCommand(
221 | 'status --porcelain',
222 | context.operation,
223 | repoPath
224 | );
225 |
226 | if (!statusResult.stdout.trim()) {
227 | return {
228 | content: [{
229 | type: 'text',
230 | text: 'No changes to commit'
231 | }],
232 | isError: true
233 | };
234 | }
235 |
236 | const result = await CommandExecutor.executeGitCommand(
237 | `commit -m "${message}"`,
238 | context.operation,
239 | repoPath
240 | );
241 |
242 | return {
243 | content: [{
244 | type: 'text',
245 | text: `Changes committed successfully\n${CommandExecutor.formatOutput(result)}`
246 | }]
247 | };
248 | },
249 | {
250 | command: 'commit',
251 | invalidateCache: true, // Invalidate status and branch caches
252 | stateType: RepoStateType.STATUS
253 | }
254 | );
255 | }
256 |
257 | static async push({ path, remote = 'origin', branch, force, noVerify, tags }: PushPullOptions, context: GitToolContext): Promise<GitToolResult> {
258 | const resolvedPath = this.getPath({ path });
259 | return await this.executeOperation(
260 | context.operation,
261 | resolvedPath,
262 | async () => {
263 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
264 | await RepositoryValidator.validateRemoteConfig(repoPath, remote, context.operation);
265 | await RepositoryValidator.validateBranchExists(repoPath, branch, context.operation);
266 |
267 | const result = await CommandExecutor.executeGitCommand(
268 | `push ${remote} ${branch}${force ? ' --force' : ''}${noVerify ? ' --no-verify' : ''}${tags ? ' --tags' : ''}`,
269 | context.operation,
270 | repoPath
271 | );
272 |
273 | return {
274 | content: [{
275 | type: 'text',
276 | text: `Changes pushed successfully\n${CommandExecutor.formatOutput(result)}`
277 | }]
278 | };
279 | },
280 | {
281 | command: 'push',
282 | invalidateCache: true, // Invalidate remote cache
283 | stateType: RepoStateType.REMOTE
284 | }
285 | );
286 | }
287 |
288 | static async executeBulkActions(options: BulkActionOptions, context: GitToolContext): Promise<GitToolResult> {
289 | const resolvedPath = this.getPath(options);
290 | return await this.executeOperation(
291 | context.operation,
292 | resolvedPath,
293 | async () => {
294 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
295 | const results: string[] = [];
296 |
297 | for (const action of options.actions) {
298 | try {
299 | switch (action.type) {
300 | case 'stage': {
301 | const files = action.files || ['.'];
302 | const addResult = await this.add({ path: repoPath, files }, context);
303 | results.push(addResult.content[0].text);
304 | break;
305 | }
306 | case 'commit': {
307 | const commitResult = await this.commit({ path: repoPath, message: action.message }, context);
308 | results.push(commitResult.content[0].text);
309 | break;
310 | }
311 | case 'push': {
312 | const pushResult = await this.push({
313 | path: repoPath,
314 | remote: action.remote,
315 | branch: action.branch
316 | }, context);
317 | results.push(pushResult.content[0].text);
318 | break;
319 | }
320 | }
321 | } catch (error: unknown) {
322 | const errorMessage = error instanceof Error ? error.message : 'Unknown error';
323 | results.push(`Failed to execute ${action.type}: ${errorMessage}`);
324 | if (error instanceof Error) {
325 | logger.error(context.operation, `Bulk action ${action.type} failed`, repoPath, error);
326 | }
327 | }
328 | }
329 |
330 | return {
331 | content: [{
332 | type: 'text',
333 | text: results.join('\n\n')
334 | }]
335 | };
336 | },
337 | {
338 | command: 'bulk_action',
339 | invalidateCache: true // Invalidate all caches
340 | }
341 | );
342 | }
343 |
344 | static async pull({ path, remote = 'origin', branch }: PushPullOptions, context: GitToolContext): Promise<GitToolResult> {
345 | const resolvedPath = this.getPath({ path });
346 | return await this.executeOperation(
347 | context.operation,
348 | resolvedPath,
349 | async () => {
350 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
351 | await RepositoryValidator.validateRemoteConfig(repoPath, remote, context.operation);
352 |
353 | const result = await CommandExecutor.executeGitCommand(
354 | `pull ${remote} ${branch}`,
355 | context.operation,
356 | repoPath
357 | );
358 |
359 | return {
360 | content: [{
361 | type: 'text',
362 | text: `Changes pulled successfully\n${CommandExecutor.formatOutput(result)}`
363 | }]
364 | };
365 | },
366 | {
367 | command: 'pull',
368 | invalidateCache: true // Invalidate all caches
369 | }
370 | );
371 | }
372 |
373 | static async branchList(options: BasePathOptions, context: GitToolContext): Promise<GitToolResult> {
374 | const path = this.getPath(options);
375 | return await this.executeOperation(
376 | context.operation,
377 | path,
378 | async () => {
379 | const { path: repoPath } = PathValidator.validateGitRepo(path);
380 | const result = await CommandExecutor.executeGitCommand(
381 | 'branch -a',
382 | context.operation,
383 | repoPath
384 | );
385 |
386 | const output = result.stdout.trim();
387 | return {
388 | content: [{
389 | type: 'text',
390 | text: output || 'No branches found'
391 | }]
392 | };
393 | },
394 | {
395 | useCache: true,
396 | stateType: RepoStateType.BRANCH,
397 | command: 'branch -a'
398 | }
399 | );
400 | }
401 |
402 | static async branchCreate({ path, name, force, track, setUpstream }: BranchOptions, context: GitToolContext): Promise<GitToolResult> {
403 | const resolvedPath = this.getPath({ path });
404 | return await this.executeOperation(
405 | context.operation,
406 | resolvedPath,
407 | async () => {
408 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
409 | PathValidator.validateBranchName(name);
410 |
411 | const result = await CommandExecutor.executeGitCommand(
412 | `checkout -b ${name}${force ? ' --force' : ''}${track ? ' --track' : ' --no-track'}${setUpstream ? ' --set-upstream' : ''}`,
413 | context.operation,
414 | repoPath
415 | );
416 |
417 | return {
418 | content: [{
419 | type: 'text',
420 | text: `Branch '${name}' created successfully\n${CommandExecutor.formatOutput(result)}`
421 | }]
422 | };
423 | },
424 | {
425 | command: 'branch_create',
426 | invalidateCache: true, // Invalidate branch cache
427 | stateType: RepoStateType.BRANCH
428 | }
429 | );
430 | }
431 |
432 | static async branchDelete({ path, name }: BranchOptions, context: GitToolContext): Promise<GitToolResult> {
433 | const resolvedPath = this.getPath({ path });
434 | return await this.executeOperation(
435 | context.operation,
436 | resolvedPath,
437 | async () => {
438 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
439 | PathValidator.validateBranchName(name);
440 | await RepositoryValidator.validateBranchExists(repoPath, name, context.operation);
441 |
442 | const currentBranch = await RepositoryValidator.getCurrentBranch(repoPath, context.operation);
443 | if (currentBranch === name) {
444 | throw ErrorHandler.handleValidationError(
445 | new Error(`Cannot delete the currently checked out branch: ${name}`),
446 | { operation: context.operation, path: repoPath }
447 | );
448 | }
449 |
450 | const result = await CommandExecutor.executeGitCommand(
451 | `branch -D ${name}`,
452 | context.operation,
453 | repoPath
454 | );
455 |
456 | return {
457 | content: [{
458 | type: 'text',
459 | text: `Branch '${name}' deleted successfully\n${CommandExecutor.formatOutput(result)}`
460 | }]
461 | };
462 | },
463 | {
464 | command: 'branch_delete',
465 | invalidateCache: true, // Invalidate branch cache
466 | stateType: RepoStateType.BRANCH
467 | }
468 | );
469 | }
470 |
471 | static async checkout({ path, target }: CheckoutOptions, context: GitToolContext): Promise<GitToolResult> {
472 | const resolvedPath = this.getPath({ path });
473 | return await this.executeOperation(
474 | context.operation,
475 | resolvedPath,
476 | async () => {
477 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
478 | await RepositoryValidator.ensureClean(repoPath, context.operation);
479 |
480 | const result = await CommandExecutor.executeGitCommand(
481 | `checkout ${target}`,
482 | context.operation,
483 | repoPath
484 | );
485 |
486 | return {
487 | content: [{
488 | type: 'text',
489 | text: `Switched to '${target}' successfully\n${CommandExecutor.formatOutput(result)}`
490 | }]
491 | };
492 | },
493 | {
494 | command: 'checkout',
495 | invalidateCache: true, // Invalidate branch and status caches
496 | stateType: RepoStateType.BRANCH
497 | }
498 | );
499 | }
500 |
501 | static async tagList(options: BasePathOptions, context: GitToolContext): Promise<GitToolResult> {
502 | const path = this.getPath(options);
503 | return await this.executeOperation(
504 | context.operation,
505 | path,
506 | async () => {
507 | const { path: repoPath } = PathValidator.validateGitRepo(path);
508 | const result = await CommandExecutor.executeGitCommand(
509 | 'tag -l',
510 | context.operation,
511 | repoPath
512 | );
513 |
514 | const output = result.stdout.trim();
515 | return {
516 | content: [{
517 | type: 'text',
518 | text: output || 'No tags found'
519 | }]
520 | };
521 | },
522 | {
523 | useCache: true,
524 | stateType: RepoStateType.TAG,
525 | command: 'tag -l'
526 | }
527 | );
528 | }
529 |
530 | static async tagCreate({ path, name, message }: TagOptions, context: GitToolContext): Promise<GitToolResult> {
531 | const resolvedPath = this.getPath({ path });
532 | return await this.executeOperation(
533 | context.operation,
534 | resolvedPath,
535 | async () => {
536 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
537 | PathValidator.validateTagName(name);
538 |
539 | let command = `tag ${name}`;
540 | if (typeof message === 'string' && message.length > 0) {
541 | command = `tag -a ${name} -m "${message}"`;
542 | }
543 |
544 | const result = await CommandExecutor.executeGitCommand(
545 | command,
546 | context.operation,
547 | repoPath
548 | );
549 |
550 | return {
551 | content: [{
552 | type: 'text',
553 | text: `Tag '${name}' created successfully\n${CommandExecutor.formatOutput(result)}`
554 | }]
555 | };
556 | },
557 | {
558 | command: 'tag_create',
559 | invalidateCache: true, // Invalidate tag cache
560 | stateType: RepoStateType.TAG
561 | }
562 | );
563 | }
564 |
565 | static async tagDelete({ path, name }: TagOptions, context: GitToolContext): Promise<GitToolResult> {
566 | const resolvedPath = this.getPath({ path });
567 | return await this.executeOperation(
568 | context.operation,
569 | resolvedPath,
570 | async () => {
571 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
572 | PathValidator.validateTagName(name);
573 | await RepositoryValidator.validateTagExists(repoPath, name, context.operation);
574 |
575 | const result = await CommandExecutor.executeGitCommand(
576 | `tag -d ${name}`,
577 | context.operation,
578 | repoPath
579 | );
580 |
581 | return {
582 | content: [{
583 | type: 'text',
584 | text: `Tag '${name}' deleted successfully\n${CommandExecutor.formatOutput(result)}`
585 | }]
586 | };
587 | },
588 | {
589 | command: 'tag_delete',
590 | invalidateCache: true, // Invalidate tag cache
591 | stateType: RepoStateType.TAG
592 | }
593 | );
594 | }
595 |
596 | static async remoteList(options: BasePathOptions, context: GitToolContext): Promise<GitToolResult> {
597 | const path = this.getPath(options);
598 | return await this.executeOperation(
599 | context.operation,
600 | path,
601 | async () => {
602 | const { path: repoPath } = PathValidator.validateGitRepo(path);
603 | const result = await CommandExecutor.executeGitCommand(
604 | 'remote -v',
605 | context.operation,
606 | repoPath
607 | );
608 |
609 | const output = result.stdout.trim();
610 | return {
611 | content: [{
612 | type: 'text',
613 | text: output || 'No remotes configured'
614 | }]
615 | };
616 | },
617 | {
618 | useCache: true,
619 | stateType: RepoStateType.REMOTE,
620 | command: 'remote -v'
621 | }
622 | );
623 | }
624 |
625 | static async remoteAdd({ path, name, url }: RemoteOptions, context: GitToolContext): Promise<GitToolResult> {
626 | const resolvedPath = this.getPath({ path });
627 | return await this.executeOperation(
628 | context.operation,
629 | resolvedPath,
630 | async () => {
631 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
632 | PathValidator.validateRemoteName(name);
633 | if (!url) {
634 | throw ErrorHandler.handleValidationError(
635 | new Error('URL is required when adding a remote'),
636 | { operation: context.operation, path: repoPath }
637 | );
638 | }
639 | PathValidator.validateRemoteUrl(url);
640 |
641 | const result = await CommandExecutor.executeGitCommand(
642 | `remote add ${name} ${url}`,
643 | context.operation,
644 | repoPath
645 | );
646 |
647 | return {
648 | content: [{
649 | type: 'text',
650 | text: `Remote '${name}' added successfully\n${CommandExecutor.formatOutput(result)}`
651 | }]
652 | };
653 | },
654 | {
655 | command: 'remote_add',
656 | invalidateCache: true, // Invalidate remote cache
657 | stateType: RepoStateType.REMOTE
658 | }
659 | );
660 | }
661 |
662 | static async remoteRemove({ path, name }: RemoteOptions, context: GitToolContext): Promise<GitToolResult> {
663 | const resolvedPath = this.getPath({ path });
664 | return await this.executeOperation(
665 | context.operation,
666 | resolvedPath,
667 | async () => {
668 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
669 | PathValidator.validateRemoteName(name);
670 |
671 | const result = await CommandExecutor.executeGitCommand(
672 | `remote remove ${name}`,
673 | context.operation,
674 | repoPath
675 | );
676 |
677 | return {
678 | content: [{
679 | type: 'text',
680 | text: `Remote '${name}' removed successfully\n${CommandExecutor.formatOutput(result)}`
681 | }]
682 | };
683 | },
684 | {
685 | command: 'remote_remove',
686 | invalidateCache: true, // Invalidate remote cache
687 | stateType: RepoStateType.REMOTE
688 | }
689 | );
690 | }
691 |
692 | static async stashList(options: BasePathOptions, context: GitToolContext): Promise<GitToolResult> {
693 | const path = this.getPath(options);
694 | return await this.executeOperation(
695 | context.operation,
696 | path,
697 | async () => {
698 | const { path: repoPath } = PathValidator.validateGitRepo(path);
699 | const result = await CommandExecutor.executeGitCommand(
700 | 'stash list',
701 | context.operation,
702 | repoPath
703 | );
704 |
705 | const output = result.stdout.trim();
706 | return {
707 | content: [{
708 | type: 'text',
709 | text: output || 'No stashes found'
710 | }]
711 | };
712 | },
713 | {
714 | useCache: true,
715 | stateType: RepoStateType.STASH,
716 | command: 'stash list'
717 | }
718 | );
719 | }
720 |
721 | static async stashSave({ path, message, includeUntracked, keepIndex, all }: StashOptions, context: GitToolContext): Promise<GitToolResult> {
722 | const resolvedPath = this.getPath({ path });
723 | return await this.executeOperation(
724 | context.operation,
725 | resolvedPath,
726 | async () => {
727 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
728 | let command = 'stash';
729 | if (typeof message === 'string' && message.length > 0) {
730 | command += ` save "${message}"`;
731 | }
732 | if (includeUntracked) {
733 | command += ' --include-untracked';
734 | }
735 | if (keepIndex) {
736 | command += ' --keep-index';
737 | }
738 | if (all) {
739 | command += ' --all';
740 | }
741 | const result = await CommandExecutor.executeGitCommand(
742 | command,
743 | context.operation,
744 | repoPath
745 | );
746 |
747 | return {
748 | content: [{
749 | type: 'text',
750 | text: `Changes stashed successfully\n${CommandExecutor.formatOutput(result)}`
751 | }]
752 | };
753 | },
754 | {
755 | command: 'stash_save',
756 | invalidateCache: true, // Invalidate stash and status caches
757 | stateType: RepoStateType.STASH
758 | }
759 | );
760 | }
761 |
762 | static async stashPop({ path, index = 0 }: StashOptions, context: GitToolContext): Promise<GitToolResult> {
763 | const resolvedPath = this.getPath({ path });
764 | return await this.executeOperation(
765 | context.operation,
766 | resolvedPath,
767 | async () => {
768 | const { path: repoPath } = PathValidator.validateGitRepo(resolvedPath);
769 | const result = await CommandExecutor.executeGitCommand(
770 | `stash pop stash@{${index}}`,
771 | context.operation,
772 | repoPath
773 | );
774 |
775 | return {
776 | content: [{
777 | type: 'text',
778 | text: `Stash applied successfully\n${CommandExecutor.formatOutput(result)}`
779 | }]
780 | };
781 | },
782 | {
783 | command: 'stash_pop',
784 | invalidateCache: true, // Invalidate stash and status caches
785 | stateType: RepoStateType.STASH
786 | }
787 | );
788 | }
789 | }
790 |
```