This is page 1 of 2. Use http://codebase.md/blade47/shadowgit-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── .npmignore
├── CHANGELOG.md
├── DEPLOYMENT.md
├── esbuild.config.js
├── jest.config.js
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── core
│ │ ├── git-executor.ts
│ │ ├── repository-manager.ts
│ │ ├── security-constants.ts
│ │ └── session-client.ts
│ ├── handlers
│ │ ├── checkpoint-handler.ts
│ │ ├── git-handler.ts
│ │ ├── list-repos-handler.ts
│ │ └── session-handler.ts
│ ├── shadowgit-mcp-server.ts
│ ├── types.ts
│ └── utils
│ ├── constants.ts
│ ├── file-utils.ts
│ ├── logger.ts
│ └── response-utils.ts
├── test-package.js
├── TESTING.md
├── tests
│ ├── __mocks__
│ │ └── @modelcontextprotocol
│ │ └── sdk
│ │ ├── server
│ │ │ ├── index.js
│ │ │ └── stdio.js
│ │ └── types.js
│ ├── core
│ │ ├── git-executor.test.ts
│ │ ├── repository-manager.test.ts
│ │ └── session-client.test.ts
│ ├── handlers
│ │ ├── checkpoint-handler.test.ts
│ │ ├── git-handler.test.ts
│ │ ├── list-repos-handler.test.ts
│ │ └── session-handler.test.ts
│ ├── integration
│ │ └── workflow.test.ts
│ ├── shadowgit-mcp-server-logic.test.ts
│ └── shadowgit-mcp-server.test.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.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
```
--------------------------------------------------------------------------------
/.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
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # ShadowGit MCP Server
2 |
3 | [](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 | [](https://lobehub.com/mcp/shadowgit-shadowgit-mcp-server)
350 |
```
--------------------------------------------------------------------------------
/tests/__mocks__/@modelcontextprotocol/sdk/server/stdio.js:
--------------------------------------------------------------------------------
```javascript
1 | module.exports = {
2 | StdioServerTransport: jest.fn(),
3 | };
```
--------------------------------------------------------------------------------
/tests/__mocks__/@modelcontextprotocol/sdk/types.js:
--------------------------------------------------------------------------------
```javascript
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:
--------------------------------------------------------------------------------
```javascript
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 | };
```
--------------------------------------------------------------------------------
/src/utils/constants.ts:
--------------------------------------------------------------------------------
```typescript
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
```
--------------------------------------------------------------------------------
/src/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
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<LogLevel, number> = {
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:
--------------------------------------------------------------------------------
```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/types.ts:
--------------------------------------------------------------------------------
```typescript
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/core/security-constants.ts:
--------------------------------------------------------------------------------
```typescript
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 | }
```
--------------------------------------------------------------------------------
/src/utils/file-utils.ts:
--------------------------------------------------------------------------------
```typescript
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<T>(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:
--------------------------------------------------------------------------------
```typescript
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:
--------------------------------------------------------------------------------
```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:
--------------------------------------------------------------------------------
```javascript
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | roots: ['<rootDir>/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$': '<rootDir>/tests/__mocks__/@modelcontextprotocol/sdk/server/index.js',
20 | '^@modelcontextprotocol/sdk/server/stdio.js$': '<rootDir>/tests/__mocks__/@modelcontextprotocol/sdk/server/stdio.js',
21 | '^@modelcontextprotocol/sdk/types.js$': '<rootDir>/tests/__mocks__/@modelcontextprotocol/sdk/types.js',
22 | '^@modelcontextprotocol/sdk/server/index$': '<rootDir>/tests/__mocks__/@modelcontextprotocol/sdk/server/index.js',
23 | '^@modelcontextprotocol/sdk/server/stdio$': '<rootDir>/tests/__mocks__/@modelcontextprotocol/sdk/server/stdio.js',
24 | '^@modelcontextprotocol/sdk/types$': '<rootDir>/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:
--------------------------------------------------------------------------------
```javascript
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:
--------------------------------------------------------------------------------
```javascript
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:
--------------------------------------------------------------------------------
```typescript
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<MCPToolResponse> {
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:
--------------------------------------------------------------------------------
```markdown
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:
--------------------------------------------------------------------------------
```typescript
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<MCPToolResponse> {
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:
--------------------------------------------------------------------------------
```markdown
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:
--------------------------------------------------------------------------------
```typescript
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/handlers/session-handler.ts:
--------------------------------------------------------------------------------
```typescript
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<MCPToolResponse> {
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<MCPToolResponse> {
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/session-client.ts:
--------------------------------------------------------------------------------
```typescript
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<string | null> {
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<boolean> {
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<boolean> {
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/core/repository-manager.ts:
--------------------------------------------------------------------------------
```typescript
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<Repository[]>(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:
--------------------------------------------------------------------------------
```typescript
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<string> {
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:
--------------------------------------------------------------------------------
```typescript
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<MCPToolResponse> {
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: '[email protected]'
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:
--------------------------------------------------------------------------------
```markdown
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:
--------------------------------------------------------------------------------
```typescript
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<RepositoryManager>;
14 |
15 | beforeEach(() => {
16 | jest.clearAllMocks();
17 |
18 | mockRepositoryManager = new RepositoryManager() as jest.Mocked<RepositoryManager>;
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:
--------------------------------------------------------------------------------
```typescript
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<void> {
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<void> {
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/core/session-client.test.ts:
--------------------------------------------------------------------------------
```typescript
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<typeof fetch>;
6 |
7 | describe('SessionClient', () => {
8 | let client: SessionClient;
9 | let mockFetch: jest.MockedFunction<typeof fetch>;
10 |
11 | beforeEach(() => {
12 | jest.clearAllMocks();
13 | client = new SessionClient();
14 | mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
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 | });
```
--------------------------------------------------------------------------------
/tests/handlers/session-handler.test.ts:
--------------------------------------------------------------------------------
```typescript
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<RepositoryManager>;
16 | let mockSessionClient: jest.Mocked<SessionClient>;
17 |
18 | beforeEach(() => {
19 | jest.clearAllMocks();
20 | jest.resetAllMocks();
21 |
22 | mockRepositoryManager = new RepositoryManager() as jest.Mocked<RepositoryManager>;
23 | mockSessionClient = new SessionClient() as jest.Mocked<SessionClient>;
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<SessionClient>;
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<SessionClient>;
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/handlers/git-handler.test.ts:
--------------------------------------------------------------------------------
```typescript
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<RepositoryManager>;
21 | let mockGitExecutor: jest.Mocked<GitExecutor>;
22 | let mockExistsSync: jest.MockedFunction<typeof fs.existsSync>;
23 |
24 | beforeEach(() => {
25 | jest.clearAllMocks();
26 |
27 | mockRepositoryManager = new RepositoryManager() as jest.Mocked<RepositoryManager>;
28 | mockGitExecutor = new GitExecutor() as jest.Mocked<GitExecutor>;
29 | mockExistsSync = fs.existsSync as jest.MockedFunction<typeof fs.existsSync>;
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:
--------------------------------------------------------------------------------
```typescript
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<typeof getStorageLocation>;
21 | let mockFileExists: jest.MockedFunction<typeof fileExists>;
22 | let mockReadJsonFile: jest.MockedFunction<typeof readJsonFile>;
23 | let mockHomedir: jest.MockedFunction<typeof os.homedir>;
24 |
25 | beforeEach(() => {
26 | jest.clearAllMocks();
27 |
28 | mockGetStorageLocation = getStorageLocation as jest.MockedFunction<typeof getStorageLocation>;
29 | mockFileExists = fileExists as jest.MockedFunction<typeof fileExists>;
30 | mockReadJsonFile = readJsonFile as jest.MockedFunction<typeof readJsonFile>;
31 | mockHomedir = os.homedir as jest.MockedFunction<typeof os.homedir>;
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:
--------------------------------------------------------------------------------
```typescript
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<RepositoryManager>;
16 | let mockGitExecutor: jest.Mocked<GitExecutor>;
17 |
18 | beforeEach(() => {
19 | jest.clearAllMocks();
20 |
21 | mockRepositoryManager = new RepositoryManager() as jest.Mocked<RepositoryManager>;
22 | mockGitExecutor = new GitExecutor() as jest.Mocked<GitExecutor>;
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: '[email protected]',
243 | GIT_COMMITTER_NAME: 'ShadowGit MCP',
244 | GIT_COMMITTER_EMAIL: '[email protected]',
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: '[email protected]',
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:
--------------------------------------------------------------------------------
```typescript
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<typeof execFileSync>;
17 | let mockExistsSync: jest.MockedFunction<typeof fs.existsSync>;
18 |
19 | beforeEach(() => {
20 | jest.clearAllMocks();
21 | executor = new GitExecutor();
22 | mockExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>;
23 | mockExistsSync = fs.existsSync as jest.MockedFunction<typeof fs.existsSync>;
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: '[email protected]',
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: '[email protected]',
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 | });
```