Browse Source

Sync from gitrepublic-web monorepo - 2026-02-23 13:50:36

master
Silberengel 3 weeks ago
parent
commit
972ca3a129
  1. 1
      scripts/commands/index.js
  2. 653
      scripts/commands/pullAll.js
  3. 208
      scripts/commands/pushAll.js
  4. 29
      scripts/commands/repos.js
  5. 9
      scripts/config.js
  6. 2
      scripts/git-commit-msg-hook.js
  7. 3
      scripts/gitrepublic.js

1
scripts/commands/index.js

@ -5,3 +5,4 @@ export { search } from './search.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';

653
scripts/commands/pullAll.js

@ -0,0 +1,653 @@ @@ -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);
}
}
}

208
scripts/commands/pushAll.js

@ -1,8 +1,133 @@ @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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 => {

29
scripts/commands/repos.js

@ -184,13 +184,40 @@ export async function repos(args, server, json) { @@ -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);

9
scripts/config.js

@ -30,11 +30,16 @@ export const DEFAULT_NOSTR_SEARCH_RELAYS = @@ -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',
];

2
scripts/git-commit-msg-hook.js

@ -390,6 +390,8 @@ async function signCommitMessage(commitMessageFile) { @@ -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)

3
scripts/gitrepublic.js

@ -105,7 +105,7 @@ Usage: gitrep <command> [options] (or gitrepublic) @@ -105,7 +105,7 @@ Usage: gitrep <command> [options] (or gitrepublic)
Commands:
config [server] Show configuration (server URL)
repos list List repositories
repos get <npub> <repo> Get repository info (or use naddr: repos get <naddr>)
repos get <npub> <repo> Get repository info with clone URL reachability (or use naddr: repos get <naddr>)
repos settings <npub> <repo> [--description <text>] [--private <true|false>] Get/update settings
repos maintainers <npub> <repo> [add|remove <npub>] Manage maintainers
repos branches <npub> <repo> List branches
@ -119,6 +119,7 @@ Commands: @@ -119,6 +119,7 @@ Commands:
publish <subcommand> [options] Publish Nostr Git events (use: publish --help for details)
verify <event-file>|<event-json> 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 <url> GitRepublic server URL (default: ${DEFAULT_SERVER})

Loading…
Cancel
Save