-
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgitConfig.ts
More file actions
321 lines (280 loc) · 9.26 KB
/
Copy pathgitConfig.ts
File metadata and controls
321 lines (280 loc) · 9.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
/**
* Git Configuration Management
*
* Handles reading and writing Git config for user identity switching.
* Uses workspace-level config when in a Git repository.
* Supports propagating config to submodules.
*
* SECURITY: Uses execFile() via secureExec to prevent command injection.
* @see https://owasp.org/www-community/attacks/Command_Injection
*
* Note: Uses vscodeLoader for lazy loading VS Code APIs to enable unit testing.
*/
// Type-only import for TypeScript (stripped at compile time)
import type * as vscodeTypes from 'vscode';
import { getVSCode, getWorkspace } from './vscodeLoader';
import { Identity, getIdentitiesWithValidation } from '../identity/identity';
import { gitExec, secureExec } from '../security/secureExec';
import { validateIdentity } from '../identity/inputValidator';
import {
listSubmodulesRecursive,
setIdentityForSubmodules,
isSubmoduleSupportEnabled,
getSubmoduleDepth,
} from './submodule';
import { createValidationError, createConfigError } from './errors';
import { extensionLogger } from '../logging/extensionLogger';
/**
* Get localized string using VS Code l10n API
* Falls back to the original string if VS Code API is not available
*/
function t(message: string): string {
const vscode = getVSCode();
return vscode?.l10n?.t(message) ?? message;
}
/**
* Check if icon should be included in Git config user.name
* @returns true if icon should be included, false otherwise (default: false)
*/
export function shouldIncludeIconInGitConfig(): boolean {
const workspace = getWorkspace();
if (!workspace) {
return false;
}
const config = workspace.getConfiguration('gitIdSwitcher');
return config.get<boolean>('includeIconInGitConfig', false);
}
/**
* Build user.name string for Git config
* Icon is only included if includeIconInGitConfig setting is true
*/
function buildGitUserName(identity: Identity): string {
if (identity.icon && shouldIncludeIconInGitConfig()) {
return `${identity.icon} ${identity.name}`;
}
return identity.name;
}
export interface GitConfig {
userName?: string;
userEmail?: string;
userSigningKey?: string;
}
/**
* Get the current workspace path
* @throws ConfigError if no workspace folder is open or VS Code API not available
*/
function getWorkspacePath(): string {
const workspace = getWorkspace();
const workspaceFolder = workspace?.workspaceFolders?.[0];
if (!workspaceFolder) {
throw createConfigError(t('No workspace folder open'));
}
return workspaceFolder.uri.fsPath;
}
/**
* Execute a git command in the current workspace
*
* @param args - Git arguments as array (NOT a string command)
* @returns stdout on success, undefined on failure
*/
async function execGitInWorkspace(args: string[]): Promise<string | undefined> {
const cwd = getWorkspacePath();
const result = await gitExec(args, cwd);
return result.success ? result.stdout : undefined;
}
/**
* Execute a git command in the current workspace, throwing on failure
*
* @param args - Git arguments as array (NOT a string command)
* @throws Error if command fails
*/
async function execGitInWorkspaceOrThrow(args: string[]): Promise<void> {
const cwd = getWorkspacePath();
const result = await gitExec(args, cwd);
if (!result.success) {
throw result.error;
}
}
/**
* Get current Git configuration
* @param token Optional cancellation token for aborting the operation
*/
export async function getCurrentGitConfig(
token?: vscodeTypes.CancellationToken
): Promise<GitConfig> {
if (token?.isCancellationRequested) {
return { userName: undefined, userEmail: undefined, userSigningKey: undefined };
}
// If no token provided, execute normally
if (!token) {
const [userName, userEmail, userSigningKey] = await Promise.all([
execGitInWorkspace(['config', 'user.name']),
execGitInWorkspace(['config', 'user.email']),
execGitInWorkspace(['config', 'user.signingkey']),
]);
return {
userName: userName || undefined,
userEmail: userEmail || undefined,
userSigningKey: userSigningKey || undefined,
};
}
// With cancellation token: race the operations against cancellation
const configPromise = Promise.all([
execGitInWorkspace(['config', 'user.name']),
execGitInWorkspace(['config', 'user.email']),
execGitInWorkspace(['config', 'user.signingkey']),
]);
// Track disposable for cleanup
let disposable: vscodeTypes.Disposable | undefined;
// Create a promise that rejects when cancellation is requested
const cancellationPromise = new Promise<never>((_, reject) => {
if (token.isCancellationRequested) {
reject(new Error('Operation cancelled'));
return;
}
disposable = token.onCancellationRequested(() => {
reject(new Error('Operation cancelled'));
});
});
try {
const [userName, userEmail, userSigningKey] = await Promise.race([
configPromise,
cancellationPromise,
]);
return {
userName: userName || undefined,
userEmail: userEmail || undefined,
userSigningKey: userSigningKey || undefined,
};
} catch (error) {
// If cancelled, return empty config
if (token.isCancellationRequested) {
return { userName: undefined, userEmail: undefined, userSigningKey: undefined };
}
// Re-throw other errors
throw error;
} finally {
// Always clean up the event listener to prevent memory leaks
disposable?.dispose();
}
}
/**
* Set Git user configuration for an identity
*
* Uses --local to set workspace-level config.
* Optionally propagates to submodules if enabled.
*
* SECURITY: Validates identity before applying config
*/
export async function setGitConfigForIdentity(identity: Identity): Promise<void> {
const workspace = getWorkspace();
const workspaceFolder = workspace?.workspaceFolders?.[0];
if (!workspaceFolder) {
throw createConfigError(t('No workspace folder open'));
}
// SECURITY: Validate identity before use
const validation = validateIdentity(identity);
if (!validation.valid) {
// SECURITY: Don't expose validation details to users
throw createValidationError(t('Invalid identity configuration'), {
field: 'identity',
context: { errorCount: validation.errors.length },
});
}
// Check if we're in a git repository
const isGitRepo = await execGitInWorkspace(['rev-parse', '--is-inside-work-tree']);
if (isGitRepo !== 'true') {
throw createConfigError(t('Not in a Git repository'));
}
// Set user.name (icon is only included if includeIconInGitConfig is true)
const userName = buildGitUserName(identity);
// SECURITY: Using execGitInWorkspaceOrThrow with array args prevents command injection
await execGitInWorkspaceOrThrow(['config', '--local', 'user.name', userName]);
// Set user.email
await execGitInWorkspaceOrThrow(['config', '--local', 'user.email', identity.email]);
// Set GPG signing key if available
if (identity.gpgKeyId) {
await execGitInWorkspaceOrThrow(['config', '--local', 'user.signingkey', identity.gpgKeyId]);
await execGitInWorkspaceOrThrow(['config', '--local', 'commit.gpgsign', 'true']);
}
// Propagate to submodules if enabled
if (isSubmoduleSupportEnabled()) {
await propagateToSubmodules(workspaceFolder.uri.fsPath, identity);
}
}
/**
* Propagate identity config to all submodules
*/
async function propagateToSubmodules(
workspacePath: string,
identity: Identity
): Promise<void> {
const depth = getSubmoduleDepth();
const submodules = await listSubmodulesRecursive(workspacePath, depth);
if (submodules.length === 0) {
return;
}
// Use same logic as main repo (icon only if includeIconInGitConfig is true)
const userName = buildGitUserName(identity);
const result = await setIdentityForSubmodules(
submodules,
userName,
identity.email,
identity.gpgKeyId
);
if (result.failed > 0) {
extensionLogger.warn(`Failed to configure ${result.failed} submodule(s)`);
}
if (result.success > 0) {
extensionLogger.info(`Configured ${result.success} submodule(s)`);
}
}
/**
* Detect current identity from Git config
* @param token Optional cancellation token for aborting the operation
*/
export async function detectCurrentIdentity(
token?: vscodeTypes.CancellationToken
): Promise<Identity | undefined> {
if (token?.isCancellationRequested) {
return undefined;
}
const config = await getCurrentGitConfig(token);
if (token?.isCancellationRequested) {
return undefined;
}
if (!config.userEmail) {
return undefined;
}
// Match by email (most reliable)
const identities = getIdentitiesWithValidation();
return identities.find(i => i.email === config.userEmail);
}
/**
* Verify Git is available
*/
export async function isGitAvailable(): Promise<boolean> {
try {
// SECURITY: Using secureExec instead of exec
await secureExec('git', ['--version']);
return true;
} catch {
return false;
}
}
/**
* Check if current directory is a Git repository
* @param token Optional cancellation token for aborting the operation
*/
export async function isGitRepository(
token?: vscodeTypes.CancellationToken
): Promise<boolean> {
if (token?.isCancellationRequested) {
return false;
}
const result = await execGitInWorkspace(['rev-parse', '--is-inside-work-tree']);
if (token?.isCancellationRequested) {
return false;
}
return result === 'true';
}