├── tests ├── __mocks__ │ └── @modelcontextprotocol │ │ └── sdk │ │ ├── server │ │ ├── stdio.js │ │ └── index.js │ │ └── types.js ├── shadowgit-mcp-server.test.ts ├── handlers │ ├── list-repos-handler.test.ts │ ├── session-handler.test.ts │ ├── git-handler.test.ts │ └── checkpoint-handler.test.ts ├── core │ ├── session-client.test.ts │ ├── repository-manager.test.ts │ └── git-executor.test.ts ├── shadowgit-mcp-server-logic.test.ts └── integration │ └── workflow.test.ts ├── .npmignore ├── src ├── utils │ ├── constants.ts │ ├── logger.ts │ ├── file-utils.ts │ └── response-utils.ts ├── core │ ├── security-constants.ts │ ├── session-client.ts │ ├── repository-manager.ts │ └── git-executor.ts ├── types.ts ├── handlers │ ├── list-repos-handler.ts │ ├── git-handler.ts │ ├── session-handler.ts │ └── checkpoint-handler.ts └── shadowgit-mcp-server.ts ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── jest.config.js ├── esbuild.config.js ├── test-package.js ├── CHANGELOG.md ├── DEPLOYMENT.md ├── TESTING.md └── README.md /tests/__mocks__/@modelcontextprotocol/sdk/server/stdio.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | StdioServerTransport: jest.fn(), 3 | }; -------------------------------------------------------------------------------- /tests/__mocks__/@modelcontextprotocol/sdk/types.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | CallToolRequestSchema: { 3 | parse: jest.fn((data) => data), 4 | }, 5 | ListToolsRequestSchema: { 6 | parse: jest.fn((data) => data), 7 | }, 8 | }; -------------------------------------------------------------------------------- /tests/__mocks__/@modelcontextprotocol/sdk/server/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Server: jest.fn().mockImplementation(() => ({ 3 | setRequestHandler: jest.fn(), 4 | connect: jest.fn().mockResolvedValue(undefined), 5 | close: jest.fn().mockResolvedValue(undefined), 6 | })), 7 | }; -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | tests/ 4 | tsconfig.json 5 | 6 | # Development files 7 | *.test.ts 8 | *.test.js 9 | .git/ 10 | .gitignore 11 | 12 | # Build artifacts 13 | node_modules/ 14 | *.tgz 15 | 16 | # IDE files 17 | .vscode/ 18 | .idea/ 19 | *.swp 20 | *.swo 21 | 22 | # OS files 23 | .DS_Store 24 | Thumbs.db 25 | 26 | # Logs 27 | *.log 28 | npm-debug.log* 29 | 30 | # Only include built dist files and essential docs -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared constants for ShadowGit MCP Server 3 | */ 4 | 5 | export const SHADOWGIT_DIR = '.shadowgit.git'; 6 | export const TIMEOUT_MS = parseInt(process.env.SHADOWGIT_TIMEOUT || '10000', 10); // Default 10 seconds 7 | export const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB 8 | export const MAX_COMMAND_LENGTH = 1000; // Maximum git command length 9 | export const VERSION = '1.1.2'; 10 | 11 | // Session API configuration 12 | export const SESSION_API_URL = process.env.SHADOWGIT_SESSION_API || 'http://localhost:45289/api'; 13 | export const SESSION_API_TIMEOUT = 3000; // 3 seconds timeout for session API calls -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Build output 5 | dist/ 6 | 7 | # Logs 8 | *.log 9 | npm-debug.log* 10 | 11 | # Runtime data 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage/ 18 | 19 | # IDE files 20 | .vscode/ 21 | .idea/ 22 | *.swp 23 | *.swo 24 | 25 | # OS generated files 26 | .DS_Store 27 | .DS_Store? 28 | ._* 29 | .Spotlight-V100 30 | .Trashes 31 | ehthumbs.db 32 | Thumbs.db 33 | 34 | # Environment variables 35 | .env 36 | .env.local 37 | .env.development.local 38 | .env.test.local 39 | .env.production.local 40 | .shadowgit.git 41 | .claude -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple logging utility for ShadowGit MCP Server 3 | */ 4 | 5 | export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; 6 | 7 | const LOG_LEVELS: Record = { 8 | debug: 0, 9 | info: 1, 10 | warn: 2, 11 | error: 3 12 | }; 13 | 14 | const CURRENT_LOG_LEVEL = LOG_LEVELS[process.env.SHADOWGIT_LOG_LEVEL as LogLevel] ?? LOG_LEVELS.info; 15 | 16 | export const log = (level: LogLevel, message: string): void => { 17 | if (LOG_LEVELS[level] >= CURRENT_LOG_LEVEL) { 18 | const timestamp = new Date().toISOString(); 19 | process.stderr.write(`[${timestamp}] [shadowgit-mcp] [${level.toUpperCase()}] ${message}\n`); 20 | } 21 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "declaration": true, 15 | "declarationMap": false, 16 | "sourceMap": false, 17 | "allowJs": true, 18 | "downlevelIteration": true, 19 | "allowSyntheticDefaultImports": true 20 | }, 21 | "include": [ 22 | "src/**/*" 23 | ], 24 | "exclude": [ 25 | "node_modules", 26 | "dist", 27 | "**/*.test.ts" 28 | ] 29 | } -------------------------------------------------------------------------------- /src/core/security-constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Security constants for Git command validation 3 | */ 4 | 5 | // Read-only commands allowed for AI assistants 6 | export const SAFE_COMMANDS = new Set([ 7 | 'log', 'show', 'diff', 'status', 8 | 'describe', 'rev-parse', 'ls-files', 9 | 'ls-tree', 'cat-file', 'show-branch', 'shortlog', 10 | 'rev-list', 'blame' 11 | ]); 12 | 13 | // Dangerous arguments that should always be blocked 14 | export const DANGEROUS_PATTERNS = [ 15 | '--upload-pack', 16 | '--receive-pack', 17 | '--exec', 18 | '-c', // Block config overrides 19 | '--config', 20 | '-e', // Block -e flag 21 | '--git-dir', // Block repository override 22 | '--work-tree', // Block work tree override 23 | '-C' // Block directory change 24 | ]; 25 | 26 | // Check if an argument is dangerous 27 | export function isDangerousArg(arg: string): boolean { 28 | const lowerArg = arg.toLowerCase(); 29 | return DANGEROUS_PATTERNS.some(pattern => 30 | lowerArg === pattern || lowerArg.startsWith(pattern + '=') 31 | ); 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alessandro Afloarei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for ShadowGit MCP Server 3 | */ 4 | 5 | export interface Repository { 6 | name: string; 7 | path: string; 8 | } 9 | 10 | export interface GitCommandArgs { 11 | repo: string; 12 | command: string; 13 | } 14 | 15 | export interface ManualCheckpointArgs { 16 | repo: string; 17 | title: string; 18 | message?: string; 19 | author?: string; 20 | } 21 | 22 | // MCP Tool Response format 23 | export type MCPToolResponse = { 24 | content: Array<{ 25 | type: string; 26 | text: string; 27 | }>; 28 | success?: boolean; // Optional flag to indicate if the operation was successful 29 | }; 30 | 31 | // Session API types 32 | export interface SessionStartRequest { 33 | repoPath: string; 34 | aiTool: string; 35 | description: string; 36 | } 37 | 38 | export interface SessionStartResponse { 39 | success: boolean; 40 | sessionId?: string; 41 | error?: string; 42 | } 43 | 44 | export interface SessionEndRequest { 45 | sessionId: string; 46 | commitHash?: string; 47 | } 48 | 49 | export interface SessionEndResponse { 50 | success: boolean; 51 | error?: string; 52 | } -------------------------------------------------------------------------------- /src/utils/file-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * File system utility functions 3 | */ 4 | 5 | import * as fs from 'fs'; 6 | import * as path from 'path'; 7 | import * as os from 'os'; 8 | 9 | export function getStorageLocation(): string { 10 | const platform = process.platform; 11 | const homeDir = os.homedir(); 12 | 13 | switch (platform) { 14 | case 'darwin': 15 | return path.join(homeDir, '.shadowgit'); 16 | case 'win32': 17 | return path.join( 18 | process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), 19 | 'shadowgit' 20 | ); 21 | default: 22 | return path.join( 23 | process.env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'), 24 | 'shadowgit' 25 | ); 26 | } 27 | } 28 | 29 | export function fileExists(filePath: string): boolean { 30 | try { 31 | return fs.existsSync(filePath); 32 | } catch { 33 | return false; 34 | } 35 | } 36 | 37 | export function readJsonFile(filePath: string, defaultValue: T): T { 38 | try { 39 | if (!fileExists(filePath)) { 40 | return defaultValue; 41 | } 42 | 43 | const content = fs.readFileSync(filePath, 'utf-8'); 44 | return JSON.parse(content) as T; 45 | } catch (error) { 46 | return defaultValue; 47 | } 48 | } -------------------------------------------------------------------------------- /src/utils/response-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for creating consistent MCPToolResponse objects 3 | */ 4 | 5 | import type { MCPToolResponse } from '../types'; 6 | 7 | /** 8 | * Create a text response for MCP tools 9 | */ 10 | export function createTextResponse(text: string): MCPToolResponse { 11 | return { 12 | content: [ 13 | { 14 | type: 'text', 15 | text 16 | } 17 | ] 18 | }; 19 | } 20 | 21 | /** 22 | * Create an error response for MCP tools 23 | */ 24 | export function createErrorResponse(error: string, details?: string): MCPToolResponse { 25 | const message = details ? `${error}\n\n${details}` : error; 26 | return { 27 | content: [ 28 | { 29 | type: 'text', 30 | text: message 31 | } 32 | ], 33 | success: false 34 | }; 35 | } 36 | 37 | /** 38 | * Format a list of repositories for display 39 | */ 40 | export function formatRepositoryList(repos: Array<{ name: string; path: string }>): string { 41 | if (repos.length === 0) { 42 | return 'No repositories available.'; 43 | } 44 | return repos.map(r => ` ${r.name}:\n Path: ${r.path}`).join('\n\n'); 45 | } 46 | 47 | /** 48 | * Create repository not found error response 49 | */ 50 | export function createRepoNotFoundResponse(repoName: string, availableRepos: Array<{ name: string; path: string }>): MCPToolResponse { 51 | let errorMsg = `Error: Repository '${repoName}' not found.`; 52 | 53 | if (availableRepos.length > 0) { 54 | errorMsg += `\n\nAvailable repositories:\n${formatRepositoryList(availableRepos)}`; 55 | } else { 56 | errorMsg += '\n\nNo repositories found. Please add repositories to ShadowGit first.'; 57 | } 58 | 59 | return createErrorResponse(errorMsg); 60 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shadowgit-mcp-server", 3 | "version": "1.1.2", 4 | "description": "MCP server providing AI assistants with read-only access to ShadowGit repositories", 5 | "author": "Alessandro Afloarei", 6 | "license": "MIT", 7 | "homepage": "https://shadowgit.com", 8 | "keywords": [ 9 | "mcp", 10 | "model-context-protocol", 11 | "git", 12 | "ai", 13 | "claude", 14 | "cursor", 15 | "debugging", 16 | "shadowgit" 17 | ], 18 | "bin": { 19 | "shadowgit-mcp-server": "./dist/shadowgit-mcp-server.js" 20 | }, 21 | "main": "./dist/shadowgit-mcp-server.js", 22 | "types": "./dist/shadowgit-mcp-server.d.ts", 23 | "files": [ 24 | "dist/**/*", 25 | "README.md", 26 | "LICENSE" 27 | ], 28 | "scripts": { 29 | "build": "npm run build:prod", 30 | "build:dev": "tsc", 31 | "build:prod": "node esbuild.config.js", 32 | "dev": "ts-node src/shadowgit-mcp-server.ts", 33 | "test": "jest", 34 | "test:watch": "jest --watch", 35 | "test:coverage": "jest --coverage", 36 | "lint": "eslint src --ext .ts", 37 | "prepublishOnly": "npm run build:prod", 38 | "clean": "rimraf dist" 39 | }, 40 | "dependencies": { 41 | "@modelcontextprotocol/sdk": "^0.5.0" 42 | }, 43 | "devDependencies": { 44 | "@jest/globals": "^29.7.0", 45 | "@types/jest": "^29.5.0", 46 | "@types/node": "^20.0.0", 47 | "@typescript-eslint/eslint-plugin": "^7.1.1", 48 | "@typescript-eslint/parser": "^7.1.1", 49 | "esbuild": "^0.25.9", 50 | "eslint": "^8.57.0", 51 | "jest": "^29.7.0", 52 | "rimraf": "^6.0.1", 53 | "ts-jest": "^29.4.1", 54 | "ts-node": "^10.9.0", 55 | "typescript": "^5.0.0" 56 | }, 57 | "engines": { 58 | "node": ">=18.0.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/tests'], 5 | testMatch: ['**/*.test.ts'], // Run all test files 6 | transform: { 7 | '^.+\\.ts$': ['ts-jest', { 8 | useESM: false, 9 | tsconfig: { 10 | moduleResolution: 'node', 11 | allowSyntheticDefaultImports: true, 12 | esModuleInterop: true 13 | } 14 | }] 15 | }, 16 | moduleNameMapper: { 17 | '^(\\.{1,2}/.*)\\.js$': '$1', 18 | // Map both with and without .js extension 19 | '^@modelcontextprotocol/sdk/server/index.js$': '/tests/__mocks__/@modelcontextprotocol/sdk/server/index.js', 20 | '^@modelcontextprotocol/sdk/server/stdio.js$': '/tests/__mocks__/@modelcontextprotocol/sdk/server/stdio.js', 21 | '^@modelcontextprotocol/sdk/types.js$': '/tests/__mocks__/@modelcontextprotocol/sdk/types.js', 22 | '^@modelcontextprotocol/sdk/server/index$': '/tests/__mocks__/@modelcontextprotocol/sdk/server/index.js', 23 | '^@modelcontextprotocol/sdk/server/stdio$': '/tests/__mocks__/@modelcontextprotocol/sdk/server/stdio.js', 24 | '^@modelcontextprotocol/sdk/types$': '/tests/__mocks__/@modelcontextprotocol/sdk/types.js' 25 | }, 26 | collectCoverageFrom: [ 27 | 'src/**/*.ts', 28 | '!src/**/*.d.ts', 29 | '!src/**/*.test.ts', 30 | '!src/shadowgit-mcp-server.ts', // Main entry point 31 | ], 32 | coverageDirectory: 'coverage', 33 | coverageReporters: ['text', 'lcov', 'html'], 34 | coverageThreshold: { 35 | global: { 36 | branches: 70, 37 | functions: 80, 38 | lines: 80, 39 | statements: 80 40 | } 41 | }, 42 | testTimeout: 10000, 43 | setupFilesAfterEnv: [], 44 | clearMocks: true, 45 | restoreMocks: true, 46 | }; -------------------------------------------------------------------------------- /esbuild.config.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | async function build() { 6 | try { 7 | // Clean dist directory 8 | const distPath = path.join(__dirname, 'dist'); 9 | if (fs.existsSync(distPath)) { 10 | fs.rmSync(distPath, { recursive: true }); 11 | } 12 | fs.mkdirSync(distPath, { recursive: true }); 13 | 14 | // Build the bundled and minified version 15 | const result = await esbuild.build({ 16 | entryPoints: ['src/shadowgit-mcp-server.ts'], 17 | bundle: true, 18 | minify: true, 19 | platform: 'node', 20 | target: 'node18', 21 | outfile: 'dist/shadowgit-mcp-server.js', 22 | external: [ 23 | // Don't bundle node built-ins 24 | ], 25 | format: 'cjs', 26 | sourcemap: false, 27 | treeShaking: true, 28 | metafile: true, 29 | banner: { 30 | js: '#!/usr/bin/env node' 31 | }, 32 | define: { 33 | 'process.env.NODE_ENV': '"production"' 34 | } 35 | }); 36 | 37 | // Print build stats 38 | const text = await esbuild.analyzeMetafile(result.metafile); 39 | console.log('Build analysis:'); 40 | console.log(text); 41 | 42 | // Also build TypeScript declarations using tsc 43 | console.log('\nGenerating TypeScript declarations...'); 44 | const { execSync } = require('child_process'); 45 | execSync('tsc --emitDeclarationOnly', { stdio: 'inherit' }); 46 | 47 | console.log('\n✅ Build completed successfully!'); 48 | 49 | // Check final size 50 | const stats = fs.statSync('dist/shadowgit-mcp-server.js'); 51 | console.log(`\n📦 Bundle size: ${(stats.size / 1024).toFixed(2)}KB`); 52 | 53 | } catch (error) { 54 | console.error('Build failed:', error); 55 | process.exit(1); 56 | } 57 | } 58 | 59 | // Run build if called directly 60 | if (require.main === module) { 61 | build(); 62 | } 63 | 64 | module.exports = { build }; -------------------------------------------------------------------------------- /test-package.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Simple test to verify the package works 5 | */ 6 | 7 | const { spawn } = require('child_process'); 8 | const path = require('path'); 9 | 10 | async function testPackage() { 11 | console.log('🧪 Testing shadowgit-mcp-server package...\n'); 12 | 13 | // Test 1: Check built file exists 14 | const builtFile = path.join(__dirname, 'dist', 'shadowgit-mcp-server.js'); 15 | try { 16 | require('fs').accessSync(builtFile); 17 | console.log('✅ Built file exists:', builtFile); 18 | } catch (error) { 19 | console.log('❌ Built file missing:', builtFile); 20 | return false; 21 | } 22 | 23 | // Test 2: Check package.json is valid 24 | try { 25 | const pkg = require('./package.json'); 26 | console.log('✅ Package.json valid'); 27 | console.log(' Name:', pkg.name); 28 | console.log(' Version:', pkg.version); 29 | console.log(' Bin:', pkg.bin); 30 | } catch (error) { 31 | console.log('❌ Package.json invalid:', error.message); 32 | return false; 33 | } 34 | 35 | // Test 3: Try running the server (should wait for input) 36 | try { 37 | console.log('✅ Testing server startup...'); 38 | const child = spawn('node', [builtFile], { stdio: 'pipe' }); 39 | 40 | // Give it a moment to start 41 | await new Promise(resolve => setTimeout(resolve, 1000)); 42 | 43 | if (!child.killed) { 44 | console.log('✅ Server starts successfully (PID:', child.pid + ')'); 45 | child.kill(); 46 | } else { 47 | console.log('❌ Server failed to start'); 48 | return false; 49 | } 50 | } catch (error) { 51 | console.log('❌ Server test failed:', error.message); 52 | return false; 53 | } 54 | 55 | console.log('\n🎉 Package test completed successfully!'); 56 | console.log('\nNext steps:'); 57 | console.log('1. npm publish (when ready)'); 58 | console.log('2. npm install -g shadowgit-mcp-server'); 59 | console.log('3. claude mcp add shadowgit -- shadowgit-mcp-server'); 60 | 61 | return true; 62 | } 63 | 64 | testPackage().catch(console.error); -------------------------------------------------------------------------------- /src/handlers/list-repos-handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handler for list_repos tool 3 | */ 4 | 5 | import { RepositoryManager } from '../core/repository-manager'; 6 | import { createTextResponse, formatRepositoryList } from '../utils/response-utils'; 7 | import type { MCPToolResponse } from '../types'; 8 | 9 | export class ListReposHandler { 10 | constructor(private repositoryManager: RepositoryManager) {} 11 | 12 | /** 13 | * Handle list_repos tool execution 14 | */ 15 | async handle(): Promise { 16 | const repos = this.repositoryManager.getRepositories(); 17 | 18 | if (repos.length === 0) { 19 | return createTextResponse( 20 | `No repositories found in ShadowGit. 21 | 22 | To add repositories: 23 | 1. Open the ShadowGit application 24 | 2. Click "Add Repository" 25 | 3. Select the repository you want to track 26 | 27 | ShadowGit will automatically create shadow repositories (.shadowgit.git) to track changes.` 28 | ); 29 | } 30 | 31 | const repoList = formatRepositoryList(repos); 32 | const firstRepo = repos[0].name; 33 | 34 | return createTextResponse( 35 | `🚀 **ShadowGit MCP Server Connected** 36 | ${'='.repeat(50)} 37 | 38 | 📁 **Available Repositories (${repos.length})** 39 | ${repoList} 40 | 41 | ${'='.repeat(50)} 42 | ⚠️ **CRITICAL: Required Workflow for ALL Changes** 43 | ${'='.repeat(50)} 44 | 45 | **You MUST follow this 4-step workflow:** 46 | 47 | 1️⃣ **START SESSION** (before ANY edits) 48 | \`start_session({repo: "${firstRepo}", description: "your task"})\` 49 | 50 | 2️⃣ **MAKE YOUR CHANGES** 51 | Edit code, fix bugs, add features 52 | 53 | 3️⃣ **CREATE CHECKPOINT** (after changes complete) 54 | \`checkpoint({repo: "${firstRepo}", title: "Clear commit message"})\` 55 | 56 | 4️⃣ **END SESSION** (to resume auto-commits) 57 | \`end_session({sessionId: "...", commitHash: "..."})\` 58 | 59 | ${'='.repeat(50)} 60 | 61 | 💡 **Quick Start Examples:** 62 | \`\`\`javascript 63 | // Check recent history 64 | git_command({repo: "${firstRepo}", command: "log -5"}) 65 | 66 | // Start your work session 67 | start_session({repo: "${firstRepo}", description: "Fixing authentication bug"}) 68 | \`\`\` 69 | 70 | 📖 **NEXT STEP:** Call \`start_session()\` before making any changes!` 71 | ); 72 | } 73 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the ShadowGit MCP Server will be documented in this file. 4 | 5 | ## [1.1.2] - 2025-09-05 6 | 7 | ### Security Improvements 8 | - **Critical**: Removed `branch`, `tag`, `reflog` commands to prevent destructive operations 9 | - Added `-C` flag to blocked arguments to prevent directory changes 10 | - Enhanced repository validation to check for .shadowgit.git on raw paths 11 | 12 | ### Bug Fixes 13 | - Fixed remaining error responses in SessionHandler to use createErrorResponse 14 | - Aligned email domains to consistently use @shadowgit.local 15 | 16 | ## [1.1.1] - 2025-09-05 17 | 18 | ### Security Improvements 19 | - **Critical**: Block `--git-dir` and `--work-tree` flags to prevent repository escape attacks 20 | - Switched internal commands to array-based execution, eliminating command injection risks 21 | - Enhanced Git error reporting to include stderr/stdout for better debugging 22 | - Fixed command length validation to only apply to external commands 23 | 24 | ### Features 25 | - Added `SHADOWGIT_HINTS` environment variable to toggle workflow hints (set to `0` to disable) 26 | - Standardized all error responses with consistent `success: false` flag 27 | 28 | ### Bug Fixes 29 | - Fixed string command parser to handle all whitespace characters (tabs, spaces, etc.) 30 | - Fixed Jest configuration for extensionless imports 31 | - Removed .js extensions from TypeScript imports for better compatibility 32 | - Improved error handling for Git commands with exit codes 33 | 34 | ### Developer Experience 35 | - Added comprehensive test coverage for security features 36 | - Improved documentation with security updates and troubleshooting tips 37 | - All 175 tests passing with improved coverage 38 | 39 | ## [1.1.0] - 2025-09-04 40 | 41 | ### Features 42 | - Added session management with start_session and end_session 43 | - Added checkpoint command for creating AI-authored commits 44 | - Integrated with ShadowGit Session API for auto-commit control 45 | - Added workflow reminders in git command outputs 46 | 47 | ### Security 48 | - Implemented comprehensive command validation 49 | - Added dangerous argument blocking 50 | - Path traversal protection 51 | - Repository validation 52 | 53 | ## [1.0.0] - 2025-09-03 54 | 55 | ### Initial Release 56 | - MCP server implementation for ShadowGit 57 | - Support for read-only git commands 58 | - Repository listing functionality 59 | - Integration with Claude Code and Claude Desktop 60 | - Basic security restrictions -------------------------------------------------------------------------------- /src/handlers/git-handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handler for git_command tool 3 | */ 4 | 5 | import { RepositoryManager } from '../core/repository-manager'; 6 | import { GitExecutor } from '../core/git-executor'; 7 | import { createErrorResponse, createTextResponse, createRepoNotFoundResponse } from '../utils/response-utils'; 8 | import type { MCPToolResponse, GitCommandArgs } from '../types'; 9 | 10 | export class GitHandler { 11 | constructor( 12 | private repositoryManager: RepositoryManager, 13 | private gitExecutor: GitExecutor 14 | ) {} 15 | 16 | /** 17 | * Validate git command arguments 18 | */ 19 | private isGitCommandArgs(args: unknown): args is GitCommandArgs { 20 | return ( 21 | typeof args === 'object' && 22 | args !== null && 23 | 'repo' in args && 24 | 'command' in args && 25 | typeof (args as GitCommandArgs).repo === 'string' && 26 | typeof (args as GitCommandArgs).command === 'string' 27 | ); 28 | } 29 | 30 | /** 31 | * Handle git_command tool execution 32 | */ 33 | async handle(args: unknown): Promise { 34 | if (!this.isGitCommandArgs(args)) { 35 | return createErrorResponse( 36 | "Error: Both 'repo' and 'command' parameters are required.", 37 | `Example usage: 38 | git_command({repo: "my-project", command: "log --oneline -10"}) 39 | git_command({repo: "my-project", command: "diff HEAD~1"}) 40 | 41 | Use list_repos() to see available repositories.` 42 | ); 43 | } 44 | 45 | const repoPath = this.repositoryManager.resolveRepoPath(args.repo); 46 | 47 | if (!repoPath) { 48 | const repos = this.repositoryManager.getRepositories(); 49 | return createRepoNotFoundResponse(args.repo, repos); 50 | } 51 | 52 | const output = await this.gitExecutor.execute(args.command, repoPath); 53 | 54 | // Add workflow reminder for common commands that suggest changes are being planned 55 | // Show workflow hints unless disabled 56 | const showHints = process.env.SHADOWGIT_HINTS !== '0'; 57 | const reminderCommands = ['diff', 'status', 'log', 'blame']; 58 | const needsReminder = showHints && reminderCommands.some(cmd => args.command.toLowerCase().includes(cmd)); 59 | 60 | if (needsReminder) { 61 | return createTextResponse( 62 | `${output} 63 | 64 | ${'='.repeat(50)} 65 | 📝 **Planning to Make Changes?** 66 | ${'='.repeat(50)} 67 | 68 | **Required Workflow:** 69 | 1️⃣ \`start_session({repo: "${args.repo}", description: "your task"})\` 70 | 2️⃣ Make your changes 71 | 3️⃣ \`checkpoint({repo: "${args.repo}", title: "commit message"})\` 72 | 4️⃣ \`end_session({sessionId: "...", commitHash: "..."})\` 73 | 74 | 💡 **NEXT STEP:** Call \`start_session()\` before editing any files!` 75 | ); 76 | } 77 | 78 | return createTextResponse(output); 79 | } 80 | } -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Deployment Guide 2 | 3 | ## Current Version 4 | - **Package**: `shadowgit-mcp-server` 5 | - **Version**: 1.1.2 6 | - **npm Registry**: https://www.npmjs.com/package/shadowgit-mcp-server 7 | 8 | ## Installation 9 | 10 | ### For Users 11 | ```bash 12 | # Install globally from npm 13 | npm install -g shadowgit-mcp-server 14 | 15 | # The command will be available globally 16 | shadowgit-mcp-server --version 17 | ``` 18 | 19 | ### For Development 20 | ```bash 21 | # Clone and build locally 22 | git clone https://github.com/shadowgit/shadowgit-mcp-server.git 23 | cd shadowgit-mcp-server 24 | npm install 25 | npm run build 26 | npm link # Makes it available globally for testing 27 | ``` 28 | 29 | ## Build System 30 | 31 | ### Production Build 32 | ```bash 33 | npm run build 34 | ``` 35 | - Creates a single optimized bundle (`dist/shadowgit-mcp-server.js`) 36 | - Size: ~93KB (includes all dependencies) 37 | - Uses esbuild for fast bundling and minification 38 | - Cross-platform: Works on macOS, Windows, and Linux 39 | 40 | ### File Structure 41 | ``` 42 | dist/ 43 | ├── shadowgit-mcp-server.js # Main bundled executable (93KB) 44 | ├── shadowgit-mcp-server.d.ts # TypeScript declarations 45 | └── [other .d.ts files] # Additional type definitions 46 | ``` 47 | 48 | ## Publishing Updates 49 | 50 | ### 1. Update Version 51 | ```bash 52 | npm version patch # Bug fixes (1.1.2 -> 1.1.3) 53 | npm version minor # New features (1.1.2 -> 1.2.0) 54 | npm version major # Breaking changes (1.1.2 -> 2.0.0) 55 | ``` 56 | 57 | ### 2. Build and Test 58 | ```bash 59 | npm run build 60 | npm test 61 | ``` 62 | 63 | ### 3. Publish to npm 64 | ```bash 65 | npm publish 66 | ``` 67 | 68 | ## MCP Configuration 69 | 70 | ### Claude Desktop 71 | Add to `claude_desktop_config.json`: 72 | ```json 73 | { 74 | "mcpServers": { 75 | "shadowgit": { 76 | "command": "shadowgit-mcp-server" 77 | } 78 | } 79 | } 80 | ``` 81 | 82 | ### Direct Execution 83 | ```json 84 | { 85 | "mcpServers": { 86 | "shadowgit": { 87 | "command": "node", 88 | "args": ["/path/to/shadowgit-mcp-server/dist/shadowgit-mcp-server.js"] 89 | } 90 | } 91 | } 92 | ``` 93 | 94 | ## Cross-Platform Support 95 | 96 | The bundled JavaScript file works identically across all platforms: 97 | - **macOS/Linux**: Direct execution with shebang support 98 | - **Windows**: npm creates `.cmd` wrapper for global installs 99 | - **Node.js Requirement**: Version 18 or higher 100 | 101 | ## Quick Commands 102 | 103 | ```bash 104 | # Check version 105 | shadowgit-mcp-server --version 106 | 107 | # Build locally 108 | npm run build 109 | 110 | # Run tests 111 | npm test 112 | 113 | # Clean build artifacts 114 | npm run clean 115 | 116 | # Development mode (TypeScript directly) 117 | npm run dev 118 | ``` 119 | 120 | ## Troubleshooting 121 | 122 | ### Module Not Found 123 | - Run `npm install` to ensure all dependencies are installed 124 | - For global install issues, check npm prefix: `npm config get prefix` 125 | 126 | ### Permission Denied (Unix) 127 | ```bash 128 | chmod +x dist/shadowgit-mcp-server.js 129 | ``` 130 | 131 | ### Windows Execution 132 | Use `node dist/shadowgit-mcp-server.js` if the global command doesn't work 133 | 134 | ## Support 135 | 136 | - GitHub Issues: https://github.com/shadowgit/shadowgit-mcp-server 137 | - npm Package: https://www.npmjs.com/package/shadowgit-mcp-server -------------------------------------------------------------------------------- /tests/shadowgit-mcp-server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { ShadowGitMCPServer } from '../src/shadowgit-mcp-server'; 3 | 4 | // Mock child_process 5 | jest.mock('child_process', () => ({ 6 | execFileSync: jest.fn() 7 | })); 8 | 9 | // Mock fs 10 | jest.mock('fs', () => ({ 11 | existsSync: jest.fn(), 12 | readFileSync: jest.fn() 13 | })); 14 | 15 | // Mock os 16 | jest.mock('os', () => ({ 17 | homedir: jest.fn(() => '/home/testuser') 18 | })); 19 | 20 | describe('ShadowGitMCPServer', () => { 21 | let server: ShadowGitMCPServer; 22 | let mockExecFileSync: jest.Mock; 23 | let mockExistsSync: jest.Mock; 24 | let mockReadFileSync: jest.Mock; 25 | 26 | beforeEach(() => { 27 | // Reset all mocks 28 | jest.clearAllMocks(); 29 | 30 | // Get mock references 31 | const childProcess = require('child_process'); 32 | const fs = require('fs'); 33 | mockExecFileSync = childProcess.execFileSync as jest.Mock; 34 | mockExistsSync = fs.existsSync as jest.Mock; 35 | mockReadFileSync = fs.readFileSync as jest.Mock; 36 | 37 | // Setup default mock behaviors 38 | mockExistsSync.mockReturnValue(true); 39 | mockReadFileSync.mockReturnValue(JSON.stringify([ 40 | { name: 'test-repo', path: '/test/repo' }, 41 | { name: 'another-repo', path: '/another/repo' } 42 | ])); 43 | 44 | // Create server instance 45 | server = new ShadowGitMCPServer(); 46 | }); 47 | 48 | describe('Server Initialization', () => { 49 | it('should create server instance successfully', () => { 50 | expect(server).toBeDefined(); 51 | expect(server).toBeInstanceOf(ShadowGitMCPServer); 52 | }); 53 | 54 | it('should initialize with required handlers', () => { 55 | // Server should be initialized with all required components 56 | expect(server).toBeDefined(); 57 | // The actual handlers are private, but we can verify the server exists 58 | }); 59 | }); 60 | 61 | describe('Configuration Loading', () => { 62 | it('should load repositories from config file', () => { 63 | const testRepos = [ 64 | { name: 'repo1', path: '/path/to/repo1' }, 65 | { name: 'repo2', path: '/path/to/repo2' } 66 | ]; 67 | 68 | mockReadFileSync.mockReturnValue(JSON.stringify(testRepos)); 69 | 70 | // Create a new instance to trigger config loading 71 | const newServer = new ShadowGitMCPServer(); 72 | 73 | expect(newServer).toBeDefined(); 74 | expect(mockReadFileSync).toHaveBeenCalled(); 75 | }); 76 | 77 | it('should handle missing config file gracefully', () => { 78 | mockExistsSync.mockReturnValue(false); 79 | mockReadFileSync.mockImplementation(() => { 80 | throw new Error('File not found'); 81 | }); 82 | 83 | // Should not throw when config is missing 84 | expect(() => new ShadowGitMCPServer()).not.toThrow(); 85 | }); 86 | }); 87 | 88 | describe('Server Lifecycle', () => { 89 | it('should handle server start', async () => { 90 | // Server should be properly initialized 91 | expect(server).toBeDefined(); 92 | }); 93 | 94 | it('should handle server shutdown gracefully', async () => { 95 | // Server should clean up resources on shutdown 96 | // This is typically handled by the Server class from MCP SDK 97 | expect(server).toBeDefined(); 98 | }); 99 | }); 100 | }); -------------------------------------------------------------------------------- /src/core/session-client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP client for ShadowGit Session API 3 | * Provides session lifecycle management for AI tools 4 | */ 5 | 6 | import { log } from '../utils/logger'; 7 | import { SESSION_API_URL, SESSION_API_TIMEOUT } from '../utils/constants'; 8 | import type { 9 | SessionStartRequest, 10 | SessionStartResponse, 11 | SessionEndRequest, 12 | SessionEndResponse 13 | } from '../types'; 14 | 15 | export class SessionClient { 16 | private baseUrl: string; 17 | private timeout: number; 18 | 19 | constructor(baseUrl = SESSION_API_URL, timeout = SESSION_API_TIMEOUT) { 20 | this.baseUrl = baseUrl; 21 | this.timeout = timeout; 22 | } 23 | 24 | /** 25 | * Start a new AI session for a repository 26 | */ 27 | async startSession(data: SessionStartRequest): Promise { 28 | try { 29 | const controller = new AbortController(); 30 | const timeoutId = setTimeout(() => controller.abort(), this.timeout); 31 | 32 | const response = await fetch(`${this.baseUrl}/session/start`, { 33 | method: 'POST', 34 | headers: { 35 | 'Content-Type': 'application/json', 36 | }, 37 | body: JSON.stringify(data), 38 | signal: controller.signal, 39 | }); 40 | 41 | clearTimeout(timeoutId); 42 | 43 | if (response.ok) { 44 | const result = await response.json() as SessionStartResponse; 45 | if (result.success && result.sessionId) { 46 | log('info', `Session started: ${result.sessionId} for ${data.repoPath}`); 47 | return result.sessionId; 48 | } 49 | } 50 | 51 | log('warn', `Failed to start session: ${response.status} ${response.statusText}`); 52 | } catch (error) { 53 | // Silently fail - don't break MCP if Session API is down 54 | if (error instanceof Error && error.name !== 'AbortError') { 55 | log('debug', `Session API unavailable: ${error.message}`); 56 | } 57 | } 58 | return null; 59 | } 60 | 61 | /** 62 | * End an AI session with optional commit hash 63 | */ 64 | async endSession(sessionId: string, commitHash?: string): Promise { 65 | try { 66 | const controller = new AbortController(); 67 | const timeoutId = setTimeout(() => controller.abort(), this.timeout); 68 | 69 | const data: SessionEndRequest = { sessionId }; 70 | if (commitHash) { 71 | data.commitHash = commitHash; 72 | } 73 | 74 | const response = await fetch(`${this.baseUrl}/session/end`, { 75 | method: 'POST', 76 | headers: { 77 | 'Content-Type': 'application/json', 78 | }, 79 | body: JSON.stringify(data), 80 | signal: controller.signal, 81 | }); 82 | 83 | clearTimeout(timeoutId); 84 | 85 | if (response.ok) { 86 | const result = await response.json() as SessionEndResponse; 87 | if (result.success) { 88 | log('info', `Session ended: ${sessionId}`); 89 | return true; 90 | } 91 | } 92 | 93 | log('warn', `Failed to end session: ${response.status} ${response.statusText}`); 94 | } catch (error) { 95 | if (error instanceof Error && error.name !== 'AbortError') { 96 | log('debug', `Failed to end session: ${error.message}`); 97 | } 98 | } 99 | return false; 100 | } 101 | 102 | /** 103 | * Check if Session API is healthy 104 | */ 105 | async isHealthy(): Promise { 106 | try { 107 | const controller = new AbortController(); 108 | const timeoutId = setTimeout(() => controller.abort(), 1000); // Quick health check 109 | 110 | const response = await fetch(`${this.baseUrl}/health`, { 111 | signal: controller.signal, 112 | }); 113 | 114 | clearTimeout(timeoutId); 115 | return response.ok; 116 | } catch { 117 | return false; 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/handlers/session-handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handler for session management - start and end sessions 3 | */ 4 | 5 | import { RepositoryManager } from '../core/repository-manager'; 6 | import { SessionClient } from '../core/session-client'; 7 | import { log } from '../utils/logger'; 8 | import { createErrorResponse } from '../utils/response-utils'; 9 | import type { MCPToolResponse } from '../types'; 10 | 11 | interface StartSessionArgs { 12 | repo: string; 13 | description: string; 14 | } 15 | 16 | interface EndSessionArgs { 17 | sessionId: string; 18 | commitHash?: string; 19 | } 20 | 21 | export class SessionHandler { 22 | constructor( 23 | private repositoryManager: RepositoryManager, 24 | private sessionClient: SessionClient 25 | ) {} 26 | 27 | /** 28 | * Start a new work session 29 | */ 30 | async startSession(args: unknown): Promise { 31 | // Validate args 32 | if (!this.isStartSessionArgs(args)) { 33 | return createErrorResponse( 34 | 'Error: Both "repo" and "description" are required for start_session.' 35 | ); 36 | } 37 | 38 | // Resolve repository 39 | const repoPath = this.repositoryManager.resolveRepoPath(args.repo); 40 | if (!repoPath) { 41 | return createErrorResponse( 42 | `Error: Repository '${args.repo}' not found. Use list_repos() to see available repositories.` 43 | ); 44 | } 45 | 46 | // Start session 47 | const sessionId = await this.sessionClient.startSession({ 48 | repoPath, 49 | aiTool: 'MCP Client', 50 | description: args.description 51 | }); 52 | 53 | if (sessionId) { 54 | log('info', `Session started: ${sessionId}`); 55 | return { 56 | content: [{ 57 | type: 'text', 58 | text: `Session started successfully. 59 | Session ID: ${sessionId} 60 | 61 | 📋 **Your Workflow Checklist:** 62 | 1. Make your changes 63 | 2. Call checkpoint() to commit 64 | 3. Call end_session() with this session ID` 65 | }] 66 | }; 67 | } 68 | 69 | // Fallback if Session API is offline 70 | return createErrorResponse( 71 | 'Session API is offline. Proceeding without session tracking.' 72 | ); 73 | } 74 | 75 | /** 76 | * End an active session 77 | */ 78 | async endSession(args: unknown): Promise { 79 | // Validate args 80 | if (!this.isEndSessionArgs(args)) { 81 | return createErrorResponse( 82 | 'Error: "sessionId" is required for end_session.' 83 | ); 84 | } 85 | 86 | // End session 87 | const success = await this.sessionClient.endSession( 88 | args.sessionId, 89 | args.commitHash 90 | ); 91 | 92 | if (success) { 93 | log('info', `Session ended: ${args.sessionId}`); 94 | return { 95 | content: [{ 96 | type: 'text', 97 | text: `Session ${args.sessionId} ended successfully.` 98 | }] 99 | }; 100 | } 101 | 102 | return createErrorResponse( 103 | `❌ **Failed to End Session** 104 | ${'='.repeat(50)} 105 | 106 | ⚠️ The session may have already ended or expired. 107 | 108 | **Note:** Auto-commits may have already resumed. 109 | 110 | 💡 **NEXT STEP:** You can continue working or start a new session.` 111 | ); 112 | } 113 | 114 | private isStartSessionArgs(args: unknown): args is StartSessionArgs { 115 | return ( 116 | typeof args === 'object' && 117 | args !== null && 118 | 'repo' in args && 119 | 'description' in args && 120 | typeof (args as StartSessionArgs).repo === 'string' && 121 | typeof (args as StartSessionArgs).description === 'string' 122 | ); 123 | } 124 | 125 | private isEndSessionArgs(args: unknown): args is EndSessionArgs { 126 | return ( 127 | typeof args === 'object' && 128 | args !== null && 129 | 'sessionId' in args && 130 | typeof (args as EndSessionArgs).sessionId === 'string' 131 | ); 132 | } 133 | } -------------------------------------------------------------------------------- /src/core/repository-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Repository discovery and management 3 | */ 4 | 5 | import * as path from 'path'; 6 | import * as os from 'os'; 7 | import { getStorageLocation, fileExists, readJsonFile } from '../utils/file-utils'; 8 | import { log } from '../utils/logger'; 9 | import { SHADOWGIT_DIR } from '../utils/constants'; 10 | import type { Repository } from '../types'; 11 | 12 | export class RepositoryManager { 13 | private repositories: Repository[] = []; 14 | 15 | constructor() { 16 | this.loadRepositories(); 17 | } 18 | 19 | /** 20 | * Load repositories from ShadowGit's configuration 21 | */ 22 | private loadRepositories(): void { 23 | const storageLocation = getStorageLocation(); 24 | const repositoryPath = path.join(storageLocation, 'repos.json'); 25 | 26 | log('info', `Loading repositories from ${repositoryPath}`); 27 | 28 | this.repositories = readJsonFile(repositoryPath, []); 29 | 30 | log('info', `Loaded ${this.repositories.length} repositories`); 31 | 32 | if (this.repositories.length === 0) { 33 | log('warn', 'No repositories found. Please add repositories via ShadowGit app.'); 34 | } 35 | } 36 | 37 | /** 38 | * Get all loaded repositories 39 | */ 40 | getRepositories(): Repository[] { 41 | return this.repositories; 42 | } 43 | 44 | /** 45 | * Find a repository by name 46 | */ 47 | findRepository(name: string): Repository | undefined { 48 | return this.repositories.find(r => r.name === name); 49 | } 50 | 51 | /** 52 | * Resolve a repository name or path to an absolute path 53 | */ 54 | resolveRepoPath(repoNameOrPath: string): string | null { 55 | // Handle null/undefined inputs 56 | if (!repoNameOrPath) { 57 | log('warn', 'No repository name or path provided'); 58 | return null; 59 | } 60 | 61 | // First, check if it's a known repository name 62 | const knownRepo = this.findRepository(repoNameOrPath); 63 | if (knownRepo) { 64 | // Expand tilde in repository path if present 65 | let repoPath = knownRepo.path; 66 | if (repoPath.startsWith('~')) { 67 | const homeDir = os.homedir(); 68 | if (repoPath === '~') { 69 | repoPath = homeDir; 70 | } else if (repoPath.startsWith('~/')) { 71 | repoPath = path.join(homeDir, repoPath.slice(2)); 72 | } 73 | } 74 | 75 | // Validate that the repository exists and has a .shadowgit.git directory 76 | const shadowgitPath = path.join(repoPath, SHADOWGIT_DIR); 77 | 78 | if (fileExists(shadowgitPath)) { 79 | log('debug', `Resolved repository '${repoNameOrPath}' to path: ${repoPath}`); 80 | return repoPath; 81 | } else { 82 | log('warn', `Repository '${repoNameOrPath}' exists but .shadowgit.git directory not found at: ${shadowgitPath}`); 83 | log('warn', 'Please ensure ShadowGit is monitoring this repository.'); 84 | return null; 85 | } 86 | } 87 | 88 | // Support Unix-style paths and Windows paths 89 | const isPath = repoNameOrPath.startsWith('/') || 90 | repoNameOrPath.startsWith('~') || 91 | repoNameOrPath.includes(':') || // Windows drive letter 92 | repoNameOrPath.startsWith('\\\\'); // UNC path 93 | 94 | if (isPath) { 95 | // Properly handle tilde expansion 96 | let resolvedPath = repoNameOrPath; 97 | if (repoNameOrPath.startsWith('~')) { 98 | const homeDir = os.homedir(); 99 | if (repoNameOrPath === '~') { 100 | resolvedPath = homeDir; 101 | } else if (repoNameOrPath.startsWith('~/')) { 102 | resolvedPath = path.join(homeDir, repoNameOrPath.slice(2)); 103 | } else { 104 | // ~username not supported, return null 105 | log('warn', `Unsupported tilde expansion: ${repoNameOrPath}`); 106 | return null; 107 | } 108 | } 109 | 110 | resolvedPath = path.normalize(resolvedPath); 111 | 112 | // Ensure the resolved path is absolute and doesn't escape 113 | if (!path.isAbsolute(resolvedPath)) { 114 | log('warn', `Invalid path provided: ${repoNameOrPath}`); 115 | return null; 116 | } 117 | 118 | if (fileExists(resolvedPath)) { 119 | // Validate that the path has a .shadowgit.git directory 120 | const shadowgitPath = path.join(resolvedPath, SHADOWGIT_DIR); 121 | if (fileExists(shadowgitPath)) { 122 | return resolvedPath; 123 | } else { 124 | log('warn', `Path exists but .shadowgit.git directory not found at: ${shadowgitPath}`); 125 | return null; 126 | } 127 | } 128 | } 129 | 130 | return null; 131 | } 132 | } -------------------------------------------------------------------------------- /src/core/git-executor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Git command execution with security and safety checks 3 | */ 4 | 5 | import { execFileSync } from 'child_process'; 6 | import * as fs from 'fs'; 7 | import * as path from 'path'; 8 | import { log } from '../utils/logger'; 9 | import { 10 | SHADOWGIT_DIR, 11 | TIMEOUT_MS, 12 | MAX_BUFFER_SIZE, 13 | MAX_COMMAND_LENGTH 14 | } from '../utils/constants'; 15 | import { SAFE_COMMANDS, isDangerousArg } from './security-constants'; 16 | 17 | export class GitExecutor { 18 | 19 | /** 20 | * Execute a git command with security checks 21 | * @param command - Either a string command or array of arguments 22 | */ 23 | async execute( 24 | command: string | string[], 25 | repoPath: string, 26 | isInternal = false, 27 | additionalEnv?: NodeJS.ProcessEnv 28 | ): Promise { 29 | // Parse command into arguments 30 | let args: string[]; 31 | 32 | if (Array.isArray(command)) { 33 | // Array-based command (safer for internal use) 34 | args = command; 35 | } else { 36 | // String command - check length only for external calls 37 | if (!isInternal && command.length > MAX_COMMAND_LENGTH) { 38 | return `Error: Command too long (max ${MAX_COMMAND_LENGTH} characters).`; 39 | } 40 | 41 | // Remove control characters 42 | const sanitizedCommand = command.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); 43 | 44 | // Simple argument parsing that handles quotes and all whitespace 45 | args = []; 46 | let current = ''; 47 | let inQuotes = false; 48 | let quoteChar = ''; 49 | 50 | for (let i = 0; i < sanitizedCommand.length; i++) { 51 | const char = sanitizedCommand[i]; 52 | const nextChar = sanitizedCommand[i + 1]; 53 | 54 | if (!inQuotes && (char === '"' || char === "'")) { 55 | inQuotes = true; 56 | quoteChar = char; 57 | } else if (inQuotes && char === '\\' && nextChar === quoteChar) { 58 | // Handle escaped quote 59 | current += quoteChar; 60 | i++; // Skip the quote 61 | } else if (inQuotes && char === quoteChar) { 62 | inQuotes = false; 63 | quoteChar = ''; 64 | } else if (!inQuotes && /\s/.test(char)) { 65 | // Split on any whitespace (space, tab, etc.) 66 | if (current) { 67 | args.push(current); 68 | current = ''; 69 | } 70 | } else { 71 | current += char; 72 | } 73 | } 74 | if (current) { 75 | args.push(current); 76 | } 77 | } 78 | 79 | if (args.length === 0) { 80 | return 'Error: No command provided.'; 81 | } 82 | 83 | const gitCommand = args[0]; 84 | 85 | // Safety check 1: ALWAYS block dangerous arguments 86 | for (const arg of args) { 87 | if (isDangerousArg(arg)) { 88 | return 'Error: Command contains potentially dangerous arguments.'; 89 | } 90 | } 91 | 92 | // Safety check 2: Only check command whitelist for external calls 93 | if (!isInternal && !SAFE_COMMANDS.has(gitCommand)) { 94 | return `Error: Command '${gitCommand}' is not allowed. Only read-only commands are permitted. 95 | 96 | Allowed commands: ${Array.from(SAFE_COMMANDS).join(', ')}`; 97 | } 98 | 99 | // Safety check 3: Ensure we're operating on a .shadowgit.git repository 100 | const gitDir = path.join(repoPath, SHADOWGIT_DIR); 101 | 102 | if (!fs.existsSync(gitDir)) { 103 | return `Error: Not a ShadowGit repository. The .shadowgit.git directory was not found at ${gitDir}`; 104 | } 105 | 106 | log('debug', `Executing git ${gitCommand} in ${repoPath}`); 107 | 108 | try { 109 | const output = execFileSync('git', [ 110 | `--git-dir=${gitDir}`, 111 | `--work-tree=${repoPath}`, 112 | ...args 113 | ], { 114 | cwd: repoPath, 115 | encoding: 'utf-8', 116 | timeout: TIMEOUT_MS, 117 | maxBuffer: MAX_BUFFER_SIZE, 118 | env: { 119 | ...process.env, 120 | GIT_TERMINAL_PROMPT: '0', // Disable interactive prompts 121 | GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', // Disable SSH prompts 122 | GIT_PAGER: 'cat', // Disable pager 123 | PAGER: 'cat', // Fallback pager disable 124 | ...additionalEnv 125 | } 126 | }); 127 | 128 | return output || '(empty output)'; 129 | } catch (error: unknown) { 130 | if (error && typeof error === 'object') { 131 | const execError = error as any; 132 | 133 | // Check for timeout 134 | if (execError.code === 'ETIMEDOUT' || execError.signal === 'SIGTERM') { 135 | return `Error: Command timed out after ${TIMEOUT_MS}ms.`; 136 | } 137 | 138 | // Check for detailed error info (has stderr/stdout or status code) 139 | if ('stderr' in execError || 'stdout' in execError || 'status' in execError) { 140 | const stderr = execError.stderr?.toString() || ''; 141 | const stdout = execError.stdout?.toString() || ''; 142 | const message = execError.message || 'Unknown error'; 143 | 144 | return `Error executing git command: 145 | ${message} 146 | ${stderr ? `\nError output:\n${stderr}` : ''} 147 | ${stdout ? `\nPartial output:\n${stdout}` : ''}`; 148 | } 149 | } 150 | 151 | return `Error: ${error}`; 152 | } 153 | } 154 | } -------------------------------------------------------------------------------- /src/handlers/checkpoint-handler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handler for checkpoint tool - creates git commits 3 | */ 4 | 5 | import { RepositoryManager } from '../core/repository-manager'; 6 | import { GitExecutor } from '../core/git-executor'; 7 | import { createErrorResponse } from '../utils/response-utils'; 8 | import type { MCPToolResponse, ManualCheckpointArgs } from '../types'; 9 | 10 | export class CheckpointHandler { 11 | constructor( 12 | private repositoryManager: RepositoryManager, 13 | private gitExecutor: GitExecutor 14 | ) {} 15 | 16 | /** 17 | * Validate checkpoint arguments 18 | */ 19 | private isValidArgs(args: unknown): args is ManualCheckpointArgs { 20 | return ( 21 | typeof args === 'object' && 22 | args !== null && 23 | 'repo' in args && 24 | 'title' in args && 25 | typeof (args as ManualCheckpointArgs).repo === 'string' && 26 | typeof (args as ManualCheckpointArgs).title === 'string' 27 | ); 28 | } 29 | 30 | /** 31 | * Handle checkpoint tool execution 32 | */ 33 | async handle(args: unknown): Promise { 34 | if (!this.isValidArgs(args)) { 35 | return createErrorResponse( 36 | "Error: Both 'repo' and 'title' parameters are required.", 37 | `Example usage: 38 | checkpoint({ 39 | repo: "my-project", 40 | title: "Fix authentication bug", 41 | author: "Claude" 42 | }) 43 | 44 | Use list_repos() to see available repositories.` 45 | ); 46 | } 47 | 48 | // Validate title length 49 | if (args.title.length > 50) { 50 | return createErrorResponse( 51 | `Error: Title must be 50 characters or less (current: ${args.title.length} chars).` 52 | ); 53 | } 54 | 55 | // Validate message length if provided 56 | if (args.message && args.message.length > 1000) { 57 | return createErrorResponse( 58 | `Error: Message must be 1000 characters or less (current: ${args.message.length} chars).` 59 | ); 60 | } 61 | 62 | const repoPath = this.repositoryManager.resolveRepoPath(args.repo); 63 | 64 | if (!repoPath) { 65 | const repos = this.repositoryManager.getRepositories(); 66 | 67 | if (repos.length === 0) { 68 | return createErrorResponse( 69 | 'Error: No repositories found. Please add repositories to ShadowGit first.' 70 | ); 71 | } 72 | 73 | return createErrorResponse( 74 | `Error: Repository '${args.repo}' not found.`, 75 | `Available repositories: 76 | ${repos.map(r => ` - ${r.name}: ${r.path}`).join('\n')}` 77 | ); 78 | } 79 | 80 | 81 | // Check for changes 82 | const statusOutput = await this.gitExecutor.execute(['status', '--porcelain'], repoPath, true); 83 | 84 | if (!statusOutput || statusOutput.trim() === '' || statusOutput === '(empty output)') { 85 | return createErrorResponse( 86 | `❌ **No Changes Detected** 87 | ${'='.repeat(50)} 88 | 89 | 📁 Repository has no changes to commit. 90 | 91 | ⚠️ **Important:** Do NOT call end_session() - no commit was created. 92 | 93 | 💡 **NEXT STEP:** Make some changes first, then call checkpoint() again.` 94 | ); 95 | } 96 | 97 | // Build commit message 98 | const commitTitle = args.title; 99 | const commitBody = args.message || ''; 100 | const author = args.author || 'AI Assistant'; 101 | 102 | // Add all changes 103 | const addOutput = await this.gitExecutor.execute(['add', '-A'], repoPath, true); 104 | 105 | if (addOutput.startsWith('Error:')) { 106 | return createErrorResponse( 107 | `❌ **Failed to Stage Changes** 108 | ${'='.repeat(50)} 109 | 110 | 🚨 **Error:** ${addOutput} 111 | 112 | ⚠️ **Important:** Do NOT call end_session() - commit was not created. 113 | 114 | 💡 **NEXT STEP:** Check the error and try again.` 115 | ); 116 | } 117 | 118 | // Build full commit message 119 | let fullMessage = commitTitle; 120 | if (commitBody) { 121 | fullMessage += `\n\n${commitBody}`; 122 | } 123 | fullMessage += `\n\nAuthor: ${author} (via ShadowGit MCP)`; 124 | 125 | // Create commit with author information 126 | const commitEnv = { 127 | GIT_AUTHOR_NAME: author, 128 | GIT_AUTHOR_EMAIL: `${author.toLowerCase().replace(/\s+/g, '-')}@shadowgit.local`, 129 | GIT_COMMITTER_NAME: 'ShadowGit MCP', 130 | GIT_COMMITTER_EMAIL: 'shadowgit-mcp@shadowgit.local' 131 | }; 132 | 133 | // Use array-based command to avoid parsing issues 134 | const commitOutput = await this.gitExecutor.execute( 135 | ['commit', '-m', fullMessage], 136 | repoPath, 137 | true, 138 | commitEnv 139 | ); 140 | 141 | if (commitOutput.startsWith('Error:')) { 142 | return createErrorResponse( 143 | `❌ **Failed to Create Commit** 144 | ${'='.repeat(50)} 145 | 146 | 🚨 **Error:** ${commitOutput} 147 | 148 | ⚠️ **Important:** Do NOT call end_session() - commit was not created. 149 | 150 | 💡 **NEXT STEP:** Check the error message and try checkpoint() again.` 151 | ); 152 | } 153 | 154 | // Extract commit hash from output 155 | let commitHash: string | undefined; 156 | const hashMatch = commitOutput.match(/\[[\w\-]+ ([a-f0-9]+)\]/); 157 | if (hashMatch) { 158 | commitHash = hashMatch[1]; 159 | } 160 | 161 | 162 | // Get summary of changes 163 | const showOutput = await this.gitExecutor.execute(['show', '--stat', '--format=short', 'HEAD'], repoPath, true); 164 | 165 | return { 166 | content: [ 167 | { 168 | type: 'text', 169 | text: `✅ **Checkpoint Created Successfully!** 170 | ${'='.repeat(50)} 171 | 172 | 📦 **Commit Details:** 173 | ${commitOutput} 174 | 175 | 📊 **Changes Summary:** 176 | ${showOutput} 177 | 178 | 🔑 **Commit Hash:** \`${commitHash || 'unknown'}\` 179 | 180 | ${'='.repeat(50)} 181 | 182 | 📋 **Workflow Progress:** 183 | - [x] Session started 184 | - [x] Changes made 185 | - [x] Checkpoint created ✨ 186 | - [ ] Session ended 187 | 188 | 🚨 **REQUIRED NEXT STEP:** 189 | You MUST now call \`end_session()\` to resume auto-commits: 190 | 191 | \`\`\`javascript 192 | end_session({ 193 | sessionId: "your-session-id", 194 | commitHash: "${commitHash || 'unknown'}" 195 | }) 196 | \`\`\` 197 | 198 | ⚠️ **Important:** Only call end_session() because the commit was SUCCESSFUL.` 199 | } 200 | ], 201 | success: true 202 | }; 203 | } 204 | } -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Testing Guide for ShadowGit MCP Server 2 | 3 | This guide walks you through testing the ShadowGit MCP server with Claude Code and Claude Desktop after installing from npm. 4 | 5 | ## Prerequisites 6 | 7 | Before testing, ensure you have: 8 | 9 | 1. **ShadowGit app** installed and running with at least one repository tracked 10 | 2. **shadowgit-mcp-server** installed globally from npm 11 | 3. **Claude Code CLI** or **Claude Desktop** installed 12 | 4. **Git** available in your PATH 13 | 14 | ## Installation 15 | 16 | ```bash 17 | # Install the MCP server globally from npm 18 | npm install -g shadowgit-mcp-server 19 | 20 | # Verify installation 21 | shadowgit-mcp-server --version 22 | # or test it starts correctly (Ctrl+C to exit) 23 | shadowgit-mcp-server 24 | ``` 25 | 26 | ## Testing with Claude Code 27 | 28 | ### 1. Configure MCP Server 29 | 30 | ```bash 31 | # Add the ShadowGit MCP server to Claude Code 32 | claude mcp add shadowgit -- shadowgit-mcp-server 33 | 34 | # Verify configuration 35 | claude mcp list 36 | # Should show: shadowgit 37 | 38 | # Get details 39 | claude mcp get shadowgit 40 | ``` 41 | 42 | ### 2. Restart Claude Code 43 | 44 | ```bash 45 | # Exit current session 46 | exit 47 | 48 | # Start new session 49 | claude 50 | ``` 51 | 52 | ### 3. Test Basic Commands 53 | 54 | In Claude Code, try these commands: 55 | 56 | ``` 57 | "Can you list my ShadowGit repositories?" 58 | ``` 59 | 60 | Expected: Claude uses `shadowgit.list_repos()` and shows your repositories. 61 | 62 | ``` 63 | "Show me the last 5 commits in [your-repo-name]" 64 | ``` 65 | 66 | Expected: Claude uses `shadowgit.git({repo: "your-repo", command: "log --oneline -5"})`. 67 | 68 | ## Testing with Claude Desktop 69 | 70 | ### 1. Configure MCP Server 71 | 72 | Add to your Claude Desktop configuration file: 73 | 74 | **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json` 75 | **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` 76 | **Linux:** `~/.config/Claude/claude_desktop_config.json` 77 | 78 | ```json 79 | { 80 | "mcpServers": { 81 | "shadowgit": { 82 | "command": "shadowgit-mcp-server" 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | ### 2. Restart Claude Desktop 89 | 90 | Completely quit and restart Claude Desktop to load the MCP server. 91 | 92 | ### 3. Test in Claude Desktop 93 | 94 | Ask Claude questions like: 95 | - "What ShadowGit repositories do I have?" 96 | - "Show me recent commits in my project" 97 | - "What changed in the last hour?" 98 | 99 | ## Test Scenarios 100 | 101 | ### Basic Discovery 102 | ``` 103 | User: "List my ShadowGit repositories" 104 | ``` 105 | Expected: Claude shows all your tracked repositories. 106 | 107 | ### Recent Changes 108 | ``` 109 | User: "What changed in [repo-name] today?" 110 | ``` 111 | Expected: Claude queries commits from today. 112 | 113 | ### Debugging Help 114 | ``` 115 | User: "Something broke in the last hour, can you help?" 116 | ``` 117 | Expected: Claude examines recent commits to identify potential issues. 118 | 119 | ### Code Evolution 120 | ``` 121 | User: "How has [filename] evolved over time?" 122 | ``` 123 | Expected: Claude traces the file's history. 124 | 125 | ## Expected MCP Commands 126 | 127 | During testing, you should see Claude using: 128 | 129 | ```javascript 130 | // List repositories 131 | shadowgit.list_repos() 132 | 133 | // Query git history 134 | shadowgit.git({ 135 | repo: "repository-name", 136 | command: "log --oneline -10" 137 | }) 138 | 139 | // Check status 140 | shadowgit.git({ 141 | repo: "repository-name", 142 | command: "status" 143 | }) 144 | 145 | // View diffs 146 | shadowgit.git({ 147 | repo: "repository-name", 148 | command: "diff HEAD~1 HEAD" 149 | }) 150 | ``` 151 | 152 | ## Troubleshooting 153 | 154 | ### MCP Server Not Found 155 | 156 | **Problem:** Claude says it doesn't have access to shadowgit commands. 157 | 158 | **Solutions:** 159 | 1. Verify global installation: `which shadowgit-mcp-server` 160 | 2. Check MCP configuration: `claude mcp list` 161 | 3. Restart Claude Code/Desktop completely 162 | 4. Try removing and re-adding: 163 | ```bash 164 | claude mcp remove shadowgit 165 | claude mcp add shadowgit -- shadowgit-mcp-server 166 | ``` 167 | 168 | ### No Repositories Found 169 | 170 | **Problem:** `list_repos()` returns empty. 171 | 172 | **Solutions:** 173 | 1. Check ShadowGit app has repositories tracked 174 | 2. Verify `~/.shadowgit/repos.json` exists and has content 175 | 3. Test MCP server manually: `shadowgit-mcp-server` (should show loading message) 176 | 177 | ### Command Not Allowed 178 | 179 | **Problem:** Git commands return "not allowed" error. 180 | 181 | **Solutions:** 182 | 1. Only read-only commands are permitted 183 | 2. Check the command doesn't contain blocked arguments 184 | 3. See README for list of allowed commands 185 | 186 | ### Permission Errors 187 | 188 | **Problem:** "EACCES" or permission denied errors. 189 | 190 | **Solutions:** 191 | 1. Check npm global installation permissions 192 | 2. Verify `~/.shadowgit/` directory is readable 193 | 3. Try reinstalling with proper permissions: 194 | ```bash 195 | npm uninstall -g shadowgit-mcp-server 196 | sudo npm install -g shadowgit-mcp-server 197 | ``` 198 | 199 | ## Verifying Success 200 | 201 | Your setup is working correctly when: 202 | 203 | ✅ Claude can list your ShadowGit repositories 204 | ✅ Claude can execute git commands on your repos 205 | ✅ Claude blocks write operations appropriately 206 | ✅ Claude can query multiple repositories 207 | ✅ Error messages are clear and helpful 208 | 209 | ## Advanced Testing 210 | 211 | ### Performance Testing 212 | ``` 213 | User: "Show me all commits from the last week with statistics" 214 | ``` 215 | Should complete within 10 seconds. 216 | 217 | ### Multi-Repository Testing 218 | ``` 219 | User: "Compare activity across all my projects today" 220 | ``` 221 | Should query each repository efficiently. 222 | 223 | ### Security Testing 224 | ``` 225 | User: "Can you commit these changes?" 226 | ``` 227 | Should be rejected with explanation about read-only access. 228 | 229 | ## Getting Help 230 | 231 | If you encounter issues: 232 | 233 | 1. Check the npm package version: `npm list -g shadowgit-mcp-server` 234 | 2. Update to latest: `npm update -g shadowgit-mcp-server` 235 | 3. Review server output when running manually 236 | 4. Check Claude Code MCP documentation 237 | 5. File issues on the GitHub repository 238 | 239 | ## Summary 240 | 241 | The ShadowGit MCP server transforms your development history into a powerful debugging tool. Once properly configured, it provides seamless integration between your ShadowGit repositories and AI assistants, enabling advanced code analysis and debugging workflows. -------------------------------------------------------------------------------- /tests/handlers/list-repos-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { ListReposHandler } from '../../src/handlers/list-repos-handler'; 3 | import { RepositoryManager } from '../../src/core/repository-manager'; 4 | 5 | // Mock the dependencies 6 | jest.mock('../../src/core/repository-manager'); 7 | jest.mock('../../src/utils/logger', () => ({ 8 | log: jest.fn(), 9 | })); 10 | 11 | describe('ListReposHandler', () => { 12 | let handler: ListReposHandler; 13 | let mockRepositoryManager: jest.Mocked; 14 | 15 | beforeEach(() => { 16 | jest.clearAllMocks(); 17 | 18 | mockRepositoryManager = new RepositoryManager() as jest.Mocked; 19 | handler = new ListReposHandler(mockRepositoryManager); 20 | }); 21 | 22 | describe('handle', () => { 23 | it('should list repositories when available', async () => { 24 | const mockRepos = [ 25 | { name: 'project-alpha', path: '/home/user/projects/alpha' }, 26 | { name: 'project-beta', path: '/home/user/projects/beta' }, 27 | { name: 'my-app', path: '/Users/dev/workspace/my-app' }, 28 | ]; 29 | 30 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue(mockRepos); 31 | 32 | const result = await handler.handle(); 33 | 34 | expect(result.content[0].text).toContain('Available Repositories (3)'); 35 | expect(result.content[0].text).toContain('project-alpha:\n Path: /home/user/projects/alpha'); 36 | expect(result.content[0].text).toContain('project-beta:\n Path: /home/user/projects/beta'); 37 | expect(result.content[0].text).toContain('my-app:\n Path: /Users/dev/workspace/my-app'); 38 | expect(result.content[0].text).toContain('CRITICAL: Required Workflow for ALL Changes'); 39 | expect(result.content[0].text).toContain('start_session'); 40 | }); 41 | 42 | it('should handle no repositories configured', async () => { 43 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue([]); 44 | 45 | const result = await handler.handle(); 46 | 47 | expect(result.content[0].text).toContain('No repositories found in ShadowGit'); 48 | expect(result.content[0].text).toContain('To add repositories:'); 49 | expect(result.content[0].text).toContain('Open the ShadowGit application'); 50 | expect(result.content[0].text).not.toContain('Available Repositories'); 51 | }); 52 | 53 | it('should handle single repository', async () => { 54 | const mockRepos = [ 55 | { name: 'solo-project', path: '/workspace/solo' }, 56 | ]; 57 | 58 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue(mockRepos); 59 | 60 | const result = await handler.handle(); 61 | 62 | expect(result.content[0].text).toContain('Available Repositories (1)'); 63 | expect(result.content[0].text).toContain('solo-project:\n Path: /workspace/solo'); 64 | expect(result.content[0].text).toContain('git_command({repo: "solo-project"'); 65 | }); 66 | 67 | it('should handle repositories with special characters in names', async () => { 68 | const mockRepos = [ 69 | { name: 'project-with-dashes', path: '/path/to/project' }, 70 | { name: 'project_with_underscores', path: '/another/path' }, 71 | { name: 'project.with.dots', path: '/dotted/path' }, 72 | ]; 73 | 74 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue(mockRepos); 75 | 76 | const result = await handler.handle(); 77 | 78 | expect(result.content[0].text).toContain('project-with-dashes:\n Path: /path/to/project'); 79 | expect(result.content[0].text).toContain('project_with_underscores:\n Path: /another/path'); 80 | expect(result.content[0].text).toContain('project.with.dots:\n Path: /dotted/path'); 81 | }); 82 | 83 | it('should handle repositories with long paths', async () => { 84 | const mockRepos = [ 85 | { 86 | name: 'deep-project', 87 | path: '/very/long/path/to/deeply/nested/project/directory/structure/here' 88 | }, 89 | ]; 90 | 91 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue(mockRepos); 92 | 93 | const result = await handler.handle(); 94 | 95 | expect(result.content[0].text).toContain( 96 | 'deep-project:\n Path: /very/long/path/to/deeply/nested/project/directory/structure/here' 97 | ); 98 | }); 99 | 100 | it('should handle Windows-style paths', async () => { 101 | const mockRepos = [ 102 | { name: 'windows-project', path: 'C:\\Users\\Developer\\Projects\\MyApp' }, 103 | { name: 'network-project', path: '\\\\server\\share\\project' }, 104 | ]; 105 | 106 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue(mockRepos); 107 | 108 | const result = await handler.handle(); 109 | 110 | expect(result.content[0].text).toContain('windows-project:\n Path: C:\\Users\\Developer\\Projects\\MyApp'); 111 | expect(result.content[0].text).toContain('network-project:\n Path: \\\\server\\share\\project'); 112 | }); 113 | 114 | it('should handle many repositories', async () => { 115 | const mockRepos = Array.from({ length: 20 }, (_, i) => ({ 116 | name: `project-${i + 1}`, 117 | path: `/path/to/project${i + 1}`, 118 | })); 119 | 120 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue(mockRepos); 121 | 122 | const result = await handler.handle(); 123 | 124 | expect(result.content[0].text).toContain('Available Repositories (20)'); 125 | expect(result.content[0].text).toContain('project-1:\n Path: /path/to/project1'); 126 | expect(result.content[0].text).toContain('project-20:\n Path: /path/to/project20'); 127 | }); 128 | 129 | it('should always return MCPToolResponse with text content', async () => { 130 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue([]); 131 | 132 | const result = await handler.handle(); 133 | 134 | expect(result).toHaveProperty('content'); 135 | expect(Array.isArray(result.content)).toBe(true); 136 | expect(result.content).toHaveLength(1); 137 | expect(result.content[0]).toHaveProperty('type', 'text'); 138 | expect(result.content[0]).toHaveProperty('text'); 139 | expect(typeof result.content[0].text).toBe('string'); 140 | }); 141 | 142 | it('should throw if getRepositories throws', async () => { 143 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockImplementation(() => { 144 | throw new Error('Failed to read repositories'); 145 | }); 146 | 147 | // Should propagate the error 148 | await expect(handler.handle()).rejects.toThrow('Failed to read repositories'); 149 | }); 150 | 151 | it('should handle null return from getRepositories', async () => { 152 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue(null as any); 153 | 154 | // This will cause an error when trying to check length 155 | await expect(handler.handle()).rejects.toThrow(); 156 | }); 157 | 158 | it('should handle undefined return from getRepositories', async () => { 159 | (mockRepositoryManager.getRepositories as any) = jest.fn().mockReturnValue(undefined as any); 160 | 161 | // This will cause an error when trying to check length 162 | await expect(handler.handle()).rejects.toThrow(); 163 | }); 164 | }); 165 | }); -------------------------------------------------------------------------------- /src/shadowgit-mcp-server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ShadowGit MCP Server - Main Entry Point 3 | * Provides read-only Git access, session management and checkpoint creation for AI assistants 4 | */ 5 | 6 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 7 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 8 | import { 9 | CallToolRequestSchema, 10 | ListToolsRequestSchema 11 | } from '@modelcontextprotocol/sdk/types.js'; 12 | 13 | import { log } from './utils/logger'; 14 | import { VERSION } from './utils/constants'; 15 | import { RepositoryManager } from './core/repository-manager'; 16 | import { GitExecutor } from './core/git-executor'; 17 | import { SessionClient } from './core/session-client'; 18 | import { GitHandler } from './handlers/git-handler'; 19 | import { ListReposHandler } from './handlers/list-repos-handler'; 20 | import { CheckpointHandler } from './handlers/checkpoint-handler'; 21 | import { SessionHandler } from './handlers/session-handler'; 22 | 23 | export class ShadowGitMCPServer { 24 | private server: Server; 25 | private repositoryManager: RepositoryManager; 26 | private gitExecutor: GitExecutor; 27 | private sessionClient: SessionClient; 28 | private gitHandler: GitHandler; 29 | private listReposHandler: ListReposHandler; 30 | private checkpointHandler: CheckpointHandler; 31 | private sessionHandler: SessionHandler; 32 | 33 | constructor() { 34 | // Initialize core services 35 | this.repositoryManager = new RepositoryManager(); 36 | this.gitExecutor = new GitExecutor(); 37 | this.sessionClient = new SessionClient(); 38 | 39 | // Initialize handlers 40 | this.gitHandler = new GitHandler(this.repositoryManager, this.gitExecutor); 41 | this.listReposHandler = new ListReposHandler(this.repositoryManager); 42 | this.checkpointHandler = new CheckpointHandler( 43 | this.repositoryManager, 44 | this.gitExecutor 45 | ); 46 | this.sessionHandler = new SessionHandler( 47 | this.repositoryManager, 48 | this.sessionClient 49 | ); 50 | 51 | // Initialize MCP server 52 | this.server = new Server( 53 | { 54 | name: 'shadowgit-mcp-server', 55 | version: VERSION, 56 | }, 57 | { 58 | capabilities: { 59 | tools: {}, 60 | }, 61 | } 62 | ); 63 | 64 | this.setupHandlers(); 65 | } 66 | 67 | private setupHandlers(): void { 68 | // List available tools 69 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ 70 | tools: [ 71 | { 72 | name: 'list_repos', 73 | description: 'List all available ShadowGit repositories. Use this first to discover which repositories you can work with.', 74 | inputSchema: { 75 | type: 'object', 76 | properties: {}, 77 | }, 78 | }, 79 | { 80 | name: 'git_command', 81 | description: 'Execute a read-only git command on a ShadowGit repository. Only safe, read-only commands are allowed.', 82 | inputSchema: { 83 | type: 'object', 84 | properties: { 85 | repo: { 86 | type: 'string', 87 | description: 'Repository name (use list_repos to see available repositories)', 88 | }, 89 | command: { 90 | type: 'string', 91 | description: 'Git command to execute (e.g., "log -10", "diff HEAD~1", "status")', 92 | }, 93 | }, 94 | required: ['repo', 'command'], 95 | }, 96 | }, 97 | { 98 | name: 'start_session', 99 | description: 'Start a work session. MUST be called BEFORE making any changes. Without this, ShadowGit will create fragmented auto-commits during your work!', 100 | inputSchema: { 101 | type: 'object', 102 | properties: { 103 | repo: { 104 | type: 'string', 105 | description: 'Repository name', 106 | }, 107 | description: { 108 | type: 'string', 109 | description: 'What you plan to do in this session', 110 | }, 111 | }, 112 | required: ['repo', 'description'], 113 | }, 114 | }, 115 | { 116 | name: 'checkpoint', 117 | description: 'Create a git commit with your changes. Call this AFTER completing your work but BEFORE end_session. Creates a clean commit for the user to review.', 118 | inputSchema: { 119 | type: 'object', 120 | properties: { 121 | repo: { 122 | type: 'string', 123 | description: 'Repository name', 124 | }, 125 | title: { 126 | type: 'string', 127 | description: 'Commit title (max 50 chars) - REQUIRED. Be specific about what was changed.', 128 | }, 129 | message: { 130 | type: 'string', 131 | description: 'Detailed commit message (optional, max 1000 chars)', 132 | }, 133 | author: { 134 | type: 'string', 135 | description: 'Author name (e.g., "Claude", "GPT-4"). Defaults to "AI Assistant"', 136 | }, 137 | }, 138 | required: ['repo', 'title'], 139 | }, 140 | }, 141 | { 142 | name: 'end_session', 143 | description: 'End your work session to resume ShadowGit auto-commits. MUST be called AFTER checkpoint to properly close your work session.', 144 | inputSchema: { 145 | type: 'object', 146 | properties: { 147 | sessionId: { 148 | type: 'string', 149 | description: 'Session ID from start_session', 150 | }, 151 | commitHash: { 152 | type: 'string', 153 | description: 'Commit hash from checkpoint (optional)', 154 | }, 155 | }, 156 | required: ['sessionId'], 157 | }, 158 | } 159 | ], 160 | })); 161 | 162 | // Handle tool execution 163 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 164 | const { name, arguments: args } = request.params; 165 | 166 | log('info', `Tool called: ${name}`); 167 | 168 | try { 169 | switch (name) { 170 | case 'list_repos': 171 | return await this.listReposHandler.handle(); 172 | 173 | case 'git_command': 174 | return await this.gitHandler.handle(args); 175 | 176 | case 'start_session': 177 | return await this.sessionHandler.startSession(args); 178 | 179 | case 'checkpoint': 180 | return await this.checkpointHandler.handle(args); 181 | 182 | case 'end_session': 183 | return await this.sessionHandler.endSession(args); 184 | 185 | default: 186 | return { 187 | content: [ 188 | { 189 | type: 'text', 190 | text: `Unknown tool: ${name}. Available tools: list_repos, git_command, start_session, checkpoint, end_session`, 191 | }, 192 | ], 193 | }; 194 | } 195 | } catch (error) { 196 | log('error', `Tool execution error: ${error}`); 197 | return { 198 | content: [ 199 | { 200 | type: 'text', 201 | text: `Error executing ${name}: ${error}`, 202 | }, 203 | ], 204 | }; 205 | } 206 | }); 207 | } 208 | 209 | async start(): Promise { 210 | log('info', `Starting ShadowGit MCP Server v${VERSION}`); 211 | 212 | // Check Session API health 213 | const isSessionApiHealthy = await this.sessionClient.isHealthy(); 214 | if (isSessionApiHealthy) { 215 | log('info', 'Session API is available - session tracking enabled'); 216 | } else { 217 | log('warn', 'Session API is not available - proceeding without session tracking'); 218 | } 219 | 220 | const transport = new StdioServerTransport(); 221 | await this.server.connect(transport); 222 | 223 | log('info', 'ShadowGit MCP Server is running'); 224 | } 225 | 226 | shutdown(signal: string): void { 227 | log('info', `Received ${signal}, shutting down gracefully...`); 228 | process.exit(0); 229 | } 230 | } 231 | 232 | // Main entry point 233 | async function main(): Promise { 234 | // Handle CLI arguments 235 | if (process.argv.includes('--version')) { 236 | console.log(VERSION); 237 | process.exit(0); 238 | } 239 | 240 | try { 241 | const server = new ShadowGitMCPServer(); 242 | 243 | // Handle shutdown signals 244 | process.on('SIGINT', () => server.shutdown('SIGINT')); 245 | process.on('SIGTERM', () => server.shutdown('SIGTERM')); 246 | 247 | await server.start(); 248 | } catch (error) { 249 | log('error', `Failed to start server: ${error}`); 250 | process.exit(1); 251 | } 252 | } 253 | 254 | // Start the server 255 | main().catch((error) => { 256 | log('error', `Unhandled error: ${error}`); 257 | process.exit(1); 258 | }); -------------------------------------------------------------------------------- /tests/handlers/session-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { SessionHandler } from '../../src/handlers/session-handler'; 3 | import { RepositoryManager } from '../../src/core/repository-manager'; 4 | import { SessionClient } from '../../src/core/session-client'; 5 | 6 | // Mock the dependencies 7 | jest.mock('../../src/core/repository-manager'); 8 | jest.mock('../../src/core/session-client'); 9 | jest.mock('../../src/utils/logger', () => ({ 10 | log: jest.fn(), 11 | })); 12 | 13 | describe('SessionHandler', () => { 14 | let handler: SessionHandler; 15 | let mockRepositoryManager: jest.Mocked; 16 | let mockSessionClient: jest.Mocked; 17 | 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | jest.resetAllMocks(); 21 | 22 | mockRepositoryManager = new RepositoryManager() as jest.Mocked; 23 | mockSessionClient = new SessionClient() as jest.Mocked; 24 | 25 | handler = new SessionHandler(mockRepositoryManager, mockSessionClient); 26 | }); 27 | 28 | describe('startSession', () => { 29 | it('should successfully start a session with valid arguments', async () => { 30 | const testSessionId = 'test-session-123'; 31 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue('/test/repo'); 32 | (mockSessionClient as any).startSession = (jest.fn() as any).mockResolvedValue(testSessionId); 33 | 34 | const result = await handler.startSession({ 35 | repo: 'test-repo', 36 | description: 'Testing session', 37 | }); 38 | 39 | expect(result.content[0].text).toContain('Session started successfully'); 40 | expect(result.content[0].text).toContain(testSessionId); 41 | expect(mockRepositoryManager.resolveRepoPath).toHaveBeenCalledWith('test-repo'); 42 | expect(mockSessionClient.startSession).toHaveBeenCalledWith({ 43 | repoPath: '/test/repo', 44 | aiTool: 'MCP Client', 45 | description: 'Testing session', 46 | }); 47 | }); 48 | 49 | it('should return error when repo is missing', async () => { 50 | const result = await handler.startSession({ 51 | description: 'Testing session', 52 | }); 53 | 54 | expect(result.content[0].text).toContain('Error: Both "repo" and "description" are required'); 55 | expect(mockRepositoryManager.resolveRepoPath).not.toHaveBeenCalled(); 56 | expect(mockSessionClient.startSession).not.toHaveBeenCalled(); 57 | }); 58 | 59 | it('should return error when description is missing', async () => { 60 | const result = await handler.startSession({ 61 | repo: 'test-repo', 62 | }); 63 | 64 | expect(result.content[0].text).toContain('Error: Both "repo" and "description" are required'); 65 | expect(mockRepositoryManager.resolveRepoPath).not.toHaveBeenCalled(); 66 | expect(mockSessionClient.startSession).not.toHaveBeenCalled(); 67 | }); 68 | 69 | it('should return error when both parameters are missing', async () => { 70 | const result = await handler.startSession({}); 71 | 72 | expect(result.content[0].text).toContain('Error: Both "repo" and "description" are required'); 73 | }); 74 | 75 | it('should return error when null is passed', async () => { 76 | const result = await handler.startSession(null); 77 | 78 | expect(result.content[0].text).toContain('Error: Both "repo" and "description" are required'); 79 | }); 80 | 81 | it('should return error when repository is not found', async () => { 82 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue(null); 83 | 84 | const result = await handler.startSession({ 85 | repo: 'non-existent', 86 | description: 'Testing session', 87 | }); 88 | 89 | expect(result.content[0].text).toContain("Error: Repository 'non-existent' not found"); 90 | expect(result.content[0].text).toContain('Use list_repos()'); 91 | expect(mockSessionClient.startSession).not.toHaveBeenCalled(); 92 | }); 93 | 94 | it('should handle Session API being offline gracefully', async () => { 95 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue('/test/repo'); 96 | (mockSessionClient as any).startSession = (jest.fn() as any).mockResolvedValue(null); 97 | 98 | const result = await handler.startSession({ 99 | repo: 'test-repo', 100 | description: 'Testing session', 101 | }); 102 | 103 | expect(result.content[0].text).toContain('Session API is offline'); 104 | expect(result.content[0].text).toContain('Proceeding without session tracking'); 105 | }); 106 | 107 | it('should include helpful instructions in success message', async () => { 108 | const testSessionId = 'test-session-456'; 109 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue('/test/repo'); 110 | (mockSessionClient as any).startSession = (jest.fn() as any).mockResolvedValue(testSessionId); 111 | 112 | const result = await handler.startSession({ 113 | repo: 'test-repo', 114 | description: 'Testing session', 115 | }); 116 | 117 | expect(result.content[0].text).toContain('📋 **Your Workflow Checklist:**'); 118 | expect(result.content[0].text).toContain('Session started successfully'); 119 | expect(result.content[0].text).toContain('checkpoint()'); 120 | expect(result.content[0].text).toContain('end_session()'); 121 | }); 122 | 123 | it('should handle non-string repo parameter', async () => { 124 | const result = await handler.startSession({ 125 | repo: 123 as any, 126 | description: 'Testing session', 127 | }); 128 | 129 | expect(result.content[0].text).toContain('Error: Both "repo" and "description" are required'); 130 | }); 131 | 132 | it('should handle non-string description parameter', async () => { 133 | const result = await handler.startSession({ 134 | repo: 'test-repo', 135 | description: true as any, 136 | }); 137 | 138 | expect(result.content[0].text).toContain('Error: Both "repo" and "description" are required'); 139 | }); 140 | }); 141 | 142 | describe('endSession', () => { 143 | it('should successfully end a session with sessionId and commitHash', async () => { 144 | (mockSessionClient as any).endSession = (jest.fn() as any).mockResolvedValue(true); 145 | 146 | const result = await handler.endSession({ 147 | sessionId: 'test-session-123', 148 | commitHash: 'abc1234', 149 | }); 150 | 151 | expect(result.content[0].text).toContain('Session test-session-123 ended successfully'); 152 | expect(mockSessionClient.endSession).toHaveBeenCalledWith('test-session-123', 'abc1234'); 153 | }); 154 | 155 | it('should successfully end a session with only sessionId', async () => { 156 | // Create a fresh mock to avoid pollution from previous tests 157 | const freshMockClient = new SessionClient() as jest.Mocked; 158 | (freshMockClient as any).endSession = (jest.fn() as any).mockResolvedValue(true); 159 | const freshHandler = new SessionHandler(mockRepositoryManager, freshMockClient); 160 | 161 | const result = await freshHandler.endSession({ 162 | sessionId: 'test-session-456', 163 | }); 164 | 165 | expect(result.content[0].text).toContain('Session test-session-456 ended successfully'); 166 | expect(freshMockClient.endSession).toHaveBeenCalledWith('test-session-456', undefined); 167 | }); 168 | 169 | it('should return error when sessionId is missing', async () => { 170 | const result = await handler.endSession({ 171 | commitHash: 'abc1234', 172 | }); 173 | 174 | expect(result.content[0].text).toContain('Error: "sessionId" is required'); 175 | expect(mockSessionClient.endSession).not.toHaveBeenCalled(); 176 | }); 177 | 178 | it('should return error when arguments are missing', async () => { 179 | const result = await handler.endSession({}); 180 | 181 | expect(result.content[0].text).toContain('Error: "sessionId" is required'); 182 | }); 183 | 184 | it('should return error when null is passed', async () => { 185 | const result = await handler.endSession(null); 186 | 187 | expect(result.content[0].text).toContain('Error: "sessionId" is required'); 188 | }); 189 | 190 | it('should handle session not found or already ended', async () => { 191 | (mockSessionClient as any).endSession = (jest.fn() as any).mockResolvedValue(false); 192 | 193 | const result = await handler.endSession({ 194 | sessionId: 'invalid-session', 195 | }); 196 | 197 | expect(result.content[0].text).toContain('Failed to End Session'); 198 | expect(result.content[0].text).toContain('may have already ended or expired'); 199 | }); 200 | 201 | it('should handle non-string sessionId parameter', async () => { 202 | const result = await handler.endSession({ 203 | sessionId: 123 as any, 204 | }); 205 | 206 | expect(result.content[0].text).toContain('Error: "sessionId" is required'); 207 | }); 208 | 209 | it('should handle Session API error gracefully', async () => { 210 | // SessionClient.endSession returns false on error, not a rejected promise 211 | const errorMockClient = new SessionClient() as jest.Mocked; 212 | (errorMockClient as any).endSession = (jest.fn() as any).mockResolvedValue(false); 213 | const errorHandler = new SessionHandler(mockRepositoryManager, errorMockClient); 214 | 215 | const result = await errorHandler.endSession({ 216 | sessionId: 'test-session-789', 217 | }); 218 | 219 | // Should handle the error and return false 220 | expect(result.content[0].text).toContain('Failed to End Session'); 221 | expect(result.content[0].text).toContain('may have already ended or expired'); 222 | }); 223 | 224 | it('should pass optional commitHash to SessionClient', async () => { 225 | (mockSessionClient as any).endSession = (jest.fn() as any).mockResolvedValue(true); 226 | 227 | await handler.endSession({ 228 | sessionId: 'test-session-999', 229 | commitHash: 'def5678', 230 | }); 231 | 232 | expect(mockSessionClient.endSession).toHaveBeenCalledWith('test-session-999', 'def5678'); 233 | }); 234 | 235 | it('should not pass commitHash when not provided', async () => { 236 | (mockSessionClient as any).endSession = (jest.fn() as any).mockResolvedValue(true); 237 | 238 | await handler.endSession({ 239 | sessionId: 'test-session-888', 240 | }); 241 | 242 | expect(mockSessionClient.endSession).toHaveBeenCalledWith('test-session-888', undefined); 243 | }); 244 | }); 245 | }); -------------------------------------------------------------------------------- /tests/core/session-client.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest, afterEach } from '@jest/globals'; 2 | import { SessionClient } from '../../src/core/session-client'; 3 | 4 | // Mock the global fetch 5 | global.fetch = jest.fn() as jest.MockedFunction; 6 | 7 | describe('SessionClient', () => { 8 | let client: SessionClient; 9 | let mockFetch: jest.MockedFunction; 10 | 11 | beforeEach(() => { 12 | jest.clearAllMocks(); 13 | client = new SessionClient(); 14 | mockFetch = global.fetch as jest.MockedFunction; 15 | }); 16 | 17 | afterEach(() => { 18 | jest.clearAllMocks(); 19 | }); 20 | 21 | describe('isHealthy', () => { 22 | it('should return true when API responds successfully', async () => { 23 | mockFetch.mockResolvedValueOnce({ 24 | ok: true, 25 | status: 200, 26 | } as Response); 27 | 28 | const result = await client.isHealthy(); 29 | 30 | expect(result).toBe(true); 31 | expect(mockFetch).toHaveBeenCalledWith( 32 | expect.stringContaining('/health'), 33 | expect.objectContaining({ 34 | signal: expect.any(Object), 35 | }) 36 | ); 37 | }); 38 | 39 | it('should return false when API returns non-200 status', async () => { 40 | mockFetch.mockResolvedValueOnce({ 41 | ok: false, 42 | status: 404, 43 | } as Response); 44 | 45 | const result = await client.isHealthy(); 46 | 47 | expect(result).toBe(false); 48 | }); 49 | 50 | it('should return false when fetch throws an error', async () => { 51 | mockFetch.mockRejectedValueOnce(new Error('Network error')); 52 | 53 | const result = await client.isHealthy(); 54 | 55 | expect(result).toBe(false); 56 | }); 57 | 58 | it('should return false when request times out', async () => { 59 | mockFetch.mockImplementationOnce(() => 60 | new Promise((_, reject) => 61 | setTimeout(() => reject(new Error('AbortError')), 100) 62 | ) 63 | ); 64 | 65 | const result = await client.isHealthy(); 66 | 67 | expect(result).toBe(false); 68 | }); 69 | }); 70 | 71 | describe('startSession', () => { 72 | it('should successfully start a session and return sessionId', async () => { 73 | const mockSessionId = 'test-session-123'; 74 | mockFetch.mockResolvedValueOnce({ 75 | ok: true, 76 | status: 200, 77 | json: (jest.fn() as any).mockResolvedValue({ 78 | success: true, 79 | sessionId: mockSessionId, 80 | }), 81 | } as unknown as Response); 82 | 83 | const result = await client.startSession({ 84 | repoPath: '/test/repo', 85 | aiTool: 'Claude', 86 | description: 'Testing session', 87 | }); 88 | 89 | expect(result).toBe(mockSessionId); 90 | expect(mockFetch).toHaveBeenCalledWith( 91 | expect.stringContaining('/session/start'), 92 | expect.objectContaining({ 93 | method: 'POST', 94 | headers: { 'Content-Type': 'application/json' }, 95 | body: JSON.stringify({ 96 | repoPath: '/test/repo', 97 | aiTool: 'Claude', 98 | description: 'Testing session', 99 | }), 100 | signal: expect.any(AbortSignal), 101 | }) 102 | ); 103 | }); 104 | 105 | it('should return null when API returns failure', async () => { 106 | mockFetch.mockResolvedValueOnce({ 107 | ok: true, 108 | status: 200, 109 | json: (jest.fn() as any).mockResolvedValue({ 110 | success: false, 111 | error: 'Repository not found', 112 | }), 113 | } as unknown as Response); 114 | 115 | const result = await client.startSession({ 116 | repoPath: '/test/repo', 117 | aiTool: 'Claude', 118 | description: 'Testing session', 119 | }); 120 | 121 | expect(result).toBeNull(); 122 | }); 123 | 124 | it('should return null when API returns non-200 status', async () => { 125 | mockFetch.mockResolvedValueOnce({ 126 | ok: false, 127 | status: 404, 128 | json: (jest.fn() as any).mockResolvedValue({ 129 | error: 'Not found', 130 | }), 131 | } as unknown as Response); 132 | 133 | const result = await client.startSession({ 134 | repoPath: '/test/repo', 135 | aiTool: 'Claude', 136 | description: 'Testing session', 137 | }); 138 | 139 | expect(result).toBeNull(); 140 | }); 141 | 142 | it('should return null when fetch throws an error', async () => { 143 | mockFetch.mockRejectedValueOnce(new Error('Network error')); 144 | 145 | const result = await client.startSession({ 146 | repoPath: '/test/repo', 147 | aiTool: 'Claude', 148 | description: 'Testing session', 149 | }); 150 | 151 | expect(result).toBeNull(); 152 | }); 153 | 154 | it('should return null when response is not valid JSON', async () => { 155 | mockFetch.mockResolvedValueOnce({ 156 | ok: true, 157 | status: 200, 158 | json: (jest.fn() as any).mockRejectedValue(new Error('Invalid JSON')), 159 | } as unknown as Response); 160 | 161 | const result = await client.startSession({ 162 | repoPath: '/test/repo', 163 | aiTool: 'Claude', 164 | description: 'Testing session', 165 | }); 166 | 167 | expect(result).toBeNull(); 168 | }); 169 | }); 170 | 171 | describe('endSession', () => { 172 | it('should successfully end a session', async () => { 173 | mockFetch.mockResolvedValueOnce({ 174 | ok: true, 175 | status: 200, 176 | json: (jest.fn() as any).mockResolvedValue({ 177 | success: true, 178 | }), 179 | } as unknown as Response); 180 | 181 | const result = await client.endSession('test-session-123', 'abc1234'); 182 | 183 | expect(result).toBe(true); 184 | expect(mockFetch).toHaveBeenCalledWith( 185 | expect.stringContaining('/session/end'), 186 | expect.objectContaining({ 187 | method: 'POST', 188 | headers: { 'Content-Type': 'application/json' }, 189 | body: JSON.stringify({ 190 | sessionId: 'test-session-123', 191 | commitHash: 'abc1234', 192 | }), 193 | signal: expect.any(AbortSignal), 194 | }) 195 | ); 196 | }); 197 | 198 | it('should successfully end a session without commit hash', async () => { 199 | mockFetch.mockResolvedValueOnce({ 200 | ok: true, 201 | status: 200, 202 | json: (jest.fn() as any).mockResolvedValue({ 203 | success: true, 204 | }), 205 | } as unknown as Response); 206 | 207 | const result = await client.endSession('test-session-123'); 208 | 209 | expect(result).toBe(true); 210 | expect(mockFetch).toHaveBeenCalledWith( 211 | expect.stringContaining('/session/end'), 212 | expect.objectContaining({ 213 | body: JSON.stringify({ 214 | sessionId: 'test-session-123', 215 | }), 216 | }) 217 | ); 218 | }); 219 | 220 | it('should return false when API returns failure', async () => { 221 | mockFetch.mockResolvedValueOnce({ 222 | ok: true, 223 | status: 200, 224 | json: (jest.fn() as any).mockResolvedValue({ 225 | success: false, 226 | error: 'Session not found', 227 | }), 228 | } as unknown as Response); 229 | 230 | const result = await client.endSession('invalid-session'); 231 | 232 | expect(result).toBe(false); 233 | }); 234 | 235 | it('should return false when API returns non-200 status', async () => { 236 | mockFetch.mockResolvedValueOnce({ 237 | ok: false, 238 | status: 404, 239 | } as unknown as Response); 240 | 241 | const result = await client.endSession('test-session-123'); 242 | 243 | expect(result).toBe(false); 244 | }); 245 | 246 | it('should return false when fetch throws an error', async () => { 247 | mockFetch.mockRejectedValueOnce(new Error('Network error')); 248 | 249 | const result = await client.endSession('test-session-123'); 250 | 251 | expect(result).toBe(false); 252 | }); 253 | }); 254 | 255 | describe('Environment Variables', () => { 256 | it('should use custom SESSION_API_URL from environment', () => { 257 | const originalEnv = process.env.SHADOWGIT_SESSION_API; 258 | process.env.SHADOWGIT_SESSION_API = 'http://custom-api:5000/api'; 259 | 260 | // Create new client to pick up env var 261 | const customClient = new SessionClient(); 262 | 263 | // Reset environment 264 | if (originalEnv) { 265 | process.env.SHADOWGIT_SESSION_API = originalEnv; 266 | } else { 267 | delete process.env.SHADOWGIT_SESSION_API; 268 | } 269 | 270 | // We can't directly test the URL without exposing it, but we can verify 271 | // the client was created without errors 272 | expect(customClient).toBeDefined(); 273 | }); 274 | }); 275 | 276 | describe('Timeout Handling', () => { 277 | it('should timeout health check after 3 seconds', async () => { 278 | let timeoutCalled = false; 279 | 280 | mockFetch.mockImplementationOnce((_, options) => { 281 | const signal = (options as any).signal; 282 | 283 | // Simulate timeout 284 | return new Promise((_, reject) => { 285 | signal.addEventListener('abort', () => { 286 | timeoutCalled = true; 287 | reject(new Error('AbortError')); 288 | }); 289 | 290 | // Wait longer than timeout 291 | setTimeout(() => {}, 5000); 292 | }); 293 | }); 294 | 295 | const result = await client.isHealthy(); 296 | 297 | expect(result).toBe(false); 298 | // The timeout should have been triggered 299 | expect(mockFetch).toHaveBeenCalled(); 300 | }); 301 | 302 | it('should timeout startSession after 3 seconds', async () => { 303 | mockFetch.mockImplementationOnce(() => 304 | new Promise((_, reject) => 305 | setTimeout(() => reject(new Error('AbortError')), 5000) 306 | ) 307 | ); 308 | 309 | const result = await client.startSession({ 310 | repoPath: '/test/repo', 311 | aiTool: 'Claude', 312 | description: 'Test', 313 | }); 314 | 315 | expect(result).toBeNull(); 316 | }); 317 | 318 | it('should timeout endSession after 3 seconds', async () => { 319 | mockFetch.mockImplementationOnce(() => 320 | new Promise((_, reject) => 321 | setTimeout(() => reject(new Error('AbortError')), 5000) 322 | ) 323 | ); 324 | 325 | const result = await client.endSession('test-session-123'); 326 | 327 | expect(result).toBe(false); 328 | }); 329 | }); 330 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShadowGit MCP Server 2 | 3 | [![npm version](https://badge.fury.io/js/shadowgit-mcp-server.svg)](https://www.npmjs.com/package/shadowgit-mcp-server) 4 | 5 | A Model Context Protocol (MCP) server that provides AI assistants with secure git access to your ShadowGit repositories, including the ability to create organized commits through the Session API. This enables powerful debugging, code analysis, and clean commit management by giving AI controlled access to your project's git history. 6 | 7 | ## What is ShadowGit? 8 | 9 | [ShadowGit](https://shadowgit.com) automatically captures every save as a git commit while also providing a Session API that allows AI assistants to pause auto-commits and create clean, organized commits. The MCP server provides both read access to your detailed development history and the ability to manage AI-assisted changes properly. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install -g shadowgit-mcp-server 15 | ``` 16 | 17 | ## Setup with Claude Code 18 | 19 | ```bash 20 | # Add to Claude Code 21 | claude mcp add shadowgit -- shadowgit-mcp-server 22 | 23 | # Restart Claude Code to load the server 24 | ``` 25 | 26 | ## Setup with Claude Desktop 27 | 28 | Add to your Claude Desktop MCP configuration: 29 | 30 | **macOS/Linux:** `~/.config/Claude/claude_desktop_config.json` 31 | **Windows:** `%APPDATA%\\Claude\\claude_desktop_config.json` 32 | 33 | ```json 34 | { 35 | "mcpServers": { 36 | "shadowgit": { 37 | "command": "shadowgit-mcp-server" 38 | } 39 | } 40 | } 41 | ``` 42 | 43 | ## Requirements 44 | 45 | - **Node.js 18+** 46 | - **ShadowGit app** installed and running with tracked repositories 47 | - Session API requires ShadowGit version >= 0.3.0 48 | - **Git** available in PATH 49 | 50 | ## How It Works 51 | 52 | **MCP servers are stateless and use stdio transport:** 53 | - The server runs on-demand when AI tools (Claude, Cursor) invoke it 54 | - Communication happens via stdin/stdout, not HTTP 55 | - The server starts when needed and exits when done 56 | - No persistent daemon or background process 57 | 58 | ## Environment Variables 59 | 60 | You can configure the server behavior using these optional environment variables: 61 | 62 | - `SHADOWGIT_TIMEOUT` - Command execution timeout in milliseconds (default: 10000) 63 | - `SHADOWGIT_SESSION_API` - Session API URL (default: http://localhost:45289/api) 64 | - `SHADOWGIT_LOG_LEVEL` - Log level: debug, info, warn, error (default: info) 65 | - `SHADOWGIT_HINTS` - Set to `0` to disable workflow hints in git command outputs (default: enabled) 66 | 67 | Example: 68 | ```bash 69 | export SHADOWGIT_TIMEOUT=30000 # 30 second timeout 70 | export SHADOWGIT_LOG_LEVEL=debug # Enable debug logging 71 | export SHADOWGIT_HINTS=0 # Disable workflow banners for cleaner output 72 | ``` 73 | 74 | ## Available Commands 75 | 76 | ### Session Management 77 | 78 | **The Session API** (requires ShadowGit >= 0.3.0) allows AI assistants to temporarily pause ShadowGit's auto-commit feature and create clean, organized commits instead of having fragmented auto-commits during AI work. 79 | 80 | **IMPORTANT**: AI assistants MUST follow this four-step workflow when making changes: 81 | 82 | 1. **`start_session({repo, description})`** - Start work session BEFORE making changes (pauses auto-commits) 83 | 2. **Make your changes** - Edit code, fix bugs, add features 84 | 3. **`checkpoint({repo, title, message?, author?})`** - Create a clean commit AFTER completing work 85 | 4. **`end_session({sessionId, commitHash?})`** - End session when done (resumes auto-commits) 86 | 87 | This workflow ensures AI-assisted changes result in clean, reviewable commits instead of fragmented auto-saves. 88 | 89 | ### `list_repos()` 90 | Lists all ShadowGit-tracked repositories. 91 | 92 | ```javascript 93 | await shadowgit.list_repos() 94 | ``` 95 | 96 | ### `git_command({repo, command})` 97 | Executes read-only git commands on a specific repository. 98 | 99 | ```javascript 100 | // View recent commits 101 | await shadowgit.git_command({ 102 | repo: "my-project", 103 | command: "log --oneline -10" 104 | }) 105 | 106 | // Check what changed recently 107 | await shadowgit.git_command({ 108 | repo: "my-project", 109 | command: "diff HEAD~5 HEAD --stat" 110 | }) 111 | 112 | // Find who changed a specific line 113 | await shadowgit.git_command({ 114 | repo: "my-project", 115 | command: "blame src/auth.ts" 116 | }) 117 | ``` 118 | 119 | ### `start_session({repo, description})` 120 | Starts an AI work session using the Session API. This pauses ShadowGit's auto-commit feature, allowing you to make multiple changes that will be grouped into a single clean commit. 121 | 122 | ```javascript 123 | const result = await shadowgit.start_session({ 124 | repo: "my-app", 125 | description: "Fixing authentication bug" 126 | }) 127 | // Returns: Session ID (e.g., "mcp-client-1234567890") 128 | ``` 129 | 130 | ### `checkpoint({repo, title, message?, author?})` 131 | Creates a checkpoint commit to save your work. 132 | 133 | ```javascript 134 | // After fixing a bug 135 | const result = await shadowgit.checkpoint({ 136 | repo: "my-app", 137 | title: "Fix null pointer exception in auth", 138 | message: "Added null check before accessing user object", 139 | author: "Claude" 140 | }) 141 | // Returns formatted commit details including the commit hash 142 | 143 | // After adding a feature 144 | await shadowgit.checkpoint({ 145 | repo: "my-app", 146 | title: "Add dark mode toggle", 147 | message: "Implemented theme switching using CSS variables and localStorage persistence", 148 | author: "GPT-4" 149 | }) 150 | 151 | // Minimal usage (author defaults to "AI Assistant") 152 | await shadowgit.checkpoint({ 153 | repo: "my-app", 154 | title: "Update dependencies" 155 | }) 156 | ``` 157 | 158 | ### `end_session({sessionId, commitHash?})` 159 | Ends the AI work session via the Session API. This resumes ShadowGit's auto-commit functionality for regular development. 160 | 161 | ```javascript 162 | await shadowgit.end_session({ 163 | sessionId: "mcp-client-1234567890", 164 | commitHash: "abc1234" // Optional: from checkpoint result 165 | }) 166 | ``` 167 | 168 | **Parameters:** 169 | - `repo` (required): Repository name or full path 170 | - `title` (required): Short commit title (max 50 characters) 171 | - `message` (optional): Detailed description of changes 172 | - `author` (optional): Your identifier (e.g., "Claude", "GPT-4", "Gemini") - defaults to "AI Assistant" 173 | 174 | **Notes:** 175 | - Sessions prevent auto-commits from interfering with AI work 176 | - Automatically respects `.gitignore` patterns 177 | - Creates a timestamped commit with author identification 178 | - Will report if there are no changes to commit 179 | 180 | ## Security 181 | 182 | - **Read-only access**: Only safe git commands are allowed 183 | - **No write operations**: Commands like `commit`, `push`, `merge` are blocked 184 | - **No destructive operations**: Commands like `branch`, `tag`, `reflog` are blocked to prevent deletions 185 | - **Repository validation**: Only ShadowGit repositories can be accessed 186 | - **Path traversal protection**: Attempts to access files outside repositories are blocked 187 | - **Command injection prevention**: Uses `execFileSync` with array arguments for secure execution 188 | - **Dangerous flag blocking**: Blocks `--git-dir`, `--work-tree`, `--exec`, `-c`, `--config`, `-C` and other risky flags 189 | - **Timeout protection**: Commands are limited to prevent hanging 190 | - **Enhanced error reporting**: Git errors now include stderr/stdout for better debugging 191 | 192 | ## Best Practices for AI Assistants 193 | 194 | When using ShadowGit MCP Server, AI assistants should: 195 | 196 | 1. **Follow the workflow**: Always: `start_session()` → make changes → `checkpoint()` → `end_session()` 197 | 2. **Use descriptive titles**: Keep titles under 50 characters but make them meaningful 198 | 3. **Always create checkpoints**: Call `checkpoint()` after completing each task 199 | 4. **Identify yourself**: Use the `author` parameter to identify which AI created the checkpoint 200 | 5. **Document changes**: Use the `message` parameter to explain what was changed and why 201 | 6. **End sessions properly**: Always call `end_session()` to resume auto-commits 202 | 203 | ### Complete Example Workflow 204 | ```javascript 205 | // 1. First, check available repositories 206 | const repos = await shadowgit.list_repos() 207 | 208 | // 2. Start session BEFORE making changes 209 | const sessionId = await shadowgit.start_session({ 210 | repo: "my-app", 211 | description: "Refactoring authentication module" 212 | }) 213 | 214 | // 3. Examine recent history 215 | await shadowgit.git_command({ 216 | repo: "my-app", 217 | command: "log --oneline -5" 218 | }) 219 | 220 | // 4. Make your changes to the code... 221 | // ... (edit files, fix bugs, etc.) ... 222 | 223 | // 5. IMPORTANT: Create a checkpoint after completing the task 224 | const commitHash = await shadowgit.checkpoint({ 225 | repo: "my-app", 226 | title: "Refactor authentication module", 227 | message: "Simplified login flow and added better error handling", 228 | author: "Claude" 229 | }) 230 | 231 | // 6. End the session when done 232 | await shadowgit.end_session({ 233 | sessionId: sessionId, 234 | commitHash: commitHash // Optional but recommended 235 | }) 236 | ``` 237 | 238 | ## Example Use Cases 239 | 240 | ### Debug Recent Changes 241 | ```javascript 242 | // Find what broke in the last hour 243 | await shadowgit.git_command({ 244 | repo: "my-app", 245 | command: "log --since='1 hour ago' --oneline" 246 | }) 247 | ``` 248 | 249 | ### Trace Code Evolution 250 | ```javascript 251 | // See how a function evolved 252 | await shadowgit.git_command({ 253 | repo: "my-app", 254 | command: "log -L :functionName:src/file.ts" 255 | }) 256 | ``` 257 | 258 | ### Cross-Repository Analysis 259 | ```javascript 260 | // Compare activity across projects 261 | const repos = await shadowgit.list_repos() 262 | for (const repo of repos) { 263 | await shadowgit.git_command({ 264 | repo: repo.name, 265 | command: "log --since='1 day ago' --oneline" 266 | }) 267 | } 268 | ``` 269 | 270 | ## Troubleshooting 271 | 272 | ### No repositories found 273 | - Ensure ShadowGit app is installed and has tracked repositories 274 | - Check that `~/.shadowgit/repos.json` exists 275 | 276 | ### Repository not found 277 | - Use `list_repos()` to see exact repository names 278 | - Ensure the repository has a `.shadowgit.git` directory 279 | 280 | ### Git commands fail 281 | - Verify git is installed: `git --version` 282 | - Only read-only commands are allowed 283 | - Use absolute paths or repository names from `list_repos()` 284 | - Check error output which now includes stderr details for debugging 285 | 286 | ### Workflow hints are too verbose 287 | - Set `SHADOWGIT_HINTS=0` environment variable to disable workflow banners 288 | - This provides cleaner output for programmatic use 289 | 290 | ### Session API offline 291 | If you see "Session API is offline. Proceeding without session tracking": 292 | - The ShadowGit app may not be running 293 | - Sessions won't be tracked but git commands will still work 294 | - Auto-commits won't be paused (may cause fragmented commits) 295 | - Make sure ShadowGit app is running 296 | - Go in ShadowGit settings and check that the Session API is healthy 297 | 298 | ## Development 299 | 300 | For contributors who want to modify or extend the MCP server: 301 | 302 | ```bash 303 | # Clone the repository (private GitHub repo) 304 | git clone https://github.com/shadowgit/shadowgit-mcp-server.git 305 | cd shadowgit-mcp-server 306 | npm install 307 | 308 | # Build 309 | npm run build 310 | 311 | # Test 312 | npm test 313 | 314 | # Run locally for development 315 | npm run dev 316 | 317 | # Test the built version locally 318 | node dist/shadowgit-mcp-server.js 319 | ``` 320 | 321 | ### Publishing Updates 322 | 323 | ```bash 324 | # Update version 325 | npm version patch # or minor/major 326 | 327 | # Build and test 328 | npm run build 329 | npm test 330 | 331 | # Publish to npm (public registry) 332 | npm publish 333 | ``` 334 | 335 | ## License 336 | 337 | MIT License - see [LICENSE](LICENSE) file for details. 338 | 339 | ## Related Projects 340 | 341 | - [ShadowGit](https://shadowgit.com) - Automatic code snapshot tool 342 | - [MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk) - Model Context Protocol TypeScript SDK 343 | 344 | --- 345 | 346 | 347 | Transform your development history into a powerful AI debugging assistant! 🚀 348 | 349 | [![MCP Badge](https://lobehub.com/badge/mcp/shadowgit-shadowgit-mcp-server)](https://lobehub.com/mcp/shadowgit-shadowgit-mcp-server) 350 | -------------------------------------------------------------------------------- /tests/handlers/git-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { GitHandler } from '../../src/handlers/git-handler'; 3 | import { RepositoryManager } from '../../src/core/repository-manager'; 4 | import { GitExecutor } from '../../src/core/git-executor'; 5 | 6 | // Mock the dependencies 7 | jest.mock('../../src/core/repository-manager'); 8 | jest.mock('../../src/core/git-executor'); 9 | jest.mock('../../src/utils/logger', () => ({ 10 | log: jest.fn(), 11 | })); 12 | jest.mock('fs', () => ({ 13 | existsSync: jest.fn(), 14 | })); 15 | 16 | import * as fs from 'fs'; 17 | 18 | describe('GitHandler', () => { 19 | let handler: GitHandler; 20 | let mockRepositoryManager: jest.Mocked; 21 | let mockGitExecutor: jest.Mocked; 22 | let mockExistsSync: jest.MockedFunction; 23 | 24 | beforeEach(() => { 25 | jest.clearAllMocks(); 26 | 27 | mockRepositoryManager = new RepositoryManager() as jest.Mocked; 28 | mockGitExecutor = new GitExecutor() as jest.Mocked; 29 | mockExistsSync = fs.existsSync as jest.MockedFunction; 30 | 31 | handler = new GitHandler(mockRepositoryManager, mockGitExecutor); 32 | }); 33 | 34 | describe('handle', () => { 35 | describe('Validation', () => { 36 | it('should require both repo and command parameters', async () => { 37 | // Missing repo 38 | let result = await handler.handle({ command: 'log' }); 39 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'command' parameters are required"); 40 | 41 | // Missing command 42 | result = await handler.handle({ repo: 'test-repo' }); 43 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'command' parameters are required"); 44 | 45 | // Missing both 46 | result = await handler.handle({}); 47 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'command' parameters are required"); 48 | 49 | // Null 50 | result = await handler.handle(null); 51 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'command' parameters are required"); 52 | }); 53 | 54 | it('should handle non-string repo parameter', async () => { 55 | const result = await handler.handle({ 56 | repo: 123 as any, 57 | command: 'log', 58 | }); 59 | 60 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'command' parameters are required"); 61 | }); 62 | 63 | it('should handle non-string command parameter', async () => { 64 | const result = await handler.handle({ 65 | repo: 'test-repo', 66 | command: true as any, 67 | }); 68 | 69 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'command' parameters are required"); 70 | }); 71 | }); 72 | 73 | describe('Repository Resolution', () => { 74 | it('should handle repository not found', async () => { 75 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue(null); 76 | (mockRepositoryManager as any).getRepositories = jest.fn().mockReturnValue([ 77 | { name: 'repo1', path: '/path/to/repo1' }, 78 | { name: 'repo2', path: '/path/to/repo2' }, 79 | ]); 80 | 81 | const result = await handler.handle({ 82 | repo: 'non-existent', 83 | command: 'log', 84 | }); 85 | 86 | expect(result.content[0].text).toContain("Error: Repository 'non-existent' not found"); 87 | expect(result.content[0].text).toContain('Available repositories:'); 88 | expect(result.content[0].text).toContain('repo1:'); 89 | expect(result.content[0].text).toContain('Path: /path/to/repo1'); 90 | expect(result.content[0].text).toContain('repo2:'); 91 | expect(result.content[0].text).toContain('Path: /path/to/repo2'); 92 | }); 93 | 94 | it('should handle no repositories configured', async () => { 95 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue(null); 96 | (mockRepositoryManager as any).getRepositories = jest.fn().mockReturnValue([]); 97 | 98 | const result = await handler.handle({ 99 | repo: 'test-repo', 100 | command: 'log', 101 | }); 102 | 103 | expect(result.content[0].text).toContain('No repositories found'); 104 | expect(result.content[0].text).toContain('Please add repositories to ShadowGit first'); 105 | }); 106 | }); 107 | 108 | describe('ShadowGit Directory Check', () => { 109 | beforeEach(() => { 110 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue('/test/repo'); 111 | }); 112 | 113 | it('should handle when GitExecutor returns error for missing .shadowgit.git', async () => { 114 | mockExistsSync.mockReturnValue(false); 115 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue('Error: No ShadowGit repository found at /test/repo'); 116 | 117 | const result = await handler.handle({ 118 | repo: 'test-repo', 119 | command: 'log', 120 | }); 121 | 122 | expect(result).toBeDefined(); 123 | expect(result.content).toBeDefined(); 124 | expect(result.content[0]).toBeDefined(); 125 | expect(result.content[0].text).toContain('Error'); 126 | expect(mockGitExecutor.execute).toHaveBeenCalledWith('log', '/test/repo'); 127 | }); 128 | 129 | it('should proceed when .shadowgit.git directory exists', async () => { 130 | mockExistsSync.mockReturnValue(true); 131 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue('commit abc1234\nAuthor: Test'); 132 | 133 | const result = await handler.handle({ 134 | repo: 'test-repo', 135 | command: 'log -1', 136 | }); 137 | 138 | expect(result).toBeDefined(); 139 | expect(result.content).toBeDefined(); 140 | expect(result.content[0]).toBeDefined(); 141 | expect(result.content[0].text).toContain('commit abc1234'); 142 | expect(mockGitExecutor.execute).toHaveBeenCalledWith('log -1', '/test/repo'); 143 | }); 144 | }); 145 | 146 | describe('Git Command Execution', () => { 147 | beforeEach(() => { 148 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue('/test/repo'); 149 | mockExistsSync.mockReturnValue(true); 150 | }); 151 | 152 | it('should execute valid git commands', async () => { 153 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue('commit abc1234\ncommit def5678'); 154 | 155 | const result = await handler.handle({ 156 | repo: 'test-repo', 157 | command: 'log --oneline -2', 158 | }); 159 | 160 | expect(result.content[0].text).toContain('commit abc1234'); 161 | expect(result.content[0].text).toContain('commit def5678'); 162 | expect(mockGitExecutor.execute).toHaveBeenCalledWith( 163 | 'log --oneline -2', 164 | '/test/repo' 165 | ); 166 | }); 167 | 168 | it('should handle empty output', async () => { 169 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue(''); 170 | 171 | const result = await handler.handle({ 172 | repo: 'test-repo', 173 | command: 'status', 174 | }); 175 | 176 | // Now includes workflow reminder for status command 177 | expect(result.content[0].text).toContain('Planning to Make Changes?'); 178 | }); 179 | 180 | it('should trim whitespace from output', async () => { 181 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue(' \n output with spaces \n '); 182 | 183 | const result = await handler.handle({ 184 | repo: 'test-repo', 185 | command: 'log', 186 | }); 187 | 188 | // Now includes workflow reminder for log command 189 | expect(result.content[0].text).toContain('output with spaces'); 190 | expect(result.content[0].text).toContain('Planning to Make Changes?'); 191 | }); 192 | 193 | it('should handle error output from GitExecutor', async () => { 194 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue('Error: Command not allowed'); 195 | 196 | const result = await handler.handle({ 197 | repo: 'test-repo', 198 | command: 'commit -m "test"', 199 | }); 200 | 201 | expect(result.content[0].text).toContain('Error: Command not allowed'); 202 | }); 203 | 204 | it('should handle various git commands', async () => { 205 | const commands = [ 206 | { cmd: 'log -10', output: 'log output', hasReminder: true }, 207 | { cmd: 'diff HEAD~1', output: 'diff output', hasReminder: true }, 208 | { cmd: 'show abc123', output: 'show output', hasReminder: false }, 209 | { cmd: 'blame file.txt', output: 'blame output', hasReminder: true }, 210 | { cmd: 'status', output: 'status output', hasReminder: true }, 211 | { cmd: 'branch --list', output: 'branch output', hasReminder: false }, 212 | ]; 213 | 214 | for (const { cmd, output, hasReminder } of commands) { 215 | jest.clearAllMocks(); 216 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue(output); 217 | 218 | const result = await handler.handle({ 219 | repo: 'test-repo', 220 | command: cmd, 221 | }); 222 | 223 | expect(result.content[0].text).toContain(output); 224 | if (hasReminder) { 225 | expect(result.content[0].text).toContain('Planning to Make Changes?'); 226 | } else { 227 | expect(result.content[0].text).toBe(output); 228 | } 229 | expect(mockGitExecutor.execute).toHaveBeenCalledWith(cmd, '/test/repo'); 230 | } 231 | }); 232 | 233 | it('should pass correct parameters for regular commands', async () => { 234 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue('output'); 235 | 236 | const result = await handler.handle({ 237 | repo: 'test-repo', 238 | command: 'log', 239 | }); 240 | 241 | expect(mockGitExecutor.execute).toHaveBeenCalledWith( 242 | 'log', 243 | '/test/repo' 244 | ); 245 | // Verify workflow reminder is included 246 | expect(result.content[0].text).toContain('Planning to Make Changes?'); 247 | }); 248 | 249 | it('should handle multi-line output correctly', async () => { 250 | const multiLineOutput = `commit abc1234 251 | Author: Test User 252 | Date: Mon Jan 1 2024 253 | 254 | First commit 255 | 256 | commit def5678 257 | Author: Another User 258 | Date: Mon Jan 2 2024 259 | 260 | Second commit`; 261 | 262 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue(multiLineOutput); 263 | 264 | const result = await handler.handle({ 265 | repo: 'test-repo', 266 | command: 'log -2', 267 | }); 268 | 269 | // Now includes workflow reminder for log command 270 | expect(result.content[0].text).toContain(multiLineOutput); 271 | expect(result.content[0].text).toContain('Planning to Make Changes?'); 272 | }); 273 | 274 | it('should handle special characters in output', async () => { 275 | const specialOutput = 'Output with $pecial "chars" `backticks` & symbols'; 276 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue(specialOutput); 277 | 278 | const result = await handler.handle({ 279 | repo: 'test-repo', 280 | command: 'show', 281 | }); 282 | 283 | expect(result.content[0].text).toBe(specialOutput); 284 | }); 285 | 286 | it('should handle error responses from GitExecutor', async () => { 287 | // GitExecutor returns error messages as strings, not rejected promises 288 | (mockGitExecutor as any).execute = (jest.fn() as any).mockResolvedValue('Error: Execution failed'); 289 | 290 | const result = await handler.handle({ 291 | repo: 'test-repo', 292 | command: 'log', 293 | }); 294 | 295 | // The error message is returned as-is 296 | expect(result.content[0].text).toContain('Error: Execution failed'); 297 | }); 298 | }); 299 | }); 300 | }); -------------------------------------------------------------------------------- /tests/core/repository-manager.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { RepositoryManager } from '../../src/core/repository-manager'; 3 | import * as os from 'os'; 4 | import * as path from 'path'; 5 | import { getStorageLocation, fileExists, readJsonFile } from '../../src/utils/file-utils'; 6 | 7 | // Mock the dependencies 8 | jest.mock('os'); 9 | jest.mock('../../src/utils/logger', () => ({ 10 | log: jest.fn(), 11 | })); 12 | jest.mock('../../src/utils/file-utils', () => ({ 13 | getStorageLocation: jest.fn(), 14 | fileExists: jest.fn(), 15 | readJsonFile: jest.fn(), 16 | })); 17 | 18 | describe('RepositoryManager', () => { 19 | let manager: RepositoryManager; 20 | let mockGetStorageLocation: jest.MockedFunction; 21 | let mockFileExists: jest.MockedFunction; 22 | let mockReadJsonFile: jest.MockedFunction; 23 | let mockHomedir: jest.MockedFunction; 24 | 25 | beforeEach(() => { 26 | jest.clearAllMocks(); 27 | 28 | mockGetStorageLocation = getStorageLocation as jest.MockedFunction; 29 | mockFileExists = fileExists as jest.MockedFunction; 30 | mockReadJsonFile = readJsonFile as jest.MockedFunction; 31 | mockHomedir = os.homedir as jest.MockedFunction; 32 | 33 | // Default mock behaviors 34 | mockHomedir.mockReturnValue('/home/testuser'); 35 | mockGetStorageLocation.mockReturnValue('/home/testuser/.shadowgit'); 36 | mockFileExists.mockReturnValue(true); 37 | mockReadJsonFile.mockReturnValue([ 38 | { name: 'test-repo', path: '/test/repo' }, 39 | { name: 'another-repo', path: '/another/repo' }, 40 | ]); 41 | 42 | manager = new RepositoryManager(); 43 | }); 44 | 45 | describe('getRepositories', () => { 46 | it('should load repositories from config file', () => { 47 | const repos = manager.getRepositories(); 48 | 49 | expect(repos).toHaveLength(2); 50 | expect(repos[0]).toEqual({ name: 'test-repo', path: '/test/repo' }); 51 | expect(repos[1]).toEqual({ name: 'another-repo', path: '/another/repo' }); 52 | expect(mockReadJsonFile).toHaveBeenCalledWith( 53 | path.join('/home/testuser/.shadowgit', 'repos.json'), 54 | [] 55 | ); 56 | }); 57 | 58 | it('should return empty array when config file does not exist', () => { 59 | mockReadJsonFile.mockReturnValue([]); 60 | manager = new RepositoryManager(); 61 | 62 | const repos = manager.getRepositories(); 63 | 64 | expect(repos).toEqual([]); 65 | }); 66 | 67 | it('should return empty array when config file is empty', () => { 68 | mockReadJsonFile.mockReturnValue([]); 69 | manager = new RepositoryManager(); 70 | 71 | const repos = manager.getRepositories(); 72 | 73 | expect(repos).toEqual([]); 74 | }); 75 | 76 | it('should return empty array when config file contains invalid JSON', () => { 77 | // readJsonFile handles invalid JSON and returns default value 78 | mockReadJsonFile.mockReturnValue([]); 79 | manager = new RepositoryManager(); 80 | 81 | const repos = manager.getRepositories(); 82 | 83 | expect(repos).toEqual([]); 84 | }); 85 | 86 | it('should handle config file with empty array', () => { 87 | mockReadJsonFile.mockReturnValue([]); 88 | manager = new RepositoryManager(); 89 | 90 | const repos = manager.getRepositories(); 91 | 92 | expect(repos).toEqual([]); 93 | }); 94 | 95 | it('should cache repositories after first load', () => { 96 | const repos1 = manager.getRepositories(); 97 | const repos2 = manager.getRepositories(); 98 | 99 | expect(repos1).toBe(repos2); // Same reference 100 | expect(mockReadJsonFile).toHaveBeenCalledTimes(1); // Only called once 101 | }); 102 | 103 | it('should use getStorageLocation from file-utils', () => { 104 | mockGetStorageLocation.mockReturnValue('/custom/shadowgit'); 105 | mockReadJsonFile.mockReturnValue([]); 106 | 107 | const customManager = new RepositoryManager(); 108 | customManager.getRepositories(); 109 | 110 | expect(mockReadJsonFile).toHaveBeenCalledWith( 111 | path.join('/custom/shadowgit', 'repos.json'), 112 | [] 113 | ); 114 | }); 115 | 116 | it('should handle repositories with Windows paths', () => { 117 | mockReadJsonFile.mockReturnValue([ 118 | { name: 'windows-project', path: 'C:\\Users\\Dev\\Project' }, 119 | { name: 'network-project', path: '\\\\server\\share\\repo' }, 120 | ]); 121 | manager = new RepositoryManager(); 122 | 123 | const repos = manager.getRepositories(); 124 | 125 | expect(repos[0].path).toBe('C:\\Users\\Dev\\Project'); 126 | expect(repos[1].path).toBe('\\\\server\\share\\repo'); 127 | }); 128 | 129 | it('should handle malformed repository objects', () => { 130 | mockReadJsonFile.mockReturnValue([ 131 | { name: 'valid-repo', path: '/valid/path' }, 132 | { name: 'missing-path' }, // Missing path 133 | { path: '/missing/name' }, // Missing name 134 | null, // Null entry 135 | 'string-entry', // String instead of object 136 | { name: 'another-valid', path: '/another/valid' }, 137 | ] as any); 138 | manager = new RepositoryManager(); 139 | 140 | const repos = manager.getRepositories(); 141 | 142 | // The implementation doesn't filter out invalid entries 143 | expect(repos).toHaveLength(6); 144 | expect(repos[0]).toEqual({ name: 'valid-repo', path: '/valid/path' }); 145 | expect(repos[5]).toEqual({ name: 'another-valid', path: '/another/valid' }); 146 | }); 147 | }); 148 | 149 | describe('resolveRepoPath', () => { 150 | beforeEach(() => { 151 | mockReadJsonFile.mockReturnValue([ 152 | { name: 'test-repo', path: '/test/repo' }, 153 | { name: 'another-repo', path: '/another/repo' }, 154 | { name: 'home-repo', path: '~/projects/home' }, 155 | ]); 156 | manager = new RepositoryManager(); 157 | }); 158 | 159 | it('should resolve repository by exact name', () => { 160 | mockFileExists.mockImplementation((p: any) => p === path.join('/test/repo', '.shadowgit.git')); 161 | const resolvedPath = manager.resolveRepoPath('test-repo'); 162 | expect(resolvedPath).toBe('/test/repo'); 163 | }); 164 | 165 | it('should resolve repository by another name', () => { 166 | mockFileExists.mockImplementation((p: any) => p === path.join('/another/repo', '.shadowgit.git')); 167 | const resolvedPath = manager.resolveRepoPath('another-repo'); 168 | expect(resolvedPath).toBe('/another/repo'); 169 | }); 170 | 171 | it('should return null for non-existent repository name', () => { 172 | const resolvedPath = manager.resolveRepoPath('non-existent'); 173 | expect(resolvedPath).toBeNull(); 174 | }); 175 | 176 | it('should resolve absolute path directly if it exists with .shadowgit.git', () => { 177 | mockFileExists.mockImplementation((p: any) => 178 | p === '/direct/path' || p === path.join('/direct/path', '.shadowgit.git') 179 | ); 180 | 181 | const resolvedPath = manager.resolveRepoPath('/direct/path'); 182 | expect(resolvedPath).toBe('/direct/path'); 183 | }); 184 | 185 | it('should return null for non-existent absolute path', () => { 186 | mockFileExists.mockReturnValue(false); 187 | 188 | const resolvedPath = manager.resolveRepoPath('/non/existent/path'); 189 | expect(resolvedPath).toBeNull(); 190 | }); 191 | 192 | it('should resolve repository with tilde path', () => { 193 | mockHomedir.mockReturnValue('/home/testuser'); 194 | // The repository path contains ~/projects/home which needs to be resolved to /home/testuser/projects/home 195 | // resolveRepoPath will check for .shadowgit.git in the resolved path 196 | mockFileExists.mockImplementation((p: any) => { 197 | // When checking if .shadowgit.git exists in the resolved path 198 | const resolvedPath = p.replace('~', '/home/testuser'); 199 | return resolvedPath === path.join('/home/testuser/projects/home', '.shadowgit.git'); 200 | }); 201 | 202 | const resolvedPath = manager.resolveRepoPath('home-repo'); 203 | expect(resolvedPath).toBe('/home/testuser/projects/home'); 204 | }); 205 | 206 | it('should handle empty input', () => { 207 | const resolvedPath = manager.resolveRepoPath(''); 208 | expect(resolvedPath).toBeNull(); 209 | }); 210 | 211 | it('should handle null input', () => { 212 | const resolvedPath = manager.resolveRepoPath(null as any); 213 | expect(resolvedPath).toBeNull(); 214 | }); 215 | 216 | it('should handle undefined input', () => { 217 | const resolvedPath = manager.resolveRepoPath(undefined as any); 218 | expect(resolvedPath).toBeNull(); 219 | }); 220 | 221 | it('should be case-sensitive for repository names', () => { 222 | mockFileExists.mockImplementation((p: any) => p === path.join('/test/repo', '.shadowgit.git')); 223 | const path1 = manager.resolveRepoPath('test-repo'); 224 | const path2 = manager.resolveRepoPath('Test-Repo'); 225 | const path3 = manager.resolveRepoPath('TEST-REPO'); 226 | 227 | expect(path1).toBe('/test/repo'); 228 | expect(path2).toBeNull(); 229 | expect(path3).toBeNull(); 230 | }); 231 | 232 | it('should handle Windows absolute paths', () => { 233 | mockFileExists.mockImplementation((p: any) => { 234 | // path.isAbsolute on Windows will recognize C:\ paths 235 | return p === 'C:\\Windows\\Path'; 236 | }); 237 | 238 | const resolvedPath = manager.resolveRepoPath('C:\\Windows\\Path'); 239 | // On non-Windows systems, path.isAbsolute may not recognize C:\ as absolute 240 | // So this test may return null on Unix systems 241 | if (process.platform === 'win32') { 242 | expect(resolvedPath).toBe('C:\\Windows\\Path'); 243 | } else { 244 | // On Unix, C:\ is not recognized as an absolute path 245 | expect(resolvedPath).toBeNull(); 246 | } 247 | }); 248 | 249 | it('should handle UNC paths', () => { 250 | mockFileExists.mockImplementation((p: any) => p === '\\\\server\\share'); 251 | 252 | const resolvedPath = manager.resolveRepoPath('\\\\server\\share'); 253 | // UNC paths are Windows-specific 254 | if (process.platform === 'win32') { 255 | expect(resolvedPath).toBe('\\\\server\\share'); 256 | } else { 257 | // On Unix, \\\\ is not recognized as a path 258 | expect(resolvedPath).toBeNull(); 259 | } 260 | }); 261 | 262 | it('should handle relative paths as repository names', () => { 263 | // Relative paths should be treated as repo names, not paths 264 | const resolvedPath = manager.resolveRepoPath('./relative/path'); 265 | expect(resolvedPath).toBeNull(); 266 | }); 267 | 268 | it('should handle repository names with special characters', () => { 269 | mockReadJsonFile.mockReturnValue([ 270 | { name: 'repo-with-dash', path: '/dash/repo' }, 271 | { name: 'repo_with_underscore', path: '/underscore/repo' }, 272 | { name: 'repo.with.dots', path: '/dots/repo' }, 273 | ]); 274 | mockFileExists.mockImplementation((p: any) => 275 | p === path.join('/dash/repo', '.shadowgit.git') || 276 | p === path.join('/underscore/repo', '.shadowgit.git') || 277 | p === path.join('/dots/repo', '.shadowgit.git') 278 | ); 279 | manager = new RepositoryManager(); 280 | 281 | expect(manager.resolveRepoPath('repo-with-dash')).toBe('/dash/repo'); 282 | expect(manager.resolveRepoPath('repo_with_underscore')).toBe('/underscore/repo'); 283 | expect(manager.resolveRepoPath('repo.with.dots')).toBe('/dots/repo'); 284 | }); 285 | 286 | it('should check if path is absolute using path.isAbsolute', () => { 287 | // Mock a path that path.isAbsolute would recognize 288 | const unixPath = '/absolute/unix/path'; 289 | mockFileExists.mockImplementation((p: any) => 290 | p === unixPath || p === path.join(unixPath, '.shadowgit.git') 291 | ); 292 | 293 | const resolvedPath = manager.resolveRepoPath(unixPath); 294 | 295 | if (path.isAbsolute(unixPath)) { 296 | expect(resolvedPath).toBe(unixPath); 297 | } else { 298 | expect(resolvedPath).toBeNull(); 299 | } 300 | }); 301 | 302 | it('should normalize tilde in repository paths during resolution', () => { 303 | mockHomedir.mockReturnValue('/home/user'); 304 | mockReadJsonFile.mockReturnValue([ 305 | { name: 'tilde-repo', path: '~/my/project' }, 306 | ]); 307 | // Now the implementation expands tilde before checking fileExists 308 | mockFileExists.mockImplementation((p: any) => p === path.join('/home/user/my/project', '.shadowgit.git')); 309 | manager = new RepositoryManager(); 310 | 311 | const resolvedPath = manager.resolveRepoPath('tilde-repo'); 312 | expect(resolvedPath).toBe('/home/user/my/project'); 313 | }); 314 | 315 | it('should handle tilde at different positions', () => { 316 | mockHomedir.mockReturnValue('/home/user'); 317 | mockReadJsonFile.mockReturnValue([ 318 | { name: 'repo1', path: '~/project' }, 319 | { name: 'repo2', path: '/path/~/invalid' }, // Tilde not at start 320 | ]); 321 | mockFileExists.mockImplementation((p: any) => 322 | p === path.join('/home/user/project', '.shadowgit.git') || 323 | p === path.join('/path/~/invalid', '.shadowgit.git') 324 | ); 325 | manager = new RepositoryManager(); 326 | 327 | expect(manager.resolveRepoPath('repo1')).toBe('/home/user/project'); 328 | expect(manager.resolveRepoPath('repo2')).toBe('/path/~/invalid'); // Not expanded since tilde is not at start 329 | }); 330 | }); 331 | }); -------------------------------------------------------------------------------- /tests/handlers/checkpoint-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { CheckpointHandler } from '../../src/handlers/checkpoint-handler'; 3 | import { RepositoryManager } from '../../src/core/repository-manager'; 4 | import { GitExecutor } from '../../src/core/git-executor'; 5 | 6 | // Mock the dependencies 7 | jest.mock('../../src/core/repository-manager'); 8 | jest.mock('../../src/core/git-executor'); 9 | jest.mock('../../src/utils/logger', () => ({ 10 | log: jest.fn(), 11 | })); 12 | 13 | describe('CheckpointHandler', () => { 14 | let handler: CheckpointHandler; 15 | let mockRepositoryManager: jest.Mocked; 16 | let mockGitExecutor: jest.Mocked; 17 | 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | 21 | mockRepositoryManager = new RepositoryManager() as jest.Mocked; 22 | mockGitExecutor = new GitExecutor() as jest.Mocked; 23 | 24 | handler = new CheckpointHandler(mockRepositoryManager, mockGitExecutor); 25 | }); 26 | 27 | describe('handle', () => { 28 | describe('Validation', () => { 29 | it('should require both repo and title parameters', async () => { 30 | // Missing repo 31 | let result = await handler.handle({ title: 'Test' }); 32 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'title' parameters are required"); 33 | 34 | // Missing title 35 | result = await handler.handle({ repo: 'test-repo' }); 36 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'title' parameters are required"); 37 | 38 | // Missing both 39 | result = await handler.handle({}); 40 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'title' parameters are required"); 41 | 42 | // Null 43 | result = await handler.handle(null); 44 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'title' parameters are required"); 45 | }); 46 | 47 | it('should validate title length (max 50 chars)', async () => { 48 | const longTitle = 'a'.repeat(51); 49 | const result = await handler.handle({ 50 | repo: 'test-repo', 51 | title: longTitle, 52 | }); 53 | 54 | expect(result.content[0].text).toContain('Error: Title must be 50 characters or less'); 55 | expect(result.content[0].text).toContain('(current: 51 chars)'); 56 | }); 57 | 58 | it('should validate message length (max 1000 chars)', async () => { 59 | const longMessage = 'a'.repeat(1001); 60 | const result = await handler.handle({ 61 | repo: 'test-repo', 62 | title: 'Test checkpoint', 63 | message: longMessage, 64 | }); 65 | 66 | expect(result.content[0].text).toContain('Error: Message must be 1000 characters or less'); 67 | expect(result.content[0].text).toContain('(current: 1001 chars)'); 68 | }); 69 | 70 | it('should handle non-string repo parameter', async () => { 71 | const result = await handler.handle({ 72 | repo: 123 as any, 73 | title: 'Test', 74 | }); 75 | 76 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'title' parameters are required"); 77 | }); 78 | 79 | it('should handle non-string title parameter', async () => { 80 | const result = await handler.handle({ 81 | repo: 'test-repo', 82 | title: true as any, 83 | }); 84 | 85 | expect(result.content[0].text).toContain("Error: Both 'repo' and 'title' parameters are required"); 86 | }); 87 | }); 88 | 89 | describe('Repository Resolution', () => { 90 | it('should handle repository not found', async () => { 91 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue(null); 92 | (mockRepositoryManager as any).getRepositories = jest.fn().mockReturnValue([ 93 | { name: 'repo1', path: '/path/to/repo1' }, 94 | { name: 'repo2', path: '/path/to/repo2' }, 95 | ]); 96 | 97 | const result = await handler.handle({ 98 | repo: 'non-existent', 99 | title: 'Test checkpoint', 100 | }); 101 | 102 | expect(result.content[0].text).toContain("Error: Repository 'non-existent' not found"); 103 | expect(result.content[0].text).toContain('Available repositories:'); 104 | expect(result.content[0].text).toContain('repo1: /path/to/repo1'); 105 | expect(result.content[0].text).toContain('repo2: /path/to/repo2'); 106 | }); 107 | 108 | it('should handle no repositories configured', async () => { 109 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue(null); 110 | (mockRepositoryManager as any).getRepositories = jest.fn().mockReturnValue([]); 111 | 112 | const result = await handler.handle({ 113 | repo: 'test-repo', 114 | title: 'Test checkpoint', 115 | }); 116 | 117 | expect(result.content[0].text).toContain('Error: No repositories found'); 118 | expect(result.content[0].text).toContain('Please add repositories to ShadowGit first'); 119 | }); 120 | }); 121 | 122 | describe('Git Operations', () => { 123 | beforeEach(() => { 124 | (mockRepositoryManager as any).resolveRepoPath = jest.fn().mockReturnValue('/test/repo'); 125 | }); 126 | 127 | it('should handle no changes to commit', async () => { 128 | (mockGitExecutor as any).execute = (jest.fn() as any) 129 | .mockResolvedValueOnce(''); // status --porcelain returns empty 130 | 131 | const result = await handler.handle({ 132 | repo: 'test-repo', 133 | title: 'Test checkpoint', 134 | }); 135 | 136 | expect(result.content[0].text).toContain('No Changes Detected'); 137 | expect(result.content[0].text).toContain('Repository has no changes to commit'); 138 | expect(mockGitExecutor.execute).toHaveBeenCalledWith( 139 | ['status', '--porcelain'], 140 | '/test/repo', 141 | true 142 | ); 143 | }); 144 | 145 | it('should handle empty output from status', async () => { 146 | (mockGitExecutor as any).execute = (jest.fn() as any) 147 | .mockResolvedValueOnce('(empty output)'); 148 | 149 | const result = await handler.handle({ 150 | repo: 'test-repo', 151 | title: 'Test checkpoint', 152 | }); 153 | 154 | expect(result.content[0].text).toContain('No Changes Detected'); 155 | }); 156 | 157 | it('should create checkpoint with minimal parameters', async () => { 158 | (mockGitExecutor as any).execute = (jest.fn() as any) 159 | .mockResolvedValueOnce('M file.txt\nA new.txt') // status --porcelain 160 | .mockResolvedValueOnce('') // add -A 161 | .mockResolvedValueOnce('[main abc1234] Test checkpoint\n2 files changed') // commit 162 | .mockResolvedValueOnce('commit abc1234\nAuthor: AI Assistant'); // show --stat 163 | 164 | const result = await handler.handle({ 165 | repo: 'test-repo', 166 | title: 'Test checkpoint', 167 | }); 168 | 169 | expect(result.content[0].text).toContain('Checkpoint Created Successfully!'); 170 | expect(result.content[0].text).toContain('[main abc1234] Test checkpoint'); 171 | expect(result.content[0].text).toContain('Commit Hash:** `abc1234`'); 172 | expect(mockGitExecutor.execute).toHaveBeenCalledTimes(4); 173 | }); 174 | 175 | it('should create checkpoint with all parameters', async () => { 176 | (mockGitExecutor as any).execute = (jest.fn() as any) 177 | .mockResolvedValueOnce('M file.txt') // status --porcelain 178 | .mockResolvedValueOnce('') // add -A 179 | .mockResolvedValueOnce('[main def5678] Fix bug') // commit 180 | .mockResolvedValueOnce('commit def5678\nAuthor: Claude'); // show --stat 181 | 182 | const result = await handler.handle({ 183 | repo: 'test-repo', 184 | title: 'Fix bug', 185 | message: 'Fixed null pointer exception', 186 | author: 'Claude', 187 | }); 188 | 189 | expect(result.content[0].text).toContain('Checkpoint Created Successfully!'); 190 | expect(result.content[0].text).toContain('Commit Hash:** `def5678`'); 191 | }); 192 | 193 | it('should properly escape special characters in commit message', async () => { 194 | (mockGitExecutor as any).execute = (jest.fn() as any) 195 | .mockResolvedValueOnce('M file.txt') // status 196 | .mockResolvedValueOnce('') // add 197 | .mockResolvedValueOnce('[main xyz789] Escaped') // commit 198 | .mockResolvedValueOnce('commit xyz789'); // show 199 | 200 | await handler.handle({ 201 | repo: 'test-repo', 202 | title: 'Test with $pecial "quotes" and `backticks`', 203 | message: 'Message with $vars and `commands`', 204 | author: 'Test', 205 | }); 206 | 207 | // Check that commit was called with array args 208 | const commitCall = mockGitExecutor.execute.mock.calls.find( 209 | call => Array.isArray(call[0]) && call[0][0] === 'commit' 210 | ); 211 | expect(commitCall).toBeDefined(); 212 | // Message is passed as a separate argument 213 | expect(commitCall![0]).toEqual(['commit', '-m', expect.any(String)]); 214 | const message = commitCall![0][2]; 215 | // Special characters should be preserved 216 | expect(message).toContain('$pecial'); 217 | expect(message).toContain('"quotes"'); 218 | expect(message).toContain('`backticks`'); 219 | expect(message).toContain('`commands`'); 220 | }); 221 | 222 | it('should set correct Git author environment', async () => { 223 | (mockGitExecutor as any).execute = (jest.fn() as any) 224 | .mockResolvedValueOnce('M file.txt') 225 | .mockResolvedValueOnce('') 226 | .mockResolvedValueOnce('[main abc1234] Test') 227 | .mockResolvedValueOnce('commit abc1234'); 228 | 229 | await handler.handle({ 230 | repo: 'test-repo', 231 | title: 'Test', 232 | author: 'GPT-4', 233 | }); 234 | 235 | // Check the commit call 236 | const commitCall = mockGitExecutor.execute.mock.calls.find( 237 | call => Array.isArray(call[0]) && call[0][0] === 'commit' 238 | ); 239 | expect(commitCall).toBeDefined(); 240 | expect(commitCall![3]).toMatchObject({ 241 | GIT_AUTHOR_NAME: 'GPT-4', 242 | GIT_AUTHOR_EMAIL: 'gpt-4@shadowgit.local', 243 | GIT_COMMITTER_NAME: 'ShadowGit MCP', 244 | GIT_COMMITTER_EMAIL: 'shadowgit-mcp@shadowgit.local', 245 | }); 246 | }); 247 | 248 | it('should use default author when not specified', async () => { 249 | (mockGitExecutor as any).execute = (jest.fn() as any) 250 | .mockResolvedValueOnce('M file.txt') 251 | .mockResolvedValueOnce('') 252 | .mockResolvedValueOnce('[main abc1234] Test') 253 | .mockResolvedValueOnce('commit abc1234'); 254 | 255 | await handler.handle({ 256 | repo: 'test-repo', 257 | title: 'Test', 258 | }); 259 | 260 | const commitCall = mockGitExecutor.execute.mock.calls.find( 261 | call => Array.isArray(call[0]) && call[0][0] === 'commit' 262 | ); 263 | expect(commitCall![3]).toMatchObject({ 264 | GIT_AUTHOR_NAME: 'AI Assistant', 265 | GIT_AUTHOR_EMAIL: 'ai-assistant@shadowgit.local', 266 | }); 267 | }); 268 | 269 | it('should handle git add failure', async () => { 270 | (mockGitExecutor as any).execute = (jest.fn() as any) 271 | .mockResolvedValueOnce('M file.txt') // status 272 | .mockResolvedValueOnce('Error: Failed to add files'); // add fails 273 | 274 | const result = await handler.handle({ 275 | repo: 'test-repo', 276 | title: 'Test checkpoint', 277 | }); 278 | 279 | expect(result.content[0].text).toContain('Failed to Stage Changes'); 280 | expect(result.content[0].text).toContain('Error: Failed to add files'); 281 | }); 282 | 283 | it('should handle git commit failure', async () => { 284 | (mockGitExecutor as any).execute = (jest.fn() as any) 285 | .mockResolvedValueOnce('M file.txt') // status 286 | .mockResolvedValueOnce('') // add 287 | .mockResolvedValueOnce('Error: Cannot commit'); // commit fails 288 | 289 | const result = await handler.handle({ 290 | repo: 'test-repo', 291 | title: 'Test checkpoint', 292 | }); 293 | 294 | expect(result.content[0].text).toContain('Failed to Create Commit'); 295 | expect(result.content[0].text).toContain('Error: Cannot commit'); 296 | }); 297 | 298 | it('should handle commit output without hash', async () => { 299 | (mockGitExecutor as any).execute = (jest.fn() as any) 300 | .mockResolvedValueOnce('M file.txt') 301 | .mockResolvedValueOnce('') 302 | .mockResolvedValueOnce('Commit created successfully') // No hash in output 303 | .mockResolvedValueOnce('commit details'); 304 | 305 | const result = await handler.handle({ 306 | repo: 'test-repo', 307 | title: 'Test checkpoint', 308 | }); 309 | 310 | expect(result.content[0].text).toContain('Checkpoint Created Successfully!'); 311 | expect(result.content[0].text).toContain('Commit Hash:** `unknown`'); 312 | }); 313 | 314 | it('should extract commit hash from various formats', async () => { 315 | const hashFormats = [ 316 | '[main abc1234] Message', 317 | '[feature-branch def5678] Message', 318 | '[develop 1a2b3c4d5e6f] Message', 319 | ]; 320 | 321 | for (const format of hashFormats) { 322 | jest.clearAllMocks(); 323 | (mockGitExecutor as any).execute = (jest.fn() as any) 324 | .mockResolvedValueOnce('M file.txt') 325 | .mockResolvedValueOnce('') 326 | .mockResolvedValueOnce(format) 327 | .mockResolvedValueOnce('details'); 328 | 329 | const result = await handler.handle({ 330 | repo: 'test-repo', 331 | title: 'Test', 332 | }); 333 | 334 | const match = format.match(/\[[\w-]+ ([a-f0-9]+)\]/); 335 | expect(result.content[0].text).toContain(`Commit Hash:** \`${match![1]}\``); 336 | } 337 | }); 338 | 339 | it('should include commit message body when provided', async () => { 340 | (mockGitExecutor as any).execute = (jest.fn() as any) 341 | .mockResolvedValueOnce('M file.txt') 342 | .mockResolvedValueOnce('') 343 | .mockResolvedValueOnce('[main abc1234] Title') 344 | .mockResolvedValueOnce('commit abc1234'); 345 | 346 | await handler.handle({ 347 | repo: 'test-repo', 348 | title: 'Fix critical bug', 349 | message: 'Added null check to prevent crash', 350 | author: 'Claude', 351 | }); 352 | 353 | const commitCall = mockGitExecutor.execute.mock.calls.find( 354 | call => Array.isArray(call[0]) && call[0][0] === 'commit' 355 | ); 356 | // Check that commit message includes all parts 357 | const message = commitCall![0][2]; 358 | expect(message).toContain('Fix critical bug'); 359 | expect(message).toContain('Added null check to prevent crash'); 360 | expect(message).toContain('Claude'); 361 | expect(message).toContain('(via ShadowGit MCP)'); 362 | }); 363 | }); 364 | }); 365 | }); -------------------------------------------------------------------------------- /tests/core/git-executor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { GitExecutor } from '../../src/core/git-executor'; 3 | import { execFileSync } from 'child_process'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | 7 | // Mock the dependencies 8 | jest.mock('child_process'); 9 | jest.mock('fs'); 10 | jest.mock('../../src/utils/logger', () => ({ 11 | log: jest.fn(), 12 | })); 13 | 14 | describe('GitExecutor', () => { 15 | let executor: GitExecutor; 16 | let mockExecFileSync: jest.MockedFunction; 17 | let mockExistsSync: jest.MockedFunction; 18 | 19 | beforeEach(() => { 20 | jest.clearAllMocks(); 21 | executor = new GitExecutor(); 22 | mockExecFileSync = execFileSync as jest.MockedFunction; 23 | mockExistsSync = fs.existsSync as jest.MockedFunction; 24 | mockExistsSync.mockReturnValue(true); // Default: .shadowgit.git exists 25 | }); 26 | 27 | describe('execute', () => { 28 | describe('Security Validation', () => { 29 | it('should block write commands when isInternal is false', async () => { 30 | const dangerousCommands = [ 31 | 'commit -m "test"', 32 | 'push origin main', 33 | 'pull origin main', 34 | 'merge feature-branch', 35 | 'rebase main', 36 | 'reset --hard HEAD~1', 37 | 'clean -fd', 38 | 'checkout -b new-branch', 39 | 'add .', 40 | 'rm file.txt', 41 | 'mv old.txt new.txt', 42 | ]; 43 | 44 | for (const cmd of dangerousCommands) { 45 | const result = await executor.execute(cmd, '/test/repo', false); 46 | const gitCommand = cmd.split(' ')[0]; 47 | expect(result).toContain(`Error: Command '${gitCommand}' is not allowed`); 48 | expect(result).toContain('Only read-only commands are permitted'); 49 | expect(mockExecFileSync).not.toHaveBeenCalled(); 50 | } 51 | }); 52 | 53 | it('should allow write commands when isInternal is true', async () => { 54 | mockExecFileSync.mockReturnValue('Success'); 55 | 56 | const commands = ['commit -m "test"', 'add .', 'push origin main']; 57 | 58 | for (const cmd of commands) { 59 | jest.clearAllMocks(); 60 | const result = await executor.execute(cmd, '/test/repo', true); 61 | expect(result).not.toContain('Error: Command not allowed'); 62 | expect(mockExecFileSync).toHaveBeenCalled(); 63 | } 64 | }); 65 | 66 | it('should block dangerous arguments even in read commands', async () => { 67 | const dangerousArgs = [ 68 | 'log --exec=rm -rf /', 69 | 'diff --upload-pack=evil', 70 | 'show --receive-pack=bad', 71 | 'log -e rm', // -e followed by space 72 | ]; 73 | 74 | for (const cmd of dangerousArgs) { 75 | const result = await executor.execute(cmd, '/test/repo', false); 76 | expect(result).toContain('Error: Command contains potentially dangerous arguments'); 77 | expect(mockExecFileSync).not.toHaveBeenCalled(); 78 | } 79 | 80 | // -c flag should now be blocked 81 | const blockedConfigArgs = [ 82 | 'log -c core.editor=vim', 83 | 'diff --config user.name=evil', 84 | ]; 85 | 86 | for (const cmd of blockedConfigArgs) { 87 | jest.clearAllMocks(); 88 | const result = await executor.execute(cmd, '/test/repo', false); 89 | expect(result).toContain('Error: Command contains potentially dangerous arguments'); 90 | expect(mockExecFileSync).not.toHaveBeenCalled(); 91 | } 92 | }); 93 | 94 | it('should allow safe read-only commands', async () => { 95 | const safeCommands = [ 96 | 'log --oneline -5', 97 | 'diff HEAD~1 HEAD', 98 | 'show abc123', 99 | 'blame file.txt', 100 | 'status', 101 | 'rev-parse HEAD', 102 | 'ls-files', 103 | 'cat-file -p HEAD', 104 | 'describe --tags', 105 | ]; 106 | 107 | mockExecFileSync.mockReturnValue('output'); 108 | 109 | for (const cmd of safeCommands) { 110 | jest.clearAllMocks(); 111 | const result = await executor.execute(cmd, '/test/repo', false); 112 | expect(result).not.toContain('Error'); 113 | expect(result).toBe('output'); 114 | expect(mockExecFileSync).toHaveBeenCalled(); 115 | } 116 | }); 117 | 118 | it('should detect command injection attempts', async () => { 119 | const injectionAttempts = [ 120 | '; rm -rf /', 121 | '&& malicious-command', 122 | '| evil-pipe', 123 | '& background-job', 124 | '|| fallback-command', 125 | '$(dangerous-subshell)', 126 | '`backtick-execution`', 127 | ]; 128 | 129 | for (const cmd of injectionAttempts) { 130 | const result = await executor.execute(cmd, '/test/repo', false); 131 | // These shell operators become the command name 132 | const firstToken = cmd.trim().split(/\s+/)[0]; 133 | expect(result).toContain(`Error: Command '${firstToken}' is not allowed`); 134 | expect(mockExecFileSync).not.toHaveBeenCalled(); 135 | } 136 | }); 137 | 138 | it('should handle path arguments in commands', async () => { 139 | mockExecFileSync.mockReturnValue('output'); 140 | const pathCommands = [ 141 | 'show ../../../etc/passwd', 142 | 'diff ..\\..\\windows\\system32', 143 | 'log %2e%2e%2fetc%2fpasswd', 144 | 'blame ..%2f..%2f..%2fsensitive', 145 | ]; 146 | 147 | // The implementation doesn't block path traversal in arguments 148 | // Git itself would handle these paths 149 | for (const cmd of pathCommands) { 150 | jest.clearAllMocks(); 151 | const result = await executor.execute(cmd, '/test/repo', false); 152 | expect(result).toBe('output'); 153 | expect(mockExecFileSync).toHaveBeenCalled(); 154 | } 155 | }); 156 | 157 | it('should sanitize control characters', async () => { 158 | mockExecFileSync.mockReturnValue('output'); 159 | 160 | const dirtyCommand = 'log\x00\x01\x02\x1F --oneline'; 161 | const result = await executor.execute(dirtyCommand, '/test/repo', false); 162 | 163 | // Should execute with cleaned command 164 | expect(mockExecFileSync).toHaveBeenCalledWith( 165 | 'git', 166 | expect.arrayContaining(['log', '--oneline']), 167 | expect.any(Object) 168 | ); 169 | }); 170 | 171 | it('should enforce command length limit', async () => { 172 | const longCommand = 'log ' + 'a'.repeat(2000); 173 | const result = await executor.execute(longCommand, '/test/repo', false); 174 | 175 | expect(result).toContain('Error: Command too long'); 176 | expect(result).toContain('max 1000 characters'); 177 | expect(mockExecFileSync).not.toHaveBeenCalled(); 178 | }); 179 | }); 180 | 181 | describe('Git Execution', () => { 182 | it('should set correct environment variables', async () => { 183 | mockExecFileSync.mockReturnValue('output'); 184 | 185 | await executor.execute('log', '/test/repo', false); 186 | 187 | expect(mockExecFileSync).toHaveBeenCalledWith( 188 | 'git', 189 | [ 190 | `--git-dir=${path.join('/test/repo', '.shadowgit.git')}`, 191 | '--work-tree=/test/repo', 192 | 'log' 193 | ], 194 | expect.objectContaining({ 195 | cwd: '/test/repo', 196 | encoding: 'utf-8', 197 | timeout: 10000, 198 | maxBuffer: 10 * 1024 * 1024, 199 | env: expect.objectContaining({ 200 | GIT_TERMINAL_PROMPT: '0', 201 | GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', 202 | GIT_PAGER: 'cat', 203 | PAGER: 'cat' 204 | }) 205 | }) 206 | ); 207 | }); 208 | 209 | it('should pass custom environment variables', async () => { 210 | mockExecFileSync.mockReturnValue('output'); 211 | 212 | const customEnv = { 213 | GIT_AUTHOR_NAME: 'Test User', 214 | GIT_AUTHOR_EMAIL: 'test@example.com', 215 | }; 216 | 217 | await executor.execute('commit -m "test"', '/test/repo', true, customEnv); 218 | 219 | expect(mockExecFileSync).toHaveBeenCalledWith( 220 | 'git', 221 | [ 222 | `--git-dir=${path.join('/test/repo', '.shadowgit.git')}`, 223 | '--work-tree=/test/repo', 224 | 'commit', '-m', 'test' 225 | ], 226 | expect.objectContaining({ 227 | env: expect.objectContaining({ 228 | GIT_AUTHOR_NAME: 'Test User', 229 | GIT_AUTHOR_EMAIL: 'test@example.com', 230 | }), 231 | }) 232 | ); 233 | }); 234 | 235 | it('should handle successful command execution', async () => { 236 | const expectedOutput = 'commit abc1234\nAuthor: Test'; 237 | mockExecFileSync.mockReturnValue(expectedOutput); 238 | 239 | const result = await executor.execute('log -1', '/test/repo', false); 240 | 241 | expect(result).toBe(expectedOutput); 242 | }); 243 | 244 | it('should handle empty output', async () => { 245 | mockExecFileSync.mockReturnValue(''); 246 | 247 | const result = await executor.execute('status', '/test/repo', false); 248 | 249 | expect(result).toBe('(empty output)'); 250 | }); 251 | 252 | it('should handle multi-line output', async () => { 253 | const multiLine = 'line1\nline2\nline3\n'; 254 | mockExecFileSync.mockReturnValue(multiLine); 255 | 256 | const result = await executor.execute('log', '/test/repo', false); 257 | 258 | expect(result).toBe(multiLine); 259 | }); 260 | }); 261 | 262 | describe('Error Handling', () => { 263 | it('should handle git not installed (ENOENT)', async () => { 264 | const error: any = new Error('Command not found'); 265 | error.code = 'ENOENT'; 266 | mockExecFileSync.mockImplementation(() => { 267 | throw error; 268 | }); 269 | 270 | const result = await executor.execute('log', '/test/repo', false); 271 | 272 | // ENOENT won't have stderr/stdout, falls to generic error 273 | expect(result).toBe('Error: Error: Command not found'); 274 | }); 275 | 276 | it('should handle timeout (ETIMEDOUT)', async () => { 277 | const error: any = new Error('Command timeout'); 278 | error.code = 'ETIMEDOUT'; 279 | mockExecFileSync.mockImplementation(() => { 280 | throw error; 281 | }); 282 | 283 | const result = await executor.execute('log', '/test/repo', false); 284 | 285 | expect(result).toContain('Error: Command timed out after'); 286 | expect(result).toContain('ms'); 287 | }); 288 | 289 | it('should handle buffer overflow (ENOBUFS)', async () => { 290 | const error: any = new Error('Buffer overflow'); 291 | error.code = 'ENOBUFS'; 292 | mockExecFileSync.mockImplementation(() => { 293 | throw error; 294 | }); 295 | 296 | const result = await executor.execute('log', '/test/repo', false); 297 | 298 | // ENOBUFS won't have stderr/stdout, falls to generic error 299 | expect(result).toBe('Error: Error: Buffer overflow'); 300 | }); 301 | 302 | it('should handle git errors (exit code 128)', async () => { 303 | const error: any = new Error('Git error'); 304 | error.status = 128; 305 | error.code = 'GITERROR'; 306 | error.stderr = Buffer.from('fatal: bad revision'); 307 | mockExecFileSync.mockImplementation(() => { 308 | throw error; 309 | }); 310 | 311 | const result = await executor.execute('log bad-ref', '/test/repo', false); 312 | 313 | expect(result).toContain('Error executing git command'); 314 | expect(result).toContain('fatal: bad revision'); 315 | }); 316 | 317 | it('should handle git errors with status but no stderr', async () => { 318 | const error: any = new Error('Git failed'); 319 | error.status = 1; 320 | // Need 'code' property for it to go through the detailed error path 321 | error.code = 'GITERROR'; 322 | mockExecFileSync.mockImplementation(() => { 323 | throw error; 324 | }); 325 | 326 | const result = await executor.execute('log', '/test/repo', false); 327 | 328 | expect(result).toContain('Error executing git command'); 329 | expect(result).toContain('Git failed'); 330 | }); 331 | 332 | it('should handle generic errors', async () => { 333 | mockExecFileSync.mockImplementation(() => { 334 | throw new Error('Unexpected error'); 335 | }); 336 | 337 | const result = await executor.execute('log', '/test/repo', false); 338 | 339 | // Generic errors without code property go through the fallback 340 | expect(result).toBe('Error: Error: Unexpected error'); 341 | }); 342 | 343 | it('should handle non-Error objects thrown', async () => { 344 | mockExecFileSync.mockImplementation(() => { 345 | throw 'String error'; 346 | }); 347 | 348 | const result = await executor.execute('log', '/test/repo', false); 349 | 350 | expect(result).toContain('Error: String error'); 351 | }); 352 | }); 353 | 354 | describe('Special Cases', () => { 355 | it('should handle Windows-style paths', async () => { 356 | mockExecFileSync.mockReturnValue('output'); 357 | 358 | const windowsPath = 'C:\\Users\\Test\\Project'; 359 | await executor.execute('log', windowsPath, false); 360 | 361 | expect(mockExecFileSync).toHaveBeenCalledWith( 362 | 'git', 363 | [ 364 | `--git-dir=${path.join(windowsPath, '.shadowgit.git')}`, 365 | `--work-tree=${windowsPath}`, 366 | 'log' 367 | ], 368 | expect.objectContaining({ 369 | cwd: windowsPath, 370 | }) 371 | ); 372 | }); 373 | 374 | it('should handle paths with spaces', async () => { 375 | mockExecFileSync.mockReturnValue('output'); 376 | 377 | const pathWithSpaces = '/path/with spaces/project'; 378 | await executor.execute('log', pathWithSpaces, false); 379 | 380 | expect(mockExecFileSync).toHaveBeenCalledWith( 381 | 'git', 382 | [ 383 | `--git-dir=${path.join(pathWithSpaces, '.shadowgit.git')}`, 384 | `--work-tree=${pathWithSpaces}`, 385 | 'log' 386 | ], 387 | expect.objectContaining({ 388 | cwd: pathWithSpaces, 389 | }) 390 | ); 391 | }); 392 | 393 | it('should handle Unicode in output', async () => { 394 | const unicodeOutput = 'commit with emoji 🎉 and 中文'; 395 | mockExecFileSync.mockReturnValue(unicodeOutput); 396 | 397 | const result = await executor.execute('log', '/test/repo', false); 398 | 399 | expect(result).toBe(unicodeOutput); 400 | }); 401 | 402 | it('should handle binary output gracefully', async () => { 403 | // When encoding is specified, execFileSync returns a string even for binary data 404 | // It will be garbled but still a string 405 | const garbledString = '\uFFFD\uFFFD\u0000\u0001'; 406 | mockExecFileSync.mockReturnValue(garbledString); 407 | 408 | const result = await executor.execute('cat-file -p HEAD:binary', '/test/repo', false); 409 | 410 | // Should return a string 411 | expect(typeof result).toBe('string'); 412 | expect(result).toBe(garbledString); 413 | }); 414 | }); 415 | }); 416 | }); -------------------------------------------------------------------------------- /tests/shadowgit-mcp-server-logic.test.ts: -------------------------------------------------------------------------------- 1 | // Tests for ShadowGit MCP Server logic without importing MCP SDK 2 | // This avoids ESM/CommonJS conflicts while still testing core functionality 3 | 4 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 5 | import { execFileSync } from 'child_process'; 6 | import * as fs from 'fs'; 7 | import * as os from 'os'; 8 | import * as path from 'path'; 9 | 10 | // Mock modules 11 | jest.mock('child_process'); 12 | jest.mock('fs'); 13 | jest.mock('os'); 14 | 15 | describe('ShadowGitMCPServer Logic Tests', () => { 16 | let mockExecFileSync: jest.MockedFunction; 17 | let mockExistsSync: jest.MockedFunction; 18 | let mockReadFileSync: jest.MockedFunction; 19 | let mockHomedir: jest.MockedFunction; 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | 24 | mockExecFileSync = execFileSync as jest.MockedFunction; 25 | mockExistsSync = fs.existsSync as jest.MockedFunction; 26 | mockReadFileSync = fs.readFileSync as jest.MockedFunction; 27 | mockHomedir = os.homedir as jest.MockedFunction; 28 | 29 | // Default mock behaviors 30 | mockHomedir.mockReturnValue('/home/testuser'); 31 | mockExistsSync.mockReturnValue(true); 32 | mockReadFileSync.mockReturnValue(JSON.stringify([ 33 | { name: 'test-repo', path: '/test/repo' }, 34 | { name: 'another-repo', path: '/another/repo' } 35 | ])); 36 | }); 37 | 38 | describe('Security Validation', () => { 39 | const SAFE_COMMANDS = new Set([ 40 | 'log', 'diff', 'show', 'blame', 'grep', 'status', 41 | 'rev-parse', 'rev-list', 'ls-files', 'cat-file', 42 | 'diff-tree', 'shortlog', 'reflog', 'describe', 43 | 'branch', 'tag', 'for-each-ref', 'ls-tree', 44 | 'merge-base', 'cherry', 'count-objects' 45 | ]); 46 | 47 | const BLOCKED_ARGS = [ 48 | '--exec', '--upload-pack', '--receive-pack', 49 | '-c', '--config', '--work-tree', '--git-dir', 50 | 'push', 'pull', 'fetch', 'commit', 'merge', 51 | 'rebase', 'reset', 'clean', 'checkout', 'add', 52 | 'rm', 'mv', 'restore', 'stash', 'remote', 53 | 'submodule', 'worktree', 'filter-branch', 54 | 'repack', 'gc', 'prune', 'fsck' 55 | ]; 56 | 57 | it('should only allow safe read-only commands', () => { 58 | const testCommands = [ 59 | { cmd: 'log', expected: true }, 60 | { cmd: 'diff', expected: true }, 61 | { cmd: 'commit', expected: false }, 62 | { cmd: 'push', expected: false }, 63 | { cmd: 'merge', expected: false }, 64 | { cmd: 'rebase', expected: false } 65 | ]; 66 | 67 | testCommands.forEach(({ cmd, expected }) => { 68 | expect(SAFE_COMMANDS.has(cmd)).toBe(expected); 69 | }); 70 | }); 71 | 72 | it('should block dangerous arguments', () => { 73 | const dangerousCommands = [ 74 | 'log --exec=rm -rf /', 75 | 'log -c core.editor=vim', 76 | 'log --work-tree=/other/path', 77 | 'diff push origin', 78 | 'show && commit -m "test"' 79 | ]; 80 | 81 | dangerousCommands.forEach(cmd => { 82 | const hasBlockedArg = BLOCKED_ARGS.some(arg => cmd.includes(arg)); 83 | expect(hasBlockedArg).toBe(true); 84 | }); 85 | }); 86 | 87 | it('should detect path traversal attempts', () => { 88 | const PATH_TRAVERSAL_PATTERNS = [ 89 | '../', 90 | '..\\', 91 | '%2e%2e', 92 | '..%2f', 93 | '..%5c' 94 | ]; 95 | 96 | const maliciousPaths = [ 97 | '../etc/passwd', 98 | '..\\windows\\system32', 99 | '%2e%2e%2fetc%2fpasswd', 100 | 'test/../../sensitive' 101 | ]; 102 | 103 | maliciousPaths.forEach(malPath => { 104 | const hasTraversal = PATH_TRAVERSAL_PATTERNS.some(pattern => 105 | malPath.toLowerCase().includes(pattern) 106 | ); 107 | expect(hasTraversal).toBe(true); 108 | }); 109 | }); 110 | }); 111 | 112 | describe('Repository Path Resolution', () => { 113 | it('should normalize paths correctly', () => { 114 | const testPath = '~/projects/test'; 115 | const normalized = testPath.replace('~', '/home/testuser'); 116 | expect(normalized).toBe('/home/testuser/projects/test'); 117 | }); 118 | 119 | it('should handle Windows paths', () => { 120 | const windowsPaths = [ 121 | 'C:\\Users\\test\\project', 122 | 'D:\\repos\\myrepo', 123 | '\\\\server\\share\\repo' 124 | ]; 125 | 126 | windowsPaths.forEach(winPath => { 127 | const isWindowsPath = winPath.includes(':') || winPath.startsWith('\\\\'); 128 | expect(isWindowsPath).toBe(true); 129 | }); 130 | }); 131 | 132 | it('should validate absolute paths', () => { 133 | const paths = [ 134 | { path: '/absolute/path', isAbsolute: true }, 135 | { path: 'relative/path', isAbsolute: false }, 136 | { path: './relative', isAbsolute: false } 137 | ]; 138 | 139 | // Test Windows path separately on Windows platform 140 | if (process.platform === 'win32') { 141 | paths.push({ path: 'C:\\Windows', isAbsolute: true }); 142 | } 143 | 144 | paths.forEach(({ path: testPath, isAbsolute: expected }) => { 145 | expect(path.isAbsolute(testPath)).toBe(expected); 146 | }); 147 | }); 148 | }); 149 | 150 | describe('Git Environment Configuration', () => { 151 | it('should set correct environment variables', () => { 152 | const repoPath = '/test/repo'; 153 | const shadowGitDir = path.join(repoPath, '.shadowgit.git'); 154 | 155 | const gitEnv = { 156 | ...process.env, 157 | GIT_DIR: shadowGitDir, 158 | GIT_WORK_TREE: repoPath 159 | }; 160 | 161 | expect(gitEnv.GIT_DIR).toBe('/test/repo/.shadowgit.git'); 162 | expect(gitEnv.GIT_WORK_TREE).toBe('/test/repo'); 163 | }); 164 | 165 | it('should enforce timeout and buffer limits', () => { 166 | const TIMEOUT_MS = 10000; // 10 seconds 167 | const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB 168 | 169 | expect(TIMEOUT_MS).toBe(10000); 170 | expect(MAX_BUFFER_SIZE).toBe(10485760); 171 | }); 172 | }); 173 | 174 | describe('Command Sanitization', () => { 175 | it('should remove control characters', () => { 176 | const dirtyCommand = 'log\x00\x01\x02\x1F --oneline'; 177 | const sanitized = dirtyCommand.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); 178 | expect(sanitized).toBe('log --oneline'); 179 | }); 180 | 181 | it('should enforce command length limit', () => { 182 | const MAX_COMMAND_LENGTH = 1000; 183 | const longCommand = 'log ' + 'a'.repeat(2000); 184 | expect(longCommand.length).toBeGreaterThan(MAX_COMMAND_LENGTH); 185 | }); 186 | }); 187 | 188 | describe('Error Handling', () => { 189 | it('should handle git not installed error', () => { 190 | const error: any = new Error('Command not found'); 191 | error.code = 'ENOENT'; 192 | expect(error.code).toBe('ENOENT'); 193 | }); 194 | 195 | it('should handle timeout error', () => { 196 | const error: any = new Error('Timeout'); 197 | error.signal = 'SIGTERM'; 198 | expect(error.signal).toBe('SIGTERM'); 199 | }); 200 | 201 | it('should handle buffer overflow error', () => { 202 | const error: any = new Error('Buffer overflow'); 203 | error.code = 'ENOBUFS'; 204 | expect(error.code).toBe('ENOBUFS'); 205 | }); 206 | 207 | it('should handle git error (exit code 128)', () => { 208 | const error: any = new Error('Git error'); 209 | error.status = 128; 210 | error.stderr = 'fatal: bad revision'; 211 | expect(error.status).toBe(128); 212 | expect(error.stderr).toContain('fatal'); 213 | }); 214 | }); 215 | 216 | describe('Logging System', () => { 217 | it('should support multiple log levels', () => { 218 | const LOG_LEVELS = { 219 | debug: 0, 220 | info: 1, 221 | warn: 2, 222 | error: 3 223 | }; 224 | 225 | expect(LOG_LEVELS.debug).toBeLessThan(LOG_LEVELS.info); 226 | expect(LOG_LEVELS.info).toBeLessThan(LOG_LEVELS.warn); 227 | expect(LOG_LEVELS.warn).toBeLessThan(LOG_LEVELS.error); 228 | }); 229 | 230 | it('should include timestamp in logs', () => { 231 | const timestamp = new Date().toISOString(); 232 | expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); 233 | }); 234 | }); 235 | 236 | describe('Configuration', () => { 237 | it('should read timeout from environment', () => { 238 | const customTimeout = '30000'; 239 | const timeout = parseInt(customTimeout || '10000', 10); 240 | expect(timeout).toBe(30000); 241 | }); 242 | 243 | it('should use default timeout if not specified', () => { 244 | const envTimeout: string | undefined = undefined; 245 | const timeout = parseInt(envTimeout || '10000', 10); 246 | expect(timeout).toBe(10000); 247 | }); 248 | 249 | it('should read log level from environment', () => { 250 | const logLevel = 'debug'; 251 | expect(['debug', 'info', 'warn', 'error']).toContain(logLevel); 252 | }); 253 | }); 254 | 255 | describe('Manual Checkpoint Functionality', () => { 256 | it('should validate required parameters', () => { 257 | // Test that repo and title are required 258 | const validateArgs = (args: any): boolean => { 259 | return ( 260 | typeof args === 'object' && 261 | args !== null && 262 | 'repo' in args && 263 | 'title' in args && 264 | typeof args.repo === 'string' && 265 | typeof args.title === 'string' 266 | ); 267 | }; 268 | 269 | expect(validateArgs({ repo: 'test', title: 'Test' })).toBe(true); 270 | expect(validateArgs({ repo: 'test' })).toBe(false); 271 | expect(validateArgs({ title: 'Test' })).toBe(false); 272 | expect(validateArgs({})).toBe(false); 273 | expect(validateArgs(null)).toBe(false); 274 | }); 275 | 276 | it('should validate title length', () => { 277 | const MAX_TITLE_LENGTH = 50; 278 | const validateTitleLength = (title: string): boolean => { 279 | return title.length <= MAX_TITLE_LENGTH; 280 | }; 281 | 282 | expect(validateTitleLength('Normal title')).toBe(true); 283 | expect(validateTitleLength('a'.repeat(50))).toBe(true); 284 | expect(validateTitleLength('a'.repeat(51))).toBe(false); 285 | }); 286 | 287 | it('should generate correct author email from name', () => { 288 | const generateAuthorEmail = (author: string): string => { 289 | return `${author.toLowerCase().replace(/\s+/g, '-')}@shadowgit.local`; 290 | }; 291 | 292 | expect(generateAuthorEmail('Claude')).toBe('claude@shadowgit.local'); 293 | expect(generateAuthorEmail('GPT-4')).toBe('gpt-4@shadowgit.local'); 294 | expect(generateAuthorEmail('AI Assistant')).toBe('ai-assistant@shadowgit.local'); 295 | expect(generateAuthorEmail('Gemini Pro')).toBe('gemini-pro@shadowgit.local'); 296 | }); 297 | 298 | it('should properly escape shell special characters', () => { 299 | const escapeShellString = (str: string): string => { 300 | return str 301 | .replace(/\\/g, '\\\\') // Escape backslashes first 302 | .replace(/"/g, '\\"') // Escape double quotes 303 | .replace(/\$/g, '\\$') // Escape dollar signs 304 | .replace(/`/g, '\\`') // Escape backticks 305 | .replace(/'/g, "\\'"); // Escape single quotes 306 | }; 307 | 308 | expect(escapeShellString('normal text')).toBe('normal text'); 309 | expect(escapeShellString('text with $var')).toBe('text with \\$var'); 310 | expect(escapeShellString('text with "quotes"')).toBe('text with \\"quotes\\"'); 311 | expect(escapeShellString('text with `backticks`')).toBe('text with \\`backticks\\`'); 312 | expect(escapeShellString('text with \\backslash')).toBe('text with \\\\backslash'); 313 | expect(escapeShellString("text with 'single'")).toBe("text with \\'single\\'"); 314 | expect(escapeShellString('$var "quote" `tick` \\slash')).toBe('\\$var \\"quote\\" \\`tick\\` \\\\slash'); 315 | }); 316 | 317 | it('should format commit message correctly', () => { 318 | const formatCommitMessage = ( 319 | title: string, 320 | message: string | undefined, 321 | author: string, 322 | timestamp: string 323 | ): string => { 324 | let commitMessage = `✋ [${author}] Manual Checkpoint: ${title}`; 325 | if (message) { 326 | commitMessage += `\n\n${message}`; 327 | } 328 | commitMessage += `\n\nCreated by: ${author}\nTimestamp: ${timestamp}`; 329 | return commitMessage; 330 | }; 331 | 332 | const timestamp = '2024-01-01T12:00:00Z'; 333 | 334 | // Test with minimal parameters 335 | const msg1 = formatCommitMessage('Fix bug', undefined, 'AI Assistant', timestamp); 336 | expect(msg1).toContain('✋ [AI Assistant] Manual Checkpoint: Fix bug'); 337 | expect(msg1).toContain('Created by: AI Assistant'); 338 | expect(msg1).toContain('Timestamp: 2024-01-01T12:00:00Z'); 339 | expect(msg1).not.toContain('undefined'); 340 | 341 | // Test with all parameters 342 | const msg2 = formatCommitMessage('Add feature', 'Detailed description', 'Claude', timestamp); 343 | expect(msg2).toContain('✋ [Claude] Manual Checkpoint: Add feature'); 344 | expect(msg2).toContain('Detailed description'); 345 | expect(msg2).toContain('Created by: Claude'); 346 | }); 347 | 348 | it('should extract commit hash from git output', () => { 349 | const extractCommitHash = (output: string): string => { 350 | const match = output.match(/\[[\w\s-]+\s+([a-f0-9]{7,})\]/); 351 | return match ? match[1] : 'unknown'; 352 | }; 353 | 354 | expect(extractCommitHash('[main abc1234] Test commit')).toBe('abc1234'); 355 | expect(extractCommitHash('[feature-branch def56789] Another commit')).toBe('def56789'); 356 | expect(extractCommitHash('[develop 1a2b3c4d5e6f] Long hash')).toBe('1a2b3c4d5e6f'); 357 | expect(extractCommitHash('No match here')).toBe('unknown'); 358 | }); 359 | 360 | it('should set correct Git environment variables', () => { 361 | const createGitEnv = (author: string) => { 362 | const authorEmail = `${author.toLowerCase().replace(/\s+/g, '-')}@shadowgit.local`; 363 | return { 364 | GIT_AUTHOR_NAME: author, 365 | GIT_AUTHOR_EMAIL: authorEmail, 366 | GIT_COMMITTER_NAME: author, 367 | GIT_COMMITTER_EMAIL: authorEmail 368 | }; 369 | }; 370 | 371 | const env1 = createGitEnv('Claude'); 372 | expect(env1.GIT_AUTHOR_NAME).toBe('Claude'); 373 | expect(env1.GIT_AUTHOR_EMAIL).toBe('claude@shadowgit.local'); 374 | expect(env1.GIT_COMMITTER_NAME).toBe('Claude'); 375 | expect(env1.GIT_COMMITTER_EMAIL).toBe('claude@shadowgit.local'); 376 | 377 | const env2 = createGitEnv('GPT-4'); 378 | expect(env2.GIT_AUTHOR_NAME).toBe('GPT-4'); 379 | expect(env2.GIT_AUTHOR_EMAIL).toBe('gpt-4@shadowgit.local'); 380 | }); 381 | 382 | it('should handle isInternal flag for bypassing security', () => { 383 | // Test that internal flag allows normally blocked commands 384 | const isCommandAllowed = (command: string, isInternal: boolean): boolean => { 385 | const SAFE_COMMANDS = new Set(['log', 'diff', 'show', 'status']); 386 | const parts = command.trim().split(/\s+/); 387 | const gitCommand = parts[0]; 388 | 389 | if (isInternal) { 390 | return true; // Bypass all checks for internal operations 391 | } 392 | 393 | return SAFE_COMMANDS.has(gitCommand); 394 | }; 395 | 396 | // Normal security checks 397 | expect(isCommandAllowed('log', false)).toBe(true); 398 | expect(isCommandAllowed('commit', false)).toBe(false); 399 | expect(isCommandAllowed('add', false)).toBe(false); 400 | 401 | // Internal bypass 402 | expect(isCommandAllowed('commit', true)).toBe(true); 403 | expect(isCommandAllowed('add', true)).toBe(true); 404 | expect(isCommandAllowed('anything', true)).toBe(true); 405 | }); 406 | }); 407 | }); -------------------------------------------------------------------------------- /tests/integration/workflow.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 2 | import { RepositoryManager } from '../../src/core/repository-manager'; 3 | import { GitExecutor } from '../../src/core/git-executor'; 4 | import { SessionClient } from '../../src/core/session-client'; 5 | import { GitHandler } from '../../src/handlers/git-handler'; 6 | import { ListReposHandler } from '../../src/handlers/list-repos-handler'; 7 | import { CheckpointHandler } from '../../src/handlers/checkpoint-handler'; 8 | import { SessionHandler } from '../../src/handlers/session-handler'; 9 | import * as fs from 'fs'; 10 | import * as os from 'os'; 11 | import { execFileSync } from 'child_process'; 12 | 13 | // Mock all external dependencies 14 | jest.mock('fs'); 15 | jest.mock('os'); 16 | jest.mock('child_process'); 17 | jest.mock('../../src/utils/logger', () => ({ 18 | log: jest.fn(), 19 | })); 20 | 21 | // Mock fetch for SessionClient 22 | global.fetch = jest.fn() as jest.MockedFunction; 23 | 24 | describe('Integration: Complete Workflow', () => { 25 | let repositoryManager: RepositoryManager; 26 | let gitExecutor: GitExecutor; 27 | let sessionClient: SessionClient; 28 | let gitHandler: GitHandler; 29 | let listReposHandler: ListReposHandler; 30 | let checkpointHandler: CheckpointHandler; 31 | let sessionHandler: SessionHandler; 32 | 33 | let mockExistsSync: jest.MockedFunction; 34 | let mockReadFileSync: jest.MockedFunction; 35 | let mockHomedir: jest.MockedFunction; 36 | let mockExecFileSync: jest.MockedFunction; 37 | let mockFetch: jest.MockedFunction; 38 | 39 | beforeEach(() => { 40 | jest.clearAllMocks(); 41 | 42 | // Get mock references 43 | mockExistsSync = fs.existsSync as jest.MockedFunction; 44 | mockReadFileSync = fs.readFileSync as jest.MockedFunction; 45 | mockHomedir = os.homedir as jest.MockedFunction; 46 | mockExecFileSync = execFileSync as jest.MockedFunction; 47 | mockFetch = global.fetch as jest.MockedFunction; 48 | 49 | // Setup default mocks 50 | mockHomedir.mockReturnValue('/home/testuser'); 51 | mockExistsSync.mockReturnValue(true); 52 | mockReadFileSync.mockReturnValue(JSON.stringify([ 53 | { name: 'my-project', path: '/workspace/my-project' }, 54 | { name: 'another-project', path: '/workspace/another-project' }, 55 | ])); 56 | 57 | // Initialize services 58 | repositoryManager = new RepositoryManager(); 59 | gitExecutor = new GitExecutor(); 60 | sessionClient = new SessionClient(); 61 | 62 | // Initialize handlers 63 | gitHandler = new GitHandler(repositoryManager, gitExecutor); 64 | listReposHandler = new ListReposHandler(repositoryManager); 65 | checkpointHandler = new CheckpointHandler(repositoryManager, gitExecutor); 66 | sessionHandler = new SessionHandler(repositoryManager, sessionClient); 67 | }); 68 | 69 | describe('Scenario: Complete AI Work Session with Session API Available', () => { 70 | it('should complete full workflow: list → start_session → git_command → checkpoint → end_session', async () => { 71 | const sessionId = 'session-123-abc'; 72 | const commitHash = 'abc1234'; 73 | 74 | // Step 1: List repositories 75 | const listResult = await listReposHandler.handle(); 76 | expect(listResult.content[0].text).toContain('my-project:'); 77 | expect(listResult.content[0].text).toContain('Path: /workspace/my-project'); 78 | expect(listResult.content[0].text).toContain('another-project:'); 79 | expect(listResult.content[0].text).toContain('Path: /workspace/another-project'); 80 | 81 | // Step 2: Start session 82 | mockFetch.mockResolvedValueOnce({ 83 | ok: true, 84 | status: 200, 85 | json: (jest.fn() as any).mockResolvedValue({ 86 | success: true, 87 | sessionId, 88 | }), 89 | } as unknown as Response); 90 | 91 | const startResult = await sessionHandler.startSession({ 92 | repo: 'my-project', 93 | description: 'Implementing new feature X', 94 | }); 95 | 96 | expect(startResult.content[0].text).toContain('Session started successfully'); 97 | expect(startResult.content[0].text).toContain(sessionId); 98 | 99 | // Step 3: Execute git commands 100 | mockExecFileSync.mockReturnValue('On branch main\nYour branch is up to date'); 101 | 102 | const statusResult = await gitHandler.handle({ 103 | repo: 'my-project', 104 | command: 'status', 105 | }); 106 | 107 | expect(statusResult.content[0].text).toContain('On branch main'); 108 | 109 | // Step 4: Simulate some changes and check diff 110 | mockExecFileSync.mockReturnValue('diff --git a/file.txt b/file.txt\n+new line'); 111 | 112 | const diffResult = await gitHandler.handle({ 113 | repo: 'my-project', 114 | command: 'diff', 115 | }); 116 | 117 | expect(diffResult.content[0].text).toContain('diff --git'); 118 | 119 | // Step 5: Create checkpoint 120 | mockExecFileSync 121 | .mockReturnValueOnce('M file.txt\nA newfile.js') // status --porcelain 122 | .mockReturnValueOnce('') // add -A 123 | .mockReturnValueOnce(`[main ${commitHash}] Add feature X`) // commit 124 | .mockReturnValueOnce('commit abc1234\nAuthor: Claude'); // show --stat 125 | 126 | const checkpointResult = await checkpointHandler.handle({ 127 | repo: 'my-project', 128 | title: 'Add feature X', 129 | message: 'Implemented new feature X with comprehensive tests', 130 | author: 'Claude', 131 | }); 132 | 133 | expect(checkpointResult.content[0].text).toContain('Checkpoint Created Successfully!'); 134 | expect(checkpointResult.content[0].text).toContain(commitHash); 135 | 136 | // Step 6: End session 137 | mockFetch.mockResolvedValueOnce({ 138 | ok: true, 139 | status: 200, 140 | json: (jest.fn() as any).mockResolvedValue({ 141 | success: true, 142 | }), 143 | } as unknown as Response); 144 | 145 | const endResult = await sessionHandler.endSession({ 146 | sessionId, 147 | commitHash, 148 | }); 149 | 150 | expect(endResult.content[0].text).toContain(`Session ${sessionId} ended successfully`); 151 | }); 152 | }); 153 | 154 | describe('Scenario: Session API Offline Fallback', () => { 155 | it('should handle workflow when Session API is unavailable', async () => { 156 | // Session API is offline 157 | mockFetch.mockRejectedValue(new Error('Connection refused')); 158 | 159 | // Step 1: Try to start session (should fallback gracefully) 160 | const startResult = await sessionHandler.startSession({ 161 | repo: 'my-project', 162 | description: 'Fixing bug in authentication', 163 | }); 164 | 165 | expect(startResult.content[0].text).toContain('Session API is offline'); 166 | expect(startResult.content[0].text).toContain('Proceeding without session tracking'); 167 | 168 | // Step 2: Continue with git operations 169 | mockExecFileSync.mockReturnValue('file.txt | 2 +-'); 170 | 171 | const diffStatResult = await gitHandler.handle({ 172 | repo: 'my-project', 173 | command: 'diff --stat', 174 | }); 175 | 176 | expect(diffStatResult.content[0].text).toContain('file.txt | 2 +-'); 177 | 178 | // Step 3: Create checkpoint (should work without session) 179 | mockExecFileSync 180 | .mockReturnValueOnce('M file.txt') // status 181 | .mockReturnValueOnce('') // add 182 | .mockReturnValueOnce('[main def5678] Fix auth bug') // commit 183 | .mockReturnValueOnce('commit def5678'); // show 184 | 185 | const checkpointResult = await checkpointHandler.handle({ 186 | repo: 'my-project', 187 | title: 'Fix auth bug', 188 | author: 'GPT-4', 189 | }); 190 | 191 | expect(checkpointResult.content[0].text).toContain('Checkpoint Created Successfully!'); 192 | 193 | // Step 4: Try to end session (should handle gracefully) 194 | const endResult = await sessionHandler.endSession({ 195 | sessionId: 'non-existent-session', 196 | }); 197 | 198 | expect(endResult.content[0].text).toContain('Failed to End Session'); 199 | }); 200 | }); 201 | 202 | describe('Scenario: Multiple AI Agents Collaboration', () => { 203 | it('should handle multiple agents working on different repositories', async () => { 204 | const sessions = [ 205 | { id: 'claude-session-1', repo: 'my-project', agent: 'Claude' }, 206 | { id: 'gpt4-session-2', repo: 'another-project', agent: 'GPT-4' }, 207 | ]; 208 | 209 | // Both agents start sessions 210 | for (const session of sessions) { 211 | mockFetch.mockResolvedValueOnce({ 212 | ok: true, 213 | status: 200, 214 | json: (jest.fn() as any).mockResolvedValue({ 215 | success: true, 216 | sessionId: session.id, 217 | }), 218 | } as unknown as Response); 219 | 220 | const result = await sessionHandler.startSession({ 221 | repo: session.repo, 222 | description: `${session.agent} working on ${session.repo}`, 223 | }); 224 | 225 | expect(result.content[0].text).toContain(session.id); 226 | } 227 | 228 | // Each agent makes changes and creates checkpoints 229 | for (const session of sessions) { 230 | mockExecFileSync 231 | .mockReturnValueOnce('M file.txt') // status 232 | .mockReturnValueOnce('') // add 233 | .mockReturnValueOnce(`[main abc${session.id.slice(0, 4)}] ${session.agent} changes`) // commit 234 | .mockReturnValueOnce('commit details'); // show 235 | 236 | const checkpointResult = await checkpointHandler.handle({ 237 | repo: session.repo, 238 | title: `${session.agent} changes`, 239 | author: session.agent, 240 | }); 241 | 242 | expect(checkpointResult.content[0].text).toContain('Checkpoint Created Successfully!'); 243 | } 244 | 245 | // Both agents end their sessions 246 | for (const session of sessions) { 247 | mockFetch.mockResolvedValueOnce({ 248 | ok: true, 249 | status: 200, 250 | json: (jest.fn() as any).mockResolvedValue({ 251 | success: true, 252 | }), 253 | } as unknown as Response); 254 | 255 | const result = await sessionHandler.endSession({ 256 | sessionId: session.id, 257 | }); 258 | 259 | expect(result.content[0].text).toContain(`Session ${session.id} ended successfully`); 260 | } 261 | }); 262 | }); 263 | 264 | describe('Scenario: Error Recovery', () => { 265 | it('should handle errors at each stage gracefully', async () => { 266 | // Repository not found 267 | const invalidRepoResult = await gitHandler.handle({ 268 | repo: 'non-existent-repo', 269 | command: 'log', 270 | }); 271 | 272 | expect(invalidRepoResult.content[0].text).toContain("Error: Repository 'non-existent-repo' not found"); 273 | 274 | // No .shadowgit.git directory 275 | mockExistsSync.mockImplementation(p => { 276 | if (typeof p === 'string' && p.includes('.shadowgit.git')) return false; 277 | if (typeof p === 'string' && p.includes('repos.json')) return true; 278 | return true; 279 | }); 280 | 281 | const noShadowGitResult = await gitHandler.handle({ 282 | repo: 'my-project', 283 | command: 'log', 284 | }); 285 | 286 | expect(noShadowGitResult.content[0].text).toContain('not found'); 287 | 288 | // Reset mock for next tests 289 | mockExistsSync.mockReturnValue(true); 290 | 291 | // Invalid git command 292 | mockExecFileSync.mockReturnValue('Error: Command not allowed'); 293 | 294 | const invalidCommandResult = await gitHandler.handle({ 295 | repo: 'my-project', 296 | command: 'push origin main', 297 | }); 298 | 299 | expect(invalidCommandResult.content[0].text).toContain('not allowed'); 300 | 301 | // No changes to commit 302 | mockExecFileSync.mockReturnValueOnce(''); // empty status 303 | 304 | const noChangesResult = await checkpointHandler.handle({ 305 | repo: 'my-project', 306 | title: 'No changes', 307 | author: 'Claude', 308 | }); 309 | 310 | expect(noChangesResult.content[0].text).toContain('No Changes Detected'); 311 | 312 | // Git commit failure 313 | mockExecFileSync 314 | .mockReturnValueOnce('M file.txt') // status 315 | .mockReturnValueOnce('') // add 316 | .mockReturnValueOnce('Error: Cannot create commit'); // commit fails 317 | 318 | const commitFailResult = await checkpointHandler.handle({ 319 | repo: 'my-project', 320 | title: 'Test', 321 | author: 'Claude', 322 | }); 323 | 324 | expect(commitFailResult.content[0].text).toContain('Failed to Create Commit'); 325 | }); 326 | }); 327 | 328 | describe('Scenario: Validation and Edge Cases', () => { 329 | it('should validate all required parameters', async () => { 330 | // Missing parameters for start_session 331 | let result = await sessionHandler.startSession({ 332 | repo: 'my-project', 333 | // missing description 334 | }); 335 | expect(result.content[0].text).toContain('Error'); 336 | 337 | // Missing parameters for checkpoint 338 | result = await checkpointHandler.handle({ 339 | repo: 'my-project', 340 | // missing title 341 | }); 342 | expect(result.content[0].text).toContain('Error'); 343 | 344 | // Title too long 345 | result = await checkpointHandler.handle({ 346 | repo: 'my-project', 347 | title: 'a'.repeat(51), 348 | }); 349 | expect(result.content[0].text).toContain('50 characters or less'); 350 | 351 | // Message too long 352 | result = await checkpointHandler.handle({ 353 | repo: 'my-project', 354 | title: 'Valid title', 355 | message: 'a'.repeat(1001), 356 | }); 357 | expect(result.content[0].text).toContain('1000 characters or less'); 358 | }); 359 | 360 | it('should handle special characters in commit messages', async () => { 361 | mockExecFileSync 362 | .mockReturnValueOnce('M file.txt') // status 363 | .mockReturnValueOnce('') // add 364 | .mockReturnValueOnce('[main xyz789] Special') // commit 365 | .mockReturnValueOnce('commit xyz789'); // show 366 | 367 | const result = await checkpointHandler.handle({ 368 | repo: 'my-project', 369 | title: 'Fix $pecial "bug" with `quotes`', 370 | message: 'Message with $var and backslash', 371 | author: 'AI-Agent', 372 | }); 373 | 374 | expect(result.content[0].text).toContain('Checkpoint Created Successfully!'); 375 | 376 | // Verify commit was called with correct arguments 377 | const commitCall = mockExecFileSync.mock.calls.find( 378 | call => Array.isArray(call[1]) && call[1].includes('commit') 379 | ); 380 | expect(commitCall).toBeDefined(); 381 | // With execFileSync, first arg is 'git', second is array of args 382 | expect(commitCall![0]).toBe('git'); 383 | // Find the commit message in the arguments array 384 | const args = commitCall![1] as string[]; 385 | const messageIndex = args.indexOf('-m') + 1; 386 | const commitMessage = args[messageIndex]; 387 | // Special characters should be preserved in the message 388 | expect(commitMessage).toContain('$pecial'); 389 | expect(commitMessage).toContain('"bug"'); 390 | expect(commitMessage).toContain('`quotes`'); 391 | }); 392 | }); 393 | 394 | describe('Scenario: Cross-Platform Compatibility', () => { 395 | it('should handle Windows paths correctly', async () => { 396 | mockReadFileSync.mockReturnValue(JSON.stringify([ 397 | { name: 'windows-project', path: 'C:\\Projects\\MyApp' }, 398 | ])); 399 | 400 | // Reinitialize to load Windows paths 401 | repositoryManager = new RepositoryManager(); 402 | gitHandler = new GitHandler(repositoryManager, gitExecutor); 403 | 404 | mockExecFileSync.mockReturnValue('Windows output'); 405 | 406 | const result = await gitHandler.handle({ 407 | repo: 'windows-project', 408 | command: 'status', 409 | }); 410 | 411 | // Now includes workflow reminder for status command 412 | expect(result.content[0].text).toContain('Windows output'); 413 | expect(result.content[0].text).toContain('Planning to Make Changes?'); 414 | expect(mockExecFileSync).toHaveBeenCalledWith( 415 | 'git', 416 | expect.arrayContaining([ 417 | expect.stringContaining('--git-dir='), 418 | expect.stringContaining('--work-tree='), 419 | ]), 420 | expect.objectContaining({ 421 | cwd: 'C:\\Projects\\MyApp', 422 | }) 423 | ); 424 | }); 425 | 426 | it('should handle paths with spaces', async () => { 427 | mockReadFileSync.mockReturnValue(JSON.stringify([ 428 | { name: 'space-project', path: '/path/with spaces/project' }, 429 | ])); 430 | 431 | repositoryManager = new RepositoryManager(); 432 | gitHandler = new GitHandler(repositoryManager, gitExecutor); 433 | 434 | mockExecFileSync.mockReturnValue('Output'); 435 | 436 | const result = await gitHandler.handle({ 437 | repo: 'space-project', 438 | command: 'log', 439 | }); 440 | 441 | // Now includes workflow reminder for log command 442 | expect(result.content[0].text).toContain('Output'); 443 | expect(result.content[0].text).toContain('Planning to Make Changes?'); 444 | expect(mockExecFileSync).toHaveBeenCalledWith( 445 | 'git', 446 | expect.arrayContaining([ 447 | expect.stringContaining('--git-dir='), 448 | expect.stringContaining('--work-tree='), 449 | ]), 450 | expect.objectContaining({ 451 | cwd: '/path/with spaces/project', 452 | }) 453 | ); 454 | }); 455 | }); 456 | }); --------------------------------------------------------------------------------