/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : cli/commands/sync.mjs * Purpose : `spbgit sync` — the killer command: commit+rebase+push all * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { basename } from 'node:path'; import pc from 'picocolors'; import { requireCliConfig } from '../lib/config.mjs'; import { workspaceRepos, git, repoStatus } from '../lib/git.mjs'; import { printTable, mapLimit } from '../lib/ui.mjs'; import { EXIT } from '../lib/api.mjs'; function defaultMessage() { const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `chore: sync ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`; } /** * Sync one repository. Never force-pushes. * @returns {Promise<{repo: string, state: 'synced'|'pushed'|'conflict'|'error', pushedCount: number, note: string}>} */ async function syncRepo(dir, message, token) { const repo = basename(dir); const before = await repoStatus(dir); if (before.dirty > 0) { await git(dir, ['add', '-A']); const commit = await git(dir, ['commit', '-m', message]); if (!commit.ok) { return { repo, state: 'error', pushedCount: 0, note: commit.stderr.trim().split('\n').pop() ?? 'commit failed' }; } } if (before.hasUpstream) { const pull = await git(dir, ['pull', '--rebase'], { token }); if (!pull.ok) { await git(dir, ['rebase', '--abort']); return { repo, state: 'conflict', pushedCount: 0, note: 'rebase conflict — resolve manually: cd ' + dir + ' && git pull --rebase', }; } } const after = await repoStatus(dir); const toPush = after.hasUpstream ? after.ahead : 1; if (after.hasUpstream && after.ahead === 0) { return { repo, state: 'synced', pushedCount: 0, note: '' }; } const pushArgs = after.hasUpstream ? ['push'] : ['push', '-u', 'origin', after.branch]; const push = await git(dir, pushArgs, { token }); if (!push.ok) { return { repo, state: 'error', pushedCount: 0, note: push.stderr.trim().split('\n').pop() ?? 'push failed' }; } return { repo, state: 'pushed', pushedCount: toPush, note: '' }; } export function registerSync(program) { program .command('sync') .description('for every workspace repo: add-all, commit, pull --rebase, push') .option('-m, --message ', 'commit message', defaultMessage()) .action(async (opts) => { const config = requireCliConfig(); const dirs = workspaceRepos(config.workspace); if (dirs.length === 0) { console.log(pc.dim(`Workspace ${config.workspace} has no repositories. Try: spbgit clone --all`)); return; } console.log(pc.dim(`Syncing ${dirs.length} repositories…\n`)); const results = await mapLimit(dirs, 4, (dir) => syncRepo(dir, opts.message, config.token)); const rows = [[pc.bold('REPO'), pc.bold('RESULT'), pc.bold('NOTE')]]; for (const r of results) { const cell = r.state === 'synced' ? pc.green('✓ synced') : r.state === 'pushed' ? pc.cyan(`↑ pushed ${r.pushedCount}`) : r.state === 'conflict' ? pc.red('✗ conflict') : pc.red('✗ error'); rows.push([r.repo, cell, pc.dim(r.note)]); } console.log(''); printTable(rows); const bad = results.filter((r) => r.state === 'conflict' || r.state === 'error'); if (bad.length > 0) process.exit(EXIT.PARTIAL); }); }