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 | });
```