From 86c33c1c0cb34176725a38f46960213714fc3e04 Mon Sep 17 00:00:00 2001 From: simosmik Date: Mon, 9 Mar 2026 07:27:41 +0000 Subject: [PATCH] fix(git): prevent shell injection in git routes --- plugins/starter | 1 + server/routes/git.js | 198 ++++++++++++++++++++++++-------------- server/routes/user.js | 27 +++++- server/utils/gitConfig.js | 20 +++- 4 files changed, 165 insertions(+), 81 deletions(-) create mode 160000 plugins/starter diff --git a/plugins/starter b/plugins/starter new file mode 160000 index 00000000..bfa63328 --- /dev/null +++ b/plugins/starter @@ -0,0 +1 @@ +Subproject commit bfa63328103ca330a012bc083e4f934adbc2086e diff --git a/server/routes/git.js b/server/routes/git.js index e6fecee7..2214d902 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -1,6 +1,5 @@ import express from 'express'; -import { exec, spawn } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; import path from 'path'; import { promises as fs } from 'fs'; import { extractProjectDirectory } from '../projects.js'; @@ -8,7 +7,6 @@ import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; const router = express.Router(); -const execAsync = promisify(exec); function spawnAsync(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -47,6 +45,36 @@ function spawnAsync(command, args, options = {}) { }); } +// Input validation helpers (defense-in-depth) +function validateCommitRef(commit) { + // Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names + if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) { + throw new Error('Invalid commit reference'); + } + return commit; +} + +function validateBranchName(branch) { + if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) { + throw new Error('Invalid branch name'); + } + return branch; +} + +function validateFilePath(file) { + if (!file || file.includes('\0')) { + throw new Error('Invalid file path'); + } + return file; +} + +function validateRemoteName(remote) { + if (!/^[a-zA-Z0-9._-]+$/.test(remote)) { + throw new Error('Invalid remote name'); + } + return remote; +} + // Helper function to get the actual project path from the encoded project name async function getActualProjectPath(projectName) { try { @@ -98,14 +126,14 @@ async function validateGitRepository(projectPath) { try { // Allow any directory that is inside a work tree (repo root or nested folder). - const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath }); + const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath }); const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true'; if (!isInsideWorkTree) { throw new Error('Not inside a git work tree'); } // Ensure git can resolve the repository root for this directory. - await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); + await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath }); } catch { throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.'); } @@ -129,7 +157,7 @@ router.get('/status', async (req, res) => { let branch = 'main'; let hasCommits = true; try { - const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); branch = branchOutput.trim(); } catch (error) { // No HEAD exists - repository has no commits yet @@ -142,7 +170,7 @@ router.get('/status', async (req, res) => { } // Get git status - const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath }); const modified = []; const added = []; @@ -201,8 +229,11 @@ router.get('/diff', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); + // Validate file path + validateFilePath(file); + // Check if file is untracked or deleted - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -223,21 +254,21 @@ router.get('/diff', async (req, res) => { } } else if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions - const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: fileContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); const lines = fileContent.split('\n'); diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + lines.map(line => `-${line}`).join('\n'); } else { // Get diff for tracked files // First check for unstaged changes (working tree vs index) - const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath }); + const { stdout: unstagedDiff } = await spawnAsync('git', ['diff', '--', file], { cwd: projectPath }); if (unstagedDiff) { // Show unstaged changes if they exist diff = stripDiffHeaders(unstagedDiff); } else { // If no unstaged changes, check for staged changes (index vs HEAD) - const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath }); + const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath }); diff = stripDiffHeaders(stagedDiff) || ''; } } @@ -263,8 +294,11 @@ router.get('/file-with-diff', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); + // Validate file path + validateFilePath(file); + // Check file status - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -273,7 +307,7 @@ router.get('/file-with-diff', async (req, res) => { if (isDeleted) { // For deleted files, get content from HEAD - const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); oldContent = headContent; currentContent = headContent; // Show the deleted content in editor } else { @@ -291,7 +325,7 @@ router.get('/file-with-diff', async (req, res) => { if (!isUntracked) { // Get the old content from HEAD for tracked files try { - const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); oldContent = headContent; } catch (error) { // File might be newly added to git (staged but not committed) @@ -328,17 +362,17 @@ router.post('/initial-commit', async (req, res) => { // Check if there are already commits try { - await execAsync('git rev-parse HEAD', { cwd: projectPath }); + await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath }); return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' }); } catch (error) { // No HEAD - this is good, we can create initial commit } // Add all files - await execAsync('git add .', { cwd: projectPath }); + await spawnAsync('git', ['add', '.'], { cwd: projectPath }); // Create initial commit - const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath }); res.json({ success: true, output: stdout, message: 'Initial commit created successfully' }); } catch (error) { @@ -372,11 +406,12 @@ router.post('/commit', async (req, res) => { // Stage selected files for (const file of files) { - await execAsync(`git add "${file}"`, { cwd: projectPath }); + validateFilePath(file); + await spawnAsync('git', ['add', file], { cwd: projectPath }); } - + // Commit with message - const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { @@ -400,7 +435,7 @@ router.get('/branches', async (req, res) => { await validateGitRepository(projectPath); // Get all branches - const { stdout } = await execAsync('git branch -a', { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath }); // Parse branches const branches = stdout @@ -439,7 +474,8 @@ router.post('/checkout', async (req, res) => { const projectPath = await getActualProjectPath(project); // Checkout the branch - const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath }); + validateBranchName(branch); + const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { @@ -460,7 +496,8 @@ router.post('/create-branch', async (req, res) => { const projectPath = await getActualProjectPath(project); // Create and checkout new branch - const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath }); + validateBranchName(branch); + const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { @@ -509,8 +546,8 @@ router.get('/commits', async (req, res) => { // Get stats for each commit for (const commit of commits) { try { - const { stdout: stats } = await execAsync( - `git show --stat --format='' ${commit.hash}`, + const { stdout: stats } = await spawnAsync( + 'git', ['show', '--stat', '--format=', commit.hash], { cwd: projectPath } ); commit.stats = stats.trim().split('\n').pop(); // Get the summary line @@ -536,10 +573,13 @@ router.get('/commit-diff', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - + + // Validate commit reference (defense-in-depth) + validateCommitRef(commit); + // Get diff for the commit - const { stdout } = await execAsync( - `git show ${commit}`, + const { stdout } = await spawnAsync( + 'git', ['show', commit], { cwd: projectPath } ); @@ -570,8 +610,9 @@ router.post('/generate-commit-message', async (req, res) => { let diffContext = ''; for (const file of files) { try { - const { stdout } = await execAsync( - `git diff HEAD -- "${file}"`, + validateFilePath(file); + const { stdout } = await spawnAsync( + 'git', ['diff', 'HEAD', '--', file], { cwd: projectPath } ); if (stdout) { @@ -764,14 +805,14 @@ router.get('/remote-status', async (req, res) => { await validateGitRepository(projectPath); // Get current branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = currentBranch.trim(); // Check if there's a remote tracking branch (smart detection) let trackingBranch; let remoteName; try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); trackingBranch = stdout.trim(); remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") } catch (error) { @@ -779,7 +820,7 @@ router.get('/remote-status', async (req, res) => { let hasRemote = false; let remoteName = null; try { - const { stdout } = await execAsync('git remote', { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length > 0) { hasRemote = true; @@ -788,8 +829,8 @@ router.get('/remote-status', async (req, res) => { } catch (remoteError) { // No remotes configured } - - return res.json({ + + return res.json({ hasRemote, hasUpstream: false, branch, @@ -799,8 +840,8 @@ router.get('/remote-status', async (req, res) => { } // Get ahead/behind counts - const { stdout: countOutput } = await execAsync( - `git rev-list --count --left-right ${trackingBranch}...HEAD`, + const { stdout: countOutput } = await spawnAsync( + 'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`], { cwd: projectPath } ); @@ -835,20 +876,21 @@ router.post('/fetch', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); remoteName = stdout.trim().split('/')[0]; // Extract remote name } catch (error) { // No upstream, try to fetch from origin anyway console.log('No upstream configured, using origin as fallback'); } - const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath }); - + validateRemoteName(remoteName); + const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath }); + res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName }); } catch (error) { console.error('Git fetch error:', error); @@ -876,13 +918,13 @@ router.post('/pull', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name @@ -891,17 +933,19 @@ router.post('/pull', async (req, res) => { console.log('No upstream configured, using origin/branch as fallback'); } - const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath }); - - res.json({ - success: true, - output: stdout || 'Pull completed successfully', + validateRemoteName(remoteName); + validateBranchName(remoteBranch); + const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath }); + + res.json({ + success: true, + output: stdout || 'Pull completed successfully', remoteName, remoteBranch }); } catch (error) { console.error('Git pull error:', error); - + // Enhanced error handling for common pull scenarios let errorMessage = 'Pull failed'; let details = error.message; @@ -943,13 +987,13 @@ router.post('/push', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name @@ -958,11 +1002,13 @@ router.post('/push', async (req, res) => { console.log('No upstream configured, using origin/branch as fallback'); } - const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath }); - - res.json({ - success: true, - output: stdout || 'Push completed successfully', + validateRemoteName(remoteName); + validateBranchName(remoteBranch); + const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath }); + + res.json({ + success: true, + output: stdout || 'Push completed successfully', remoteName, remoteBranch }); @@ -1012,35 +1058,39 @@ router.post('/publish', async (req, res) => { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); + // Validate branch name + validateBranchName(branch); + // Get current branch to verify it matches the requested branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const currentBranchName = currentBranch.trim(); - + if (currentBranchName !== branch) { - return res.status(400).json({ - error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` + return res.status(400).json({ + error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` }); } // Check if remote exists let remoteName = 'origin'; try { - const { stdout } = await execAsync('git remote', { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length === 0) { - return res.status(400).json({ - error: 'No remote repository configured. Add a remote with: git remote add origin ' + return res.status(400).json({ + error: 'No remote repository configured. Add a remote with: git remote add origin ' }); } remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; } catch (error) { - return res.status(400).json({ - error: 'No remote repository configured. Add a remote with: git remote add origin ' + return res.status(400).json({ + error: 'No remote repository configured. Add a remote with: git remote add origin ' }); } // Publish the branch (set upstream and push) - const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath }); + validateRemoteName(remoteName); + const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath }); res.json({ success: true, @@ -1088,9 +1138,12 @@ router.post('/discard', async (req, res) => { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); + // Validate file path + validateFilePath(file); + // Check file status to determine correct discard command - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); - + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); } @@ -1109,10 +1162,10 @@ router.post('/discard', async (req, res) => { } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD - await execAsync(`git restore "${file}"`, { cwd: projectPath }); + await spawnAsync('git', ['restore', file], { cwd: projectPath }); } else if (status.includes('A')) { // Added file - unstage it - await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath }); + await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath }); } res.json({ success: true, message: `Changes discarded for ${file}` }); @@ -1134,8 +1187,11 @@ router.post('/delete-untracked', async (req, res) => { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); + // Validate file path + validateFilePath(file); + // Check if file is actually untracked - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' }); diff --git a/server/routes/user.js b/server/routes/user.js index ed39b0b3..877cd45b 100644 --- a/server/routes/user.js +++ b/server/routes/user.js @@ -2,12 +2,29 @@ import express from 'express'; import { userDb } from '../database/db.js'; import { authenticateToken } from '../middleware/auth.js'; import { getSystemGitConfig } from '../utils/gitConfig.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; -const execAsync = promisify(exec); const router = express.Router(); +function spawnAsync(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { ...options, shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.stderr.on('data', (data) => { stderr += data.toString(); }); + child.on('error', (error) => { reject(error); }); + child.on('close', (code) => { + if (code === 0) { resolve({ stdout, stderr }); return; } + const error = new Error(`Command failed: ${command} ${args.join(' ')}`); + error.code = code; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }); + }); +} + router.get('/git-config', authenticateToken, async (req, res) => { try { const userId = req.user.id; @@ -55,8 +72,8 @@ router.post('/git-config', authenticateToken, async (req, res) => { userDb.updateGitConfig(userId, gitName, gitEmail); try { - await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`); - await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`); + await spawnAsync('git', ['config', '--global', 'user.name', gitName]); + await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]); console.log(`Applied git config globally: ${gitName} <${gitEmail}>`); } catch (gitError) { console.error('Error applying git config:', gitError); diff --git a/server/utils/gitConfig.js b/server/utils/gitConfig.js index 2cae485a..586933a0 100644 --- a/server/utils/gitConfig.js +++ b/server/utils/gitConfig.js @@ -1,7 +1,17 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; -const execAsync = promisify(exec); +function spawnAsync(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false }); + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.on('error', (error) => { reject(error); }); + child.on('close', (code) => { + if (code === 0) { resolve({ stdout }); return; } + reject(new Error(`Command failed with code ${code}`)); + }); + }); +} /** * Read git configuration from system's global git config @@ -10,8 +20,8 @@ const execAsync = promisify(exec); export async function getSystemGitConfig() { try { const [nameResult, emailResult] = await Promise.all([ - execAsync('git config --global user.name').catch(() => ({ stdout: '' })), - execAsync('git config --global user.email').catch(() => ({ stdout: '' })) + spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })), + spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' })) ]); return {