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