diff --git a/scripts/commands/index.js b/scripts/commands/index.js index a6cab31..fe38db7 100644 --- a/scripts/commands/index.js +++ b/scripts/commands/index.js @@ -5,3 +5,4 @@ export { search } from './search.js'; export { publish } from './publish/index.js'; export { verify } from './verify.js'; export { pushAll } from './pushAll.js'; +export { pullAll } from './pullAll.js'; \ No newline at end of file diff --git a/scripts/commands/pullAll.js b/scripts/commands/pullAll.js new file mode 100644 index 0000000..8e9d6a9 --- /dev/null +++ b/scripts/commands/pullAll.js @@ -0,0 +1,653 @@ +// Note: Using spawn instead of execSync for security (prevents command injection) + +/** + * 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); + }); +} + +/** + * Check if a git URL is reachable + * Tests the info/refs endpoint to see if the server responds + */ +async function checkUrlReachability(url, timeout = 5000) { + try { + // Handle SSH URLs (git@host:path or ssh://git@host/path) + if (url.startsWith('git@') || url.startsWith('ssh://')) { + // For SSH URLs, we can't easily test reachability via HTTP + // Assume reachable (user has SSH access configured) + return { reachable: true, error: undefined }; + } + + // Parse URL and construct test endpoint + let testUrl = url; + + // Handle git:// URLs + if (url.startsWith('git://')) { + testUrl = url.replace('git://', 'http://'); + } + + // Ensure URL ends with .git for the test + if (!testUrl.endsWith('.git')) { + testUrl = testUrl.replace(/\/$/, '') + '.git'; + } + + const urlObj = new URL(testUrl); + const testEndpoint = `${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(testEndpoint, { + 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) { + return { reachable: false, error: urlError instanceof Error ? urlError.message : 'Invalid URL' }; + } +} + +/** + * Check if merge would have conflicts (dry-run) + * Uses git merge-tree for a true dry-run without modifying the working tree + */ +async function checkMergeConflicts(remoteBranch, currentBranch, rebase = false) { + const { spawn } = await import('child_process'); + + if (rebase) { + // For rebase, check if branches have diverged + // If currentBranch is an ancestor of remoteBranch, it's a fast-forward (no conflict) + // If they've diverged, there could be conflicts + return new Promise((resolve) => { + // First check if currentBranch is ancestor of remoteBranch (fast-forward case) + const ancestorProc = spawn('git', ['merge-base', '--is-ancestor', currentBranch, remoteBranch], { + stdio: 'pipe', + encoding: 'utf-8' + }); + ancestorProc.on('close', (ancestorCode) => { + if (ancestorCode === 0) { + // Fast-forward possible, no conflict + resolve(false); + } else { + // Branches have diverged, check if merge would conflict + // Use merge-tree to check for conflicts + const mergeTreeProc = spawn('git', ['merge-tree', currentBranch, remoteBranch], { + stdio: 'pipe', + encoding: 'utf-8' + }); + let output = ''; + mergeTreeProc.stdout.on('data', (chunk) => { output += chunk.toString(); }); + mergeTreeProc.stderr.on('data', (chunk) => { output += chunk.toString(); }); + mergeTreeProc.on('close', (code) => { + // If output contains conflict markers or exit code indicates conflict + const hasConflicts = output.includes('<<<<<<<') || + output.includes('=======') || + output.includes('>>>>>>>') || + code !== 0; + resolve(hasConflicts); + }); + mergeTreeProc.on('error', () => resolve(true)); // Assume conflict on error + } + }); + ancestorProc.on('error', () => resolve(true)); // Assume conflict on error + }); + } else { + // For merge, use merge-tree to check for conflicts without modifying working tree + return new Promise((resolve) => { + const proc = spawn('git', ['merge-tree', currentBranch, remoteBranch], { + stdio: 'pipe', + encoding: 'utf-8' + }); + let output = ''; + proc.stdout.on('data', (chunk) => { output += chunk.toString(); }); + proc.stderr.on('data', (chunk) => { output += chunk.toString(); }); + proc.on('close', (code) => { + // Check for conflict markers in output + const hasConflicts = output.includes('<<<<<<<') || + output.includes('=======') || + output.includes('>>>>>>>') || + code !== 0; + resolve(hasConflicts); + }); + proc.on('error', () => resolve(true)); // Assume conflict on error + }); + } +} + +/** + * Fetch from all remotes and optionally merge/rebase changes + * Security: Uses spawn with argument arrays to prevent command injection + * + * This command fetches from all configured git remotes sequentially and optionally + * merges or rebases the changes into your current branch. It always does a dry-run + * first to check for conflicts, and requires explicit confirmation if conflicts are + * detected. + */ +export async function pullAll(args, server, json) { + // Check for help flag + if (args.includes('--help') || args.includes('-h')) { + console.log(`Fetch and Merge from All Remotes + +Usage: gitrep pull-all [branch] [options] + +Description: + Fetches from all configured git remotes sequentially and optionally merges + or rebases the changes into your current branch (or specified branch). + This is useful when you have multiple remotes and want to pull changes from + all of them, such as from GRASP servers, GitHub, GitLab, etc. + +Arguments: + branch Optional branch name. If not specified, uses current branch. + +Options: + --merge Merge changes from remotes into current branch (default: fetch only) + --rebase Rebase current branch onto remote branches (instead of merge) + --no-ff Create merge commit even if fast-forward is possible (with --merge) + --allow-conflicts Allow proceeding even if conflicts are detected (default: abort on conflicts) + --skip-reachability Skip reachability check (attempt to fetch from all remotes regardless) + --dry-run, -n Show what would be fetched/merged without actually doing it + --help, -h Show this help message + +Examples: + gitrep pull-all Fetch from all remotes (no merge) + gitrep pull-all --merge Fetch and merge changes from all remotes + gitrep pull-all main --merge Fetch and merge main branch from all remotes + gitrep pull-all --rebase Fetch and rebase current branch onto remotes + gitrep pull-all --merge --no-ff Fetch and merge with merge commits + +Notes: + - This command requires you to be in a git repository + - It will fetch from all remotes listed by 'git remote' + - Checks reachability of each remote before fetching (skips unreachable ones) + - Aborts if no remotes are reachable + - By default, only fetches (doesn't merge) - use --merge or --rebase to integrate changes + - Always performs a conflict check first - aborts if conflicts detected (unless --allow-conflicts) + - If multiple remotes have the same branch, merges/rebases happen sequentially + - Use --allow-conflicts if you want to proceed despite conflicts (you'll resolve manually) + - Use --skip-reachability to bypass reachability checks + - Use --dry-run to see what would happen without making changes +`); + return; + } + + // Parse arguments + const branch = args.find(arg => !arg.startsWith('--')); + const merge = args.includes('--merge'); + const rebase = args.includes('--rebase'); + const noff = args.includes('--no-ff'); + const allowConflicts = args.includes('--allow-conflicts'); + const skipReachabilityCheck = args.includes('--skip-reachability'); + const dryRun = args.includes('--dry-run') || args.includes('-n'); + + // Validate options + if (merge && rebase) { + console.error('Error: Cannot use both --merge and --rebase. Choose one.'); + process.exit(1); + } + + // 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 fetch`); + } + } + } + } 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); + + // Abort if no remotes are reachable + if (reachableRemotes.length === 0) { + console.error('\n❌ Error: No reachable remotes found'); + if (unreachableRemotes.length > 0) { + console.error('\nUnreachable remotes:'); + unreachableRemotes.forEach(({ remote, url, error }) => { + console.error(` - ${remote} (${url})${error ? `: ${error}` : ''}`); + }); + } + console.error('\nCannot proceed without at least one reachable remote.'); + console.error('Use --skip-reachability to bypass this check (not recommended).'); + process.exit(1); + } + + if (unreachableRemotes.length > 0 && !json) { + console.log(`\n⚠️ Skipping ${unreachableRemotes.length} unreachable remote(s):`); + unreachableRemotes.forEach(({ remote, url, error }) => { + console.log(` - ${remote} (${url})${error ? `: ${error}` : ''}`); + }); + console.log(''); + } + + // Update remotes list to only include reachable ones + remotes = reachableRemotes.map(info => info.remote); + + // Get current branch if not specified + let currentBranch = branch; + if (!currentBranch) { + try { + const { spawn } = await import('child_process'); + const branchOutput = await new Promise((resolve, reject) => { + const proc = spawn('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { 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 rev-parse exited with code ${code}`)); + }); + proc.on('error', reject); + }); + currentBranch = branchOutput; + } catch (err) { + console.error('Error: Could not determine current branch'); + console.error(err instanceof Error ? err.message : 'Unknown error'); + process.exit(1); + } + } + + const results = []; + let successCount = 0; + let failCount = 0; + let mergeCount = 0; + let conflictCount = 0; + const potentialConflicts = []; + + if (!json && !dryRun) { + console.log(`Fetching from ${remotes.length} remote(s) and ${merge ? 'merging' : rebase ? 'rebasing' : 'fetching'} changes...`); + console.log(`Target branch: ${currentBranch}\n`); + } + + // Phase 1: Fetch from all remotes first + if (!json && !dryRun) { + console.log('Phase 1: Fetching from all remotes...'); + } + + for (const remote of remotes) { + try { + if (!json && !dryRun) { + console.log(`\nFetching from ${remote}...`); + } + + const { spawn } = await import('child_process'); + + // Fetch from remote + if (!dryRun) { + await new Promise((resolve, reject) => { + const proc = spawn('git', ['fetch', remote], { + stdio: json ? 'pipe' : 'inherit', + encoding: 'utf-8' + }); + proc.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`git fetch exited with code ${code}`)); + }); + proc.on('error', reject); + }); + + if (!json && !dryRun) { + console.log(` ✅ Fetched from ${remote}`); + } + } else { + if (!json) { + console.log(` [DRY RUN] Would fetch from ${remote}`); + } + } + + results.push({ remote, status: 'fetched', branch: currentBranch }); + successCount++; + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + results.push({ remote, status: 'fetch-failed', error: errorMessage, branch: currentBranch }); + failCount++; + + if (!json && !dryRun) { + console.error(` ❌ Failed to fetch from ${remote}: ${errorMessage}`); + } + } + } + + // Phase 2: Check for conflicts (if merge/rebase requested) + if ((merge || rebase) && !dryRun) { + if (!json) { + console.log('\n' + '='.repeat(70)); + console.log('Phase 2: Checking for potential conflicts...'); + console.log('='.repeat(70)); + } + + for (const remote of remotes) { + const remoteBranch = `${remote}/${currentBranch}`; + + // Check if remote branch exists + let remoteBranchExists = false; + try { + const { spawn } = await import('child_process'); + await new Promise((resolve, reject) => { + const proc = spawn('git', ['rev-parse', '--verify', `refs/remotes/${remoteBranch}`], { + stdio: 'pipe', + encoding: 'utf-8' + }); + proc.on('close', (code) => { + if (code === 0) { + remoteBranchExists = true; + resolve(); + } else { + resolve(); // Branch doesn't exist, that's okay + } + }); + proc.on('error', reject); + }); + } catch { + // Branch doesn't exist, skip + } + + if (remoteBranchExists) { + if (!json) { + console.log(`\nChecking ${remoteBranch}...`); + } + + const hasConflicts = await checkMergeConflicts(remoteBranch, currentBranch, rebase); + + if (hasConflicts) { + potentialConflicts.push({ remote, remoteBranch }); + if (!json) { + console.log(` ⚠️ Potential conflicts detected with ${remoteBranch}`); + } + } else { + if (!json) { + console.log(` ✅ No conflicts with ${remoteBranch}`); + } + } + } + } + + // If conflicts detected and not allowed, abort + if (potentialConflicts.length > 0 && !allowConflicts) { + console.error('\n' + '='.repeat(70)); + console.error('❌ CONFLICTS DETECTED - Aborting'); + console.error('='.repeat(70)); + console.error(`\nPotential conflicts detected with ${potentialConflicts.length} remote(s):`); + potentialConflicts.forEach(({ remote, remoteBranch }) => { + console.error(` - ${remote}: ${remoteBranch}`); + }); + console.error('\nTo proceed despite conflicts, use --allow-conflicts flag:'); + console.error(` gitrep pull-all ${merge ? '--merge' : '--rebase'} --allow-conflicts`); + console.error('\nYou will need to resolve conflicts manually if you proceed.'); + process.exit(1); + } else if (potentialConflicts.length > 0 && allowConflicts) { + if (!json) { + console.log('\n⚠️ Conflicts detected but --allow-conflicts specified, proceeding...'); + console.log('You will need to resolve conflicts manually.'); + } + } else { + if (!json) { + console.log('\n✅ No conflicts detected, proceeding with merge/rebase...'); + } + } + } + + // Phase 3: Perform merges/rebases (if requested and no conflicts or conflicts allowed) + if ((merge || rebase) && !dryRun && (potentialConflicts.length === 0 || allowConflicts)) { + if (!json) { + console.log('\n' + '='.repeat(70)); + console.log('Phase 3: Merging/Rebasing changes...'); + console.log('='.repeat(70)); + } + + for (const remote of remotes) { + try { + if (!json) { + console.log(`\nMerging/Rebasing from ${remote}...`); + } + + const { spawn } = await import('child_process'); + + const remoteBranch = `${remote}/${currentBranch}`; + + // Check if remote branch exists + let remoteBranchExists = false; + try { + await new Promise((resolve, reject) => { + const proc = spawn('git', ['rev-parse', '--verify', `refs/remotes/${remoteBranch}`], { + stdio: 'pipe', + encoding: 'utf-8' + }); + proc.on('close', (code) => { + if (code === 0) { + remoteBranchExists = true; + resolve(); + } else { + resolve(); // Branch doesn't exist, that's okay + } + }); + proc.on('error', reject); + }); + } catch { + // Branch doesn't exist, skip merge/rebase + } + + if (remoteBranchExists) { + // Check if this remote had conflicts (skip if conflicts not allowed) + const hasConflict = potentialConflicts.some(c => c.remote === remote); + if (hasConflict && !allowConflicts) { + // Shouldn't reach here, but just in case + continue; + } + + if (merge) { + const mergeArgs = ['merge', remoteBranch]; + if (noff) mergeArgs.push('--no-ff'); + + try { + await new Promise((resolve, reject) => { + const proc = spawn('git', mergeArgs, { + stdio: json ? 'pipe' : 'inherit', + encoding: 'utf-8' + }); + proc.on('close', (code) => { + if (code === 0) resolve(); + else if (code === 1) { + // Merge conflict + conflictCount++; + reject(new Error('Merge conflict')); + } else { + reject(new Error(`git merge exited with code ${code}`)); + } + }); + proc.on('error', reject); + }); + + mergeCount++; + if (!json) { + console.log(` ✅ Merged ${remoteBranch} into ${currentBranch}`); + } + results.push({ remote, status: 'merged', branch: currentBranch, remoteBranch }); + } catch (mergeErr) { + if (mergeErr instanceof Error && mergeErr.message === 'Merge conflict') { + if (!json) { + console.log(` ⚠️ Merge conflict with ${remoteBranch} - resolve manually`); + } + results.push({ remote, status: 'conflict', branch: currentBranch, remoteBranch }); + conflictCount++; + } else { + throw mergeErr; + } + } + } else if (rebase) { + try { + await new Promise((resolve, reject) => { + const proc = spawn('git', ['rebase', remoteBranch], { + stdio: json ? 'pipe' : 'inherit', + encoding: 'utf-8' + }); + proc.on('close', (code) => { + if (code === 0) resolve(); + else if (code === 1) { + // Rebase conflict + conflictCount++; + reject(new Error('Rebase conflict')); + } else { + reject(new Error(`git rebase exited with code ${code}`)); + } + }); + proc.on('error', reject); + }); + + mergeCount++; + if (!json) { + console.log(` ✅ Rebased ${currentBranch} onto ${remoteBranch}`); + } + results.push({ remote, status: 'rebased', branch: currentBranch, remoteBranch }); + } catch (rebaseErr) { + if (rebaseErr instanceof Error && rebaseErr.message === 'Rebase conflict') { + if (!json) { + console.log(` ⚠️ Rebase conflict with ${remoteBranch} - resolve manually`); + } + results.push({ remote, status: 'conflict', branch: currentBranch, remoteBranch }); + conflictCount++; + } else { + throw rebaseErr; + } + } + } + } else { + if (!json) { + console.log(` ℹ️ Remote branch ${remoteBranch} does not exist, skipping merge/rebase`); + } + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + results.push({ remote, status: 'failed', error: errorMessage, branch: currentBranch }); + failCount++; + + if (!json) { + console.error(` ❌ Failed to process ${remote}: ${errorMessage}`); + } + } + } + } else if (dryRun && (merge || rebase)) { + // Dry run mode - just show what would happen + if (!json) { + console.log('\n[DRY RUN] Would merge/rebase from the following remotes:'); + for (const remote of remotes) { + const remoteBranch = `${remote}/${currentBranch}`; + console.log(` - ${remote}: ${remoteBranch}`); + } + } + } + + if (json) { + console.log(JSON.stringify({ + total: remotes.length, + success: successCount, + failed: failCount, + merged: mergeCount, + conflicts: conflictCount, + branch: currentBranch, + results + }, null, 2)); + } else { + console.log('\n' + '='.repeat(70)); + const summary = `Summary: ${successCount} succeeded, ${failCount} failed${merge || rebase ? `, ${mergeCount} merged/rebased` : ''}${conflictCount > 0 ? `, ${conflictCount} conflicts` : ''} out of ${remotes.length} remotes`; + console.log(summary); + console.log('='.repeat(70)); + + if (conflictCount > 0) { + console.log('\n⚠️ Conflicts detected:'); + results.filter(r => r.status === 'conflict').forEach(r => { + console.log(` ${r.remote}: ${r.remoteBranch} into ${r.branch}`); + }); + console.log('\nResolve conflicts manually and commit to complete the merge/rebase.'); + } + + if (failCount > 0) { + console.log('\nFailed remotes:'); + results.filter(r => r.status === 'failed').forEach(r => { + console.log(` ${r.remote}: ${r.error}`); + }); + process.exit(1); + } + } +} diff --git a/scripts/commands/pushAll.js b/scripts/commands/pushAll.js index 7a72e4f..a0736b6 100644 --- a/scripts/commands/pushAll.js +++ b/scripts/commands/pushAll.js @@ -1,8 +1,133 @@ // 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 @@ -23,6 +148,7 @@ 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: @@ -35,7 +161,8 @@ Examples: Notes: - This command requires you to be in a git repository - It will push to all remotes listed by 'git remote' - - If any remote fails, the command will exit with an error code + - 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; @@ -46,6 +173,7 @@ Notes: 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 @@ -74,6 +202,58 @@ Notes: 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'); @@ -90,8 +270,10 @@ Notes: const results = []; let successCount = 0; let failCount = 0; + let skippedCount = unreachableRemotes.length; - for (const remote of remotes) { + for (const remoteInfo of reachableRemotes) { + const remote = remoteInfo.remote; try { if (!json && !dryRun) { console.log(`\nPushing to ${remote}...`); @@ -130,18 +312,38 @@ Notes: } } + // 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)); - console.log(`Push Summary: ${successCount} succeeded, ${failCount} failed out of ${remotes.length} remotes`); + 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 => { diff --git a/scripts/commands/repos.js b/scripts/commands/repos.js index ff39f35..bf5483f 100644 --- a/scripts/commands/repos.js +++ b/scripts/commands/repos.js @@ -184,13 +184,40 @@ export async function repos(args, server, json) { } const data = await apiRequest(server, `/repos/${npub}/${repo}/settings`, 'GET'); + + // Fetch clone URL reachability information + let cloneUrlReachability = null; + try { + const reachabilityData = await apiRequest(server, `/repos/${npub}/${repo}/clone-urls/reachability`, 'GET'); + if (reachabilityData.results && Array.isArray(reachabilityData.results)) { + cloneUrlReachability = reachabilityData.results; + } + } catch (err) { + // Silently fail - reachability endpoint might not be available or might fail + // This is optional information + } + if (json) { - console.log(JSON.stringify(data, null, 2)); + const output = { ...data }; + if (cloneUrlReachability) { + output.cloneUrls = cloneUrlReachability; + } + console.log(JSON.stringify(output, null, 2)); } else { console.log(`Repository: ${npub}/${repo}`); console.log(`Description: ${data.description || 'No description'}`); console.log(`Private: ${data.private ? 'Yes' : 'No'}`); console.log(`Owner: ${data.owner || npub}`); + + if (cloneUrlReachability && cloneUrlReachability.length > 0) { + console.log('\nClone URLs:'); + for (const result of cloneUrlReachability) { + const status = result.reachable ? '✅' : '❌'; + const serverType = result.serverType === 'grasp' ? ' (GRASP)' : result.serverType === 'git' ? ' (Git)' : ''; + const error = result.error ? ` - ${result.error}` : ''; + console.log(` ${status} ${result.url}${serverType}${error}`); + } + } } } else if (subcommand === 'settings' && args[1] && args[2]) { const [npub, repo] = args.slice(1); diff --git a/scripts/config.js b/scripts/config.js index fdcadc9..5d49d15 100755 --- a/scripts/config.js +++ b/scripts/config.js @@ -30,11 +30,16 @@ export const DEFAULT_NOSTR_SEARCH_RELAYS = typeof process !== 'undefined' && process.env?.NOSTR_SEARCH_RELAYS ? process.env.NOSTR_SEARCH_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) : [ - 'wss://theforest.nostr1.com', 'wss://nostr.land', 'wss://relay.damus.io', 'wss://thecitadel.nostr1.com', 'wss://nostr21.com', + 'wss://theforest.nostr1.com', + 'wss://freelay.sovbit.host', + 'wss://nostr.sovbit.host', + 'wss://bevos.nostr1.com', 'wss://relay.primal.net', - + 'wss://nostr.mom', + 'wss://relay.snort.social', + 'wss://aggr.nostr.land', ]; \ No newline at end of file diff --git a/scripts/git-commit-msg-hook.js b/scripts/git-commit-msg-hook.js index 97bd2e7..6d0b0d5 100755 --- a/scripts/git-commit-msg-hook.js +++ b/scripts/git-commit-msg-hook.js @@ -390,6 +390,8 @@ async function signCommitMessage(commitMessageFile) { 'wss://bevos.nostr1.com', 'wss://relay.primal.net', 'wss://nostr.mom', + 'wss://relay.snort.social', + 'wss://aggr.nostr.land', ]; // Enhance relay list with user's relay preferences (outboxes, local relays, blocked relays) diff --git a/scripts/gitrepublic.js b/scripts/gitrepublic.js index 6713266..820b84d 100755 --- a/scripts/gitrepublic.js +++ b/scripts/gitrepublic.js @@ -105,7 +105,7 @@ Usage: gitrep [options] (or gitrepublic) Commands: config [server] Show configuration (server URL) repos list List repositories - repos get Get repository info (or use naddr: repos get ) + repos get Get repository info with clone URL reachability (or use naddr: repos get ) repos settings [--description ] [--private ] Get/update settings repos maintainers [add|remove ] Manage maintainers repos branches List branches @@ -119,6 +119,7 @@ Commands: publish [options] Publish Nostr Git events (use: publish --help for details) verify | Verify a Nostr event signature and ID push-all [branch] [--force] [--tags] [--dry-run] Push to all configured remotes + pull-all [branch] [--merge] [--rebase] Fetch from all remotes and optionally merge/rebase changes Options: --server GitRepublic server URL (default: ${DEFAULT_SERVER})