#
tokens: 9760/50000 2/38 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 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

--------------------------------------------------------------------------------
/tests/shadowgit-mcp-server-logic.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | // Tests for ShadowGit MCP Server logic without importing MCP SDK
  2 | // This avoids ESM/CommonJS conflicts while still testing core functionality
  3 | 
  4 | import { describe, it, expect, beforeEach, jest } from '@jest/globals';
  5 | import { execFileSync } from 'child_process';
  6 | import * as fs from 'fs';
  7 | import * as os from 'os';
  8 | import * as path from 'path';
  9 | 
 10 | // Mock modules
 11 | jest.mock('child_process');
 12 | jest.mock('fs');
 13 | jest.mock('os');
 14 | 
 15 | describe('ShadowGitMCPServer Logic Tests', () => {
 16 |   let mockExecFileSync: jest.MockedFunction<typeof execFileSync>;
 17 |   let mockExistsSync: jest.MockedFunction<typeof fs.existsSync>;
 18 |   let mockReadFileSync: jest.MockedFunction<typeof fs.readFileSync>;
 19 |   let mockHomedir: jest.MockedFunction<typeof os.homedir>;
 20 | 
 21 |   beforeEach(() => {
 22 |     jest.clearAllMocks();
 23 |     
 24 |     mockExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>;
 25 |     mockExistsSync = fs.existsSync as jest.MockedFunction<typeof fs.existsSync>;
 26 |     mockReadFileSync = fs.readFileSync as jest.MockedFunction<typeof fs.readFileSync>;
 27 |     mockHomedir = os.homedir as jest.MockedFunction<typeof os.homedir>;
 28 |     
 29 |     // Default mock behaviors
 30 |     mockHomedir.mockReturnValue('/home/testuser');
 31 |     mockExistsSync.mockReturnValue(true);
 32 |     mockReadFileSync.mockReturnValue(JSON.stringify([
 33 |       { name: 'test-repo', path: '/test/repo' },
 34 |       { name: 'another-repo', path: '/another/repo' }
 35 |     ]));
 36 |   });
 37 | 
 38 |   describe('Security Validation', () => {
 39 |     const SAFE_COMMANDS = new Set([
 40 |       'log', 'diff', 'show', 'blame', 'grep', 'status',
 41 |       'rev-parse', 'rev-list', 'ls-files', 'cat-file',
 42 |       'diff-tree', 'shortlog', 'reflog', 'describe',
 43 |       'branch', 'tag', 'for-each-ref', 'ls-tree',
 44 |       'merge-base', 'cherry', 'count-objects'
 45 |     ]);
 46 | 
 47 |     const BLOCKED_ARGS = [
 48 |       '--exec', '--upload-pack', '--receive-pack',
 49 |       '-c', '--config', '--work-tree', '--git-dir',
 50 |       'push', 'pull', 'fetch', 'commit', 'merge',
 51 |       'rebase', 'reset', 'clean', 'checkout', 'add',
 52 |       'rm', 'mv', 'restore', 'stash', 'remote',
 53 |       'submodule', 'worktree', 'filter-branch',
 54 |       'repack', 'gc', 'prune', 'fsck'
 55 |     ];
 56 | 
 57 |     it('should only allow safe read-only commands', () => {
 58 |       const testCommands = [
 59 |         { cmd: 'log', expected: true },
 60 |         { cmd: 'diff', expected: true },
 61 |         { cmd: 'commit', expected: false },
 62 |         { cmd: 'push', expected: false },
 63 |         { cmd: 'merge', expected: false },
 64 |         { cmd: 'rebase', expected: false }
 65 |       ];
 66 | 
 67 |       testCommands.forEach(({ cmd, expected }) => {
 68 |         expect(SAFE_COMMANDS.has(cmd)).toBe(expected);
 69 |       });
 70 |     });
 71 | 
 72 |     it('should block dangerous arguments', () => {
 73 |       const dangerousCommands = [
 74 |         'log --exec=rm -rf /',
 75 |         'log -c core.editor=vim',
 76 |         'log --work-tree=/other/path',
 77 |         'diff push origin',
 78 |         'show && commit -m "test"'
 79 |       ];
 80 | 
 81 |       dangerousCommands.forEach(cmd => {
 82 |         const hasBlockedArg = BLOCKED_ARGS.some(arg => cmd.includes(arg));
 83 |         expect(hasBlockedArg).toBe(true);
 84 |       });
 85 |     });
 86 | 
 87 |     it('should detect path traversal attempts', () => {
 88 |       const PATH_TRAVERSAL_PATTERNS = [
 89 |         '../',
 90 |         '..\\',
 91 |         '%2e%2e',
 92 |         '..%2f',
 93 |         '..%5c'
 94 |       ];
 95 | 
 96 |       const maliciousPaths = [
 97 |         '../etc/passwd',
 98 |         '..\\windows\\system32',
 99 |         '%2e%2e%2fetc%2fpasswd',
100 |         'test/../../sensitive'
101 |       ];
102 | 
103 |       maliciousPaths.forEach(malPath => {
104 |         const hasTraversal = PATH_TRAVERSAL_PATTERNS.some(pattern => 
105 |           malPath.toLowerCase().includes(pattern)
106 |         );
107 |         expect(hasTraversal).toBe(true);
108 |       });
109 |     });
110 |   });
111 | 
112 |   describe('Repository Path Resolution', () => {
113 |     it('should normalize paths correctly', () => {
114 |       const testPath = '~/projects/test';
115 |       const normalized = testPath.replace('~', '/home/testuser');
116 |       expect(normalized).toBe('/home/testuser/projects/test');
117 |     });
118 | 
119 |     it('should handle Windows paths', () => {
120 |       const windowsPaths = [
121 |         'C:\\Users\\test\\project',
122 |         'D:\\repos\\myrepo',
123 |         '\\\\server\\share\\repo'
124 |       ];
125 | 
126 |       windowsPaths.forEach(winPath => {
127 |         const isWindowsPath = winPath.includes(':') || winPath.startsWith('\\\\');
128 |         expect(isWindowsPath).toBe(true);
129 |       });
130 |     });
131 | 
132 |     it('should validate absolute paths', () => {
133 |       const paths = [
134 |         { path: '/absolute/path', isAbsolute: true },
135 |         { path: 'relative/path', isAbsolute: false },
136 |         { path: './relative', isAbsolute: false }
137 |       ];
138 |       
139 |       // Test Windows path separately on Windows platform
140 |       if (process.platform === 'win32') {
141 |         paths.push({ path: 'C:\\Windows', isAbsolute: true });
142 |       }
143 | 
144 |       paths.forEach(({ path: testPath, isAbsolute: expected }) => {
145 |         expect(path.isAbsolute(testPath)).toBe(expected);
146 |       });
147 |     });
148 |   });
149 | 
150 |   describe('Git Environment Configuration', () => {
151 |     it('should set correct environment variables', () => {
152 |       const repoPath = '/test/repo';
153 |       const shadowGitDir = path.join(repoPath, '.shadowgit.git');
154 |       
155 |       const gitEnv = {
156 |         ...process.env,
157 |         GIT_DIR: shadowGitDir,
158 |         GIT_WORK_TREE: repoPath
159 |       };
160 | 
161 |       expect(gitEnv.GIT_DIR).toBe('/test/repo/.shadowgit.git');
162 |       expect(gitEnv.GIT_WORK_TREE).toBe('/test/repo');
163 |     });
164 | 
165 |     it('should enforce timeout and buffer limits', () => {
166 |       const TIMEOUT_MS = 10000; // 10 seconds
167 |       const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB
168 | 
169 |       expect(TIMEOUT_MS).toBe(10000);
170 |       expect(MAX_BUFFER_SIZE).toBe(10485760);
171 |     });
172 |   });
173 | 
174 |   describe('Command Sanitization', () => {
175 |     it('should remove control characters', () => {
176 |       const dirtyCommand = 'log\x00\x01\x02\x1F --oneline';
177 |       const sanitized = dirtyCommand.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
178 |       expect(sanitized).toBe('log --oneline');
179 |     });
180 | 
181 |     it('should enforce command length limit', () => {
182 |       const MAX_COMMAND_LENGTH = 1000;
183 |       const longCommand = 'log ' + 'a'.repeat(2000);
184 |       expect(longCommand.length).toBeGreaterThan(MAX_COMMAND_LENGTH);
185 |     });
186 |   });
187 | 
188 |   describe('Error Handling', () => {
189 |     it('should handle git not installed error', () => {
190 |       const error: any = new Error('Command not found');
191 |       error.code = 'ENOENT';
192 |       expect(error.code).toBe('ENOENT');
193 |     });
194 | 
195 |     it('should handle timeout error', () => {
196 |       const error: any = new Error('Timeout');
197 |       error.signal = 'SIGTERM';
198 |       expect(error.signal).toBe('SIGTERM');
199 |     });
200 | 
201 |     it('should handle buffer overflow error', () => {
202 |       const error: any = new Error('Buffer overflow');
203 |       error.code = 'ENOBUFS';
204 |       expect(error.code).toBe('ENOBUFS');
205 |     });
206 | 
207 |     it('should handle git error (exit code 128)', () => {
208 |       const error: any = new Error('Git error');
209 |       error.status = 128;
210 |       error.stderr = 'fatal: bad revision';
211 |       expect(error.status).toBe(128);
212 |       expect(error.stderr).toContain('fatal');
213 |     });
214 |   });
215 | 
216 |   describe('Logging System', () => {
217 |     it('should support multiple log levels', () => {
218 |       const LOG_LEVELS = {
219 |         debug: 0,
220 |         info: 1,
221 |         warn: 2,
222 |         error: 3
223 |       };
224 | 
225 |       expect(LOG_LEVELS.debug).toBeLessThan(LOG_LEVELS.info);
226 |       expect(LOG_LEVELS.info).toBeLessThan(LOG_LEVELS.warn);
227 |       expect(LOG_LEVELS.warn).toBeLessThan(LOG_LEVELS.error);
228 |     });
229 | 
230 |     it('should include timestamp in logs', () => {
231 |       const timestamp = new Date().toISOString();
232 |       expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
233 |     });
234 |   });
235 | 
236 |   describe('Configuration', () => {
237 |     it('should read timeout from environment', () => {
238 |       const customTimeout = '30000';
239 |       const timeout = parseInt(customTimeout || '10000', 10);
240 |       expect(timeout).toBe(30000);
241 |     });
242 | 
243 |     it('should use default timeout if not specified', () => {
244 |       const envTimeout: string | undefined = undefined;
245 |       const timeout = parseInt(envTimeout || '10000', 10);
246 |       expect(timeout).toBe(10000);
247 |     });
248 | 
249 |     it('should read log level from environment', () => {
250 |       const logLevel = 'debug';
251 |       expect(['debug', 'info', 'warn', 'error']).toContain(logLevel);
252 |     });
253 |   });
254 | 
255 |   describe('Manual Checkpoint Functionality', () => {
256 |     it('should validate required parameters', () => {
257 |       // Test that repo and title are required
258 |       const validateArgs = (args: any): boolean => {
259 |         return (
260 |           typeof args === 'object' &&
261 |           args !== null &&
262 |           'repo' in args &&
263 |           'title' in args &&
264 |           typeof args.repo === 'string' &&
265 |           typeof args.title === 'string'
266 |         );
267 |       };
268 | 
269 |       expect(validateArgs({ repo: 'test', title: 'Test' })).toBe(true);
270 |       expect(validateArgs({ repo: 'test' })).toBe(false);
271 |       expect(validateArgs({ title: 'Test' })).toBe(false);
272 |       expect(validateArgs({})).toBe(false);
273 |       expect(validateArgs(null)).toBe(false);
274 |     });
275 | 
276 |     it('should validate title length', () => {
277 |       const MAX_TITLE_LENGTH = 50;
278 |       const validateTitleLength = (title: string): boolean => {
279 |         return title.length <= MAX_TITLE_LENGTH;
280 |       };
281 | 
282 |       expect(validateTitleLength('Normal title')).toBe(true);
283 |       expect(validateTitleLength('a'.repeat(50))).toBe(true);
284 |       expect(validateTitleLength('a'.repeat(51))).toBe(false);
285 |     });
286 | 
287 |     it('should generate correct author email from name', () => {
288 |       const generateAuthorEmail = (author: string): string => {
289 |         return `${author.toLowerCase().replace(/\s+/g, '-')}@shadowgit.local`;
290 |       };
291 | 
292 |       expect(generateAuthorEmail('Claude')).toBe('[email protected]');
293 |       expect(generateAuthorEmail('GPT-4')).toBe('[email protected]');
294 |       expect(generateAuthorEmail('AI Assistant')).toBe('[email protected]');
295 |       expect(generateAuthorEmail('Gemini Pro')).toBe('[email protected]');
296 |     });
297 | 
298 |     it('should properly escape shell special characters', () => {
299 |       const escapeShellString = (str: string): string => {
300 |         return str
301 |           .replace(/\\/g, '\\\\')  // Escape backslashes first
302 |           .replace(/"/g, '\\"')    // Escape double quotes
303 |           .replace(/\$/g, '\\$')   // Escape dollar signs
304 |           .replace(/`/g, '\\`')    // Escape backticks
305 |           .replace(/'/g, "\\'");   // Escape single quotes
306 |       };
307 | 
308 |       expect(escapeShellString('normal text')).toBe('normal text');
309 |       expect(escapeShellString('text with $var')).toBe('text with \\$var');
310 |       expect(escapeShellString('text with "quotes"')).toBe('text with \\"quotes\\"');
311 |       expect(escapeShellString('text with `backticks`')).toBe('text with \\`backticks\\`');
312 |       expect(escapeShellString('text with \\backslash')).toBe('text with \\\\backslash');
313 |       expect(escapeShellString("text with 'single'")).toBe("text with \\'single\\'");
314 |       expect(escapeShellString('$var "quote" `tick` \\slash')).toBe('\\$var \\"quote\\" \\`tick\\` \\\\slash');
315 |     });
316 | 
317 |     it('should format commit message correctly', () => {
318 |       const formatCommitMessage = (
319 |         title: string,
320 |         message: string | undefined,
321 |         author: string,
322 |         timestamp: string
323 |       ): string => {
324 |         let commitMessage = `✋ [${author}] Manual Checkpoint: ${title}`;
325 |         if (message) {
326 |           commitMessage += `\n\n${message}`;
327 |         }
328 |         commitMessage += `\n\nCreated by: ${author}\nTimestamp: ${timestamp}`;
329 |         return commitMessage;
330 |       };
331 | 
332 |       const timestamp = '2024-01-01T12:00:00Z';
333 |       
334 |       // Test with minimal parameters
335 |       const msg1 = formatCommitMessage('Fix bug', undefined, 'AI Assistant', timestamp);
336 |       expect(msg1).toContain('✋ [AI Assistant] Manual Checkpoint: Fix bug');
337 |       expect(msg1).toContain('Created by: AI Assistant');
338 |       expect(msg1).toContain('Timestamp: 2024-01-01T12:00:00Z');
339 |       expect(msg1).not.toContain('undefined');
340 | 
341 |       // Test with all parameters
342 |       const msg2 = formatCommitMessage('Add feature', 'Detailed description', 'Claude', timestamp);
343 |       expect(msg2).toContain('✋ [Claude] Manual Checkpoint: Add feature');
344 |       expect(msg2).toContain('Detailed description');
345 |       expect(msg2).toContain('Created by: Claude');
346 |     });
347 | 
348 |     it('should extract commit hash from git output', () => {
349 |       const extractCommitHash = (output: string): string => {
350 |         const match = output.match(/\[[\w\s-]+\s+([a-f0-9]{7,})\]/);
351 |         return match ? match[1] : 'unknown';
352 |       };
353 | 
354 |       expect(extractCommitHash('[main abc1234] Test commit')).toBe('abc1234');
355 |       expect(extractCommitHash('[feature-branch def56789] Another commit')).toBe('def56789');
356 |       expect(extractCommitHash('[develop 1a2b3c4d5e6f] Long hash')).toBe('1a2b3c4d5e6f');
357 |       expect(extractCommitHash('No match here')).toBe('unknown');
358 |     });
359 | 
360 |     it('should set correct Git environment variables', () => {
361 |       const createGitEnv = (author: string) => {
362 |         const authorEmail = `${author.toLowerCase().replace(/\s+/g, '-')}@shadowgit.local`;
363 |         return {
364 |           GIT_AUTHOR_NAME: author,
365 |           GIT_AUTHOR_EMAIL: authorEmail,
366 |           GIT_COMMITTER_NAME: author,
367 |           GIT_COMMITTER_EMAIL: authorEmail
368 |         };
369 |       };
370 | 
371 |       const env1 = createGitEnv('Claude');
372 |       expect(env1.GIT_AUTHOR_NAME).toBe('Claude');
373 |       expect(env1.GIT_AUTHOR_EMAIL).toBe('[email protected]');
374 |       expect(env1.GIT_COMMITTER_NAME).toBe('Claude');
375 |       expect(env1.GIT_COMMITTER_EMAIL).toBe('[email protected]');
376 | 
377 |       const env2 = createGitEnv('GPT-4');
378 |       expect(env2.GIT_AUTHOR_NAME).toBe('GPT-4');
379 |       expect(env2.GIT_AUTHOR_EMAIL).toBe('[email protected]');
380 |     });
381 | 
382 |     it('should handle isInternal flag for bypassing security', () => {
383 |       // Test that internal flag allows normally blocked commands
384 |       const isCommandAllowed = (command: string, isInternal: boolean): boolean => {
385 |         const SAFE_COMMANDS = new Set(['log', 'diff', 'show', 'status']);
386 |         const parts = command.trim().split(/\s+/);
387 |         const gitCommand = parts[0];
388 |         
389 |         if (isInternal) {
390 |           return true; // Bypass all checks for internal operations
391 |         }
392 |         
393 |         return SAFE_COMMANDS.has(gitCommand);
394 |       };
395 | 
396 |       // Normal security checks
397 |       expect(isCommandAllowed('log', false)).toBe(true);
398 |       expect(isCommandAllowed('commit', false)).toBe(false);
399 |       expect(isCommandAllowed('add', false)).toBe(false);
400 |       
401 |       // Internal bypass
402 |       expect(isCommandAllowed('commit', true)).toBe(true);
403 |       expect(isCommandAllowed('add', true)).toBe(true);
404 |       expect(isCommandAllowed('anything', true)).toBe(true);
405 |     });
406 |   });
407 | });
```

--------------------------------------------------------------------------------
/tests/integration/workflow.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, beforeEach, jest } from '@jest/globals';
  2 | import { RepositoryManager } from '../../src/core/repository-manager';
  3 | import { GitExecutor } from '../../src/core/git-executor';
  4 | import { SessionClient } from '../../src/core/session-client';
  5 | import { GitHandler } from '../../src/handlers/git-handler';
  6 | import { ListReposHandler } from '../../src/handlers/list-repos-handler';
  7 | import { CheckpointHandler } from '../../src/handlers/checkpoint-handler';
  8 | import { SessionHandler } from '../../src/handlers/session-handler';
  9 | import * as fs from 'fs';
 10 | import * as os from 'os';
 11 | import { execFileSync } from 'child_process';
 12 | 
 13 | // Mock all external dependencies
 14 | jest.mock('fs');
 15 | jest.mock('os');
 16 | jest.mock('child_process');
 17 | jest.mock('../../src/utils/logger', () => ({
 18 |   log: jest.fn(),
 19 | }));
 20 | 
 21 | // Mock fetch for SessionClient
 22 | global.fetch = jest.fn() as jest.MockedFunction<typeof fetch>;
 23 | 
 24 | describe('Integration: Complete Workflow', () => {
 25 |   let repositoryManager: RepositoryManager;
 26 |   let gitExecutor: GitExecutor;
 27 |   let sessionClient: SessionClient;
 28 |   let gitHandler: GitHandler;
 29 |   let listReposHandler: ListReposHandler;
 30 |   let checkpointHandler: CheckpointHandler;
 31 |   let sessionHandler: SessionHandler;
 32 |   
 33 |   let mockExistsSync: jest.MockedFunction<typeof fs.existsSync>;
 34 |   let mockReadFileSync: jest.MockedFunction<typeof fs.readFileSync>;
 35 |   let mockHomedir: jest.MockedFunction<typeof os.homedir>;
 36 |   let mockExecFileSync: jest.MockedFunction<typeof execFileSync>;
 37 |   let mockFetch: jest.MockedFunction<typeof fetch>;
 38 | 
 39 |   beforeEach(() => {
 40 |     jest.clearAllMocks();
 41 |     
 42 |     // Get mock references
 43 |     mockExistsSync = fs.existsSync as jest.MockedFunction<typeof fs.existsSync>;
 44 |     mockReadFileSync = fs.readFileSync as jest.MockedFunction<typeof fs.readFileSync>;
 45 |     mockHomedir = os.homedir as jest.MockedFunction<typeof os.homedir>;
 46 |     mockExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>;
 47 |     mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
 48 |     
 49 |     // Setup default mocks
 50 |     mockHomedir.mockReturnValue('/home/testuser');
 51 |     mockExistsSync.mockReturnValue(true);
 52 |     mockReadFileSync.mockReturnValue(JSON.stringify([
 53 |       { name: 'my-project', path: '/workspace/my-project' },
 54 |       { name: 'another-project', path: '/workspace/another-project' },
 55 |     ]));
 56 |     
 57 |     // Initialize services
 58 |     repositoryManager = new RepositoryManager();
 59 |     gitExecutor = new GitExecutor();
 60 |     sessionClient = new SessionClient();
 61 |     
 62 |     // Initialize handlers
 63 |     gitHandler = new GitHandler(repositoryManager, gitExecutor);
 64 |     listReposHandler = new ListReposHandler(repositoryManager);
 65 |     checkpointHandler = new CheckpointHandler(repositoryManager, gitExecutor);
 66 |     sessionHandler = new SessionHandler(repositoryManager, sessionClient);
 67 |   });
 68 | 
 69 |   describe('Scenario: Complete AI Work Session with Session API Available', () => {
 70 |     it('should complete full workflow: list → start_session → git_command → checkpoint → end_session', async () => {
 71 |       const sessionId = 'session-123-abc';
 72 |       const commitHash = 'abc1234';
 73 |       
 74 |       // Step 1: List repositories
 75 |       const listResult = await listReposHandler.handle();
 76 |       expect(listResult.content[0].text).toContain('my-project:');
 77 |       expect(listResult.content[0].text).toContain('Path: /workspace/my-project');
 78 |       expect(listResult.content[0].text).toContain('another-project:');
 79 |       expect(listResult.content[0].text).toContain('Path: /workspace/another-project');
 80 |       
 81 |       // Step 2: Start session
 82 |       mockFetch.mockResolvedValueOnce({
 83 |         ok: true,
 84 |         status: 200,
 85 |         json: (jest.fn() as any).mockResolvedValue({
 86 |           success: true,
 87 |           sessionId,
 88 |         }),
 89 |       } as unknown as Response);
 90 |       
 91 |       const startResult = await sessionHandler.startSession({
 92 |         repo: 'my-project',
 93 |         description: 'Implementing new feature X',
 94 |       });
 95 |       
 96 |       expect(startResult.content[0].text).toContain('Session started successfully');
 97 |       expect(startResult.content[0].text).toContain(sessionId);
 98 |       
 99 |       // Step 3: Execute git commands
100 |       mockExecFileSync.mockReturnValue('On branch main\nYour branch is up to date');
101 |       
102 |       const statusResult = await gitHandler.handle({
103 |         repo: 'my-project',
104 |         command: 'status',
105 |       });
106 |       
107 |       expect(statusResult.content[0].text).toContain('On branch main');
108 |       
109 |       // Step 4: Simulate some changes and check diff
110 |       mockExecFileSync.mockReturnValue('diff --git a/file.txt b/file.txt\n+new line');
111 |       
112 |       const diffResult = await gitHandler.handle({
113 |         repo: 'my-project',
114 |         command: 'diff',
115 |       });
116 |       
117 |       expect(diffResult.content[0].text).toContain('diff --git');
118 |       
119 |       // Step 5: Create checkpoint
120 |       mockExecFileSync
121 |         .mockReturnValueOnce('M file.txt\nA newfile.js') // status --porcelain
122 |         .mockReturnValueOnce('') // add -A
123 |         .mockReturnValueOnce(`[main ${commitHash}] Add feature X`) // commit
124 |         .mockReturnValueOnce('commit abc1234\nAuthor: Claude'); // show --stat
125 |       
126 |       const checkpointResult = await checkpointHandler.handle({
127 |         repo: 'my-project',
128 |         title: 'Add feature X',
129 |         message: 'Implemented new feature X with comprehensive tests',
130 |         author: 'Claude',
131 |       });
132 |       
133 |       expect(checkpointResult.content[0].text).toContain('Checkpoint Created Successfully!');
134 |       expect(checkpointResult.content[0].text).toContain(commitHash);
135 |       
136 |       // Step 6: End session
137 |       mockFetch.mockResolvedValueOnce({
138 |         ok: true,
139 |         status: 200,
140 |         json: (jest.fn() as any).mockResolvedValue({
141 |           success: true,
142 |         }),
143 |       } as unknown as Response);
144 |       
145 |       const endResult = await sessionHandler.endSession({
146 |         sessionId,
147 |         commitHash,
148 |       });
149 |       
150 |       expect(endResult.content[0].text).toContain(`Session ${sessionId} ended successfully`);
151 |     });
152 |   });
153 | 
154 |   describe('Scenario: Session API Offline Fallback', () => {
155 |     it('should handle workflow when Session API is unavailable', async () => {
156 |       // Session API is offline
157 |       mockFetch.mockRejectedValue(new Error('Connection refused'));
158 |       
159 |       // Step 1: Try to start session (should fallback gracefully)
160 |       const startResult = await sessionHandler.startSession({
161 |         repo: 'my-project',
162 |         description: 'Fixing bug in authentication',
163 |       });
164 |       
165 |       expect(startResult.content[0].text).toContain('Session API is offline');
166 |       expect(startResult.content[0].text).toContain('Proceeding without session tracking');
167 |       
168 |       // Step 2: Continue with git operations
169 |       mockExecFileSync.mockReturnValue('file.txt | 2 +-');
170 |       
171 |       const diffStatResult = await gitHandler.handle({
172 |         repo: 'my-project',
173 |         command: 'diff --stat',
174 |       });
175 |       
176 |       expect(diffStatResult.content[0].text).toContain('file.txt | 2 +-');
177 |       
178 |       // Step 3: Create checkpoint (should work without session)
179 |       mockExecFileSync
180 |         .mockReturnValueOnce('M file.txt') // status
181 |         .mockReturnValueOnce('') // add
182 |         .mockReturnValueOnce('[main def5678] Fix auth bug') // commit
183 |         .mockReturnValueOnce('commit def5678'); // show
184 |       
185 |       const checkpointResult = await checkpointHandler.handle({
186 |         repo: 'my-project',
187 |         title: 'Fix auth bug',
188 |         author: 'GPT-4',
189 |       });
190 |       
191 |       expect(checkpointResult.content[0].text).toContain('Checkpoint Created Successfully!');
192 |       
193 |       // Step 4: Try to end session (should handle gracefully)
194 |       const endResult = await sessionHandler.endSession({
195 |         sessionId: 'non-existent-session',
196 |       });
197 |       
198 |       expect(endResult.content[0].text).toContain('Failed to End Session');
199 |     });
200 |   });
201 | 
202 |   describe('Scenario: Multiple AI Agents Collaboration', () => {
203 |     it('should handle multiple agents working on different repositories', async () => {
204 |       const sessions = [
205 |         { id: 'claude-session-1', repo: 'my-project', agent: 'Claude' },
206 |         { id: 'gpt4-session-2', repo: 'another-project', agent: 'GPT-4' },
207 |       ];
208 |       
209 |       // Both agents start sessions
210 |       for (const session of sessions) {
211 |         mockFetch.mockResolvedValueOnce({
212 |           ok: true,
213 |           status: 200,
214 |           json: (jest.fn() as any).mockResolvedValue({
215 |             success: true,
216 |             sessionId: session.id,
217 |           }),
218 |         } as unknown as Response);
219 |         
220 |         const result = await sessionHandler.startSession({
221 |           repo: session.repo,
222 |           description: `${session.agent} working on ${session.repo}`,
223 |         });
224 |         
225 |         expect(result.content[0].text).toContain(session.id);
226 |       }
227 |       
228 |       // Each agent makes changes and creates checkpoints
229 |       for (const session of sessions) {
230 |         mockExecFileSync
231 |           .mockReturnValueOnce('M file.txt') // status
232 |           .mockReturnValueOnce('') // add
233 |           .mockReturnValueOnce(`[main abc${session.id.slice(0, 4)}] ${session.agent} changes`) // commit
234 |           .mockReturnValueOnce('commit details'); // show
235 |         
236 |         const checkpointResult = await checkpointHandler.handle({
237 |           repo: session.repo,
238 |           title: `${session.agent} changes`,
239 |           author: session.agent,
240 |         });
241 |         
242 |         expect(checkpointResult.content[0].text).toContain('Checkpoint Created Successfully!');
243 |       }
244 |       
245 |       // Both agents end their sessions
246 |       for (const session of sessions) {
247 |         mockFetch.mockResolvedValueOnce({
248 |           ok: true,
249 |           status: 200,
250 |           json: (jest.fn() as any).mockResolvedValue({
251 |             success: true,
252 |           }),
253 |         } as unknown as Response);
254 |         
255 |         const result = await sessionHandler.endSession({
256 |           sessionId: session.id,
257 |         });
258 |         
259 |         expect(result.content[0].text).toContain(`Session ${session.id} ended successfully`);
260 |       }
261 |     });
262 |   });
263 | 
264 |   describe('Scenario: Error Recovery', () => {
265 |     it('should handle errors at each stage gracefully', async () => {
266 |       // Repository not found
267 |       const invalidRepoResult = await gitHandler.handle({
268 |         repo: 'non-existent-repo',
269 |         command: 'log',
270 |       });
271 |       
272 |       expect(invalidRepoResult.content[0].text).toContain("Error: Repository 'non-existent-repo' not found");
273 |       
274 |       // No .shadowgit.git directory
275 |       mockExistsSync.mockImplementation(p => {
276 |         if (typeof p === 'string' && p.includes('.shadowgit.git')) return false;
277 |         if (typeof p === 'string' && p.includes('repos.json')) return true;
278 |         return true;
279 |       });
280 |       
281 |       const noShadowGitResult = await gitHandler.handle({
282 |         repo: 'my-project',
283 |         command: 'log',
284 |       });
285 |       
286 |       expect(noShadowGitResult.content[0].text).toContain('not found');
287 |       
288 |       // Reset mock for next tests
289 |       mockExistsSync.mockReturnValue(true);
290 |       
291 |       // Invalid git command
292 |       mockExecFileSync.mockReturnValue('Error: Command not allowed');
293 |       
294 |       const invalidCommandResult = await gitHandler.handle({
295 |         repo: 'my-project',
296 |         command: 'push origin main',
297 |       });
298 |       
299 |       expect(invalidCommandResult.content[0].text).toContain('not allowed');
300 |       
301 |       // No changes to commit
302 |       mockExecFileSync.mockReturnValueOnce(''); // empty status
303 |       
304 |       const noChangesResult = await checkpointHandler.handle({
305 |         repo: 'my-project',
306 |         title: 'No changes',
307 |         author: 'Claude',
308 |       });
309 |       
310 |       expect(noChangesResult.content[0].text).toContain('No Changes Detected');
311 |       
312 |       // Git commit failure
313 |       mockExecFileSync
314 |         .mockReturnValueOnce('M file.txt') // status
315 |         .mockReturnValueOnce('') // add
316 |         .mockReturnValueOnce('Error: Cannot create commit'); // commit fails
317 |       
318 |       const commitFailResult = await checkpointHandler.handle({
319 |         repo: 'my-project',
320 |         title: 'Test',
321 |         author: 'Claude',
322 |       });
323 |       
324 |       expect(commitFailResult.content[0].text).toContain('Failed to Create Commit');
325 |     });
326 |   });
327 | 
328 |   describe('Scenario: Validation and Edge Cases', () => {
329 |     it('should validate all required parameters', async () => {
330 |       // Missing parameters for start_session
331 |       let result = await sessionHandler.startSession({
332 |         repo: 'my-project',
333 |         // missing description
334 |       });
335 |       expect(result.content[0].text).toContain('Error');
336 |       
337 |       // Missing parameters for checkpoint
338 |       result = await checkpointHandler.handle({
339 |         repo: 'my-project',
340 |         // missing title
341 |       });
342 |       expect(result.content[0].text).toContain('Error');
343 |       
344 |       // Title too long
345 |       result = await checkpointHandler.handle({
346 |         repo: 'my-project',
347 |         title: 'a'.repeat(51),
348 |       });
349 |       expect(result.content[0].text).toContain('50 characters or less');
350 |       
351 |       // Message too long
352 |       result = await checkpointHandler.handle({
353 |         repo: 'my-project',
354 |         title: 'Valid title',
355 |         message: 'a'.repeat(1001),
356 |       });
357 |       expect(result.content[0].text).toContain('1000 characters or less');
358 |     });
359 |     
360 |     it('should handle special characters in commit messages', async () => {
361 |       mockExecFileSync
362 |         .mockReturnValueOnce('M file.txt') // status
363 |         .mockReturnValueOnce('') // add
364 |         .mockReturnValueOnce('[main xyz789] Special') // commit
365 |         .mockReturnValueOnce('commit xyz789'); // show
366 |       
367 |       const result = await checkpointHandler.handle({
368 |         repo: 'my-project',
369 |         title: 'Fix $pecial "bug" with `quotes`',
370 |         message: 'Message with $var and backslash',
371 |         author: 'AI-Agent',
372 |       });
373 |       
374 |       expect(result.content[0].text).toContain('Checkpoint Created Successfully!');
375 |       
376 |       // Verify commit was called with correct arguments
377 |       const commitCall = mockExecFileSync.mock.calls.find(
378 |         call => Array.isArray(call[1]) && call[1].includes('commit')
379 |       );
380 |       expect(commitCall).toBeDefined();
381 |       // With execFileSync, first arg is 'git', second is array of args
382 |       expect(commitCall![0]).toBe('git');
383 |       // Find the commit message in the arguments array
384 |       const args = commitCall![1] as string[];
385 |       const messageIndex = args.indexOf('-m') + 1;
386 |       const commitMessage = args[messageIndex];
387 |       // Special characters should be preserved in the message
388 |       expect(commitMessage).toContain('$pecial');
389 |       expect(commitMessage).toContain('"bug"');
390 |       expect(commitMessage).toContain('`quotes`');
391 |     });
392 |   });
393 | 
394 |   describe('Scenario: Cross-Platform Compatibility', () => {
395 |     it('should handle Windows paths correctly', async () => {
396 |       mockReadFileSync.mockReturnValue(JSON.stringify([
397 |         { name: 'windows-project', path: 'C:\\Projects\\MyApp' },
398 |       ]));
399 |       
400 |       // Reinitialize to load Windows paths
401 |       repositoryManager = new RepositoryManager();
402 |       gitHandler = new GitHandler(repositoryManager, gitExecutor);
403 |       
404 |       mockExecFileSync.mockReturnValue('Windows output');
405 |       
406 |       const result = await gitHandler.handle({
407 |         repo: 'windows-project',
408 |         command: 'status',
409 |       });
410 |       
411 |       // Now includes workflow reminder for status command
412 |       expect(result.content[0].text).toContain('Windows output');
413 |       expect(result.content[0].text).toContain('Planning to Make Changes?');
414 |       expect(mockExecFileSync).toHaveBeenCalledWith(
415 |         'git',
416 |         expect.arrayContaining([
417 |           expect.stringContaining('--git-dir='),
418 |           expect.stringContaining('--work-tree='),
419 |         ]),
420 |         expect.objectContaining({
421 |           cwd: 'C:\\Projects\\MyApp',
422 |         })
423 |       );
424 |     });
425 |     
426 |     it('should handle paths with spaces', async () => {
427 |       mockReadFileSync.mockReturnValue(JSON.stringify([
428 |         { name: 'space-project', path: '/path/with spaces/project' },
429 |       ]));
430 |       
431 |       repositoryManager = new RepositoryManager();
432 |       gitHandler = new GitHandler(repositoryManager, gitExecutor);
433 |       
434 |       mockExecFileSync.mockReturnValue('Output');
435 |       
436 |       const result = await gitHandler.handle({
437 |         repo: 'space-project',
438 |         command: 'log',
439 |       });
440 |       
441 |       // Now includes workflow reminder for log command
442 |       expect(result.content[0].text).toContain('Output');
443 |       expect(result.content[0].text).toContain('Planning to Make Changes?');
444 |       expect(mockExecFileSync).toHaveBeenCalledWith(
445 |         'git',
446 |         expect.arrayContaining([
447 |           expect.stringContaining('--git-dir='),
448 |           expect.stringContaining('--work-tree='),
449 |         ]),
450 |         expect.objectContaining({
451 |           cwd: '/path/with spaces/project',
452 |         })
453 |       );
454 |     });
455 |   });
456 | });
```
Page 2/2FirstPrevNextLast