@cyanheads/git-mcp-server 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,965 @@
1
+ /**
2
+ * Git Service
3
+ * ===========
4
+ *
5
+ * An abstraction layer for Git operations using simple-git.
6
+ * Provides a clean interface for the MCP server to interact with Git repositories.
7
+ */
8
+ import { simpleGit } from 'simple-git';
9
+ import fs from 'fs/promises';
10
+ import { execSync } from 'child_process';
11
+ import path from 'path';
12
+ import { PathValidation } from '../utils/validation.js'; // Import PathValidation
13
+ import { getGlobalSettings } from '../utils/global-settings.js'; // Import GlobalSettings
14
+ import { createGitError, createSuccessResult, createFailureResult, wrapExceptionAsStandardizedError, createStandardizedError, // Use the correct function
15
+ ErrorCategoryType, // Import category type
16
+ ErrorSeverityLevel // Import severity level
17
+ } from './error-service.js';
18
+ export class GitService {
19
+ git;
20
+ repoPath;
21
+ /**
22
+ * Creates a new GitService instance for a specific repository path
23
+ *
24
+ * @param repoPath - Path to the git repository
25
+ * @param options - Additional simple-git options
26
+ */
27
+ constructor(repoPath, options = {}) {
28
+ // --- Path Sandboxing Validation ---
29
+ const settings = getGlobalSettings();
30
+ const normalizedRepoPath = PathValidation.normalizePath(repoPath);
31
+ if (!PathValidation.isWithinDirectory(normalizedRepoPath, settings.allowedBaseDir)) {
32
+ // Use createStandardizedError with appropriate parameters
33
+ throw createStandardizedError(`Access denied: Repository path '${repoPath}' (resolved to '${normalizedRepoPath}') is outside the allowed base directory '${settings.allowedBaseDir}'.`, 'PATH_OUTSIDE_BASEDIR', // Error code
34
+ ErrorCategoryType.CATEGORY_VALIDATION, // Category
35
+ ErrorSeverityLevel.SEVERITY_ERROR, // Severity
36
+ { requestedPath: repoPath, resolvedPath: normalizedRepoPath, allowedBaseDir: settings.allowedBaseDir } // Context
37
+ );
38
+ }
39
+ this.repoPath = normalizedRepoPath; // Use the validated, normalized path
40
+ // --- End Path Sandboxing Validation ---
41
+ try {
42
+ // Try to get the global git user configuration
43
+ const globalUserName = execSync('git config --global user.name').toString().trim();
44
+ const globalUserEmail = execSync('git config --global user.email').toString().trim();
45
+ // Initialize git with this configuration to ensure it uses the global values
46
+ this.git = simpleGit(this.repoPath, {
47
+ ...options,
48
+ config: [
49
+ `user.name=${globalUserName}`,
50
+ `user.email=${globalUserEmail}`
51
+ ]
52
+ });
53
+ }
54
+ catch (error) {
55
+ // If we can't get the global config, fall back to standard initialization
56
+ console.error('Failed to get global git config, using default initialization', error);
57
+ this.git = simpleGit(this.repoPath, {
58
+ ...options,
59
+ baseDir: this.repoPath
60
+ });
61
+ }
62
+ }
63
+ /**
64
+ * Validates a path relative to the repository root to prevent traversal.
65
+ * Throws a standardized error if validation fails.
66
+ *
67
+ * @param relativePath - The path relative to the repository root.
68
+ * @param paramName - The name of the parameter being validated (for error messages).
69
+ * @returns The validated and normalized relative path.
70
+ */
71
+ validateRelativePath(relativePath, paramName) {
72
+ // Avoid validating special case '.' which means 'current directory' or 'all' in some contexts
73
+ if (relativePath === '.' || relativePath === '') {
74
+ return relativePath;
75
+ }
76
+ // Normalize the input relative path *before* resolving against the repo path
77
+ // This handles inputs like 'subdir/../file' correctly within the context of the repo
78
+ const normalizedRelativePath = path.normalize(relativePath);
79
+ // Resolve the normalized relative path against the repository path
80
+ const resolvedPath = path.resolve(this.repoPath, normalizedRelativePath);
81
+ // Use the existing validation utility
82
+ // Check if the resolved path is the repo path itself OR is within it
83
+ if (resolvedPath !== this.repoPath && !PathValidation.isWithinDirectory(resolvedPath, this.repoPath)) {
84
+ throw createStandardizedError(`Access denied: Path '${relativePath}' in parameter '${paramName}' (resolved to '${resolvedPath}') attempts to traverse outside the repository root '${this.repoPath}'.`, 'PATH_TRAVERSAL_ATTEMPT', // Keep specific error code
85
+ ErrorCategoryType.CATEGORY_VALIDATION, // Use existing validation category
86
+ ErrorSeverityLevel.SEVERITY_ERROR, // Use existing error severity
87
+ { requestedPath: relativePath, resolvedPath: resolvedPath, repoPath: this.repoPath, parameter: paramName });
88
+ }
89
+ // Return the normalized relative path, as simple-git expects paths relative to repoPath
90
+ return normalizedRelativePath;
91
+ }
92
+ /**
93
+ * Handles Git errors in a standardized way
94
+ *
95
+ * @param error - The error to handle
96
+ * @param defaultMessage - Default message if error is not a Git error
97
+ * @returns Standardized error object
98
+ */
99
+ handleGitError(error, defaultMessage) {
100
+ if (error.code) {
101
+ const gitError = error;
102
+ return createGitError(gitError.message || defaultMessage, gitError.code || 'GIT_ERROR', {
103
+ command: gitError.command,
104
+ args: gitError.args,
105
+ stderr: gitError.stderr
106
+ });
107
+ }
108
+ return wrapExceptionAsStandardizedError(error, defaultMessage);
109
+ }
110
+ /**
111
+ * Ensures the repository directory exists
112
+ *
113
+ * @returns Promise resolving when directory exists or is created
114
+ */
115
+ async ensureRepoPathExists() {
116
+ try {
117
+ await fs.access(this.repoPath);
118
+ }
119
+ catch (error) {
120
+ // Create directory if it doesn't exist
121
+ await fs.mkdir(this.repoPath, { recursive: true });
122
+ }
123
+ }
124
+ /**
125
+ * Checks if a path is a Git repository
126
+ *
127
+ * @param dirPath - Path to check
128
+ * @returns Promise resolving to true if path is a Git repository
129
+ */
130
+ async isGitRepository(dirPath = this.repoPath) {
131
+ // --- Path Sandboxing Validation ---
132
+ const settings = getGlobalSettings();
133
+ const normalizedDirPath = PathValidation.normalizePath(dirPath);
134
+ if (!PathValidation.isWithinDirectory(normalizedDirPath, settings.allowedBaseDir)) {
135
+ console.error(`Security Warning: isGitRepository check attempted outside allowed base directory. Path: ${dirPath}, Base: ${settings.allowedBaseDir}`);
136
+ // Return false instead of throwing an error, as this might be used for checks
137
+ return false;
138
+ }
139
+ // --- End Path Sandboxing Validation ---
140
+ try {
141
+ const gitDir = path.join(normalizedDirPath, '.git'); // Use normalized path
142
+ await fs.access(gitDir);
143
+ return true;
144
+ }
145
+ catch (error) {
146
+ try {
147
+ // Check if it's a bare repository by looking for common Git files
148
+ const gitFiles = ['HEAD', 'config', 'objects', 'refs'];
149
+ for (const file of gitFiles) {
150
+ await fs.access(path.join(normalizedDirPath, file)); // Use normalized path
151
+ }
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ }
159
+ // ==========================================
160
+ // Repository Operations
161
+ // ==========================================
162
+ /**
163
+ * Initializes a new Git repository
164
+ *
165
+ * @param bare - Whether to create a bare repository
166
+ * @param initialBranch - Initial branch name (default: main)
167
+ * @returns Promise resolving to operation result
168
+ */
169
+ async initRepo(bare = false, initialBranch = 'main') {
170
+ try {
171
+ await this.ensureRepoPathExists();
172
+ // Use init with options to set the initial branch name
173
+ const initOptions = {
174
+ '--initial-branch': initialBranch,
175
+ '--bare': bare ? true : undefined,
176
+ };
177
+ const result = await this.git.init(initOptions);
178
+ // If we're not in a bare repository, make an initial commit to establish the branch
179
+ if (!bare) {
180
+ try {
181
+ // Create a README.md file as first commit to establish the branch
182
+ const readmePath = path.join(this.repoPath, 'README.md');
183
+ await fs.writeFile(readmePath, `# Git Repository\n\nInitialized with branch '${initialBranch}'.`);
184
+ await this.git.add('README.md');
185
+ await this.git.commit(`Initial commit`, { '--allow-empty': null });
186
+ }
187
+ catch (commitError) {
188
+ // If initial commit fails, it's not critical - the repo is still initialized
189
+ console.error('Failed to create initial commit:', commitError);
190
+ }
191
+ }
192
+ return createSuccessResult(result);
193
+ }
194
+ catch (error) {
195
+ return createFailureResult(this.handleGitError(error, 'Failed to initialize repository'));
196
+ }
197
+ }
198
+ /**
199
+ * Clones a Git repository
200
+ *
201
+ * @param url - URL of the repository to clone
202
+ * @param options - Clone options
203
+ * @returns Promise resolving to operation result
204
+ */
205
+ async cloneRepo(url, options = {}) {
206
+ try {
207
+ await this.ensureRepoPathExists();
208
+ const result = await this.git.clone(url, this.repoPath, options);
209
+ return createSuccessResult(result);
210
+ }
211
+ catch (error) {
212
+ return createFailureResult(this.handleGitError(error, `Failed to clone repository from ${url}`));
213
+ }
214
+ }
215
+ /**
216
+ * Gets the status of the repository
217
+ *
218
+ * @returns Promise resolving to repository status
219
+ */
220
+ async getStatus() {
221
+ try {
222
+ const status = await this.git.status();
223
+ return createSuccessResult(status);
224
+ }
225
+ catch (error) {
226
+ return createFailureResult(this.handleGitError(error, 'Failed to get repository status'));
227
+ }
228
+ }
229
+ // ==========================================
230
+ // Commit Operations
231
+ // ==========================================
232
+ /**
233
+ * Stages files for commit
234
+ *
235
+ * @param files - Array of file paths to stage, or '.' for all
236
+ * @returns Promise resolving to operation result
237
+ */
238
+ async stageFiles(files = '.') {
239
+ try {
240
+ let validatedFiles;
241
+ if (Array.isArray(files)) {
242
+ if (files.length === 0) {
243
+ return createSuccessResult('No files to stage');
244
+ }
245
+ // Validate each file path in the array
246
+ validatedFiles = files.map(file => this.validateRelativePath(file, 'files'));
247
+ }
248
+ else {
249
+ // Validate the single file path (unless it's '.')
250
+ validatedFiles = this.validateRelativePath(files, 'files');
251
+ }
252
+ const result = await this.git.add(validatedFiles); // Use validated files
253
+ return createSuccessResult(result);
254
+ }
255
+ catch (error) {
256
+ // Check if it's our standardized error by looking for errorCode property
257
+ if (error?.errorCode) {
258
+ throw error; // Rethrow standardized errors directly
259
+ }
260
+ return createFailureResult(this.handleGitError(error, 'Failed to stage files'));
261
+ }
262
+ }
263
+ /**
264
+ * Unstages files
265
+ *
266
+ * @param files - Array of file paths to unstage, or '.' for all
267
+ * @returns Promise resolving to operation result
268
+ */
269
+ async unstageFiles(files = '.') {
270
+ try {
271
+ let validatedFiles;
272
+ if (Array.isArray(files)) {
273
+ if (files.length === 0) {
274
+ return createSuccessResult('No files to unstage');
275
+ }
276
+ // Validate each file path in the array
277
+ validatedFiles = files.map(file => this.validateRelativePath(file, 'files'));
278
+ }
279
+ else {
280
+ // Validate the single file path (unless it's '.')
281
+ validatedFiles = this.validateRelativePath(files, 'files');
282
+ }
283
+ // Use reset to unstage files
284
+ const result = await this.git.reset(['--', ...(Array.isArray(validatedFiles) ? validatedFiles : [validatedFiles])]); // Use validated files
285
+ return createSuccessResult(result);
286
+ }
287
+ catch (error) {
288
+ // Check if it's our standardized error by looking for errorCode property
289
+ if (error?.errorCode) {
290
+ throw error; // Rethrow standardized errors directly
291
+ }
292
+ return createFailureResult(this.handleGitError(error, 'Failed to unstage files'));
293
+ }
294
+ }
295
+ /**
296
+ * Creates a commit
297
+ *
298
+ * @param options - Commit options
299
+ * @returns Promise resolving to commit hash
300
+ */
301
+ async commit(options) {
302
+ try {
303
+ // Simple-git uses the underlying git config for author information
304
+ // when these specific options aren't provided, so we'll only set
305
+ // them when explicitly specified
306
+ const commitOptions = {
307
+ '--allow-empty': options.allowEmpty ? null : undefined,
308
+ '--amend': options.amend ? null : undefined
309
+ };
310
+ if (options.author && options.author.name) {
311
+ commitOptions['--author'] = `${options.author.name} <${options.author.email || ''}>`;
312
+ }
313
+ const result = await this.git.commit(options.message, commitOptions);
314
+ return createSuccessResult(result.commit || '');
315
+ }
316
+ catch (error) {
317
+ return createFailureResult(this.handleGitError(error, 'Failed to create commit'));
318
+ }
319
+ }
320
+ // ==========================================
321
+ // Branch Operations
322
+ // ==========================================
323
+ /**
324
+ * Creates a new branch
325
+ *
326
+ * @param options - Branch options
327
+ * @returns Promise resolving to operation result
328
+ */
329
+ async createBranch(options) {
330
+ try {
331
+ const branchParams = [options.name];
332
+ if (options.startPoint)
333
+ branchParams.push(options.startPoint);
334
+ await this.git.branch(branchParams);
335
+ if (options.checkout) {
336
+ await this.git.checkout(options.name);
337
+ }
338
+ return createSuccessResult(`Branch '${options.name}' created successfully`);
339
+ }
340
+ catch (error) {
341
+ return createFailureResult(this.handleGitError(error, `Failed to create branch '${options.name}'`));
342
+ }
343
+ }
344
+ /**
345
+ * Lists all branches
346
+ *
347
+ * @param all - Whether to include remote branches
348
+ * @returns Promise resolving to branch summary including current branch
349
+ */
350
+ async listBranches(all = false) {
351
+ try {
352
+ const branchSummary = await this.git.branch(all ? ['-a'] : []);
353
+ // Directly return the summary object provided by simple-git
354
+ return createSuccessResult(branchSummary);
355
+ }
356
+ catch (error) {
357
+ return createFailureResult(this.handleGitError(error, 'Failed to list branches'));
358
+ }
359
+ }
360
+ /**
361
+ * Checkout a branch or commit
362
+ *
363
+ * @param target - Branch name, commit hash, or reference to checkout
364
+ * @param createBranch - Whether to create the branch if it doesn't exist
365
+ * @returns Promise resolving to operation result
366
+ */
367
+ async checkout(target, createBranch = false) {
368
+ try {
369
+ const options = createBranch ? ['-b'] : [];
370
+ const result = await this.git.checkout([...options, target]);
371
+ return createSuccessResult(result);
372
+ }
373
+ catch (error) {
374
+ return createFailureResult(this.handleGitError(error, `Failed to checkout '${target}'`));
375
+ }
376
+ }
377
+ /**
378
+ * Delete a branch
379
+ *
380
+ * @param branchName - Name of the branch to delete
381
+ * @param force - Whether to force delete
382
+ * @returns Promise resolving to operation result
383
+ */
384
+ async deleteBranch(branchName, force = false) {
385
+ try {
386
+ const options = force ? ['-D'] : ['-d'];
387
+ const result = await this.git.branch([...options, branchName]);
388
+ return createSuccessResult(result);
389
+ }
390
+ catch (error) {
391
+ return createFailureResult(this.handleGitError(error, `Failed to delete branch '${branchName}'`));
392
+ }
393
+ }
394
+ /**
395
+ * Merge a branch into the current branch
396
+ *
397
+ * @param options - Merge options
398
+ * @returns Promise resolving to merge result
399
+ */
400
+ async merge(options) {
401
+ try {
402
+ const mergeParams = [options.branch];
403
+ if (options.fastForwardOnly)
404
+ mergeParams.unshift('--ff-only');
405
+ if (options.noFastForward)
406
+ mergeParams.unshift('--no-ff');
407
+ if (options.message) {
408
+ mergeParams.unshift('-m', options.message);
409
+ }
410
+ const result = await this.git.merge(mergeParams);
411
+ return createSuccessResult(result);
412
+ }
413
+ catch (error) {
414
+ return createFailureResult(this.handleGitError(error, `Failed to merge branch '${options.branch}'`));
415
+ }
416
+ }
417
+ // ==========================================
418
+ // Remote Operations
419
+ // ==========================================
420
+ /**
421
+ * Add a remote
422
+ *
423
+ * @param options - Remote options
424
+ * @returns Promise resolving to operation result
425
+ */
426
+ async addRemote(options) {
427
+ try {
428
+ const result = await this.git.addRemote(options.name, options.url);
429
+ return createSuccessResult(result);
430
+ }
431
+ catch (error) {
432
+ return createFailureResult(this.handleGitError(error, `Failed to add remote '${options.name}'`));
433
+ }
434
+ }
435
+ /**
436
+ * List remotes
437
+ *
438
+ * @returns Promise resolving to list of remotes
439
+ */
440
+ async listRemotes() {
441
+ try {
442
+ const remotes = await this.git.getRemotes(true);
443
+ return createSuccessResult(remotes);
444
+ }
445
+ catch (error) {
446
+ return createFailureResult(this.handleGitError(error, 'Failed to list remotes'));
447
+ }
448
+ }
449
+ /**
450
+ * Fetch from a remote
451
+ *
452
+ * @param remote - Remote to fetch from (default: origin)
453
+ * @param branch - Branch to fetch (default: all branches)
454
+ * @returns Promise resolving to fetch result
455
+ */
456
+ async fetch(remote = 'origin', branch) {
457
+ try {
458
+ const options = branch ? [remote, branch] : [remote];
459
+ const result = await this.git.fetch(options);
460
+ return createSuccessResult(result);
461
+ }
462
+ catch (error) {
463
+ return createFailureResult(this.handleGitError(error, `Failed to fetch from remote '${remote}'`));
464
+ }
465
+ }
466
+ /**
467
+ * Pull from a remote
468
+ *
469
+ * @param options - Pull options
470
+ * @returns Promise resolving to pull result
471
+ */
472
+ async pull(options = {}) {
473
+ try {
474
+ const pullOptions = {};
475
+ if (options.remote)
476
+ pullOptions.remote = options.remote;
477
+ if (options.branch)
478
+ pullOptions.branch = options.branch;
479
+ if (options.rebase)
480
+ pullOptions['--rebase'] = null;
481
+ const result = await this.git.pull(pullOptions);
482
+ return createSuccessResult(result);
483
+ }
484
+ catch (error) {
485
+ return createFailureResult(this.handleGitError(error, 'Failed to pull changes'));
486
+ }
487
+ }
488
+ /**
489
+ * Push to a remote
490
+ *
491
+ * @param options - Push options
492
+ * @returns Promise resolving to push result
493
+ */
494
+ async push(options = {}) {
495
+ try {
496
+ const pushOptions = [];
497
+ if (options.force)
498
+ pushOptions.push('--force');
499
+ if (options.setUpstream)
500
+ pushOptions.push('--set-upstream');
501
+ const remote = options.remote || 'origin';
502
+ const branch = options.branch || 'HEAD';
503
+ const result = await this.git.push(remote, branch, pushOptions);
504
+ return createSuccessResult(result);
505
+ }
506
+ catch (error) {
507
+ return createFailureResult(this.handleGitError(error, 'Failed to push changes'));
508
+ }
509
+ }
510
+ // ==========================================
511
+ // History Operations
512
+ // ==========================================
513
+ /**
514
+ * Get commit history
515
+ *
516
+ * @param options - Options for git log
517
+ * @returns Promise resolving to commit history
518
+ */
519
+ async getLog(options = {}) {
520
+ try {
521
+ let validatedFilePath;
522
+ if (options.file) {
523
+ validatedFilePath = this.validateRelativePath(options.file, 'options.file');
524
+ }
525
+ // Build options object for simple-git in the format it expects
526
+ const logOptions = {
527
+ maxCount: options.maxCount || 50,
528
+ format: {
529
+ hash: '%H',
530
+ abbrevHash: '%h',
531
+ author_name: '%an',
532
+ author_email: '%ae',
533
+ date: '%ai',
534
+ message: '%s'
535
+ }
536
+ };
537
+ if (validatedFilePath) {
538
+ logOptions.file = validatedFilePath; // Use validated path
539
+ }
540
+ const result = await this.git.log(logOptions);
541
+ // Parse the log output into structured data
542
+ const entries = result.all.map((entry) => ({
543
+ hash: entry.hash,
544
+ abbrevHash: entry.hash.substring(0, 7),
545
+ author: entry.author_name,
546
+ authorEmail: entry.author_email,
547
+ date: new Date(entry.date),
548
+ message: entry.message,
549
+ refs: entry.refs
550
+ }));
551
+ return createSuccessResult(entries);
552
+ }
553
+ catch (error) {
554
+ // Check if it's our standardized error by looking for errorCode property
555
+ if (error?.errorCode) {
556
+ throw error; // Rethrow standardized errors directly
557
+ }
558
+ return createFailureResult(this.handleGitError(error, 'Failed to get commit history'));
559
+ }
560
+ }
561
+ /**
562
+ * Get file blame information
563
+ *
564
+ * @param filePath - Path to the file
565
+ * @returns Promise resolving to blame information
566
+ */
567
+ async getBlame(filePath) {
568
+ try {
569
+ const validatedPath = this.validateRelativePath(filePath, 'filePath');
570
+ const result = await this.git.raw(['blame', validatedPath]); // Use validated path
571
+ return createSuccessResult(result);
572
+ }
573
+ catch (error) {
574
+ // Check if it's our standardized error by looking for errorCode property
575
+ if (error?.errorCode) {
576
+ throw error; // Rethrow standardized errors directly
577
+ }
578
+ return createFailureResult(this.handleGitError(error, `Failed to get blame for file '${filePath}'`));
579
+ }
580
+ }
581
+ /**
582
+ * Get the diff between commits
583
+ *
584
+ * @param fromRef - Starting reference (commit, branch, etc.)
585
+ * @param toRef - Ending reference (default: current working tree)
586
+ * @param path - Optional path to restrict the diff to
587
+ * @returns Promise resolving to diff information
588
+ */
589
+ async getDiff(fromRef, toRef = 'HEAD', filePath) {
590
+ try {
591
+ let validatedPath;
592
+ if (filePath) {
593
+ validatedPath = this.validateRelativePath(filePath, 'filePath');
594
+ }
595
+ const args = ['diff', '--name-status', fromRef];
596
+ if (toRef !== 'HEAD') {
597
+ args.push(toRef);
598
+ }
599
+ if (validatedPath) {
600
+ args.push('--', validatedPath); // Use validated path
601
+ }
602
+ const result = await this.git.raw(args);
603
+ const entries = [];
604
+ // Parse the diff output into structured data
605
+ const lines = result.split('\n').filter((line) => line.trim() !== '');
606
+ for (const line of lines) {
607
+ const [status, ...pathParts] = line.split('\t');
608
+ const filePath = pathParts.join('\t');
609
+ entries.push({
610
+ path: filePath,
611
+ // Mapping status letters to more descriptive terms
612
+ status: status === 'A' ? 'added' :
613
+ status === 'M' ? 'modified' :
614
+ status === 'D' ? 'deleted' :
615
+ status === 'R' ? 'renamed' :
616
+ status === 'C' ? 'copied' : status
617
+ });
618
+ }
619
+ return createSuccessResult(entries);
620
+ }
621
+ catch (error) {
622
+ // Check if it's our standardized error by looking for errorCode property
623
+ if (error?.errorCode) {
624
+ throw error; // Rethrow standardized errors directly
625
+ }
626
+ return createFailureResult(this.handleGitError(error, 'Failed to get diff'));
627
+ }
628
+ }
629
+ /**
630
+ * Get the content of a file at a specific reference
631
+ *
632
+ * @param filePath - Path to the file
633
+ * @param ref - Git reference (commit, branch, etc.)
634
+ * @returns Promise resolving to file content
635
+ */
636
+ async getFileAtRef(filePath, ref = 'HEAD') {
637
+ try {
638
+ const validatedPath = this.validateRelativePath(filePath, 'filePath');
639
+ // Note: simple-git's show command expects the path within the ref string itself.
640
+ // We validated the path component, but we still construct the argument as needed.
641
+ const result = await this.git.show([`${ref}:${validatedPath}`]); // Use validated path component
642
+ return createSuccessResult(result);
643
+ }
644
+ catch (error) {
645
+ // Check if it's our standardized error by looking for errorCode property
646
+ if (error?.errorCode) {
647
+ throw error; // Rethrow standardized errors directly
648
+ }
649
+ return createFailureResult(this.handleGitError(error, `Failed to get file '${filePath}' at ref '${ref}'`));
650
+ }
651
+ }
652
+ /**
653
+ * Get unstaged diff (changes in working directory)
654
+ *
655
+ * @param path - Optional path to restrict the diff to
656
+ * @param showUntracked - Whether to include information about untracked files
657
+ * @returns Promise resolving to diff content
658
+ */
659
+ async getUnstagedDiff(filePath, showUntracked = true) {
660
+ try {
661
+ let validatedPath;
662
+ if (filePath) {
663
+ validatedPath = this.validateRelativePath(filePath, 'filePath');
664
+ }
665
+ const args = ['diff'];
666
+ if (validatedPath) {
667
+ args.push('--', validatedPath); // Use validated path
668
+ }
669
+ let diffResult = await this.git.raw(args);
670
+ // If requested, also include information about untracked files
671
+ if (showUntracked) {
672
+ try {
673
+ // Get status to find untracked files
674
+ const statusResult = await this.getStatus();
675
+ if (statusResult.resultSuccessful && statusResult.resultData.not_added.length > 0) {
676
+ // Filter untracked files by validatedPath if specified
677
+ const untrackedFiles = validatedPath
678
+ ? statusResult.resultData.not_added.filter(file => file === validatedPath || file.startsWith(validatedPath + '/'))
679
+ : statusResult.resultData.not_added;
680
+ if (untrackedFiles.length > 0) {
681
+ // Add header for untracked files if we have a diff and untracked files
682
+ if (diffResult.trim() !== '') {
683
+ diffResult += '\n\n';
684
+ }
685
+ diffResult += '# Untracked files:\n';
686
+ for (const file of untrackedFiles) {
687
+ diffResult += `# - ${file}\n`;
688
+ }
689
+ }
690
+ }
691
+ }
692
+ catch (error) {
693
+ // Silently ignore errors with listing untracked files
694
+ console.error('Error listing untracked files:', error);
695
+ }
696
+ }
697
+ return createSuccessResult(diffResult);
698
+ }
699
+ catch (error) {
700
+ // Check if it's our standardized error by looking for errorCode property
701
+ if (error?.errorCode) {
702
+ throw error; // Rethrow standardized errors directly
703
+ }
704
+ return createFailureResult(this.handleGitError(error, 'Failed to get unstaged diff'));
705
+ }
706
+ }
707
+ /**
708
+ * Get staged diff (changes in index)
709
+ *
710
+ * @param path - Optional path to restrict the diff to
711
+ * @returns Promise resolving to diff content
712
+ */
713
+ async getStagedDiff(filePath) {
714
+ try {
715
+ let validatedPath;
716
+ if (filePath) {
717
+ validatedPath = this.validateRelativePath(filePath, 'filePath');
718
+ }
719
+ const args = ['diff', '--cached'];
720
+ if (validatedPath) {
721
+ args.push('--', validatedPath); // Use validated path
722
+ }
723
+ const result = await this.git.raw(args);
724
+ return createSuccessResult(result);
725
+ }
726
+ catch (error) {
727
+ // Check if it's our standardized error by looking for errorCode property
728
+ if (error?.errorCode) {
729
+ throw error; // Rethrow standardized errors directly
730
+ }
731
+ return createFailureResult(this.handleGitError(error, 'Failed to get staged diff'));
732
+ }
733
+ }
734
+ /**
735
+ * List files in a directory at a specific reference
736
+ *
737
+ * @param dirPath - Path to the directory (relative to the repo root)
738
+ * @param ref - Git reference (commit, branch, etc.)
739
+ * @returns Promise resolving to list of immediate files/directories within the specified path
740
+ */
741
+ async listFilesAtRef(dirPath = '.', ref = 'HEAD') {
742
+ try {
743
+ const validatedDirPath = this.validateRelativePath(dirPath, 'dirPath');
744
+ // Remove '-r' to list only immediate children, not recursive
745
+ // Use the validated path
746
+ const result = await this.git.raw(['ls-tree', '--name-only', ref, validatedDirPath]);
747
+ // Parse the output
748
+ const files = result.split('\n')
749
+ .filter((line) => line.trim() !== '');
750
+ // No need for post-filtering, ls-tree with validatedDirPath handles it.
751
+ // .filter((file: string) => {
752
+ // // If validatedDirPath is empty or root, include all files
753
+ // if (!validatedDirPath || validatedDirPath === '.') {
754
+ // return true;
755
+ // }
756
+ // // Otherwise, only include files that are within the directory
757
+ // // This check might be redundant now with ls-tree using the validated path
758
+ // return file.startsWith(validatedDirPath + '/');
759
+ // });
760
+ return createSuccessResult(files);
761
+ }
762
+ catch (error) {
763
+ // Check if it's our standardized error by looking for errorCode property
764
+ if (error?.errorCode) {
765
+ throw error; // Rethrow standardized errors directly
766
+ }
767
+ return createFailureResult(this.handleGitError(error, `Failed to list files in directory '${dirPath}' at ref '${ref}'`));
768
+ }
769
+ }
770
+ // ==========================================
771
+ // Advanced Operations
772
+ // ==========================================
773
+ /**
774
+ * Create a tag
775
+ *
776
+ * @param options - Tag options
777
+ * @returns Promise resolving to operation result
778
+ */
779
+ async createTag(options) {
780
+ try {
781
+ const tagArgs = [options.name];
782
+ if (options.ref) {
783
+ tagArgs.push(options.ref);
784
+ }
785
+ if (options.message) {
786
+ tagArgs.unshift('-m', options.message);
787
+ // -a creates an annotated tag
788
+ tagArgs.unshift('-a');
789
+ }
790
+ const result = await this.git.tag(tagArgs);
791
+ return createSuccessResult(result);
792
+ }
793
+ catch (error) {
794
+ return createFailureResult(this.handleGitError(error, `Failed to create tag '${options.name}'`));
795
+ }
796
+ }
797
+ /**
798
+ * List tags
799
+ *
800
+ * @returns Promise resolving to list of tags
801
+ */
802
+ async listTags() {
803
+ try {
804
+ const tags = await this.git.tags();
805
+ return createSuccessResult(tags.all);
806
+ }
807
+ catch (error) {
808
+ return createFailureResult(this.handleGitError(error, 'Failed to list tags'));
809
+ }
810
+ }
811
+ /**
812
+ * Create a stash
813
+ *
814
+ * @param options - Stash options
815
+ * @returns Promise resolving to operation result
816
+ */
817
+ async createStash(options = {}) {
818
+ try {
819
+ const stashArgs = [];
820
+ if (options.message) {
821
+ stashArgs.push('save', options.message);
822
+ }
823
+ if (options.includeUntracked) {
824
+ stashArgs.push('--include-untracked');
825
+ }
826
+ const result = await this.git.stash(stashArgs);
827
+ return createSuccessResult(result);
828
+ }
829
+ catch (error) {
830
+ return createFailureResult(this.handleGitError(error, 'Failed to create stash'));
831
+ }
832
+ }
833
+ /**
834
+ * List stashes
835
+ *
836
+ * @returns Promise resolving to list of stashes
837
+ */
838
+ async listStashes() {
839
+ try {
840
+ const result = await this.git.stash(['list']);
841
+ return createSuccessResult(result);
842
+ }
843
+ catch (error) {
844
+ return createFailureResult(this.handleGitError(error, 'Failed to list stashes'));
845
+ }
846
+ }
847
+ /**
848
+ * Show a commit's details
849
+ *
850
+ * @param commitHash - Hash of the commit to show
851
+ * @returns Promise resolving to commit details
852
+ */
853
+ async showCommit(commitHash) {
854
+ try {
855
+ const result = await this.git.raw(['show', commitHash]);
856
+ return createSuccessResult(result);
857
+ }
858
+ catch (error) {
859
+ return createFailureResult(this.handleGitError(error, `Failed to show commit '${commitHash}'`));
860
+ }
861
+ }
862
+ /**
863
+ * Apply a stash
864
+ *
865
+ * @param stashId - Stash identifier (default: most recent stash)
866
+ * @returns Promise resolving to operation result
867
+ */
868
+ async applyStash(stashId = 'stash@{0}') {
869
+ try {
870
+ const result = await this.git.stash(['apply', stashId]);
871
+ return createSuccessResult(result);
872
+ }
873
+ catch (error) {
874
+ return createFailureResult(this.handleGitError(error, `Failed to apply stash '${stashId}'`));
875
+ }
876
+ }
877
+ /**
878
+ * Pop a stash
879
+ *
880
+ * @param stashId - Stash identifier (default: most recent stash)
881
+ * @returns Promise resolving to operation result
882
+ */
883
+ async popStash(stashId = 'stash@{0}') {
884
+ try {
885
+ const result = await this.git.stash(['pop', stashId]);
886
+ return createSuccessResult(result);
887
+ }
888
+ catch (error) {
889
+ return createFailureResult(this.handleGitError(error, `Failed to pop stash '${stashId}'`));
890
+ }
891
+ }
892
+ /**
893
+ * Cherry-pick commits
894
+ *
895
+ * @param commits - Array of commit hashes to cherry-pick
896
+ * @returns Promise resolving to operation result
897
+ */
898
+ async cherryPick(commits) {
899
+ try {
900
+ if (commits.length === 0) {
901
+ return createSuccessResult('No commits specified for cherry-pick');
902
+ }
903
+ const result = await this.git.raw(['cherry-pick', ...commits]);
904
+ return createSuccessResult(result);
905
+ }
906
+ catch (error) {
907
+ return createFailureResult(this.handleGitError(error, 'Failed to cherry-pick commits'));
908
+ }
909
+ }
910
+ /**
911
+ * Rebase the current branch
912
+ *
913
+ * @param branch - Branch to rebase onto
914
+ * @param interactive - Whether to use interactive rebase
915
+ * @returns Promise resolving to operation result
916
+ */
917
+ async rebase(branch, interactive = false) {
918
+ try {
919
+ const args = interactive ? ['-i', branch] : [branch];
920
+ const result = await this.git.rebase(args);
921
+ return createSuccessResult(result);
922
+ }
923
+ catch (error) {
924
+ return createFailureResult(this.handleGitError(error, `Failed to rebase onto '${branch}'`));
925
+ }
926
+ }
927
+ /**
928
+ * Reset the repository to a specific commit
929
+ *
930
+ * @param ref - Reference to reset to
931
+ * @param mode - Reset mode (hard, soft, mixed)
932
+ * @returns Promise resolving to operation result
933
+ */
934
+ async reset(ref = 'HEAD', mode = 'mixed') {
935
+ try {
936
+ const args = [`--${mode}`, ref];
937
+ const result = await this.git.reset(args);
938
+ return createSuccessResult(result);
939
+ }
940
+ catch (error) {
941
+ return createFailureResult(this.handleGitError(error, `Failed to reset to '${ref}'`));
942
+ }
943
+ }
944
+ /**
945
+ * Clean the working directory
946
+ *
947
+ * @param directories - Whether to remove directories too
948
+ * @param force - Whether to force clean
949
+ * @returns Promise resolving to operation result
950
+ */
951
+ async clean(directories = false, force = false) {
952
+ try {
953
+ const args = ['-f'];
954
+ if (directories)
955
+ args.push('-d');
956
+ if (force)
957
+ args.push('-x');
958
+ const result = await this.git.clean(args);
959
+ return createSuccessResult(result);
960
+ }
961
+ catch (error) {
962
+ return createFailureResult(this.handleGitError(error, 'Failed to clean working directory'));
963
+ }
964
+ }
965
+ }