|
|
// Note: Using spawn instead of execSync for security (prevents command injection) |
|
|
|
|
|
/** |
|
|
* Check if a URL is an SSH URL (git@host:path or ssh://) |
|
|
*/ |
|
|
function isSshUrl(url) { |
|
|
return url.startsWith('git@') || url.startsWith('ssh://') || /^[a-zA-Z0-9_]+@/.test(url); |
|
|
} |
|
|
|
|
|
/** |
|
|
* Convert SSH URL to HTTPS URL for reachability testing |
|
|
* Examples: |
|
|
* git@github.com:user/repo.git -> https://github.com/user/repo.git |
|
|
* git@git.imwald.eu:2222/user/repo.git -> https://git.imwald.eu/user/repo.git |
|
|
* ssh://git@host:port/path -> https://host/path |
|
|
*/ |
|
|
function sshToHttps(url) { |
|
|
// Handle ssh:// URLs |
|
|
if (url.startsWith('ssh://')) { |
|
|
const match = url.match(/^ssh:\/\/(?:[^@]+@)?([^:\/]+)(?::(\d+))?(?:\/(.+))?$/); |
|
|
if (match) { |
|
|
const [, host, port, path] = match; |
|
|
const cleanPath = path || ''; |
|
|
// Remove port from HTTPS URL (ports are usually SSH-specific) |
|
|
return `https://${host}${cleanPath.startsWith('/') ? cleanPath : '/' + cleanPath}`; |
|
|
} |
|
|
} |
|
|
|
|
|
// Handle git@host:path format |
|
|
if (url.startsWith('git@') || /^[a-zA-Z0-9_]+@/.test(url)) { |
|
|
const match = url.match(/^(?:[^@]+@)?([^:]+):(.+)$/); |
|
|
if (match) { |
|
|
const [, host, path] = match; |
|
|
// Remove port if present (e.g., git.imwald.eu:2222 -> git.imwald.eu) |
|
|
const hostWithoutPort = host.split(':')[0]; |
|
|
const cleanPath = path.startsWith('/') ? path : '/' + path; |
|
|
return `https://${hostWithoutPort}${cleanPath}`; |
|
|
} |
|
|
} |
|
|
|
|
|
return null; |
|
|
} |
|
|
|
|
|
/** |
|
|
* Check if a git URL is reachable |
|
|
* Tests the info/refs endpoint to see if the server responds |
|
|
* Converts SSH URLs to HTTPS for testing |
|
|
*/ |
|
|
async function checkUrlReachability(url, timeout = 5000) { |
|
|
let testUrl = url; |
|
|
|
|
|
// Convert SSH URLs to HTTPS for testing |
|
|
if (isSshUrl(url)) { |
|
|
const httpsUrl = sshToHttps(url); |
|
|
if (httpsUrl) { |
|
|
testUrl = httpsUrl; |
|
|
} else { |
|
|
// If we can't convert, assume reachable (will fail on actual push if not) |
|
|
return { reachable: true, error: undefined }; |
|
|
} |
|
|
} |
|
|
|
|
|
try { |
|
|
// Parse URL and construct test endpoint |
|
|
const urlObj = new URL(testUrl); |
|
|
|
|
|
// Only test HTTP/HTTPS URLs |
|
|
if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') { |
|
|
// For other protocols (like git://), assume reachable |
|
|
return { reachable: true, error: undefined }; |
|
|
} |
|
|
|
|
|
const infoRefsUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}/info/refs?service=git-upload-pack`; |
|
|
|
|
|
const controller = new AbortController(); |
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout); |
|
|
|
|
|
try { |
|
|
const response = await fetch(infoRefsUrl, { |
|
|
method: 'GET', |
|
|
signal: controller.signal, |
|
|
redirect: 'manual', |
|
|
headers: { |
|
|
'User-Agent': 'GitRepublic-CLI/1.0' |
|
|
} |
|
|
}); |
|
|
|
|
|
clearTimeout(timeoutId); |
|
|
// Any HTTP status < 600 means server is reachable |
|
|
return { reachable: response.status < 600, error: response.status >= 600 ? `HTTP ${response.status}` : undefined }; |
|
|
} catch (fetchError) { |
|
|
clearTimeout(timeoutId); |
|
|
if (fetchError instanceof Error && fetchError.name === 'AbortError') { |
|
|
return { reachable: false, error: 'Timeout' }; |
|
|
} |
|
|
return { reachable: false, error: fetchError instanceof Error ? fetchError.message : 'Network error' }; |
|
|
} |
|
|
} catch (urlError) { |
|
|
// If URL parsing fails, it might be a malformed URL |
|
|
// For SSH URLs that we couldn't convert, assume reachable (will fail on actual push if not) |
|
|
if (isSshUrl(url)) { |
|
|
return { reachable: true, error: undefined }; |
|
|
} |
|
|
return { reachable: false, error: urlError instanceof Error ? urlError.message : 'Invalid URL' }; |
|
|
} |
|
|
} |
|
|
|
|
|
/** |
|
|
* Get the URL for a git remote |
|
|
*/ |
|
|
async function getRemoteUrl(remote) { |
|
|
const { spawn } = await import('child_process'); |
|
|
return new Promise((resolve, reject) => { |
|
|
const proc = spawn('git', ['remote', 'get-url', remote], { encoding: 'utf-8' }); |
|
|
let output = ''; |
|
|
proc.stdout.on('data', (chunk) => { output += chunk.toString(); }); |
|
|
proc.on('close', (code) => { |
|
|
if (code === 0) resolve(output.trim()); |
|
|
else reject(new Error(`git remote get-url exited with code ${code}`)); |
|
|
}); |
|
|
proc.on('error', reject); |
|
|
}); |
|
|
} |
|
|
|
|
|
/** |
|
|
* Push to all remotes |
|
|
* Security: Uses spawn with argument arrays to prevent command injection |
|
|
* |
|
|
* Checks reachability of each remote before pushing, skipping unreachable ones. |
|
|
* This allows skipping GRASP servers that aren't reachable or public. |
|
|
*/ |
|
|
export async function pushAll(args, server, json) { |
|
|
// Check for help flag |
|
|
if (args.includes('--help') || args.includes('-h')) { |
|
|
console.log(`Push to All Remotes |
|
|
|
|
|
Usage: gitrep push-all [branch] [options] |
|
|
|
|
|
Description: |
|
|
Pushes the current branch (or specified branch) to all configured git remotes. |
|
|
This is useful when you have multiple remotes (e.g., GitHub, GitLab, GitRepublic) |
|
|
and want to push to all of them at once. |
|
|
|
|
|
Arguments: |
|
|
branch Optional branch name to push. If not specified, pushes all branches. |
|
|
|
|
|
Options: |
|
|
--force, -f Force push (use with caution) |
|
|
--tags Also push tags |
|
|
--dry-run, -n Show what would be pushed without actually pushing |
|
|
--skip-reachability Skip reachability check (push to all remotes regardless) |
|
|
--help, -h Show this help message |
|
|
|
|
|
Examples: |
|
|
gitrep push-all Push all branches to all remotes |
|
|
gitrep push-all main Push main branch to all remotes |
|
|
gitrep push-all main --force Force push main branch to all remotes |
|
|
gitrep push-all --tags Push all branches and tags to all remotes |
|
|
gitrep push-all main --dry-run Show what would be pushed without pushing |
|
|
|
|
|
Notes: |
|
|
- This command requires you to be in a git repository |
|
|
- It will push to all remotes listed by 'git remote' |
|
|
- Checks reachability of each remote before pushing (skips unreachable ones) |
|
|
- If any reachable remote fails, the command will exit with an error code |
|
|
- Use --dry-run to test before actually pushing |
|
|
`); |
|
|
return; |
|
|
} |
|
|
|
|
|
// Parse arguments |
|
|
const branch = args.find(arg => !arg.startsWith('--')); |
|
|
const force = args.includes('--force') || args.includes('-f'); |
|
|
const tags = args.includes('--tags'); |
|
|
const dryRun = args.includes('--dry-run') || args.includes('-n'); |
|
|
const skipReachabilityCheck = args.includes('--skip-reachability'); |
|
|
|
|
|
// Get all remotes |
|
|
// Security: Use spawn with argument arrays to prevent command injection |
|
|
let remotes = []; |
|
|
try { |
|
|
const { spawn } = await import('child_process'); |
|
|
const remoteOutput = await new Promise((resolve, reject) => { |
|
|
const proc = spawn('git', ['remote'], { encoding: 'utf-8' }); |
|
|
let output = ''; |
|
|
proc.stdout.on('data', (chunk) => { output += chunk.toString(); }); |
|
|
proc.on('close', (code) => { |
|
|
if (code === 0) resolve(output.trim()); |
|
|
else reject(new Error(`git remote exited with code ${code}`)); |
|
|
}); |
|
|
proc.on('error', reject); |
|
|
}); |
|
|
remotes = remoteOutput.split('\n').filter(r => r.trim()); |
|
|
} catch (err) { |
|
|
console.error('Error: Not in a git repository or unable to read remotes'); |
|
|
console.error(err instanceof Error ? err.message : 'Unknown error'); |
|
|
process.exit(1); |
|
|
} |
|
|
|
|
|
if (remotes.length === 0) { |
|
|
console.error('Error: No remotes configured'); |
|
|
process.exit(1); |
|
|
} |
|
|
|
|
|
// Get remote URLs and check reachability |
|
|
const remoteInfo = []; |
|
|
if (!skipReachabilityCheck && !dryRun) { |
|
|
if (!json) { |
|
|
console.log('Checking remote reachability...'); |
|
|
} |
|
|
for (const remote of remotes) { |
|
|
try { |
|
|
const remoteUrl = await getRemoteUrl(remote); |
|
|
const reachability = await checkUrlReachability(remoteUrl); |
|
|
remoteInfo.push({ remote, url: remoteUrl, ...reachability }); |
|
|
|
|
|
if (!json) { |
|
|
const status = reachability.reachable ? '✅' : '❌'; |
|
|
console.log(` ${status} ${remote} (${remoteUrl})${reachability.error ? ` - ${reachability.error}` : ''}`); |
|
|
} |
|
|
} catch (err) { |
|
|
// If we can't get URL or check reachability, assume reachable (fallback) |
|
|
remoteInfo.push({ remote, url: 'unknown', reachable: true, error: undefined }); |
|
|
if (!json) { |
|
|
console.log(` ⚠️ ${remote} - Could not check reachability, will attempt push`); |
|
|
} |
|
|
} |
|
|
} |
|
|
} else { |
|
|
// Skip reachability check - assume all are reachable |
|
|
for (const remote of remotes) { |
|
|
try { |
|
|
const remoteUrl = await getRemoteUrl(remote); |
|
|
remoteInfo.push({ remote, url: remoteUrl, reachable: true }); |
|
|
} catch { |
|
|
remoteInfo.push({ remote, url: 'unknown', reachable: true }); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// Filter to only reachable remotes |
|
|
const reachableRemotes = remoteInfo.filter(info => info.reachable); |
|
|
const unreachableRemotes = remoteInfo.filter(info => !info.reachable); |
|
|
|
|
|
if (unreachableRemotes.length > 0 && !json) { |
|
|
console.log(`\n⚠️ Skipping ${unreachableRemotes.length} unreachable remote(s):`); |
|
|
unreachableRemotes.forEach(info => { |
|
|
console.log(` - ${info.remote} (${info.url}): ${info.error || 'Unreachable'}`); |
|
|
}); |
|
|
} |
|
|
|
|
|
if (reachableRemotes.length === 0) { |
|
|
console.error('Error: No reachable remotes found'); |
|
|
process.exit(1); |
|
|
} |
|
|
|
|
|
// Build push command |
|
|
const pushArgs = []; |
|
|
if (force) pushArgs.push('--force'); |
|
|
if (tags) pushArgs.push('--tags'); |
|
|
if (dryRun) pushArgs.push('--dry-run'); |
|
|
if (branch) { |
|
|
// If branch is specified, push to each remote with that branch |
|
|
pushArgs.push(branch); |
|
|
} else { |
|
|
// Push all branches |
|
|
pushArgs.push('--all'); |
|
|
} |
|
|
|
|
|
const results = []; |
|
|
let successCount = 0; |
|
|
let failCount = 0; |
|
|
let skippedCount = unreachableRemotes.length; |
|
|
|
|
|
for (const remoteInfo of reachableRemotes) { |
|
|
const remote = remoteInfo.remote; |
|
|
try { |
|
|
if (!json && !dryRun) { |
|
|
console.log(`\nPushing to ${remote}...`); |
|
|
} |
|
|
|
|
|
// Security: Use spawn with argument arrays to prevent command injection |
|
|
const { spawn } = await import('child_process'); |
|
|
const command = ['push', remote, ...pushArgs]; |
|
|
|
|
|
await new Promise((resolve, reject) => { |
|
|
const proc = spawn('git', command, { |
|
|
stdio: json ? 'pipe' : 'inherit', |
|
|
encoding: 'utf-8' |
|
|
}); |
|
|
proc.on('close', (code) => { |
|
|
if (code === 0) resolve(); |
|
|
else reject(new Error(`git push exited with code ${code}`)); |
|
|
}); |
|
|
proc.on('error', reject); |
|
|
}); |
|
|
|
|
|
results.push({ remote, status: 'success' }); |
|
|
successCount++; |
|
|
|
|
|
if (!json && !dryRun) { |
|
|
console.log(`✅ Successfully pushed to ${remote}`); |
|
|
} |
|
|
} catch (err) { |
|
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error'; |
|
|
results.push({ remote, status: 'failed', error: errorMessage }); |
|
|
failCount++; |
|
|
|
|
|
if (!json && !dryRun) { |
|
|
console.error(`❌ Failed to push to ${remote}: ${errorMessage}`); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// Add skipped remotes to results |
|
|
unreachableRemotes.forEach(info => { |
|
|
results.push({ |
|
|
remote: info.remote, |
|
|
status: 'skipped', |
|
|
error: info.error || 'Unreachable', |
|
|
url: info.url |
|
|
}); |
|
|
}); |
|
|
|
|
|
if (json) { |
|
|
console.log(JSON.stringify({ |
|
|
total: remotes.length, |
|
|
reachable: reachableRemotes.length, |
|
|
skipped: skippedCount, |
|
|
success: successCount, |
|
|
failed: failCount, |
|
|
results |
|
|
}, null, 2)); |
|
|
} else { |
|
|
console.log('\n' + '='.repeat(70)); |
|
|
const summary = `Push Summary: ${successCount} succeeded, ${failCount} failed, ${skippedCount} skipped out of ${remotes.length} remotes`; |
|
|
console.log(summary); |
|
|
console.log('='.repeat(70)); |
|
|
|
|
|
if (skippedCount > 0) { |
|
|
console.log('\nSkipped remotes (unreachable):'); |
|
|
unreachableRemotes.forEach(info => { |
|
|
console.log(` ${info.remote} (${info.url}): ${info.error || 'Unreachable'}`); |
|
|
}); |
|
|
} |
|
|
|
|
|
if (failCount > 0) { |
|
|
console.log('\nFailed remotes:'); |
|
|
results.filter(r => r.status === 'failed').forEach(r => { |
|
|
console.log(` ${r.remote}: ${r.error}`); |
|
|
}); |
|
|
process.exit(1); |
|
|
} |
|
|
} |
|
|
}
|
|
|
|