spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
1/**2 * ─────────────────────────────────────────────3 * SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : cli/commands/sync.mjs8 * Purpose : `spbgit sync` — the killer command: commit+rebase+push all9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { basename } from 'node:path';14import pc from 'picocolors';15import { requireCliConfig } from '../lib/config.mjs';16import { workspaceRepos, git, repoStatus } from '../lib/git.mjs';17import { printTable, mapLimit } from '../lib/ui.mjs';18import { EXIT } from '../lib/api.mjs';1920function defaultMessage() {21 const now = new Date();22 const pad = (n) => String(n).padStart(2, '0');23 return `chore: sync ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;24}2526/**27 * Sync one repository. Never force-pushes.28 * @returns {Promise<{repo: string, state: 'synced'|'pushed'|'conflict'|'error', pushedCount: number, note: string}>}29 */30async function syncRepo(dir, message, token) {31 const repo = basename(dir);32 const before = await repoStatus(dir);3334 if (before.dirty > 0) {35 await git(dir, ['add', '-A']);36 const commit = await git(dir, ['commit', '-m', message]);37 if (!commit.ok) {38 return { repo, state: 'error', pushedCount: 0, note: commit.stderr.trim().split('\n').pop() ?? 'commit failed' };39 }40 }4142 if (before.hasUpstream) {43 const pull = await git(dir, ['pull', '--rebase'], { token });44 if (!pull.ok) {45 await git(dir, ['rebase', '--abort']);46 return {47 repo,48 state: 'conflict',49 pushedCount: 0,50 note: 'rebase conflict — resolve manually: cd ' + dir + ' && git pull --rebase',51 };52 }53 }5455 const after = await repoStatus(dir);56 const toPush = after.hasUpstream ? after.ahead : 1;57 if (after.hasUpstream && after.ahead === 0) {58 return { repo, state: 'synced', pushedCount: 0, note: '' };59 }60 const pushArgs = after.hasUpstream ? ['push'] : ['push', '-u', 'origin', after.branch];61 const push = await git(dir, pushArgs, { token });62 if (!push.ok) {63 return { repo, state: 'error', pushedCount: 0, note: push.stderr.trim().split('\n').pop() ?? 'push failed' };64 }65 return { repo, state: 'pushed', pushedCount: toPush, note: '' };66}6768export function registerSync(program) {69 program70 .command('sync')71 .description('for every workspace repo: add-all, commit, pull --rebase, push')72 .option('-m, --message <msg>', 'commit message', defaultMessage())73 .action(async (opts) => {74 const config = requireCliConfig();75 const dirs = workspaceRepos(config.workspace);76 if (dirs.length === 0) {77 console.log(pc.dim(`Workspace ${config.workspace} has no repositories. Try: spbgit clone --all`));78 return;79 }80 console.log(pc.dim(`Syncing ${dirs.length} repositories…\n`));81 const results = await mapLimit(dirs, 4, (dir) => syncRepo(dir, opts.message, config.token));8283 const rows = [[pc.bold('REPO'), pc.bold('RESULT'), pc.bold('NOTE')]];84 for (const r of results) {85 const cell =86 r.state === 'synced' ? pc.green('✓ synced')87 : r.state === 'pushed' ? pc.cyan(`↑ pushed ${r.pushedCount}`)88 : r.state === 'conflict' ? pc.red('✗ conflict')89 : pc.red('✗ error');90 rows.push([r.repo, cell, pc.dim(r.note)]);91 }92 console.log('');93 printTable(rows);94 const bad = results.filter((r) => r.state === 'conflict' || r.state === 'error');95 if (bad.length > 0) process.exit(EXIT.PARTIAL);96 });97}98