gmsc
Version:
Git Multi-repo Sync Command - Manage multiple git repositories in parallel
232 lines (197 loc) • 6.59 kB
JavaScript
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import pLimit from 'p-limit';
import fs from 'fs/promises';
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const program = new Command();
const CONFIG_FILE = '.gms.json';
async function loadConfig() {
try {
const configPath = path.join(process.cwd(), CONFIG_FILE);
const data = await fs.readFile(configPath, 'utf8');
return JSON.parse(data);
} catch (error) {
return null;
}
}
async function saveConfig(config) {
const configPath = path.join(process.cwd(), CONFIG_FILE);
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
}
async function getSubmodules() {
const config = await loadConfig();
if (!config) {
console.error(chalk.red('Not a gms repository. Run "gms init" first.'));
process.exit(1);
}
return config.submodules;
}
async function isGitRepo(dir) {
try {
const gitPath = path.join(dir, '.git');
const stats = await fs.stat(gitPath);
// Handle both regular .git directories and .git files (submodules/worktrees)
if (stats.isDirectory()) {
return true;
} else if (stats.isFile()) {
// Check if it's a gitdir file
const content = await fs.readFile(gitPath, 'utf8');
return content.startsWith('gitdir:');
}
return false;
} catch {
return false;
}
}
async function executeInRepo(repoPath, command, showOutput = false) {
try {
const { stdout, stderr } = await execAsync(command, { cwd: repoPath });
return { success: true, stdout, stderr, repoPath };
} catch (error) {
return { success: false, error: error.message, repoPath };
}
}
async function executeInParallel(command, showOutput = false) {
const submodules = await getSubmodules();
const limit = pLimit(5); // Limit to 5 concurrent operations
const spinner = ora(`Running "${command}" in ${submodules.length} repositories...`).start();
const promises = submodules.map(submodule =>
limit(() => executeInRepo(submodule.path, command, showOutput))
);
const results = await Promise.all(promises);
spinner.stop();
// Display results
console.log(chalk.bold(`\nResults for: ${command}\n`));
results.forEach(result => {
const repoName = path.basename(result.repoPath);
if (result.success) {
console.log(chalk.green(`✓ ${repoName}`));
if (showOutput && result.stdout) {
console.log(chalk.gray(` ${result.stdout.trim().replace(/\n/g, '\n ')}`));
}
} else {
console.log(chalk.red(`✗ ${repoName}`));
console.log(chalk.red(` Error: ${result.error}`));
}
});
const successCount = results.filter(r => r.success).length;
console.log(chalk.bold(`\n${successCount}/${results.length} repositories succeeded`));
}
program
.name('gms')
.description('Git Multi-repo Sync - Manage multiple git repositories in parallel')
.version('1.0.0');
program
.command('init')
.description('Initialize gms in the current directory')
.action(async () => {
const spinner = ora('Initializing gms...').start();
try {
// Check if already initialized
const existingConfig = await loadConfig();
if (existingConfig) {
spinner.fail('Already initialized');
process.exit(1);
}
// Find all subdirectories that are git repos
const entries = await fs.readdir(process.cwd(), { withFileTypes: true });
const submodules = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const dirPath = path.join(process.cwd(), entry.name);
if (await isGitRepo(dirPath)) {
submodules.push({
name: entry.name,
path: dirPath
});
}
}
}
if (submodules.length === 0) {
spinner.fail('No git repositories found in subdirectories');
process.exit(1);
}
// Save config
await saveConfig({ submodules });
spinner.succeed(`Initialized with ${submodules.length} repositories`);
console.log(chalk.gray('\nTracking:'));
submodules.forEach(sm => console.log(chalk.gray(` - ${sm.name}`)));
} catch (error) {
spinner.fail(`Failed to initialize: ${error.message}`);
process.exit(1);
}
});
program
.command('status')
.description('Show git status for all repositories')
.action(async () => {
await executeInParallel('git status --short', true);
});
program
.command('branch')
.description('Show current branch for all repositories')
.action(async () => {
await executeInParallel('git branch --show-current', true);
});
program
.command('checkout <branch>')
.description('Checkout branch in all repositories')
.action(async (branch) => {
await executeInParallel(`git checkout ${branch}`, true);
});
program
.command('pull')
.description('Pull in all repositories')
.action(async () => {
await executeInParallel('git pull', true);
});
program
.command('push')
.description('Push in all repositories')
.argument('[remote]', 'Remote name', 'origin')
.argument('[branch]', 'Branch name')
.action(async (remote, branch) => {
const command = branch ? `git push ${remote} ${branch}` : `git push ${remote}`;
await executeInParallel(command, true);
});
program
.command('fetch')
.description('Fetch in all repositories')
.action(async () => {
await executeInParallel('git fetch', true);
});
program
.command('remote')
.description('Show remotes for all repositories')
.option('-v, --verbose', 'Show remote URLs')
.action(async (options) => {
const command = options.verbose ? 'git remote -v' : 'git remote';
await executeInParallel(command, true);
});
program
.command('exec <command>')
.description('Execute any git command in all repositories')
.action(async (command) => {
await executeInParallel(`git ${command}`, true);
});
program
.command('list')
.alias('ls')
.description('List all tracked repositories')
.action(async () => {
const config = await loadConfig();
if (!config) {
console.error(chalk.red('Not a gms repository. Run "gms init" first.'));
process.exit(1);
}
console.log(chalk.bold('Tracked repositories:\n'));
config.submodules.forEach(sm => {
console.log(` ${chalk.green('•')} ${sm.name} ${chalk.gray(`(${sm.path})`)}`);
});
});
program.parse();