spck 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for GitService
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs/promises';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { GitService } from '../GitService.js';
|
|
10
|
+
import { ErrorCode } from '../../types.js';
|
|
11
|
+
// Helper to execute git commands for test setup
|
|
12
|
+
async function execGit(args, cwd) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const git = spawn('git', args, { cwd });
|
|
15
|
+
let stdout = '';
|
|
16
|
+
let stderr = '';
|
|
17
|
+
git.stdout.on('data', (data) => (stdout += data.toString()));
|
|
18
|
+
git.stderr.on('data', (data) => (stderr += data.toString()));
|
|
19
|
+
git.on('close', (code) => {
|
|
20
|
+
if (code !== 0) {
|
|
21
|
+
reject(new Error(`Git failed: ${stderr}`));
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
resolve(stdout.trim());
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
describe('GitService', () => {
|
|
30
|
+
let service;
|
|
31
|
+
let testRoot;
|
|
32
|
+
let repoPath;
|
|
33
|
+
let mockSocket;
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
// Create temporary test directory
|
|
36
|
+
testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'git-test-'));
|
|
37
|
+
repoPath = path.join(testRoot, 'repo');
|
|
38
|
+
await fs.mkdir(repoPath);
|
|
39
|
+
service = new GitService(testRoot);
|
|
40
|
+
mockSocket = {
|
|
41
|
+
id: 'test-socket',
|
|
42
|
+
data: { uid: 'test-user', deviceId: 'test-device' },
|
|
43
|
+
emit: vi.fn(),
|
|
44
|
+
on: vi.fn(),
|
|
45
|
+
off: vi.fn(),
|
|
46
|
+
broadcast: {
|
|
47
|
+
emit: vi.fn(),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
// Clean up test directory
|
|
53
|
+
try {
|
|
54
|
+
await fs.rm(testRoot, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
});
|
|
58
|
+
describe('Path Validation', () => {
|
|
59
|
+
it('should accept valid paths', async () => {
|
|
60
|
+
await execGit(['init'], repoPath);
|
|
61
|
+
const result = await service.handle('currentBranch', { dir: '/repo' }, mockSocket);
|
|
62
|
+
// Should not throw error
|
|
63
|
+
expect(result).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
it('should prevent directory traversal', async () => {
|
|
66
|
+
await expect(service.handle('currentBranch', { dir: '../../../etc' }, mockSocket)).rejects.toMatchObject({
|
|
67
|
+
code: ErrorCode.INVALID_PATH,
|
|
68
|
+
message: expect.stringContaining('directory traversal'),
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
it('should prevent access outside root', async () => {
|
|
72
|
+
// Path validation clamps to root, so the directory just won't exist
|
|
73
|
+
// This will result in a git error rather than a path validation error
|
|
74
|
+
const result = await service.handle('currentBranch', { dir: '/../../../../etc' }, mockSocket);
|
|
75
|
+
// currentBranch returns null for invalid repos (no error thrown)
|
|
76
|
+
expect(result.branch).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('Git Operations - init', () => {
|
|
80
|
+
it('should initialize a new repository', async () => {
|
|
81
|
+
const result = await service.handle('init', { dir: '/repo' }, mockSocket);
|
|
82
|
+
expect(result.success).toBe(true);
|
|
83
|
+
// Verify .git directory was created
|
|
84
|
+
const gitDir = path.join(repoPath, '.git');
|
|
85
|
+
const stats = await fs.stat(gitDir);
|
|
86
|
+
expect(stats.isDirectory()).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
it('should initialize with custom default branch', async () => {
|
|
89
|
+
await service.handle('init', { dir: '/repo', defaultBranch: 'main' }, mockSocket);
|
|
90
|
+
const branch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
|
|
91
|
+
expect(branch).toBe('main');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('Git Operations - currentBranch', () => {
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await execGit(['init'], repoPath);
|
|
97
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
98
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
99
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
100
|
+
await execGit(['add', '.'], repoPath);
|
|
101
|
+
await execGit(['commit', '-m', 'Initial commit'], repoPath);
|
|
102
|
+
});
|
|
103
|
+
it('should return current branch short name', async () => {
|
|
104
|
+
const result = await service.handle('currentBranch', { dir: '/repo' }, mockSocket);
|
|
105
|
+
expect(result.branch).toBeTruthy();
|
|
106
|
+
expect(result.branch).not.toContain('refs/heads/');
|
|
107
|
+
});
|
|
108
|
+
it('should return full branch name when fullname is true', async () => {
|
|
109
|
+
const result = await service.handle('currentBranch', { dir: '/repo', fullname: true }, mockSocket);
|
|
110
|
+
expect(result.branch).toContain('refs/heads/');
|
|
111
|
+
});
|
|
112
|
+
it('should return null for detached HEAD', async () => {
|
|
113
|
+
// Get commit hash and checkout to detach HEAD
|
|
114
|
+
const oid = await execGit(['rev-parse', 'HEAD'], repoPath);
|
|
115
|
+
await execGit(['checkout', oid], repoPath);
|
|
116
|
+
const result = await service.handle('currentBranch', { dir: '/repo' }, mockSocket);
|
|
117
|
+
expect(result.branch).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe('Git Operations - add', () => {
|
|
121
|
+
beforeEach(async () => {
|
|
122
|
+
await execGit(['init'], repoPath);
|
|
123
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
124
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
125
|
+
});
|
|
126
|
+
it('should stage single file', async () => {
|
|
127
|
+
await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
|
|
128
|
+
const result = await service.handle('add', { dir: '/repo', filepaths: ['new.txt'] }, mockSocket);
|
|
129
|
+
expect(result.success).toBe(true);
|
|
130
|
+
const status = await execGit(['status', '--porcelain'], repoPath);
|
|
131
|
+
expect(status).toContain('A new.txt');
|
|
132
|
+
});
|
|
133
|
+
it('should stage multiple files', async () => {
|
|
134
|
+
await fs.writeFile(path.join(repoPath, 'file1.txt'), 'content1');
|
|
135
|
+
await fs.writeFile(path.join(repoPath, 'file2.txt'), 'content2');
|
|
136
|
+
await service.handle('add', { dir: '/repo', filepaths: ['file1.txt', 'file2.txt'] }, mockSocket);
|
|
137
|
+
const status = await execGit(['status', '--porcelain'], repoPath);
|
|
138
|
+
expect(status).toContain('file1.txt');
|
|
139
|
+
expect(status).toContain('file2.txt');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe('Git Operations - commit', () => {
|
|
143
|
+
beforeEach(async () => {
|
|
144
|
+
await execGit(['init'], repoPath);
|
|
145
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
146
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
147
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
148
|
+
await execGit(['add', '.'], repoPath);
|
|
149
|
+
});
|
|
150
|
+
it('should create a commit', async () => {
|
|
151
|
+
const result = await service.handle('commit', {
|
|
152
|
+
dir: '/repo',
|
|
153
|
+
message: 'Test commit',
|
|
154
|
+
author: {
|
|
155
|
+
name: 'Test Author',
|
|
156
|
+
email: 'author@example.com',
|
|
157
|
+
},
|
|
158
|
+
}, mockSocket);
|
|
159
|
+
expect(result.oid).toBeTruthy();
|
|
160
|
+
expect(result.oid).toMatch(/^[0-9a-f]{40}$/);
|
|
161
|
+
const commitMsg = await execGit(['log', '-1', '--format=%s'], repoPath);
|
|
162
|
+
expect(commitMsg).toBe('Test commit');
|
|
163
|
+
});
|
|
164
|
+
it('should broadcast change notification', async () => {
|
|
165
|
+
await service.handle('commit', {
|
|
166
|
+
dir: '/repo',
|
|
167
|
+
message: 'Test commit',
|
|
168
|
+
author: {
|
|
169
|
+
name: 'Test Author',
|
|
170
|
+
email: 'author@example.com',
|
|
171
|
+
},
|
|
172
|
+
}, mockSocket);
|
|
173
|
+
expect(mockSocket.broadcast.emit).toHaveBeenCalledWith('rpc', expect.objectContaining({
|
|
174
|
+
jsonrpc: '2.0',
|
|
175
|
+
method: 'git.changed',
|
|
176
|
+
params: { dir: expect.any(String) },
|
|
177
|
+
}));
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
describe('Git Operations - readCommit', () => {
|
|
181
|
+
let commitOid;
|
|
182
|
+
beforeEach(async () => {
|
|
183
|
+
await execGit(['init'], repoPath);
|
|
184
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
185
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
186
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
187
|
+
await execGit(['add', '.'], repoPath);
|
|
188
|
+
await execGit(['commit', '-m', 'Test commit message'], repoPath);
|
|
189
|
+
commitOid = await execGit(['rev-parse', 'HEAD'], repoPath);
|
|
190
|
+
});
|
|
191
|
+
it('should read commit object', async () => {
|
|
192
|
+
const result = await service.handle('readCommit', { dir: '/repo', oid: commitOid }, mockSocket);
|
|
193
|
+
expect(result.oid).toBe(commitOid);
|
|
194
|
+
expect(result.commit).toMatchObject({
|
|
195
|
+
message: expect.stringContaining('Test commit message'),
|
|
196
|
+
tree: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
197
|
+
parent: expect.any(Array),
|
|
198
|
+
author: expect.objectContaining({
|
|
199
|
+
name: expect.any(String),
|
|
200
|
+
email: expect.any(String),
|
|
201
|
+
timestamp: expect.any(Number),
|
|
202
|
+
}),
|
|
203
|
+
committer: expect.objectContaining({
|
|
204
|
+
name: expect.any(String),
|
|
205
|
+
email: expect.any(String),
|
|
206
|
+
timestamp: expect.any(Number),
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
it('should parse author and committer correctly', async () => {
|
|
211
|
+
const result = await service.handle('readCommit', { dir: '/repo', oid: commitOid }, mockSocket);
|
|
212
|
+
expect(result.commit.author.name).toBe('Test User');
|
|
213
|
+
expect(result.commit.author.email).toBe('test@example.com');
|
|
214
|
+
expect(result.commit.committer.name).toBe('Test User');
|
|
215
|
+
expect(result.commit.committer.email).toBe('test@example.com');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
describe('Git Operations - log', () => {
|
|
219
|
+
beforeEach(async () => {
|
|
220
|
+
await execGit(['init'], repoPath);
|
|
221
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
222
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
223
|
+
// Create multiple commits
|
|
224
|
+
for (let i = 1; i <= 3; i++) {
|
|
225
|
+
await fs.writeFile(path.join(repoPath, `file${i}.txt`), `content${i}`);
|
|
226
|
+
await execGit(['add', '.'], repoPath);
|
|
227
|
+
await execGit(['commit', '-m', `Commit ${i}`], repoPath);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
it('should return commit history', async () => {
|
|
231
|
+
const result = await service.handle('log', { dir: '/repo' }, mockSocket);
|
|
232
|
+
expect(result.commits).toHaveLength(3);
|
|
233
|
+
expect(result.commits[0].commit.message).toContain('Commit 3');
|
|
234
|
+
expect(result.commits[2].commit.message).toContain('Commit 1');
|
|
235
|
+
});
|
|
236
|
+
it('should limit history with depth parameter', async () => {
|
|
237
|
+
const result = await service.handle('log', { dir: '/repo', depth: 2 }, mockSocket);
|
|
238
|
+
expect(result.commits).toHaveLength(2);
|
|
239
|
+
});
|
|
240
|
+
it('should return commits in reverse chronological order', async () => {
|
|
241
|
+
const result = await service.handle('log', { dir: '/repo' }, mockSocket);
|
|
242
|
+
const messages = result.commits.map((c) => c.commit.message.trim());
|
|
243
|
+
expect(messages).toEqual(['Commit 3', 'Commit 2', 'Commit 1']);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
describe('Git Operations - status', () => {
|
|
247
|
+
beforeEach(async () => {
|
|
248
|
+
await execGit(['init'], repoPath);
|
|
249
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
250
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
251
|
+
await fs.writeFile(path.join(repoPath, 'committed.txt'), 'content');
|
|
252
|
+
await execGit(['add', '.'], repoPath);
|
|
253
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
254
|
+
});
|
|
255
|
+
it('should return empty array for clean working directory', async () => {
|
|
256
|
+
const result = await service.handle('status', { dir: '/repo' }, mockSocket);
|
|
257
|
+
expect(result).toEqual([]);
|
|
258
|
+
});
|
|
259
|
+
it('should show modified files in status', async () => {
|
|
260
|
+
await fs.writeFile(path.join(repoPath, 'committed.txt'), 'modified');
|
|
261
|
+
const result = await service.handle('status', { dir: '/repo' }, mockSocket);
|
|
262
|
+
expect(result.length).toBeGreaterThan(0);
|
|
263
|
+
expect(result[0].path).toBe('committed.txt');
|
|
264
|
+
expect(result[0].status).toBeDefined();
|
|
265
|
+
});
|
|
266
|
+
it('should show new untracked files', async () => {
|
|
267
|
+
await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
|
|
268
|
+
const result = await service.handle('status', { dir: '/repo' }, mockSocket);
|
|
269
|
+
expect(result.some((item) => item.path === 'new.txt')).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
describe('Git Operations - listBranches', () => {
|
|
273
|
+
beforeEach(async () => {
|
|
274
|
+
await execGit(['init'], repoPath);
|
|
275
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
276
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
277
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
278
|
+
await execGit(['add', '.'], repoPath);
|
|
279
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
280
|
+
});
|
|
281
|
+
it('should list local branches', async () => {
|
|
282
|
+
await execGit(['checkout', '-b', 'feature'], repoPath);
|
|
283
|
+
const result = await service.handle('listBranches', { dir: '/repo' }, mockSocket);
|
|
284
|
+
expect(result.branches).toContain('feature');
|
|
285
|
+
});
|
|
286
|
+
it('should filter out empty lines', async () => {
|
|
287
|
+
const result = await service.handle('listBranches', { dir: '/repo' }, mockSocket);
|
|
288
|
+
expect(result.branches.every((b) => b.length > 0)).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
describe('Git Operations - checkout', () => {
|
|
292
|
+
beforeEach(async () => {
|
|
293
|
+
await execGit(['init'], repoPath);
|
|
294
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
295
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
296
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
297
|
+
await execGit(['add', '.'], repoPath);
|
|
298
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
299
|
+
await execGit(['checkout', '-b', 'develop'], repoPath);
|
|
300
|
+
});
|
|
301
|
+
it('should checkout branch', async () => {
|
|
302
|
+
const defaultBranch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
|
|
303
|
+
await service.handle('checkout', { dir: '/repo', ref: defaultBranch }, mockSocket);
|
|
304
|
+
const currentBranch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
|
|
305
|
+
expect(currentBranch).toBe(defaultBranch);
|
|
306
|
+
});
|
|
307
|
+
it('should checkout with force option', async () => {
|
|
308
|
+
// Make uncommitted changes
|
|
309
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'modified');
|
|
310
|
+
const result = await service.handle('checkout', { dir: '/repo', ref: 'HEAD', force: true }, mockSocket);
|
|
311
|
+
expect(result.success).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
describe('Git Operations - requestAuth', () => {
|
|
315
|
+
it('should request authentication from client', async () => {
|
|
316
|
+
const mockResponse = {
|
|
317
|
+
username: 'testuser',
|
|
318
|
+
password: 'testpass',
|
|
319
|
+
};
|
|
320
|
+
let requestId = null;
|
|
321
|
+
// Capture the request ID when emit is called
|
|
322
|
+
mockSocket.emit.mockImplementation((event, data) => {
|
|
323
|
+
if (event === 'rpc' && data.method === 'git.requestAuth') {
|
|
324
|
+
requestId = data.id;
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
// Set up response handler
|
|
328
|
+
const onHandlers = [];
|
|
329
|
+
mockSocket.on.mockImplementation((event, handler) => {
|
|
330
|
+
if (event === 'rpc') {
|
|
331
|
+
onHandlers.push(handler);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
// Start the auth request
|
|
335
|
+
const authPromise = service.handle('requestAuth', { dir: '/repo', url: 'https://github.com/user/repo.git' }, mockSocket);
|
|
336
|
+
// Wait a tick for the emit to happen
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
338
|
+
// Simulate client response
|
|
339
|
+
expect(requestId).not.toBeNull();
|
|
340
|
+
onHandlers.forEach((handler) => {
|
|
341
|
+
handler({
|
|
342
|
+
id: requestId,
|
|
343
|
+
result: mockResponse,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
const result = await authPromise;
|
|
347
|
+
expect(result).toEqual(mockResponse);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
describe('Error Handling', () => {
|
|
351
|
+
it('should throw error for unknown git method', async () => {
|
|
352
|
+
await execGit(['init'], repoPath);
|
|
353
|
+
await expect(service.handle('unknownMethod', { dir: '/repo' }, mockSocket)).rejects.toMatchObject({
|
|
354
|
+
code: ErrorCode.METHOD_NOT_FOUND,
|
|
355
|
+
message: expect.stringContaining('Method not found'),
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
it('should wrap git command errors', async () => {
|
|
359
|
+
await execGit(['init'], repoPath);
|
|
360
|
+
// Try to read non-existent commit
|
|
361
|
+
await expect(service.handle('readCommit', { dir: '/repo', oid: '0000000000000000000000000000000000000000' }, mockSocket)).rejects.toMatchObject({
|
|
362
|
+
code: ErrorCode.GIT_OPERATION_FAILED,
|
|
363
|
+
message: expect.stringContaining('Git operation failed'),
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
it('should handle invalid git directories', async () => {
|
|
367
|
+
// currentBranch catches errors and returns null for detached/invalid repos
|
|
368
|
+
const result = await service.handle('currentBranch', { dir: '/nonexistent' }, mockSocket);
|
|
369
|
+
expect(result.branch).toBeNull();
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
describe('Git Operations - remove', () => {
|
|
373
|
+
beforeEach(async () => {
|
|
374
|
+
await execGit(['init'], repoPath);
|
|
375
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
376
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
377
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
378
|
+
await execGit(['add', '.'], repoPath);
|
|
379
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
380
|
+
});
|
|
381
|
+
it('should remove files from index', async () => {
|
|
382
|
+
await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
|
|
383
|
+
await execGit(['add', 'new.txt'], repoPath);
|
|
384
|
+
const result = await service.handle('remove', { dir: '/repo', filepaths: ['new.txt'] }, mockSocket);
|
|
385
|
+
expect(result.success).toBe(true);
|
|
386
|
+
const status = await execGit(['status', '--porcelain'], repoPath);
|
|
387
|
+
expect(status).not.toContain('A new.txt');
|
|
388
|
+
expect(status).toContain('?? new.txt');
|
|
389
|
+
});
|
|
390
|
+
it('should broadcast change notification', async () => {
|
|
391
|
+
await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
|
|
392
|
+
await execGit(['add', 'new.txt'], repoPath);
|
|
393
|
+
await service.handle('remove', { dir: '/repo', filepaths: ['new.txt'] }, mockSocket);
|
|
394
|
+
expect(mockSocket.broadcast.emit).toHaveBeenCalledWith('rpc', expect.objectContaining({
|
|
395
|
+
jsonrpc: '2.0',
|
|
396
|
+
method: 'git.changed',
|
|
397
|
+
params: { dir: expect.any(String) },
|
|
398
|
+
}));
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
describe('Git Operations - resetIndex', () => {
|
|
402
|
+
beforeEach(async () => {
|
|
403
|
+
await execGit(['init'], repoPath);
|
|
404
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
405
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
406
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'original');
|
|
407
|
+
await execGit(['add', '.'], repoPath);
|
|
408
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
409
|
+
});
|
|
410
|
+
it('should reset modified file to HEAD', async () => {
|
|
411
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'modified');
|
|
412
|
+
await execGit(['add', 'file.txt'], repoPath);
|
|
413
|
+
const result = await service.handle('resetIndex', { dir: '/repo', filepath: 'file.txt' }, mockSocket);
|
|
414
|
+
expect(result.success).toBe(true);
|
|
415
|
+
const status = await execGit(['status', '--porcelain'], repoPath);
|
|
416
|
+
// After reset, file should be unstaged (not "M " which is staged)
|
|
417
|
+
expect(status).not.toContain('M file.txt');
|
|
418
|
+
// File should show as modified in working directory
|
|
419
|
+
expect(status).toContain('file.txt');
|
|
420
|
+
});
|
|
421
|
+
it('should reset new file (remove from index)', async () => {
|
|
422
|
+
await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
|
|
423
|
+
await execGit(['add', 'new.txt'], repoPath);
|
|
424
|
+
await service.handle('resetIndex', { dir: '/repo', filepath: 'new.txt' }, mockSocket);
|
|
425
|
+
const status = await execGit(['status', '--porcelain'], repoPath);
|
|
426
|
+
expect(status).not.toContain('A new.txt');
|
|
427
|
+
expect(status).toContain('?? new.txt');
|
|
428
|
+
});
|
|
429
|
+
it('should use custom ref when provided', async () => {
|
|
430
|
+
// Create a second commit
|
|
431
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'second version');
|
|
432
|
+
await execGit(['add', '.'], repoPath);
|
|
433
|
+
await execGit(['commit', '-m', 'Second'], repoPath);
|
|
434
|
+
// Modify and stage
|
|
435
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'modified');
|
|
436
|
+
await execGit(['add', 'file.txt'], repoPath);
|
|
437
|
+
// Reset to HEAD~1 (first commit)
|
|
438
|
+
await service.handle('resetIndex', { dir: '/repo', filepath: 'file.txt', ref: 'HEAD~1' }, mockSocket);
|
|
439
|
+
const result = await service.handle('resetIndex', { dir: '/repo', filepath: 'file.txt' }, mockSocket);
|
|
440
|
+
expect(result.success).toBe(true);
|
|
441
|
+
});
|
|
442
|
+
it('should broadcast change notification', async () => {
|
|
443
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'modified');
|
|
444
|
+
await execGit(['add', 'file.txt'], repoPath);
|
|
445
|
+
await service.handle('resetIndex', { dir: '/repo', filepath: 'file.txt' }, mockSocket);
|
|
446
|
+
expect(mockSocket.broadcast.emit).toHaveBeenCalledWith('rpc', expect.objectContaining({
|
|
447
|
+
jsonrpc: '2.0',
|
|
448
|
+
method: 'git.changed',
|
|
449
|
+
params: { dir: expect.any(String) },
|
|
450
|
+
}));
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
describe('Git Operations - getConfig and setConfig', () => {
|
|
454
|
+
beforeEach(async () => {
|
|
455
|
+
await execGit(['init'], repoPath);
|
|
456
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
457
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
458
|
+
});
|
|
459
|
+
it('should get existing config value', async () => {
|
|
460
|
+
const result = await service.handle('getConfig', { dir: '/repo', path: 'user.name' }, mockSocket);
|
|
461
|
+
expect(result.value).toBe('Test User');
|
|
462
|
+
});
|
|
463
|
+
it('should return undefined for non-existent config key', async () => {
|
|
464
|
+
const result = await service.handle('getConfig', { dir: '/repo', path: 'core.pager' }, mockSocket);
|
|
465
|
+
expect(result.value).toBeUndefined();
|
|
466
|
+
});
|
|
467
|
+
it('should set config value', async () => {
|
|
468
|
+
await service.handle('setConfig', { dir: '/repo', path: 'user.name', value: 'New Name' }, mockSocket);
|
|
469
|
+
const result = await execGit(['config', '--get', 'user.name'], repoPath);
|
|
470
|
+
expect(result).toBe('New Name');
|
|
471
|
+
});
|
|
472
|
+
it('should set boolean config value', async () => {
|
|
473
|
+
await service.handle('setConfig', { dir: '/repo', path: 'core.bare', value: true }, mockSocket);
|
|
474
|
+
const result = await execGit(['config', '--get', 'core.bare'], repoPath);
|
|
475
|
+
expect(result).toBe('true');
|
|
476
|
+
});
|
|
477
|
+
it('should set number config value', async () => {
|
|
478
|
+
await service.handle('setConfig', { dir: '/repo', path: 'core.compression', value: 5 }, mockSocket);
|
|
479
|
+
const result = await execGit(['config', '--get', 'core.compression'], repoPath);
|
|
480
|
+
expect(result).toBe('5');
|
|
481
|
+
});
|
|
482
|
+
it('should unset config value when value is undefined', async () => {
|
|
483
|
+
await execGit(['config', 'core.pager', 'less'], repoPath);
|
|
484
|
+
await service.handle('setConfig', { dir: '/repo', path: 'core.pager', value: undefined }, mockSocket);
|
|
485
|
+
const result = await service.handle('getConfig', { dir: '/repo', path: 'core.pager' }, mockSocket);
|
|
486
|
+
expect(result.value).toBeUndefined();
|
|
487
|
+
});
|
|
488
|
+
it('should append config value when append is true', async () => {
|
|
489
|
+
await execGit(['config', 'remote.origin.fetch', 'value1'], repoPath);
|
|
490
|
+
await service.handle('setConfig', { dir: '/repo', path: 'remote.origin.fetch', value: 'value2', append: true }, mockSocket);
|
|
491
|
+
// Get all values
|
|
492
|
+
const result = await execGit(['config', '--get-all', 'remote.origin.fetch'], repoPath);
|
|
493
|
+
expect(result).toContain('value1');
|
|
494
|
+
expect(result).toContain('value2');
|
|
495
|
+
});
|
|
496
|
+
it('should handle unsetting non-existent key gracefully', async () => {
|
|
497
|
+
const result = await service.handle('setConfig', { dir: '/repo', path: 'core.pager', value: undefined }, mockSocket);
|
|
498
|
+
expect(result.success).toBe(true);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
describe('Commit Parsing', () => {
|
|
502
|
+
it('should handle commits with multiple parents (merge commits)', async () => {
|
|
503
|
+
await execGit(['init'], repoPath);
|
|
504
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
505
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
506
|
+
// Create initial commit on default branch
|
|
507
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'base');
|
|
508
|
+
await execGit(['add', '.'], repoPath);
|
|
509
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
510
|
+
const defaultBranch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
|
|
511
|
+
// Create a commit on the default branch to prevent fast-forward
|
|
512
|
+
await fs.writeFile(path.join(repoPath, 'base.txt'), 'content');
|
|
513
|
+
await execGit(['add', '.'], repoPath);
|
|
514
|
+
await execGit(['commit', '-m', 'Base commit'], repoPath);
|
|
515
|
+
// Create branch from first commit
|
|
516
|
+
const firstCommit = await execGit(['rev-list', '--max-parents=0', 'HEAD'], repoPath);
|
|
517
|
+
await execGit(['checkout', '-b', 'feature', firstCommit], repoPath);
|
|
518
|
+
await fs.writeFile(path.join(repoPath, 'feature.txt'), 'feature');
|
|
519
|
+
await execGit(['add', '.'], repoPath);
|
|
520
|
+
await execGit(['commit', '-m', 'Feature'], repoPath);
|
|
521
|
+
// Merge back to create a true merge commit
|
|
522
|
+
await execGit(['checkout', defaultBranch], repoPath);
|
|
523
|
+
await execGit(['merge', 'feature', '--no-ff', '-m', 'Merge feature'], repoPath);
|
|
524
|
+
const mergeOid = await execGit(['rev-parse', 'HEAD'], repoPath);
|
|
525
|
+
const result = await service.handle('readCommit', { dir: '/repo', oid: mergeOid }, mockSocket);
|
|
526
|
+
// Should have 2 parents (merge commit)
|
|
527
|
+
expect(result.commit.parent.length).toBeGreaterThanOrEqual(2);
|
|
528
|
+
});
|
|
529
|
+
it('should handle multiline commit messages', async () => {
|
|
530
|
+
await execGit(['init'], repoPath);
|
|
531
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
532
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
533
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
534
|
+
await execGit(['add', '.'], repoPath);
|
|
535
|
+
const multilineMessage = 'First line\n\nSecond paragraph\nThird line';
|
|
536
|
+
await execGit(['commit', '-m', multilineMessage], repoPath);
|
|
537
|
+
const oid = await execGit(['rev-parse', 'HEAD'], repoPath);
|
|
538
|
+
const result = await service.handle('readCommit', { dir: '/repo', oid }, mockSocket);
|
|
539
|
+
expect(result.commit.message.trim()).toBe(multilineMessage);
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
describe('Git Operations - listRemotes', () => {
|
|
543
|
+
it('should return empty array when no remotes', async () => {
|
|
544
|
+
await execGit(['init'], repoPath);
|
|
545
|
+
const result = await service.handle('listRemotes', { dir: '/repo' }, mockSocket);
|
|
546
|
+
expect(result.remotes).toEqual([]);
|
|
547
|
+
});
|
|
548
|
+
it('should list configured remotes', async () => {
|
|
549
|
+
await execGit(['init'], repoPath);
|
|
550
|
+
await execGit(['remote', 'add', 'origin', 'https://github.com/user/repo.git'], repoPath);
|
|
551
|
+
await execGit(['remote', 'add', 'upstream', 'https://github.com/upstream/repo.git'], repoPath);
|
|
552
|
+
const result = await service.handle('listRemotes', { dir: '/repo' }, mockSocket);
|
|
553
|
+
expect(result.remotes).toHaveLength(2);
|
|
554
|
+
expect(result.remotes.some((r) => r.remote === 'origin')).toBe(true);
|
|
555
|
+
expect(result.remotes.some((r) => r.remote === 'upstream')).toBe(true);
|
|
556
|
+
expect(result.remotes[0]).toHaveProperty('url');
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
describe('Git Operations - addRemote', () => {
|
|
560
|
+
it('should add a new remote', async () => {
|
|
561
|
+
await execGit(['init'], repoPath);
|
|
562
|
+
const result = await service.handle('addRemote', { dir: '/repo', remote: 'origin', url: 'https://github.com/user/repo.git' }, mockSocket);
|
|
563
|
+
expect(result.success).toBe(true);
|
|
564
|
+
// Verify remote was added
|
|
565
|
+
const remotes = await execGit(['remote', '-v'], repoPath);
|
|
566
|
+
expect(remotes).toContain('origin');
|
|
567
|
+
expect(remotes).toContain('https://github.com/user/repo.git');
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
describe('Git Operations - deleteRemote', () => {
|
|
571
|
+
it('should delete an existing remote', async () => {
|
|
572
|
+
await execGit(['init'], repoPath);
|
|
573
|
+
await execGit(['remote', 'add', 'origin', 'https://github.com/user/repo.git'], repoPath);
|
|
574
|
+
const result = await service.handle('deleteRemote', { dir: '/repo', remote: 'origin' }, mockSocket);
|
|
575
|
+
expect(result.success).toBe(true);
|
|
576
|
+
// Verify remote was deleted
|
|
577
|
+
const remotes = await execGit(['remote'], repoPath);
|
|
578
|
+
expect(remotes).not.toContain('origin');
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
describe('Git Operations - clearIndex', () => {
|
|
582
|
+
it('should clear the git index', async () => {
|
|
583
|
+
await execGit(['init'], repoPath);
|
|
584
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
585
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
586
|
+
// Add files to index
|
|
587
|
+
await fs.writeFile(path.join(repoPath, 'file1.txt'), 'content1');
|
|
588
|
+
await fs.writeFile(path.join(repoPath, 'file2.txt'), 'content2');
|
|
589
|
+
await execGit(['add', '.'], repoPath);
|
|
590
|
+
const result = await service.handle('clearIndex', { dir: '/repo' }, mockSocket);
|
|
591
|
+
expect(result.success).toBe(true);
|
|
592
|
+
// Verify index is empty
|
|
593
|
+
const status = await execGit(['status', '--porcelain'], repoPath);
|
|
594
|
+
expect(status).toContain('??'); // Files should be untracked now
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
describe('Git Operations - isIgnored', () => {
|
|
598
|
+
it('should return false for non-ignored files', async () => {
|
|
599
|
+
await execGit(['init'], repoPath);
|
|
600
|
+
await fs.writeFile(path.join(repoPath, 'regular.txt'), 'content');
|
|
601
|
+
const result = await service.handle('isIgnored', { dir: '/repo', filepath: 'regular.txt' }, mockSocket);
|
|
602
|
+
expect(result).toBe(false);
|
|
603
|
+
});
|
|
604
|
+
it('should return true for ignored files', async () => {
|
|
605
|
+
await execGit(['init'], repoPath);
|
|
606
|
+
await fs.writeFile(path.join(repoPath, '.gitignore'), '*.log\nnode_modules/');
|
|
607
|
+
await fs.writeFile(path.join(repoPath, 'test.log'), 'logs');
|
|
608
|
+
const result = await service.handle('isIgnored', { dir: '/repo', filepath: 'test.log' }, mockSocket);
|
|
609
|
+
expect(result).toBe(true);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
describe('Git Operations - isInitialized', () => {
|
|
613
|
+
it('should return false for non-git directory', async () => {
|
|
614
|
+
const result = await service.handle('isInitialized', { dir: '/repo' }, mockSocket);
|
|
615
|
+
expect(result).toBe(false);
|
|
616
|
+
});
|
|
617
|
+
it('should return true for initialized git repository', async () => {
|
|
618
|
+
await execGit(['init'], repoPath);
|
|
619
|
+
const result = await service.handle('isInitialized', { dir: '/repo' }, mockSocket);
|
|
620
|
+
expect(result).toBe(true);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
describe('Git Operations - resolveRef', () => {
|
|
624
|
+
it('should resolve HEAD to commit oid', async () => {
|
|
625
|
+
await execGit(['init'], repoPath);
|
|
626
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
627
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
628
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
629
|
+
await execGit(['add', '.'], repoPath);
|
|
630
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
631
|
+
const expected = await execGit(['rev-parse', 'HEAD'], repoPath);
|
|
632
|
+
const result = await service.handle('resolveRef', { dir: '/repo', ref: 'HEAD' }, mockSocket);
|
|
633
|
+
expect(result.oid).toBe(expected);
|
|
634
|
+
});
|
|
635
|
+
it('should resolve branch name to commit oid', async () => {
|
|
636
|
+
await execGit(['init'], repoPath);
|
|
637
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
638
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
639
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
640
|
+
await execGit(['add', '.'], repoPath);
|
|
641
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
642
|
+
const branch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
|
|
643
|
+
const expected = await execGit(['rev-parse', 'HEAD'], repoPath);
|
|
644
|
+
const result = await service.handle('resolveRef', { dir: '/repo', ref: branch }, mockSocket);
|
|
645
|
+
expect(result.oid).toBe(expected);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
describe('Command Injection Prevention', () => {
|
|
649
|
+
beforeEach(async () => {
|
|
650
|
+
await execGit(['init'], repoPath);
|
|
651
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
652
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
653
|
+
});
|
|
654
|
+
describe('add command', () => {
|
|
655
|
+
it('should reject filenames starting with dash', async () => {
|
|
656
|
+
await fs.writeFile(path.join(repoPath, '-evil.txt'), 'content');
|
|
657
|
+
await expect(service.handle('add', { dir: '/repo', filepaths: ['-evil.txt'] }, mockSocket)).rejects.toMatchObject({
|
|
658
|
+
code: ErrorCode.INVALID_PATH,
|
|
659
|
+
message: expect.stringContaining('cannot start with dash'),
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
it('should reject filenames with control characters', async () => {
|
|
663
|
+
await expect(service.handle('add', { dir: '/repo', filepaths: ['file\x00.txt'] }, mockSocket)).rejects.toMatchObject({
|
|
664
|
+
code: ErrorCode.INVALID_PATH,
|
|
665
|
+
message: expect.stringContaining('control characters'),
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
it('should reject filenames with newlines', async () => {
|
|
669
|
+
await expect(service.handle('add', { dir: '/repo', filepaths: ['file\n.txt'] }, mockSocket)).rejects.toMatchObject({
|
|
670
|
+
code: ErrorCode.INVALID_PATH,
|
|
671
|
+
message: expect.stringContaining('newline'),
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
it('should accept valid filenames with special characters', async () => {
|
|
675
|
+
await fs.writeFile(path.join(repoPath, 'file@#$%.txt'), 'content');
|
|
676
|
+
const result = await service.handle('add', { dir: '/repo', filepaths: ['file@#$%.txt'] }, mockSocket);
|
|
677
|
+
expect(result.success).toBe(true);
|
|
678
|
+
});
|
|
679
|
+
it('should reject when any filename in array is malicious', async () => {
|
|
680
|
+
await fs.writeFile(path.join(repoPath, 'good.txt'), 'content');
|
|
681
|
+
await fs.writeFile(path.join(repoPath, '-evil.txt'), 'content');
|
|
682
|
+
await expect(service.handle('add', { dir: '/repo', filepaths: ['good.txt', '-evil.txt'] }, mockSocket)).rejects.toMatchObject({
|
|
683
|
+
code: ErrorCode.INVALID_PATH,
|
|
684
|
+
message: expect.stringContaining('cannot start with dash'),
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
describe('remove command', () => {
|
|
689
|
+
beforeEach(async () => {
|
|
690
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
691
|
+
await execGit(['add', '.'], repoPath);
|
|
692
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
693
|
+
});
|
|
694
|
+
it('should reject filenames starting with dash', async () => {
|
|
695
|
+
await expect(service.handle('remove', { dir: '/repo', filepaths: ['-evil.txt'] }, mockSocket)).rejects.toMatchObject({
|
|
696
|
+
code: ErrorCode.INVALID_PATH,
|
|
697
|
+
message: expect.stringContaining('cannot start with dash'),
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
it('should reject filenames with control characters', async () => {
|
|
701
|
+
await expect(service.handle('remove', { dir: '/repo', filepaths: ['file\x1F.txt'] }, mockSocket)).rejects.toMatchObject({
|
|
702
|
+
code: ErrorCode.INVALID_PATH,
|
|
703
|
+
message: expect.stringContaining('control characters'),
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
it('should reject filenames with carriage returns', async () => {
|
|
707
|
+
await expect(service.handle('remove', { dir: '/repo', filepaths: ['file\r.txt'] }, mockSocket)).rejects.toMatchObject({
|
|
708
|
+
code: ErrorCode.INVALID_PATH,
|
|
709
|
+
message: expect.stringContaining('newline'),
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
describe('resetIndex command', () => {
|
|
714
|
+
beforeEach(async () => {
|
|
715
|
+
await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
|
|
716
|
+
await execGit(['add', '.'], repoPath);
|
|
717
|
+
await execGit(['commit', '-m', 'Initial'], repoPath);
|
|
718
|
+
});
|
|
719
|
+
it('should reject single filepath starting with dash', async () => {
|
|
720
|
+
await expect(service.handle('resetIndex', { dir: '/repo', filepath: '-evil.txt' }, mockSocket)).rejects.toMatchObject({
|
|
721
|
+
code: ErrorCode.INVALID_PATH,
|
|
722
|
+
message: expect.stringContaining('cannot start with dash'),
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
it('should reject filepaths array with dash-prefixed filename', async () => {
|
|
726
|
+
await expect(service.handle('resetIndex', { dir: '/repo', filepaths: ['-evil.txt'] }, mockSocket)).rejects.toMatchObject({
|
|
727
|
+
code: ErrorCode.INVALID_PATH,
|
|
728
|
+
message: expect.stringContaining('cannot start with dash'),
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
it('should reject filepath with DEL character', async () => {
|
|
732
|
+
await expect(service.handle('resetIndex', { dir: '/repo', filepath: 'file\x7F.txt' }, mockSocket)).rejects.toMatchObject({
|
|
733
|
+
code: ErrorCode.INVALID_PATH,
|
|
734
|
+
message: expect.stringContaining('control characters'),
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
it('should accept valid filepath in single mode', async () => {
|
|
738
|
+
await fs.writeFile(path.join(repoPath, 'valid.txt'), 'content');
|
|
739
|
+
await execGit(['add', 'valid.txt'], repoPath);
|
|
740
|
+
const result = await service.handle('resetIndex', { dir: '/repo', filepath: 'valid.txt' }, mockSocket);
|
|
741
|
+
expect(result.success).toBe(true);
|
|
742
|
+
});
|
|
743
|
+
it('should accept valid filepaths in array mode', async () => {
|
|
744
|
+
await fs.writeFile(path.join(repoPath, 'valid1.txt'), 'content1');
|
|
745
|
+
await fs.writeFile(path.join(repoPath, 'valid2.txt'), 'content2');
|
|
746
|
+
await execGit(['add', '.'], repoPath);
|
|
747
|
+
const result = await service.handle('resetIndex', { dir: '/repo', filepaths: ['valid1.txt', 'valid2.txt'] }, mockSocket);
|
|
748
|
+
expect(result.success).toBe(true);
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
describe('flag injection via -- separator', () => {
|
|
752
|
+
it('should safely handle files that look like git flags in add', async () => {
|
|
753
|
+
// This test verifies that the -- separator prevents flag injection
|
|
754
|
+
// Even though the filename starts with -, it's sanitized before reaching git
|
|
755
|
+
await expect(service.handle('add', { dir: '/repo', filepaths: ['--version'] }, mockSocket)).rejects.toMatchObject({
|
|
756
|
+
code: ErrorCode.INVALID_PATH,
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
it('should safely handle files that look like git flags in remove', async () => {
|
|
760
|
+
await expect(service.handle('remove', { dir: '/repo', filepaths: ['--help'] }, mockSocket)).rejects.toMatchObject({
|
|
761
|
+
code: ErrorCode.INVALID_PATH,
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
it('should safely handle files that look like git flags in resetIndex', async () => {
|
|
765
|
+
await expect(service.handle('resetIndex', { dir: '/repo', filepath: '--cached' }, mockSocket)).rejects.toMatchObject({
|
|
766
|
+
code: ErrorCode.INVALID_PATH,
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
describe('bulkIsIgnored', () => {
|
|
772
|
+
beforeEach(async () => {
|
|
773
|
+
// Initialize git repository
|
|
774
|
+
await execGit(['init'], repoPath);
|
|
775
|
+
await execGit(['config', 'user.email', 'test@example.com'], repoPath);
|
|
776
|
+
await execGit(['config', 'user.name', 'Test User'], repoPath);
|
|
777
|
+
// Create .gitignore with test patterns
|
|
778
|
+
await fs.writeFile(path.join(repoPath, '.gitignore'), '*.log\nnode_modules/\n.env\nbuild/\n');
|
|
779
|
+
// Create test files
|
|
780
|
+
await fs.writeFile(path.join(repoPath, 'file1.js'), 'test');
|
|
781
|
+
await fs.writeFile(path.join(repoPath, 'file2.log'), 'test');
|
|
782
|
+
await fs.writeFile(path.join(repoPath, '.env'), 'test');
|
|
783
|
+
await fs.mkdir(path.join(repoPath, 'src'), { recursive: true });
|
|
784
|
+
await fs.writeFile(path.join(repoPath, 'src', 'index.js'), 'test');
|
|
785
|
+
await fs.mkdir(path.join(repoPath, 'node_modules'), { recursive: true });
|
|
786
|
+
await fs.writeFile(path.join(repoPath, 'node_modules', 'test.js'), 'test');
|
|
787
|
+
});
|
|
788
|
+
it('should check multiple files and return 1/0 for ignored status', async () => {
|
|
789
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
790
|
+
dir: '/repo',
|
|
791
|
+
filepaths: [
|
|
792
|
+
'file1.js', // not ignored
|
|
793
|
+
'file2.log', // ignored (*.log)
|
|
794
|
+
'.env', // ignored
|
|
795
|
+
'src/index.js', // not ignored
|
|
796
|
+
'node_modules/test.js' // ignored
|
|
797
|
+
]
|
|
798
|
+
}, mockSocket);
|
|
799
|
+
expect(result).toEqual([0, 1, 1, 0, 1]);
|
|
800
|
+
});
|
|
801
|
+
it('should return empty array for empty input', async () => {
|
|
802
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
803
|
+
dir: '/repo',
|
|
804
|
+
filepaths: []
|
|
805
|
+
}, mockSocket);
|
|
806
|
+
expect(result).toEqual([]);
|
|
807
|
+
});
|
|
808
|
+
it('should handle all ignored files', async () => {
|
|
809
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
810
|
+
dir: '/repo',
|
|
811
|
+
filepaths: [
|
|
812
|
+
'file2.log',
|
|
813
|
+
'debug.log',
|
|
814
|
+
'.env',
|
|
815
|
+
'node_modules/package.json'
|
|
816
|
+
]
|
|
817
|
+
}, mockSocket);
|
|
818
|
+
expect(result).toEqual([1, 1, 1, 1]);
|
|
819
|
+
});
|
|
820
|
+
it('should handle all non-ignored files', async () => {
|
|
821
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
822
|
+
dir: '/repo',
|
|
823
|
+
filepaths: [
|
|
824
|
+
'file1.js',
|
|
825
|
+
'src/index.js',
|
|
826
|
+
'package.json',
|
|
827
|
+
'README.md'
|
|
828
|
+
]
|
|
829
|
+
}, mockSocket);
|
|
830
|
+
expect(result).toEqual([0, 0, 0, 0]);
|
|
831
|
+
});
|
|
832
|
+
it('should work with single file', async () => {
|
|
833
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
834
|
+
dir: '/repo',
|
|
835
|
+
filepaths: ['file2.log']
|
|
836
|
+
}, mockSocket);
|
|
837
|
+
expect(result).toEqual([1]);
|
|
838
|
+
});
|
|
839
|
+
it('should handle nested gitignore patterns', async () => {
|
|
840
|
+
await fs.mkdir(path.join(repoPath, 'build'), { recursive: true });
|
|
841
|
+
await fs.writeFile(path.join(repoPath, 'build', 'output.js'), 'test');
|
|
842
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
843
|
+
dir: '/repo',
|
|
844
|
+
filepaths: [
|
|
845
|
+
'build/output.js', // ignored (build/ pattern)
|
|
846
|
+
'src/build.js' // not ignored
|
|
847
|
+
]
|
|
848
|
+
}, mockSocket);
|
|
849
|
+
expect(result).toEqual([1, 0]);
|
|
850
|
+
});
|
|
851
|
+
it('should throw error for invalid params', async () => {
|
|
852
|
+
await expect(service.handle('bulkIsIgnored', {
|
|
853
|
+
dir: '/repo',
|
|
854
|
+
filepaths: 'not-an-array'
|
|
855
|
+
}, mockSocket)).rejects.toMatchObject({
|
|
856
|
+
code: ErrorCode.INVALID_PARAMS,
|
|
857
|
+
message: 'filepaths must be an array'
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
it('should respect .gitignore inside submodules', async () => {
|
|
861
|
+
// Create a submodule with its own .gitignore
|
|
862
|
+
const submodulePath = path.join(testRoot, 'mylib');
|
|
863
|
+
await fs.mkdir(submodulePath);
|
|
864
|
+
await execGit(['init'], submodulePath);
|
|
865
|
+
await execGit(['config', 'user.email', 'test@example.com'], submodulePath);
|
|
866
|
+
await execGit(['config', 'user.name', 'Test User'], submodulePath);
|
|
867
|
+
await fs.writeFile(path.join(submodulePath, '.gitignore'), 'dist/\n*.tmp\n');
|
|
868
|
+
await fs.writeFile(path.join(submodulePath, 'index.js'), 'module.exports = {}');
|
|
869
|
+
await fs.mkdir(path.join(submodulePath, 'dist'), { recursive: true });
|
|
870
|
+
await fs.writeFile(path.join(submodulePath, 'dist', 'bundle.js'), 'bundled');
|
|
871
|
+
await fs.writeFile(path.join(submodulePath, 'cache.tmp'), 'temp');
|
|
872
|
+
await execGit(['add', '.'], submodulePath);
|
|
873
|
+
await execGit(['commit', '-m', 'init submodule'], submodulePath);
|
|
874
|
+
// Add submodule to main repo (allow file transport for local test)
|
|
875
|
+
await execGit(['-c', 'protocol.file.allow=always', 'submodule', 'add', submodulePath, 'mylib'], repoPath);
|
|
876
|
+
await execGit(['commit', '-m', 'add submodule'], repoPath);
|
|
877
|
+
// Create submodule-specific ignored files in the working tree
|
|
878
|
+
await fs.mkdir(path.join(repoPath, 'mylib', 'dist'), { recursive: true });
|
|
879
|
+
await fs.writeFile(path.join(repoPath, 'mylib', 'dist', 'bundle.js'), 'bundled');
|
|
880
|
+
await fs.writeFile(path.join(repoPath, 'mylib', 'cache.tmp'), 'temp');
|
|
881
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
882
|
+
dir: '/repo',
|
|
883
|
+
filepaths: [
|
|
884
|
+
'mylib/index.js', // not ignored by submodule's .gitignore
|
|
885
|
+
'mylib/dist/bundle.js', // ignored by submodule's .gitignore (dist/)
|
|
886
|
+
'mylib/cache.tmp', // ignored by submodule's .gitignore (*.tmp)
|
|
887
|
+
'file1.js', // not ignored by main repo
|
|
888
|
+
'file2.log', // ignored by main repo's .gitignore (*.log)
|
|
889
|
+
]
|
|
890
|
+
}, mockSocket);
|
|
891
|
+
expect(result).toEqual([0, 1, 1, 0, 1]);
|
|
892
|
+
});
|
|
893
|
+
it('should respect .gitignore inside submodules for paths not ignored by parent', async () => {
|
|
894
|
+
// Create a submodule with its own .gitignore that differs from parent
|
|
895
|
+
const submodulePath = path.join(testRoot, 'mylib');
|
|
896
|
+
await fs.mkdir(submodulePath);
|
|
897
|
+
await execGit(['init'], submodulePath);
|
|
898
|
+
await execGit(['config', 'user.email', 'test@example.com'], submodulePath);
|
|
899
|
+
await execGit(['config', 'user.name', 'Test User'], submodulePath);
|
|
900
|
+
// Submodule ignores "vendor/" which the parent does NOT ignore
|
|
901
|
+
await fs.writeFile(path.join(submodulePath, '.gitignore'), 'vendor/\n');
|
|
902
|
+
await fs.writeFile(path.join(submodulePath, 'lib.js'), 'code');
|
|
903
|
+
await execGit(['add', '.'], submodulePath);
|
|
904
|
+
await execGit(['commit', '-m', 'init submodule'], submodulePath);
|
|
905
|
+
// Add submodule to main repo
|
|
906
|
+
await execGit(['-c', 'protocol.file.allow=always', 'submodule', 'add', submodulePath, 'mylib'], repoPath);
|
|
907
|
+
await execGit(['commit', '-m', 'add submodule'], repoPath);
|
|
908
|
+
// Create vendor dir inside submodule in working tree
|
|
909
|
+
await fs.mkdir(path.join(repoPath, 'mylib', 'vendor'), { recursive: true });
|
|
910
|
+
await fs.writeFile(path.join(repoPath, 'mylib', 'vendor', 'dep.js'), 'dependency');
|
|
911
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
912
|
+
dir: '/repo',
|
|
913
|
+
filepaths: [
|
|
914
|
+
'mylib/lib.js', // not ignored
|
|
915
|
+
'mylib/vendor/dep.js', // ignored by submodule's .gitignore (vendor/)
|
|
916
|
+
]
|
|
917
|
+
}, mockSocket);
|
|
918
|
+
// vendor/dep.js should be ignored by the submodule's .gitignore, NOT the parent's
|
|
919
|
+
expect(result).toEqual([0, 1]);
|
|
920
|
+
});
|
|
921
|
+
it('should respect .gitignore when gitRoot targets a submodule', async () => {
|
|
922
|
+
// Create a submodule with its own .gitignore
|
|
923
|
+
const submodulePath = path.join(testRoot, 'mylib');
|
|
924
|
+
await fs.mkdir(submodulePath);
|
|
925
|
+
await execGit(['init'], submodulePath);
|
|
926
|
+
await execGit(['config', 'user.email', 'test@example.com'], submodulePath);
|
|
927
|
+
await execGit(['config', 'user.name', 'Test User'], submodulePath);
|
|
928
|
+
await fs.writeFile(path.join(submodulePath, '.gitignore'), 'coverage/\n*.bak\n');
|
|
929
|
+
await fs.writeFile(path.join(submodulePath, 'src.js'), 'code');
|
|
930
|
+
await execGit(['add', '.'], submodulePath);
|
|
931
|
+
await execGit(['commit', '-m', 'init submodule'], submodulePath);
|
|
932
|
+
// Add submodule to main repo
|
|
933
|
+
await execGit(['-c', 'protocol.file.allow=always', 'submodule', 'add', submodulePath, 'mylib'], repoPath);
|
|
934
|
+
await execGit(['commit', '-m', 'add submodule'], repoPath);
|
|
935
|
+
// Create ignored content in working tree
|
|
936
|
+
await fs.mkdir(path.join(repoPath, 'mylib', 'coverage'), { recursive: true });
|
|
937
|
+
await fs.writeFile(path.join(repoPath, 'mylib', 'coverage', 'report.html'), 'report');
|
|
938
|
+
await fs.writeFile(path.join(repoPath, 'mylib', 'old.bak'), 'backup');
|
|
939
|
+
// Call with gitRoot pointing to submodule, filepaths relative to submodule
|
|
940
|
+
const result = await service.handle('bulkIsIgnored', {
|
|
941
|
+
dir: '/repo',
|
|
942
|
+
gitRoot: 'mylib',
|
|
943
|
+
filepaths: [
|
|
944
|
+
'src.js', // not ignored
|
|
945
|
+
'coverage/report.html', // ignored (coverage/)
|
|
946
|
+
'old.bak', // ignored (*.bak)
|
|
947
|
+
]
|
|
948
|
+
}, mockSocket);
|
|
949
|
+
expect(result).toEqual([0, 1, 1]);
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
});
|
|
953
|
+
//# sourceMappingURL=GitService.test.js.map
|