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,1639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git service - executes command-line git operations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn, execFileSync } from 'child_process';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as http from 'http';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as os from 'os';
|
|
10
|
+
import * as crypto from 'crypto';
|
|
11
|
+
import { AuthenticatedSocket, ErrorCode, createRPCError } from '../types.js';
|
|
12
|
+
import { logGitRead, logGitWrite } from '../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
interface ExecGitOptions {
|
|
15
|
+
cwd?: string;
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
input?: string;
|
|
18
|
+
acceptExitCodes?: number[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ExecGitResult {
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
code: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class GitService {
|
|
28
|
+
private submoduleCache: Map<string, { submodules: Array<{ name: string; path: string }>; timestamp: number }> = new Map();
|
|
29
|
+
private static SUBMODULE_CACHE_TTL = 30000; // 30 seconds
|
|
30
|
+
private _hasCurl: boolean | null = null;
|
|
31
|
+
|
|
32
|
+
constructor(private rootPath: string) {}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if curl is available on the system (cached)
|
|
36
|
+
*/
|
|
37
|
+
private hasCurl(): boolean {
|
|
38
|
+
if (this._hasCurl === null) {
|
|
39
|
+
try {
|
|
40
|
+
execFileSync('curl', ['--version'], { stdio: 'ignore' });
|
|
41
|
+
this._hasCurl = true;
|
|
42
|
+
} catch {
|
|
43
|
+
this._hasCurl = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return this._hasCurl;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get submodules for a directory, using a short-lived cache to avoid
|
|
51
|
+
* repeated git submodule status calls during bulk operations.
|
|
52
|
+
*/
|
|
53
|
+
private async getCachedSubmodules(dir: string): Promise<Array<{ name: string; path: string }>> {
|
|
54
|
+
const cached = this.submoduleCache.get(dir);
|
|
55
|
+
if (cached && Date.now() - cached.timestamp < GitService.SUBMODULE_CACHE_TTL) {
|
|
56
|
+
return cached.submodules;
|
|
57
|
+
}
|
|
58
|
+
const { submodules } = await this.listSubmodules(dir);
|
|
59
|
+
this.submoduleCache.set(dir, { submodules, timestamp: Date.now() });
|
|
60
|
+
return submodules;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the effective git working directory.
|
|
65
|
+
* When gitRoot is provided (submodule path), resolve it relative to dir.
|
|
66
|
+
*/
|
|
67
|
+
private resolveGitCwd(dir: string, gitRoot?: string): string {
|
|
68
|
+
if (gitRoot) {
|
|
69
|
+
const resolved = path.resolve(dir, gitRoot);
|
|
70
|
+
// Security: ensure resolved path stays within dir
|
|
71
|
+
if (!resolved.startsWith(dir)) {
|
|
72
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Invalid gitRoot: path traversal not allowed');
|
|
73
|
+
}
|
|
74
|
+
return resolved;
|
|
75
|
+
}
|
|
76
|
+
return dir;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Handle git RPC methods
|
|
81
|
+
*/
|
|
82
|
+
async handle(method: string, params: any, socket: AuthenticatedSocket): Promise<any> {
|
|
83
|
+
const deviceId = socket.data.deviceId;
|
|
84
|
+
let result: any;
|
|
85
|
+
let error: any;
|
|
86
|
+
|
|
87
|
+
// Validate and resolve git directory
|
|
88
|
+
const dir = this.validateGitDir(params.dir);
|
|
89
|
+
// Resolve effective cwd for submodule support
|
|
90
|
+
const gitCwd = this.resolveGitCwd(dir, params.gitRoot);
|
|
91
|
+
|
|
92
|
+
// Define read and write operations
|
|
93
|
+
const readOps = ['readCommit', 'readObject', 'getHeadTree', 'getOidAtPath', 'listFiles', 'resolveRef',
|
|
94
|
+
'currentBranch', 'log', 'status', 'statusCounts', 'getConfig', 'listBranches',
|
|
95
|
+
'listRemotes', 'isIgnored', 'bulkIsIgnored', 'isInitialized', 'listSubmodules', 'requestAuth',
|
|
96
|
+
'getDefaultAuthor'];
|
|
97
|
+
const writeOps = ['add', 'remove', 'resetIndex', 'commit', 'setConfig', 'checkout', 'init',
|
|
98
|
+
'addRemote', 'deleteRemote', 'clearIndex', 'push', 'pull', 'fetch', 'clone'];
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
switch (method) {
|
|
102
|
+
case 'readCommit':
|
|
103
|
+
result = await this.readCommit(gitCwd, params);
|
|
104
|
+
logGitRead(method, params, deviceId, true, undefined, { oid: params.oid });
|
|
105
|
+
return result;
|
|
106
|
+
case 'readObject':
|
|
107
|
+
result = await this.readObject(gitCwd, params);
|
|
108
|
+
logGitRead(method, params, deviceId, true, undefined, { oid: params.oid });
|
|
109
|
+
return result;
|
|
110
|
+
case 'getHeadTree':
|
|
111
|
+
result = await this.getHeadTree(gitCwd, params);
|
|
112
|
+
logGitRead(method, params, deviceId, true, undefined, { oid: result.oid });
|
|
113
|
+
return result;
|
|
114
|
+
case 'getOidAtPath':
|
|
115
|
+
result = await this.getOidAtPath(gitCwd, params);
|
|
116
|
+
logGitRead(method, params, deviceId, true, undefined, { path: params.path, oid: result.oid });
|
|
117
|
+
return result;
|
|
118
|
+
case 'listFiles':
|
|
119
|
+
result = await this.listFiles(gitCwd, params);
|
|
120
|
+
logGitRead(method, params, deviceId, true, undefined, { count: result.files.length });
|
|
121
|
+
return result;
|
|
122
|
+
case 'resolveRef':
|
|
123
|
+
result = await this.resolveRef(gitCwd, params);
|
|
124
|
+
logGitRead(method, params, deviceId, true, undefined, { ref: params.ref, oid: result.oid });
|
|
125
|
+
return result;
|
|
126
|
+
case 'currentBranch':
|
|
127
|
+
result = await this.currentBranch(gitCwd, params);
|
|
128
|
+
logGitRead(method, params, deviceId, true, undefined, { branch: result.branch });
|
|
129
|
+
return result;
|
|
130
|
+
case 'log':
|
|
131
|
+
result = await this.log(gitCwd, params);
|
|
132
|
+
logGitRead(method, params, deviceId, true, undefined, { commits: result.commits.length });
|
|
133
|
+
return result;
|
|
134
|
+
case 'status':
|
|
135
|
+
result = await this.status(gitCwd, params);
|
|
136
|
+
logGitRead(method, params, deviceId, true, undefined, { files: result.length });
|
|
137
|
+
return result;
|
|
138
|
+
case 'statusCounts':
|
|
139
|
+
result = await this.statusCounts(gitCwd, params);
|
|
140
|
+
logGitRead(method, params, deviceId, true, undefined, result);
|
|
141
|
+
return result;
|
|
142
|
+
case 'add':
|
|
143
|
+
result = await this.add(gitCwd, params, socket);
|
|
144
|
+
logGitWrite(method, params, deviceId, true, undefined, { count: params.filepaths?.length || 0 });
|
|
145
|
+
return result;
|
|
146
|
+
case 'remove':
|
|
147
|
+
result = await this.remove(gitCwd, params, socket);
|
|
148
|
+
logGitWrite(method, params, deviceId, true, undefined, { count: params.filepaths?.length || 0 });
|
|
149
|
+
return result;
|
|
150
|
+
case 'resetIndex':
|
|
151
|
+
result = await this.resetIndex(gitCwd, params, socket);
|
|
152
|
+
logGitWrite(method, params, deviceId, true);
|
|
153
|
+
return result;
|
|
154
|
+
case 'commit':
|
|
155
|
+
result = await this.commit(gitCwd, params, socket);
|
|
156
|
+
logGitWrite(method, params, deviceId, true, undefined, { oid: result.oid });
|
|
157
|
+
return result;
|
|
158
|
+
case 'getConfig':
|
|
159
|
+
result = await this.getConfig(gitCwd, params);
|
|
160
|
+
logGitRead(method, params, deviceId, true, undefined, { path: params.path });
|
|
161
|
+
return result;
|
|
162
|
+
case 'setConfig':
|
|
163
|
+
result = await this.setConfig(gitCwd, params);
|
|
164
|
+
logGitWrite(method, params, deviceId, true, undefined, { path: params.path });
|
|
165
|
+
return result;
|
|
166
|
+
case 'listBranches':
|
|
167
|
+
result = await this.listBranches(gitCwd, params);
|
|
168
|
+
logGitRead(method, params, deviceId, true, undefined, { count: result.branches.length });
|
|
169
|
+
return result;
|
|
170
|
+
case 'checkout':
|
|
171
|
+
result = await this.checkout(gitCwd, params);
|
|
172
|
+
logGitWrite(method, params, deviceId, true);
|
|
173
|
+
return result;
|
|
174
|
+
case 'init':
|
|
175
|
+
result = await this.init(gitCwd, params);
|
|
176
|
+
logGitWrite(method, params, deviceId, true);
|
|
177
|
+
return result;
|
|
178
|
+
case 'listRemotes':
|
|
179
|
+
result = await this.listRemotes(gitCwd, params);
|
|
180
|
+
logGitRead(method, params, deviceId, true, undefined, { count: result.remotes.length });
|
|
181
|
+
return result;
|
|
182
|
+
case 'addRemote':
|
|
183
|
+
result = await this.addRemote(gitCwd, params);
|
|
184
|
+
logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote, url: params.url });
|
|
185
|
+
return result;
|
|
186
|
+
case 'deleteRemote':
|
|
187
|
+
result = await this.deleteRemote(gitCwd, params);
|
|
188
|
+
logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote });
|
|
189
|
+
return result;
|
|
190
|
+
case 'clearIndex':
|
|
191
|
+
result = await this.clearIndex(gitCwd, params);
|
|
192
|
+
logGitWrite(method, params, deviceId, true);
|
|
193
|
+
return result;
|
|
194
|
+
case 'isIgnored':
|
|
195
|
+
result = await this.isIgnored(gitCwd, params);
|
|
196
|
+
logGitRead(method, params, deviceId, true, undefined, { filepath: params.filepath, ignored: result });
|
|
197
|
+
return result;
|
|
198
|
+
case 'bulkIsIgnored':
|
|
199
|
+
result = await this.bulkIsIgnored(gitCwd, params);
|
|
200
|
+
logGitRead(method, params, deviceId, true, undefined, { count: result.length });
|
|
201
|
+
return result;
|
|
202
|
+
case 'isInitialized':
|
|
203
|
+
result = await this.isInitialized(gitCwd, params);
|
|
204
|
+
logGitRead(method, params, deviceId, true, undefined, { initialized: result });
|
|
205
|
+
return result;
|
|
206
|
+
case 'listSubmodules':
|
|
207
|
+
result = await this.listSubmodules(dir);
|
|
208
|
+
logGitRead(method, params, deviceId, true, undefined, { count: result.submodules.length });
|
|
209
|
+
return result;
|
|
210
|
+
case 'requestAuth':
|
|
211
|
+
// This is called by server to request credentials from client
|
|
212
|
+
result = await this.requestAuth(socket, params);
|
|
213
|
+
logGitRead(method, params, deviceId, true);
|
|
214
|
+
return result;
|
|
215
|
+
case 'getDefaultAuthor':
|
|
216
|
+
result = await this.getDefaultAuthor(gitCwd);
|
|
217
|
+
logGitRead(method, params, deviceId, true, undefined, result);
|
|
218
|
+
return result;
|
|
219
|
+
case 'push':
|
|
220
|
+
result = await this.push(gitCwd, params, socket);
|
|
221
|
+
logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote });
|
|
222
|
+
return result;
|
|
223
|
+
case 'pull':
|
|
224
|
+
result = await this.pull(gitCwd, params, socket);
|
|
225
|
+
logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote });
|
|
226
|
+
return result;
|
|
227
|
+
case 'fetch':
|
|
228
|
+
result = await this.fetch(gitCwd, params, socket);
|
|
229
|
+
logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote });
|
|
230
|
+
return result;
|
|
231
|
+
case 'clone':
|
|
232
|
+
result = await this.clone(gitCwd, params, socket);
|
|
233
|
+
logGitWrite(method, params, deviceId, true, undefined, { url: params.url });
|
|
234
|
+
return result;
|
|
235
|
+
default:
|
|
236
|
+
throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: git.${method}`);
|
|
237
|
+
}
|
|
238
|
+
} catch (err: any) {
|
|
239
|
+
error = err;
|
|
240
|
+
// Log error based on operation type
|
|
241
|
+
if (readOps.includes(method)) {
|
|
242
|
+
logGitRead(method, params, deviceId, false, error);
|
|
243
|
+
} else if (writeOps.includes(method)) {
|
|
244
|
+
logGitWrite(method, params, deviceId, false, error);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (error.code && error.message) {
|
|
248
|
+
throw error; // Re-throw RPC errors
|
|
249
|
+
}
|
|
250
|
+
throw createRPCError(
|
|
251
|
+
ErrorCode.GIT_OPERATION_FAILED,
|
|
252
|
+
`Git operation failed: ${error.message}`,
|
|
253
|
+
{ method, error: error.toString() }
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Validate git directory
|
|
260
|
+
*/
|
|
261
|
+
private validateGitDir(dir: string): string {
|
|
262
|
+
const normalized = path.normalize(dir);
|
|
263
|
+
if (normalized.includes('..')) {
|
|
264
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Invalid path: directory traversal not allowed');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const absolute = path.resolve(this.rootPath, normalized.startsWith('/') ? normalized.slice(1) : normalized);
|
|
268
|
+
if (!absolute.startsWith(path.resolve(this.rootPath))) {
|
|
269
|
+
throw createRPCError(ErrorCode.INVALID_PATH, 'Access denied: path outside root directory');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return absolute;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Sanitize filename to prevent command injection
|
|
277
|
+
* Rejects filenames that could be interpreted as git flags or contain control characters
|
|
278
|
+
*/
|
|
279
|
+
private sanitizeFilename(filename: string): string {
|
|
280
|
+
// Reject filenames starting with dash (potential git flag injection)
|
|
281
|
+
if (filename.startsWith('-')) {
|
|
282
|
+
throw createRPCError(
|
|
283
|
+
ErrorCode.INVALID_PATH,
|
|
284
|
+
'Invalid filename: cannot start with dash (potential command injection)'
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Reject newlines (could break git command parsing) - check before general control chars
|
|
289
|
+
if (filename.includes('\n') || filename.includes('\r')) {
|
|
290
|
+
throw createRPCError(
|
|
291
|
+
ErrorCode.INVALID_PATH,
|
|
292
|
+
'Invalid filename: contains newline characters'
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Reject other control characters (including null bytes)
|
|
297
|
+
if (/[\x00-\x1F\x7F]/.test(filename)) {
|
|
298
|
+
throw createRPCError(
|
|
299
|
+
ErrorCode.INVALID_PATH,
|
|
300
|
+
'Invalid filename: contains control characters'
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return filename;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Execute git command
|
|
309
|
+
*/
|
|
310
|
+
private async execGit(args: string[], options: ExecGitOptions = {}): Promise<ExecGitResult> {
|
|
311
|
+
return new Promise((resolve, reject) => {
|
|
312
|
+
const git = spawn('git', args, {
|
|
313
|
+
cwd: options.cwd,
|
|
314
|
+
env: { ...process.env, ...options.env },
|
|
315
|
+
stdio: options.input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
let stdout = '';
|
|
319
|
+
let stderr = '';
|
|
320
|
+
|
|
321
|
+
if (git.stdout) {
|
|
322
|
+
git.stdout.on('data', (data) => {
|
|
323
|
+
stdout += data.toString();
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (git.stderr) {
|
|
328
|
+
git.stderr.on('data', (data) => {
|
|
329
|
+
stderr += data.toString();
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
git.on('error', (error) => {
|
|
334
|
+
// Log full error server-side
|
|
335
|
+
console.error('Failed to execute git:', {
|
|
336
|
+
args,
|
|
337
|
+
error: error.message,
|
|
338
|
+
cwd: options.cwd,
|
|
339
|
+
});
|
|
340
|
+
// Send sanitized error to client
|
|
341
|
+
reject(new Error('Failed to execute git command'));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
git.on('close', (code) => {
|
|
345
|
+
const exitCode = code || 0;
|
|
346
|
+
if (exitCode !== 0 && !(options.acceptExitCodes && options.acceptExitCodes.includes(exitCode))) {
|
|
347
|
+
// Log full output server-side for debugging
|
|
348
|
+
console.error('Git command failed:', {
|
|
349
|
+
args,
|
|
350
|
+
code,
|
|
351
|
+
stderr: stderr.substring(0, 500), // Truncate for logs
|
|
352
|
+
stdout: stdout.substring(0, 500),
|
|
353
|
+
});
|
|
354
|
+
// Send sanitized error to client (no file paths from stderr/stdout)
|
|
355
|
+
reject(new Error(`Git command failed with exit code ${code}`));
|
|
356
|
+
} else {
|
|
357
|
+
resolve({ stdout, stderr, code: exitCode });
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (options.input !== undefined && git.stdin) {
|
|
362
|
+
git.stdin.write(options.input);
|
|
363
|
+
git.stdin.end();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Read commit object
|
|
370
|
+
*/
|
|
371
|
+
private async readCommit(dir: string, params: any): Promise<any> {
|
|
372
|
+
const { stdout } = await this.execGit(['cat-file', 'commit', params.oid], { cwd: dir });
|
|
373
|
+
const commit = this.parseCommit(stdout);
|
|
374
|
+
return {
|
|
375
|
+
oid: params.oid,
|
|
376
|
+
commit,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Parse commit object
|
|
382
|
+
*/
|
|
383
|
+
private parseCommit(commitText: string): any {
|
|
384
|
+
const lines = commitText.split('\n');
|
|
385
|
+
const commit: any = {
|
|
386
|
+
message: '',
|
|
387
|
+
tree: '',
|
|
388
|
+
parent: [],
|
|
389
|
+
author: null,
|
|
390
|
+
committer: null,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
let i = 0;
|
|
394
|
+
while (i < lines.length && lines[i] !== '') {
|
|
395
|
+
const line = lines[i];
|
|
396
|
+
if (line.startsWith('tree ')) {
|
|
397
|
+
commit.tree = line.substring(5);
|
|
398
|
+
} else if (line.startsWith('parent ')) {
|
|
399
|
+
commit.parent.push(line.substring(7));
|
|
400
|
+
} else if (line.startsWith('author ')) {
|
|
401
|
+
commit.author = this.parseIdentity(line.substring(7));
|
|
402
|
+
} else if (line.startsWith('committer ')) {
|
|
403
|
+
commit.committer = this.parseIdentity(line.substring(10));
|
|
404
|
+
}
|
|
405
|
+
i++;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
i++;
|
|
409
|
+
commit.message = lines.slice(i).join('\n');
|
|
410
|
+
|
|
411
|
+
return commit;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Parse git identity (author/committer)
|
|
416
|
+
*/
|
|
417
|
+
private parseIdentity(identityString: string): any {
|
|
418
|
+
const match = identityString.match(/^(.+) <(.+)> (\d+) ([+-]\d{4})$/);
|
|
419
|
+
if (!match) {
|
|
420
|
+
// Log full identity string server-side for debugging
|
|
421
|
+
console.error('Invalid git identity format:', identityString);
|
|
422
|
+
// Send sanitized error to client
|
|
423
|
+
throw new Error('Invalid git identity format');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
name: match[1],
|
|
428
|
+
email: match[2],
|
|
429
|
+
timestamp: parseInt(match[3], 10),
|
|
430
|
+
timezoneOffset: (parseInt(match[4], 10) / 100) * 60,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Read git object
|
|
436
|
+
*/
|
|
437
|
+
private async readObject(dir: string, params: any): Promise<any> {
|
|
438
|
+
const { oid, encoding } = params;
|
|
439
|
+
const { stdout } = await this.execGit(['cat-file', '-p', oid], { cwd: dir });
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
oid,
|
|
443
|
+
object: encoding === 'utf8' ? stdout : Buffer.from(stdout, 'utf8'),
|
|
444
|
+
format: 'content'
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Get HEAD tree oid
|
|
450
|
+
*/
|
|
451
|
+
private async getHeadTree(dir: string, _params: any): Promise<any> {
|
|
452
|
+
try {
|
|
453
|
+
const { stdout } = await this.execGit(['rev-parse', 'HEAD^{tree}'], { cwd: dir });
|
|
454
|
+
return { oid: stdout.trim() };
|
|
455
|
+
} catch (error) {
|
|
456
|
+
// No commits yet
|
|
457
|
+
return { oid: null };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Get object ID at path in tree or index stage
|
|
463
|
+
* @param dir - Git repository directory
|
|
464
|
+
* @param params.path - File path to lookup
|
|
465
|
+
* @param params.tree - Tree object ID to search in (e.g., HEAD tree)
|
|
466
|
+
* @param params.stage - Index stage number (0=normal, 1=ancestor, 2=ours, 3=theirs during merge)
|
|
467
|
+
*/
|
|
468
|
+
private async getOidAtPath(dir: string, params: any): Promise<any> {
|
|
469
|
+
const { path, tree, stage } = params;
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
// If stage is provided, read from git index at that stage
|
|
473
|
+
if (stage !== undefined) {
|
|
474
|
+
const { stdout } = await this.execGit(['ls-files', '--stage', path], { cwd: dir });
|
|
475
|
+
|
|
476
|
+
// Parse output: <mode> <hash> <stage> <path>
|
|
477
|
+
// Example: "100644 abc123... 0 path/to/file.txt"
|
|
478
|
+
const lines = stdout.split('\n').filter(l => l.trim());
|
|
479
|
+
|
|
480
|
+
for (const line of lines) {
|
|
481
|
+
const match = line.match(/^(\d+)\s+([a-f0-9]+)\s+(\d+)\s+(.+)$/);
|
|
482
|
+
if (match) {
|
|
483
|
+
const fileStage = parseInt(match[3], 10);
|
|
484
|
+
const filePath = match[4];
|
|
485
|
+
|
|
486
|
+
// Match both stage number and path
|
|
487
|
+
if (fileStage === stage && filePath === path) {
|
|
488
|
+
return { oid: match[2] };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return { oid: null };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// If tree is provided, read from tree object (original behavior)
|
|
497
|
+
if (tree) {
|
|
498
|
+
const { stdout } = await this.execGit(['ls-tree', tree, path], { cwd: dir });
|
|
499
|
+
const match = stdout.trim().match(/^(\d+)\s+(\w+)\s+([a-f0-9]+)\s+(.+)$/);
|
|
500
|
+
if (match) {
|
|
501
|
+
return { oid: match[3] };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return { oid: null };
|
|
506
|
+
} catch (error) {
|
|
507
|
+
return { oid: null };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* List files in ref
|
|
513
|
+
*/
|
|
514
|
+
private async listFiles(dir: string, params: any): Promise<any> {
|
|
515
|
+
const { ref } = params;
|
|
516
|
+
try {
|
|
517
|
+
const { stdout } = await this.execGit(['ls-tree', '-r', '--name-only', ref || 'HEAD'], { cwd: dir });
|
|
518
|
+
const files = stdout.split('\n').filter(f => f.trim());
|
|
519
|
+
return { files };
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return { files: [] };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Resolve ref to oid
|
|
527
|
+
*/
|
|
528
|
+
private async resolveRef(dir: string, params: any): Promise<any> {
|
|
529
|
+
const { ref } = params;
|
|
530
|
+
try {
|
|
531
|
+
const { stdout } = await this.execGit(['rev-parse', ref], { cwd: dir });
|
|
532
|
+
return { oid: stdout.trim() };
|
|
533
|
+
} catch (error) {
|
|
534
|
+
return { oid: null };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Get current branch
|
|
540
|
+
*/
|
|
541
|
+
private async currentBranch(dir: string, params: any): Promise<any> {
|
|
542
|
+
try {
|
|
543
|
+
const args = params.fullname
|
|
544
|
+
? ['symbolic-ref', 'HEAD']
|
|
545
|
+
: ['symbolic-ref', '--short', 'HEAD'];
|
|
546
|
+
const { stdout } = await this.execGit(args, { cwd: dir });
|
|
547
|
+
return { branch: stdout.trim() };
|
|
548
|
+
} catch {
|
|
549
|
+
return { branch: null }; // Detached HEAD
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Get commit history
|
|
555
|
+
*/
|
|
556
|
+
private async log(dir: string, params: any): Promise<any> {
|
|
557
|
+
const args = [
|
|
558
|
+
'log',
|
|
559
|
+
'--format=%H%n%T%n%P%n%an%n%ae%n%at%n%cn%n%ce%n%ct%n%B%n--END-COMMIT--',
|
|
560
|
+
params.ref ? this.sanitizeRef(params.ref) : 'HEAD',
|
|
561
|
+
];
|
|
562
|
+
|
|
563
|
+
if (params.depth) {
|
|
564
|
+
args.push(`-${params.depth}`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const { stdout } = await this.execGit(args, { cwd: dir });
|
|
568
|
+
const commits = [];
|
|
569
|
+
const commitTexts = stdout.split('--END-COMMIT--\n').filter((t) => t.trim());
|
|
570
|
+
|
|
571
|
+
for (const commitText of commitTexts) {
|
|
572
|
+
const lines = commitText.trim().split('\n');
|
|
573
|
+
commits.push({
|
|
574
|
+
oid: lines[0],
|
|
575
|
+
commit: {
|
|
576
|
+
tree: lines[1],
|
|
577
|
+
parent: lines[2] ? lines[2].split(' ') : [],
|
|
578
|
+
author: {
|
|
579
|
+
name: lines[3],
|
|
580
|
+
email: lines[4],
|
|
581
|
+
timestamp: parseInt(lines[5], 10),
|
|
582
|
+
timezoneOffset: 0,
|
|
583
|
+
},
|
|
584
|
+
committer: {
|
|
585
|
+
name: lines[6],
|
|
586
|
+
email: lines[7],
|
|
587
|
+
timestamp: parseInt(lines[8], 10),
|
|
588
|
+
timezoneOffset: 0,
|
|
589
|
+
},
|
|
590
|
+
message: lines.slice(9).join('\n'),
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return { commits };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Get working directory status
|
|
600
|
+
* Uses two git commands:
|
|
601
|
+
* 1. With -uall for detailed untracked files
|
|
602
|
+
* 2. With --ignored (no -uall) for rolled-up ignored directories
|
|
603
|
+
*/
|
|
604
|
+
private async status(dir: string, params: any): Promise<any> {
|
|
605
|
+
const filterFilepath = params?.filepath;
|
|
606
|
+
|
|
607
|
+
// Run two git status commands in parallel
|
|
608
|
+
const [untrackedResult, ignoredResult] = await Promise.all([
|
|
609
|
+
// Get all untracked files individually with -uall
|
|
610
|
+
this.execGit(['status', '--porcelain=v1', '-M', '-uall'], { cwd: dir }),
|
|
611
|
+
// Get ignored files rolled up to directories (without -uall)
|
|
612
|
+
this.execGit(['status', '--porcelain=v1', '--ignored'], { cwd: dir })
|
|
613
|
+
]);
|
|
614
|
+
|
|
615
|
+
// Parse untracked files (exclude ignored files from this result)
|
|
616
|
+
const untrackedFiles = await this.parseGitStatus(
|
|
617
|
+
untrackedResult.stdout,
|
|
618
|
+
dir,
|
|
619
|
+
filterFilepath,
|
|
620
|
+
new Set(['!!']) // Exclude ignored files
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
// Parse ignored files only
|
|
624
|
+
const ignoredFiles = await this.parseGitStatus(
|
|
625
|
+
ignoredResult.stdout,
|
|
626
|
+
dir,
|
|
627
|
+
filterFilepath,
|
|
628
|
+
new Set(), // Include all
|
|
629
|
+
new Set(['!!']) // Only include ignored files
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
// Merge results: untracked + ignored
|
|
633
|
+
return [...untrackedFiles, ...ignoredFiles];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Parse git status output into status array
|
|
638
|
+
*/
|
|
639
|
+
private async parseGitStatus(
|
|
640
|
+
stdout: string,
|
|
641
|
+
dir: string,
|
|
642
|
+
filterFilepath: string | undefined,
|
|
643
|
+
excludeStatuses: Set<string> = new Set(),
|
|
644
|
+
includeOnlyStatuses?: Set<string>
|
|
645
|
+
): Promise<Array<{ path: string; status: string }>> {
|
|
646
|
+
const result: Array<{ path: string; status: string }> = [];
|
|
647
|
+
const conflictCodes = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
|
|
648
|
+
|
|
649
|
+
const lines = stdout.split('\n').filter((l) => l.trim());
|
|
650
|
+
for (const line of lines) {
|
|
651
|
+
const gitStatus = line.substring(0, 2);
|
|
652
|
+
|
|
653
|
+
// Skip excluded statuses
|
|
654
|
+
if (excludeStatuses.has(gitStatus)) {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// If includeOnlyStatuses is specified, only include those
|
|
659
|
+
if (includeOnlyStatuses && !includeOnlyStatuses.has(gitStatus)) {
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
let filepath = line.substring(3);
|
|
664
|
+
|
|
665
|
+
// Handle rename format: "R old_name -> new_name"
|
|
666
|
+
if (gitStatus.startsWith('R')) {
|
|
667
|
+
const renameMatch = filepath.match(/^(.+)\s+->\s+(.+)$/);
|
|
668
|
+
if (renameMatch) {
|
|
669
|
+
filepath = renameMatch[2]; // Use new filename
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Skip if we're filtering for a specific filepath and this isn't it
|
|
674
|
+
if (filterFilepath && filepath !== filterFilepath) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Check if this is a conflict
|
|
679
|
+
const isConflict = conflictCodes.has(gitStatus);
|
|
680
|
+
|
|
681
|
+
// Direct mapping from git porcelain codes to status strings
|
|
682
|
+
let status: string;
|
|
683
|
+
switch (gitStatus) {
|
|
684
|
+
// Untracked
|
|
685
|
+
case '??':
|
|
686
|
+
status = ' ?';
|
|
687
|
+
break;
|
|
688
|
+
case '!!':
|
|
689
|
+
status = ' I';
|
|
690
|
+
break;
|
|
691
|
+
|
|
692
|
+
// Added
|
|
693
|
+
case 'A ':
|
|
694
|
+
status = 'A ';
|
|
695
|
+
break;
|
|
696
|
+
case 'AM':
|
|
697
|
+
status = 'AM';
|
|
698
|
+
break;
|
|
699
|
+
case 'AD':
|
|
700
|
+
status = 'AD';
|
|
701
|
+
break;
|
|
702
|
+
|
|
703
|
+
// Modified
|
|
704
|
+
case 'M ':
|
|
705
|
+
status = 'M ';
|
|
706
|
+
break;
|
|
707
|
+
case ' M':
|
|
708
|
+
status = ' M';
|
|
709
|
+
break;
|
|
710
|
+
case 'MM':
|
|
711
|
+
status = 'MM';
|
|
712
|
+
break;
|
|
713
|
+
|
|
714
|
+
// Deleted
|
|
715
|
+
case 'D ':
|
|
716
|
+
status = 'D ';
|
|
717
|
+
break;
|
|
718
|
+
case ' D':
|
|
719
|
+
status = ' D';
|
|
720
|
+
break;
|
|
721
|
+
case 'MD':
|
|
722
|
+
status = 'MD';
|
|
723
|
+
break;
|
|
724
|
+
|
|
725
|
+
// Renamed - check if file was new (not in HEAD)
|
|
726
|
+
case 'R ':
|
|
727
|
+
// For renamed files, check if they existed in HEAD
|
|
728
|
+
// If not in HEAD, treat as Added instead of Renamed
|
|
729
|
+
status = await this.isFileInHead(dir, filepath) ? 'R ' : 'A ';
|
|
730
|
+
break;
|
|
731
|
+
case ' R':
|
|
732
|
+
status = ' R';
|
|
733
|
+
break;
|
|
734
|
+
|
|
735
|
+
// Copied
|
|
736
|
+
case 'C ':
|
|
737
|
+
status = 'C ';
|
|
738
|
+
break;
|
|
739
|
+
case ' C':
|
|
740
|
+
status = ' C';
|
|
741
|
+
break;
|
|
742
|
+
|
|
743
|
+
// Type changed
|
|
744
|
+
case 'T ':
|
|
745
|
+
status = 'T ';
|
|
746
|
+
break;
|
|
747
|
+
case ' T':
|
|
748
|
+
status = ' T';
|
|
749
|
+
break;
|
|
750
|
+
|
|
751
|
+
// Conflicts (all marked with !)
|
|
752
|
+
case 'DD':
|
|
753
|
+
case 'AU':
|
|
754
|
+
case 'UD':
|
|
755
|
+
case 'UA':
|
|
756
|
+
case 'DU':
|
|
757
|
+
case 'AA':
|
|
758
|
+
case 'UU':
|
|
759
|
+
status = gitStatus;
|
|
760
|
+
break;
|
|
761
|
+
|
|
762
|
+
// No changes
|
|
763
|
+
case ' ':
|
|
764
|
+
default:
|
|
765
|
+
status = ' ';
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Add conflict marker
|
|
770
|
+
status += isConflict ? '!' : ' ';
|
|
771
|
+
|
|
772
|
+
result.push({ path: filepath, status });
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return result;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Get status counts (conflicts, changes, fileCount)
|
|
780
|
+
*/
|
|
781
|
+
private async statusCounts(dir: string, params: any): Promise<any> {
|
|
782
|
+
const statusArray = await this.status(dir, params);
|
|
783
|
+
let conflicts = 0;
|
|
784
|
+
let changes = 0;
|
|
785
|
+
|
|
786
|
+
for (const { status } of statusArray) {
|
|
787
|
+
// Check for conflict marker (3rd character is '!')
|
|
788
|
+
if (status[2] === '!') conflicts++;
|
|
789
|
+
// Check for changes (not ' ')
|
|
790
|
+
if (status.substring(0, 2) !== ' ') changes++;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
conflicts,
|
|
795
|
+
changes,
|
|
796
|
+
fileCount: statusArray.length
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Stage files
|
|
802
|
+
*/
|
|
803
|
+
private async add(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
|
|
804
|
+
// Skip if no files to add (prevents "No pathspec was given" error)
|
|
805
|
+
if (!params.filepaths || params.filepaths.length === 0) {
|
|
806
|
+
return { success: true };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Sanitize filenames to prevent command injection
|
|
810
|
+
const sanitizedPaths = params.filepaths.map((p: string) => this.sanitizeFilename(p));
|
|
811
|
+
|
|
812
|
+
// Use -- separator to prevent filenames being interpreted as flags (command injection prevention)
|
|
813
|
+
await this.execGit(['add', '--', ...sanitizedPaths], { cwd: dir });
|
|
814
|
+
|
|
815
|
+
// Send change notification
|
|
816
|
+
socket.broadcast.emit('rpc', {
|
|
817
|
+
jsonrpc: '2.0',
|
|
818
|
+
method: 'git.changed',
|
|
819
|
+
params: { dir },
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
return { success: true };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Remove files from index (git rm --cached)
|
|
827
|
+
*/
|
|
828
|
+
private async remove(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
|
|
829
|
+
// Skip if no files to remove (prevents "No pathspec was given" error)
|
|
830
|
+
if (!params.filepaths || params.filepaths.length === 0) {
|
|
831
|
+
return { success: true };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Sanitize filenames to prevent command injection
|
|
835
|
+
const sanitizedPaths = params.filepaths.map((p: string) => this.sanitizeFilename(p));
|
|
836
|
+
|
|
837
|
+
// Use -- separator to prevent filenames being interpreted as flags (command injection prevention)
|
|
838
|
+
await this.execGit(['rm', '--cached', '--', ...sanitizedPaths], { cwd: dir });
|
|
839
|
+
|
|
840
|
+
// Send change notification
|
|
841
|
+
socket.broadcast.emit('rpc', {
|
|
842
|
+
jsonrpc: '2.0',
|
|
843
|
+
method: 'git.changed',
|
|
844
|
+
params: { dir },
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
return { success: true };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Reset file(s) in index to match ref (git reset <ref> -- <filepath(s)>)
|
|
852
|
+
*/
|
|
853
|
+
private async resetIndex(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
|
|
854
|
+
const ref = params.ref ? this.sanitizeRef(params.ref) : 'HEAD';
|
|
855
|
+
|
|
856
|
+
// Handle both single filepath and multiple filepaths
|
|
857
|
+
if (params.filepaths && Array.isArray(params.filepaths)) {
|
|
858
|
+
// Skip if no files to reset (prevents "No pathspec was given" error)
|
|
859
|
+
if (params.filepaths.length === 0) {
|
|
860
|
+
return { success: true };
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Sanitize filenames to prevent command injection
|
|
864
|
+
const sanitizedPaths = params.filepaths.map((p: string) => this.sanitizeFilename(p));
|
|
865
|
+
|
|
866
|
+
await this.execGit(['reset', ref, '--', ...sanitizedPaths], { cwd: dir });
|
|
867
|
+
} else if (params.filepath) {
|
|
868
|
+
// Reset single file (backward compatibility)
|
|
869
|
+
const sanitizedPath = this.sanitizeFilename(params.filepath);
|
|
870
|
+
await this.execGit(['reset', ref, '--', sanitizedPath], { cwd: dir });
|
|
871
|
+
} else {
|
|
872
|
+
return { success: true };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Send change notification
|
|
876
|
+
socket.broadcast.emit('rpc', {
|
|
877
|
+
jsonrpc: '2.0',
|
|
878
|
+
method: 'git.changed',
|
|
879
|
+
params: { dir },
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
return { success: true };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Create commit
|
|
887
|
+
*/
|
|
888
|
+
private async commit(dir: string, params: any, socket: AuthenticatedSocket): Promise<any> {
|
|
889
|
+
const env = {
|
|
890
|
+
GIT_AUTHOR_NAME: params.author.name,
|
|
891
|
+
GIT_AUTHOR_EMAIL: params.author.email,
|
|
892
|
+
GIT_COMMITTER_NAME: params.author.name,
|
|
893
|
+
GIT_COMMITTER_EMAIL: params.author.email,
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
await this.execGit(['commit', '-m', params.message], { cwd: dir, env });
|
|
897
|
+
|
|
898
|
+
const { stdout } = await this.execGit(['rev-parse', 'HEAD'], { cwd: dir });
|
|
899
|
+
const oid = stdout.trim();
|
|
900
|
+
|
|
901
|
+
// Send change notification
|
|
902
|
+
socket.broadcast.emit('rpc', {
|
|
903
|
+
jsonrpc: '2.0',
|
|
904
|
+
method: 'git.changed',
|
|
905
|
+
params: { dir },
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
return { oid };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Allowed git config key patterns.
|
|
913
|
+
* Only these keys can be read or written via the RPC interface
|
|
914
|
+
* to prevent injection of dangerous config like core.sshCommand.
|
|
915
|
+
*/
|
|
916
|
+
private static ALLOWED_CONFIG_KEYS = new Set([
|
|
917
|
+
'user.name',
|
|
918
|
+
'user.email',
|
|
919
|
+
'push.default',
|
|
920
|
+
'pull.rebase',
|
|
921
|
+
'pull.ff',
|
|
922
|
+
'init.defaultBranch',
|
|
923
|
+
'merge.ff',
|
|
924
|
+
]);
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Allowed git config key prefixes for dynamic keys (e.g. remote.origin.url)
|
|
928
|
+
*/
|
|
929
|
+
private static ALLOWED_CONFIG_PREFIXES = [
|
|
930
|
+
'remote.',
|
|
931
|
+
'branch.',
|
|
932
|
+
'core.',
|
|
933
|
+
];
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Validate that a git config key is allowed
|
|
937
|
+
*/
|
|
938
|
+
private validateConfigKey(key: string): void {
|
|
939
|
+
if (GitService.ALLOWED_CONFIG_KEYS.has(key)) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
for (const prefix of GitService.ALLOWED_CONFIG_PREFIXES) {
|
|
943
|
+
if (key.startsWith(prefix)) {
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
throw createRPCError(
|
|
948
|
+
ErrorCode.INVALID_PARAMS,
|
|
949
|
+
`Git config key not allowed: ${key}`
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Get config value
|
|
955
|
+
*/
|
|
956
|
+
private async getConfig(dir: string, params: any): Promise<any> {
|
|
957
|
+
this.validateConfigKey(params.path);
|
|
958
|
+
try {
|
|
959
|
+
const { stdout } = await this.execGit(['config', '--get', params.path], { cwd: dir });
|
|
960
|
+
return { value: stdout.trim() };
|
|
961
|
+
} catch (error: any) {
|
|
962
|
+
// Git returns exit code 1 if config key doesn't exist
|
|
963
|
+
if (error.message.includes('code 1')) {
|
|
964
|
+
return { value: undefined };
|
|
965
|
+
}
|
|
966
|
+
throw error;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Set config value
|
|
972
|
+
*/
|
|
973
|
+
private async setConfig(dir: string, params: any): Promise<{ success: boolean }> {
|
|
974
|
+
const { path, value, append } = params;
|
|
975
|
+
this.validateConfigKey(path);
|
|
976
|
+
|
|
977
|
+
if (value === undefined) {
|
|
978
|
+
// Delete the config entry
|
|
979
|
+
try {
|
|
980
|
+
await this.execGit(['config', '--unset', path], { cwd: dir });
|
|
981
|
+
} catch (error: any) {
|
|
982
|
+
// Ignore error if key doesn't exist
|
|
983
|
+
if (!error.message.includes('code 5')) {
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
} else if (append) {
|
|
988
|
+
// Append to existing value (for multi-valued config options)
|
|
989
|
+
await this.execGit(['config', '--add', path, String(value)], { cwd: dir });
|
|
990
|
+
} else {
|
|
991
|
+
// Set/replace the value
|
|
992
|
+
await this.execGit(['config', path, String(value)], { cwd: dir });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return { success: true };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* List branches
|
|
1000
|
+
* When remote is specified, returns branch names without the remote prefix
|
|
1001
|
+
* to match isomorphic-git behavior (e.g. 'main' instead of 'origin/main')
|
|
1002
|
+
*/
|
|
1003
|
+
private async listBranches(dir: string, params: any): Promise<any> {
|
|
1004
|
+
const args = params.remote ? ['branch', '-r'] : ['branch'];
|
|
1005
|
+
const { stdout } = await this.execGit(args, { cwd: dir });
|
|
1006
|
+
|
|
1007
|
+
const remote = params.remote;
|
|
1008
|
+
const remotePrefix = remote ? `${remote}/` : '';
|
|
1009
|
+
|
|
1010
|
+
const branches = stdout
|
|
1011
|
+
.split('\n')
|
|
1012
|
+
.map((line) => line.trim().replace(/^\* /, ''))
|
|
1013
|
+
.filter((line) => line)
|
|
1014
|
+
.filter((line) => {
|
|
1015
|
+
// When listing remote branches, filter to only the specified remote
|
|
1016
|
+
// and exclude HEAD pointer lines like "origin/HEAD -> origin/main"
|
|
1017
|
+
if (remote) {
|
|
1018
|
+
return line.startsWith(remotePrefix) && !line.includes(' -> ');
|
|
1019
|
+
}
|
|
1020
|
+
return true;
|
|
1021
|
+
})
|
|
1022
|
+
.map((line) => {
|
|
1023
|
+
// Strip remote prefix to match isomorphic-git behavior
|
|
1024
|
+
if (remote && line.startsWith(remotePrefix)) {
|
|
1025
|
+
return line.substring(remotePrefix.length);
|
|
1026
|
+
}
|
|
1027
|
+
return line;
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
return { branches };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Validate a git ref (branch name, tag, or commit hash)
|
|
1035
|
+
* Rejects refs that could be interpreted as flags
|
|
1036
|
+
*/
|
|
1037
|
+
private sanitizeRef(ref: string): string {
|
|
1038
|
+
if (!ref || typeof ref !== 'string') {
|
|
1039
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid ref: must be a non-empty string');
|
|
1040
|
+
}
|
|
1041
|
+
if (ref.startsWith('-')) {
|
|
1042
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid ref: cannot start with dash');
|
|
1043
|
+
}
|
|
1044
|
+
if (/[\x00-\x1F\x7F]/.test(ref)) {
|
|
1045
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid ref: contains control characters');
|
|
1046
|
+
}
|
|
1047
|
+
return ref;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Checkout branch or commit
|
|
1052
|
+
*/
|
|
1053
|
+
private async checkout(dir: string, params: any): Promise<{ success: boolean }> {
|
|
1054
|
+
const ref = this.sanitizeRef(params.ref);
|
|
1055
|
+
const args = ['checkout'];
|
|
1056
|
+
if (params.force) {
|
|
1057
|
+
args.push('--force');
|
|
1058
|
+
}
|
|
1059
|
+
args.push(ref);
|
|
1060
|
+
|
|
1061
|
+
await this.execGit(args, { cwd: dir });
|
|
1062
|
+
|
|
1063
|
+
return { success: true };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Initialize repository
|
|
1068
|
+
*/
|
|
1069
|
+
private async init(dir: string, params: any): Promise<{ success: boolean }> {
|
|
1070
|
+
const args = ['init'];
|
|
1071
|
+
if (params.defaultBranch) {
|
|
1072
|
+
args.push('--initial-branch', params.defaultBranch);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
await this.execGit(args, { cwd: dir });
|
|
1076
|
+
return { success: true };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* List git remotes
|
|
1081
|
+
*/
|
|
1082
|
+
private async listRemotes(dir: string, _params: any): Promise<any> {
|
|
1083
|
+
try {
|
|
1084
|
+
const { stdout } = await this.execGit(['remote', '-v'], { cwd: dir });
|
|
1085
|
+
const remotes: Array<{ remote: string; url: string }> = [];
|
|
1086
|
+
|
|
1087
|
+
const lines = stdout.split('\n').filter(l => l.trim());
|
|
1088
|
+
const seen = new Set<string>();
|
|
1089
|
+
|
|
1090
|
+
for (const line of lines) {
|
|
1091
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
|
|
1092
|
+
if (match && !seen.has(match[1])) {
|
|
1093
|
+
remotes.push({
|
|
1094
|
+
remote: match[1],
|
|
1095
|
+
url: match[2]
|
|
1096
|
+
});
|
|
1097
|
+
seen.add(match[1]);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
return { remotes };
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
return { remotes: [] };
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Add git remote
|
|
1109
|
+
*/
|
|
1110
|
+
private async addRemote(dir: string, params: any): Promise<{ success: boolean }> {
|
|
1111
|
+
const { remote, url } = params;
|
|
1112
|
+
await this.execGit(['remote', 'add', remote, url], { cwd: dir });
|
|
1113
|
+
return { success: true };
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Delete git remote
|
|
1118
|
+
*/
|
|
1119
|
+
private async deleteRemote(dir: string, params: any): Promise<{ success: boolean }> {
|
|
1120
|
+
const { remote } = params;
|
|
1121
|
+
await this.execGit(['remote', 'remove', remote], { cwd: dir });
|
|
1122
|
+
return { success: true };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Clear git index (remove cached files)
|
|
1127
|
+
*/
|
|
1128
|
+
private async clearIndex(dir: string, _params: any): Promise<{ success: boolean }> {
|
|
1129
|
+
try {
|
|
1130
|
+
await this.execGit(['rm', '-r', '--cached', '-f', '.'], { cwd: dir });
|
|
1131
|
+
return { success: true };
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
// If no files in index, this is fine
|
|
1134
|
+
return { success: true };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Find which submodule a filepath belongs to (if any).
|
|
1140
|
+
* Returns the submodule path and the filepath relative to the submodule root.
|
|
1141
|
+
*/
|
|
1142
|
+
private findSubmoduleForPath(
|
|
1143
|
+
filepath: string,
|
|
1144
|
+
submodules: Array<{ name: string; path: string }>
|
|
1145
|
+
): { submodulePath: string; relativePath: string } | null {
|
|
1146
|
+
for (const sub of submodules) {
|
|
1147
|
+
if (filepath === sub.path || filepath.startsWith(sub.path + '/')) {
|
|
1148
|
+
const relativePath = filepath === sub.path ? '.' : filepath.slice(sub.path.length + 1);
|
|
1149
|
+
return { submodulePath: sub.path, relativePath };
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Check if file is ignored by .gitignore
|
|
1157
|
+
* Handles files inside submodules by running check-ignore from the submodule dir
|
|
1158
|
+
*/
|
|
1159
|
+
private async isIgnored(dir: string, params: any): Promise<boolean> {
|
|
1160
|
+
const { filepath } = params;
|
|
1161
|
+
|
|
1162
|
+
// Check if filepath is inside a submodule
|
|
1163
|
+
const submodules = await this.getCachedSubmodules(dir);
|
|
1164
|
+
if (submodules.length > 0) {
|
|
1165
|
+
const match = this.findSubmoduleForPath(filepath, submodules);
|
|
1166
|
+
if (match) {
|
|
1167
|
+
const subDir = path.join(dir, match.submodulePath);
|
|
1168
|
+
// Exit code 0 = ignored, exit code 1 = not ignored
|
|
1169
|
+
const result = await this.execGit(['check-ignore', match.relativePath], { cwd: subDir, acceptExitCodes: [1] });
|
|
1170
|
+
return result.code === 0;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Exit code 0 = ignored, exit code 1 = not ignored
|
|
1175
|
+
const result = await this.execGit(['check-ignore', filepath], { cwd: dir, acceptExitCodes: [1] });
|
|
1176
|
+
return result.code === 0;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Run git check-ignore for a list of paths in a single directory.
|
|
1181
|
+
* Returns a Set of ignored paths.
|
|
1182
|
+
*/
|
|
1183
|
+
private async checkIgnorePaths(cwd: string, filepaths: string[]): Promise<Set<string>> {
|
|
1184
|
+
if (filepaths.length === 0) return new Set();
|
|
1185
|
+
// Exit code 1 means none are ignored - this is expected
|
|
1186
|
+
const result = await this.execGit(['check-ignore', '--stdin', '-z'], {
|
|
1187
|
+
cwd,
|
|
1188
|
+
input: filepaths.join('\0') + '\0',
|
|
1189
|
+
acceptExitCodes: [1]
|
|
1190
|
+
});
|
|
1191
|
+
return new Set(result.stdout.split('\0').filter(p => p.length > 0));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Check multiple files if they are ignored by .gitignore
|
|
1196
|
+
* Returns array of 1 (ignored) or 0 (not ignored) for bandwidth efficiency
|
|
1197
|
+
* Handles files inside submodules by routing check-ignore to the submodule dir
|
|
1198
|
+
*/
|
|
1199
|
+
private async bulkIsIgnored(dir: string, params: any): Promise<number[]> {
|
|
1200
|
+
const { filepaths } = params;
|
|
1201
|
+
if (!Array.isArray(filepaths)) {
|
|
1202
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'filepaths must be an array');
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (filepaths.length === 0) {
|
|
1206
|
+
return [];
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Check for submodules to handle paths inside them
|
|
1210
|
+
const submodules = await this.getCachedSubmodules(dir);
|
|
1211
|
+
|
|
1212
|
+
if (submodules.length === 0) {
|
|
1213
|
+
// No submodules, check all paths in the main repo
|
|
1214
|
+
const ignoredPaths = await this.checkIgnorePaths(dir, filepaths);
|
|
1215
|
+
return filepaths.map(fp => ignoredPaths.has(fp) ? 1 : 0);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Group paths by main repo vs submodule
|
|
1219
|
+
const mainPaths: Array<{ index: number; filepath: string }> = [];
|
|
1220
|
+
const submoduleGroups: Map<string, Array<{ index: number; relativePath: string }>> = new Map();
|
|
1221
|
+
|
|
1222
|
+
for (let i = 0; i < filepaths.length; i++) {
|
|
1223
|
+
const fp = filepaths[i];
|
|
1224
|
+
const match = this.findSubmoduleForPath(fp, submodules);
|
|
1225
|
+
if (match) {
|
|
1226
|
+
let group = submoduleGroups.get(match.submodulePath);
|
|
1227
|
+
if (!group) {
|
|
1228
|
+
group = [];
|
|
1229
|
+
submoduleGroups.set(match.submodulePath, group);
|
|
1230
|
+
}
|
|
1231
|
+
group.push({ index: i, relativePath: match.relativePath });
|
|
1232
|
+
} else {
|
|
1233
|
+
mainPaths.push({ index: i, filepath: fp });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const results = Array.from({ length: filepaths.length }).fill(0) as number[];
|
|
1238
|
+
const promises: Promise<void>[] = [];
|
|
1239
|
+
|
|
1240
|
+
// Check main repo paths
|
|
1241
|
+
if (mainPaths.length > 0) {
|
|
1242
|
+
const mainFilepaths = mainPaths.map(m => m.filepath);
|
|
1243
|
+
promises.push(
|
|
1244
|
+
this.checkIgnorePaths(dir, mainFilepaths).then(ignoredPaths => {
|
|
1245
|
+
for (let i = 0; i < mainPaths.length; i++) {
|
|
1246
|
+
results[mainPaths[i].index] = ignoredPaths.has(mainPaths[i].filepath) ? 1 : 0;
|
|
1247
|
+
}
|
|
1248
|
+
})
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Check each submodule's paths from its own directory
|
|
1253
|
+
for (const [submodulePath, group] of submoduleGroups) {
|
|
1254
|
+
const subDir = path.join(dir, submodulePath);
|
|
1255
|
+
const subFilepaths = group.map(g => g.relativePath);
|
|
1256
|
+
promises.push(
|
|
1257
|
+
this.checkIgnorePaths(subDir, subFilepaths).then(ignoredPaths => {
|
|
1258
|
+
for (let i = 0; i < group.length; i++) {
|
|
1259
|
+
results[group[i].index] = ignoredPaths.has(group[i].relativePath) ? 1 : 0;
|
|
1260
|
+
}
|
|
1261
|
+
})
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
await Promise.all(promises);
|
|
1266
|
+
return results;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Check if directory is a git repository
|
|
1271
|
+
* Uses git rev-parse --is-inside-work-tree
|
|
1272
|
+
*/
|
|
1273
|
+
private async isInitialized(dir: string, _params: any): Promise<boolean> {
|
|
1274
|
+
try {
|
|
1275
|
+
const { stdout } = await this.execGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir });
|
|
1276
|
+
// Exit code 0 and stdout "true" means it's a git repository
|
|
1277
|
+
return stdout.trim() === 'true';
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
// Exit code 128 means not a git repository
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* List git submodules
|
|
1286
|
+
* Uses git submodule status to detect submodules
|
|
1287
|
+
* Note: always runs from the main repo dir (not gitCwd)
|
|
1288
|
+
*/
|
|
1289
|
+
private async listSubmodules(dir: string): Promise<{ submodules: Array<{ name: string; path: string }> }> {
|
|
1290
|
+
try {
|
|
1291
|
+
const { stdout } = await this.execGit(['submodule', 'status'], { cwd: dir });
|
|
1292
|
+
const submodules: Array<{ name: string; path: string }> = [];
|
|
1293
|
+
const lines = stdout.split('\n').filter(l => l.trim());
|
|
1294
|
+
for (const line of lines) {
|
|
1295
|
+
// Format: " <sha1> <path> (<describe>)" or "-<sha1> <path>" (uninitialized)
|
|
1296
|
+
const match = line.match(/^[\s+-]?([a-f0-9]+)\s+(\S+)/);
|
|
1297
|
+
if (match) {
|
|
1298
|
+
const subPath = match[2];
|
|
1299
|
+
submodules.push({ name: subPath, path: subPath });
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
return { submodules };
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
return { submodules: [] };
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Check if a file exists in HEAD commit
|
|
1310
|
+
* Used to determine if a renamed file is truly new or was previously committed
|
|
1311
|
+
*/
|
|
1312
|
+
private async isFileInHead(dir: string, filepath: string): Promise<boolean> {
|
|
1313
|
+
try {
|
|
1314
|
+
// Use git ls-tree to check if file exists in HEAD
|
|
1315
|
+
// This is faster than checking out or reading the entire tree
|
|
1316
|
+
const { stdout } = await this.execGit(['ls-tree', 'HEAD', '--', filepath], { cwd: dir });
|
|
1317
|
+
return stdout.trim().length > 0;
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
// File doesn't exist in HEAD or no commits yet
|
|
1320
|
+
return false;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Get default author from git config
|
|
1326
|
+
*/
|
|
1327
|
+
private async getDefaultAuthor(dir: string): Promise<{ name: string; email: string }> {
|
|
1328
|
+
let name = '';
|
|
1329
|
+
let email = '';
|
|
1330
|
+
try {
|
|
1331
|
+
const nameResult = await this.execGit(['config', 'user.name'], { cwd: dir, acceptExitCodes: [1] });
|
|
1332
|
+
if (nameResult.code === 0) name = nameResult.stdout.trim();
|
|
1333
|
+
} catch (_) { /* ignore */ }
|
|
1334
|
+
try {
|
|
1335
|
+
const emailResult = await this.execGit(['config', 'user.email'], { cwd: dir, acceptExitCodes: [1] });
|
|
1336
|
+
if (emailResult.code === 0) email = emailResult.stdout.trim();
|
|
1337
|
+
} catch (_) { /* ignore */ }
|
|
1338
|
+
return { name, email };
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Push to remote
|
|
1343
|
+
*/
|
|
1344
|
+
private async push(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
|
|
1345
|
+
const remote = params.remote || 'origin';
|
|
1346
|
+
const args = ['push', remote];
|
|
1347
|
+
|
|
1348
|
+
if (params.remoteRef) {
|
|
1349
|
+
// Get current branch for the local side of the refspec
|
|
1350
|
+
const { stdout } = await this.execGit(['symbolic-ref', '--short', 'HEAD'], { cwd: dir });
|
|
1351
|
+
const currentBranch = stdout.trim();
|
|
1352
|
+
args.push(`${currentBranch}:${params.remoteRef}`);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
const { env: askpassEnv, cleanup } = await this.setupAskpass(socket);
|
|
1356
|
+
try {
|
|
1357
|
+
await this.execGit(args, { cwd: dir, env: askpassEnv });
|
|
1358
|
+
} finally {
|
|
1359
|
+
cleanup();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Send change notification
|
|
1363
|
+
socket.broadcast.emit('rpc', {
|
|
1364
|
+
jsonrpc: '2.0',
|
|
1365
|
+
method: 'git.changed',
|
|
1366
|
+
params: { dir },
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
return { success: true };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Pull from remote
|
|
1374
|
+
*/
|
|
1375
|
+
private async pull(dir: string, params: any, socket: AuthenticatedSocket): Promise<any> {
|
|
1376
|
+
const remote = params.remote || 'origin';
|
|
1377
|
+
const args = ['pull'];
|
|
1378
|
+
|
|
1379
|
+
if (params.fastForwardOnly) {
|
|
1380
|
+
args.push('--ff-only');
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
args.push(remote);
|
|
1384
|
+
|
|
1385
|
+
if (params.ref) {
|
|
1386
|
+
args.push(this.sanitizeRef(params.ref));
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const env: Record<string, string> = {};
|
|
1390
|
+
if (params.author) {
|
|
1391
|
+
env.GIT_AUTHOR_NAME = params.author.name;
|
|
1392
|
+
env.GIT_AUTHOR_EMAIL = params.author.email;
|
|
1393
|
+
env.GIT_COMMITTER_NAME = params.author.name;
|
|
1394
|
+
env.GIT_COMMITTER_EMAIL = params.author.email;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const { env: askpassEnv, cleanup } = await this.setupAskpass(socket);
|
|
1398
|
+
Object.assign(env, askpassEnv);
|
|
1399
|
+
|
|
1400
|
+
let result;
|
|
1401
|
+
try {
|
|
1402
|
+
result = await this.execGit(args, { cwd: dir, env });
|
|
1403
|
+
} finally {
|
|
1404
|
+
cleanup();
|
|
1405
|
+
}
|
|
1406
|
+
const output = result.stdout + result.stderr;
|
|
1407
|
+
|
|
1408
|
+
// Parse output to determine merge type
|
|
1409
|
+
const alreadyMerged = output.includes('Already up to date') || output.includes('Already up-to-date');
|
|
1410
|
+
const fastForward = output.includes('Fast-forward');
|
|
1411
|
+
const recursiveMerge = !alreadyMerged && !fastForward;
|
|
1412
|
+
|
|
1413
|
+
// Send change notification
|
|
1414
|
+
socket.broadcast.emit('rpc', {
|
|
1415
|
+
jsonrpc: '2.0',
|
|
1416
|
+
method: 'git.changed',
|
|
1417
|
+
params: { dir },
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
return {
|
|
1421
|
+
alreadyMerged,
|
|
1422
|
+
fastForward,
|
|
1423
|
+
recursiveMerge,
|
|
1424
|
+
mergeCommit: recursiveMerge ? true : undefined,
|
|
1425
|
+
};
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Fetch from remote
|
|
1430
|
+
*/
|
|
1431
|
+
private async fetch(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
|
|
1432
|
+
const remote = params.remote || 'origin';
|
|
1433
|
+
|
|
1434
|
+
const { env: askpassEnv, cleanup } = await this.setupAskpass(socket);
|
|
1435
|
+
try {
|
|
1436
|
+
await this.execGit(['fetch', remote], { cwd: dir, env: askpassEnv });
|
|
1437
|
+
} finally {
|
|
1438
|
+
cleanup();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Send change notification
|
|
1442
|
+
socket.broadcast.emit('rpc', {
|
|
1443
|
+
jsonrpc: '2.0',
|
|
1444
|
+
method: 'git.changed',
|
|
1445
|
+
params: { dir },
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1448
|
+
return { success: true };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* Clone a repository
|
|
1453
|
+
*/
|
|
1454
|
+
private async clone(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
|
|
1455
|
+
// Validate clone URL - reject dangerous transports
|
|
1456
|
+
const url = params.url;
|
|
1457
|
+
if (!url || typeof url !== 'string') {
|
|
1458
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'Clone URL is required');
|
|
1459
|
+
}
|
|
1460
|
+
if (/^ext::/i.test(url)) {
|
|
1461
|
+
throw createRPCError(ErrorCode.INVALID_PARAMS, 'ext:: transport is not allowed');
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const args = ['clone', url, '.'];
|
|
1465
|
+
|
|
1466
|
+
if (params.ref) {
|
|
1467
|
+
args.push('--branch', this.sanitizeRef(params.ref));
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
if (params.depth) {
|
|
1471
|
+
args.push('--depth', String(params.depth));
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (params.singleBranch) {
|
|
1475
|
+
args.push('--single-branch');
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const { env: askpassEnv, cleanup } = await this.setupAskpass(socket);
|
|
1479
|
+
try {
|
|
1480
|
+
await this.execGit(args, { cwd: dir, env: askpassEnv });
|
|
1481
|
+
} finally {
|
|
1482
|
+
cleanup();
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// Send change notification
|
|
1486
|
+
socket.broadcast.emit('rpc', {
|
|
1487
|
+
jsonrpc: '2.0',
|
|
1488
|
+
method: 'git.changed',
|
|
1489
|
+
params: { dir },
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
return { success: true };
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Set up an askpass mechanism for SSH/Git credential prompts.
|
|
1497
|
+
* Creates a temporary HTTP server and shell script that SSH_ASKPASS/GIT_ASKPASS
|
|
1498
|
+
* points to. When SSH or git needs a passphrase/password, it calls the script,
|
|
1499
|
+
* which forwards the prompt to the client via requestAuth.
|
|
1500
|
+
*/
|
|
1501
|
+
private async setupAskpass(socket: AuthenticatedSocket): Promise<{
|
|
1502
|
+
env: Record<string, string>;
|
|
1503
|
+
cleanup: () => void;
|
|
1504
|
+
}> {
|
|
1505
|
+
return new Promise((resolve, reject) => {
|
|
1506
|
+
const server = http.createServer((req, res) => {
|
|
1507
|
+
let body = '';
|
|
1508
|
+
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
|
1509
|
+
req.on('end', async () => {
|
|
1510
|
+
try {
|
|
1511
|
+
const prompt = body || 'Password:';
|
|
1512
|
+
const result = await this.requestAuth(socket, {
|
|
1513
|
+
prompt,
|
|
1514
|
+
type: 'askpass'
|
|
1515
|
+
});
|
|
1516
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
1517
|
+
res.end(result?.password || '');
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
// Auth was denied or timed out
|
|
1520
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
1521
|
+
res.end('');
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
// Don't prevent Node.js from exiting
|
|
1527
|
+
server.unref();
|
|
1528
|
+
|
|
1529
|
+
server.listen(0, '127.0.0.1', () => {
|
|
1530
|
+
const addr = server.address() as { port: number };
|
|
1531
|
+
const port = addr.port;
|
|
1532
|
+
const useCurl = this.hasCurl();
|
|
1533
|
+
const isWindows = process.platform === 'win32';
|
|
1534
|
+
const filesToClean: string[] = [];
|
|
1535
|
+
|
|
1536
|
+
let scriptPath: string;
|
|
1537
|
+
|
|
1538
|
+
if (useCurl) {
|
|
1539
|
+
// Use curl directly — available on macOS, most Linux, and newer Windows
|
|
1540
|
+
if (isWindows) {
|
|
1541
|
+
scriptPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.cmd`);
|
|
1542
|
+
fs.writeFileSync(scriptPath,
|
|
1543
|
+
`@curl -s --max-time 0 -X POST -d "%~1" "http://127.0.0.1:${port}/askpass" 2>nul\r\n`
|
|
1544
|
+
);
|
|
1545
|
+
} else {
|
|
1546
|
+
scriptPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.sh`);
|
|
1547
|
+
fs.writeFileSync(scriptPath,
|
|
1548
|
+
`#!/bin/sh\ncurl -s --max-time 0 -X POST -d "$1" "http://127.0.0.1:${port}/askpass" 2>/dev/null\n`,
|
|
1549
|
+
{ mode: 0o700 }
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
filesToClean.push(scriptPath);
|
|
1553
|
+
} else {
|
|
1554
|
+
// Fallback to Node.js script (node is always available since we're running on it)
|
|
1555
|
+
const nodeExe = process.execPath;
|
|
1556
|
+
const jsPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.js`);
|
|
1557
|
+
fs.writeFileSync(jsPath, [
|
|
1558
|
+
`const h=require('http'),d=process.argv[2]||'';`,
|
|
1559
|
+
`const r=h.request({hostname:'127.0.0.1',port:${port},path:'/askpass',method:'POST',` +
|
|
1560
|
+
`headers:{'Content-Length':Buffer.byteLength(d)}},s=>{let b='';s.on('data',c=>b+=c);s.on('end',()=>process.stdout.write(b))});`,
|
|
1561
|
+
`r.write(d);r.end();`,
|
|
1562
|
+
''
|
|
1563
|
+
].join('\n'));
|
|
1564
|
+
filesToClean.push(jsPath);
|
|
1565
|
+
|
|
1566
|
+
if (isWindows) {
|
|
1567
|
+
scriptPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.cmd`);
|
|
1568
|
+
fs.writeFileSync(scriptPath,
|
|
1569
|
+
`@"${nodeExe}" "${jsPath}" %1\r\n`
|
|
1570
|
+
);
|
|
1571
|
+
} else {
|
|
1572
|
+
scriptPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.sh`);
|
|
1573
|
+
fs.writeFileSync(scriptPath,
|
|
1574
|
+
`#!/bin/sh\n"${nodeExe}" "${jsPath}" "$1"\n`,
|
|
1575
|
+
{ mode: 0o700 }
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
filesToClean.push(scriptPath);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
const cleanup = () => {
|
|
1582
|
+
server.close();
|
|
1583
|
+
for (const f of filesToClean) {
|
|
1584
|
+
try { fs.unlinkSync(f); } catch (_) { /* ignore */ }
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
|
|
1588
|
+
resolve({
|
|
1589
|
+
env: {
|
|
1590
|
+
GIT_ASKPASS: scriptPath,
|
|
1591
|
+
SSH_ASKPASS: scriptPath,
|
|
1592
|
+
SSH_ASKPASS_REQUIRE: 'force',
|
|
1593
|
+
DISPLAY: process.env.DISPLAY || ':0',
|
|
1594
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
1595
|
+
},
|
|
1596
|
+
cleanup
|
|
1597
|
+
});
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
server.on('error', reject);
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* Request authentication from client (server-to-client RPC)
|
|
1606
|
+
*/
|
|
1607
|
+
private async requestAuth(socket: AuthenticatedSocket, params: any): Promise<any> {
|
|
1608
|
+
return new Promise((resolve, reject) => {
|
|
1609
|
+
const requestId = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
|
1610
|
+
|
|
1611
|
+
// Set up one-time response listener (no timeout - wait indefinitely for user input)
|
|
1612
|
+
const responseHandler = (response: any) => {
|
|
1613
|
+
if (response.id === requestId) {
|
|
1614
|
+
socket.off('rpc', responseHandler);
|
|
1615
|
+
if (response.error) {
|
|
1616
|
+
reject(new Error(response.error.message));
|
|
1617
|
+
} else {
|
|
1618
|
+
resolve(response.result);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
};
|
|
1622
|
+
|
|
1623
|
+
socket.on('rpc', responseHandler);
|
|
1624
|
+
|
|
1625
|
+
// Send request to client
|
|
1626
|
+
socket.emit('rpc', {
|
|
1627
|
+
jsonrpc: '2.0',
|
|
1628
|
+
method: 'git.requestAuth',
|
|
1629
|
+
params: {
|
|
1630
|
+
url: params.url,
|
|
1631
|
+
prompt: params.prompt,
|
|
1632
|
+
type: params.type,
|
|
1633
|
+
attempt: params.attempt || 1,
|
|
1634
|
+
},
|
|
1635
|
+
id: requestId,
|
|
1636
|
+
});
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
}
|