diff --git a/src/cli.js b/src/cli.js index 9c8cfbd..addb70a 100755 --- a/src/cli.js +++ b/src/cli.js @@ -8,6 +8,7 @@ const { execSync, spawnSync } = require('node:child_process'); const { stdin: input, stdout: output } = require('node:process'); const { loadConfig, saveConfig, clearConfig, getConfigPath } = require('./config'); const { ForgejoClient } = require('./api'); +const { syncRepository } = require('./repo-sync'); const pkg = require('../package.json'); @@ -451,6 +452,38 @@ repo } }); +repo + .command('sync') + .description('Fast-forward an imported repository from its upstream') + .requiredOption('-o, --owner ', 'repository owner') + .requiredOption('-r, --repo ', 'repository name') + .requiredOption('--from ', 'upstream Git URL') + .option('--branch ', 'branch to synchronize') + .option('--tags', 'also create new upstream tags', false) + .option('--dry-run', 'report changes without pushing', false) + .action((options) => { + try { + const config = loadConfig(); + if (!config || !config.url || !config.token) { + throw new Error('Not authenticated. Run: stoke auth login'); + } + if (!options.branch) throw new Error('--branch is required'); + + const base = config.url.replace(/\/+$/, ''); + const forgeUrl = `${base}/${encodeURIComponent(options.owner)}/${encodeURIComponent(options.repo)}.git`; + const result = syncRepository({ + forgeUrl, + upstreamUrl: options.from, + branch: options.branch, + env: gitAuthEnv({ ...config, url: base }), + }); + console.log(`${result.branch} ${result.oldSha}..${result.newSha}`); + } catch (err) { + console.error(`Repository sync failed: ${err.message}`); + process.exit(1); + } + }); + repo .command('create') .description('Create a new repository for the authenticated user or an organization') diff --git a/src/repo-sync.js b/src/repo-sync.js new file mode 100644 index 0000000..43b5132 --- /dev/null +++ b/src/repo-sync.js @@ -0,0 +1,54 @@ +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +function runGit(args, { cwd, env, accept = [0] }) { + const result = spawnSync('git', args, { cwd, env, encoding: 'utf8' }); + if (result.error) throw new Error(`Failed to run git: ${result.error.message}`); + if (!accept.includes(result.status)) { + throw new Error((result.stderr || result.stdout || `git exited ${result.status}`).trim()); + } + return result; +} + +function syncRepository({ forgeUrl, upstreamUrl, branch, env }) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-repo-sync-')); + const forgeRef = 'refs/stoke/forge-branch'; + const upstreamRef = 'refs/stoke/upstream-branch'; + + try { + runGit(['init', '--bare', directory], { cwd: directory, env }); + runGit(['fetch', '--no-tags', forgeUrl, `refs/heads/${branch}:${forgeRef}`], { + cwd: directory, + env, + }); + runGit(['fetch', '--no-tags', upstreamUrl, `refs/heads/${branch}:${upstreamRef}`], { + cwd: directory, + env, + }); + + const oldSha = runGit(['rev-parse', forgeRef], { cwd: directory, env }).stdout.trim(); + const newSha = runGit(['rev-parse', upstreamRef], { cwd: directory, env }).stdout.trim(); + const ancestry = runGit(['merge-base', '--is-ancestor', oldSha, newSha], { + cwd: directory, + env, + accept: [0, 1], + }); + if (ancestry.status !== 0) { + throw new Error(`Refusing diverged branch ${branch}: forge ${oldSha}, upstream ${newSha}. Diverged trees are out of scope; follow ceremony docs/UPSTREAM-SYNC.md.`); + } + + if (oldSha !== newSha) { + runGit(['push', forgeUrl, `${upstreamRef}:refs/heads/${branch}`], { + cwd: directory, + env, + }); + } + return { branch, oldSha, newSha }; + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +module.exports = { syncRepository }; diff --git a/test/sync.test.js b/test/sync.test.js new file mode 100644 index 0000000..4ef49c1 --- /dev/null +++ b/test/sync.test.js @@ -0,0 +1,85 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const CLI = path.join(__dirname, '..', 'src', 'cli.js'); +const TOKEN = 'stoke-secret-token-for-sync-tests'; + +function git(args, cwd) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); +} + +function commit(directory, message, contents) { + fs.writeFileSync(path.join(directory, 'content.txt'), `${contents}\n`); + git(['add', 'content.txt'], directory); + git(['-c', 'user.name=Tester', '-c', 'user.email=tester@example.com', 'commit', '-m', message], directory); + return git(['rev-parse', 'HEAD'], directory); +} + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-sync-test-')); + const forgeRoot = path.join(root, 'forge'); + const forgeRepo = path.join(forgeRoot, 'o', 'r.git'); + const seed = path.join(root, 'seed'); + const upstreamWork = path.join(root, 'upstream-work'); + const upstreamRepo = path.join(root, 'upstream.git'); + fs.mkdirSync(path.dirname(forgeRepo), { recursive: true }); + + git(['init', '-b', 'main', seed], root); + const oldSha = commit(seed, 'initial', 'initial'); + git(['clone', '--bare', seed, forgeRepo], root); + git(['clone', seed, upstreamWork], root); + const newSha = commit(upstreamWork, 'upstream advance', 'advanced'); + git(['clone', '--bare', upstreamWork, upstreamRepo], root); + + const config = path.join(root, 'config.json'); + fs.writeFileSync(config, JSON.stringify({ + url: `file://${forgeRoot}`, + token: TOKEN, + login: 'tester', + })); + + return { + root, + forgeRepo, + upstreamRepo, + config, + oldSha, + newSha, + cleanup() { + fs.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +function runSync(fx, extra = []) { + return spawnSync(process.execPath, [ + CLI, + 'repo', + 'sync', + '-o', 'o', + '-r', 'r', + '--from', `file://${fx.upstreamRepo}`, + '--branch', 'main', + ...extra, + ], { + encoding: 'utf8', + env: { ...process.env, STOKE_CONFIG_FILE: fx.config }, + }); +} + +test('repo sync fast-forwards an undiverged forge branch', () => { + const fx = fixture(); + try { + const result = runSync(fx); + + assert.equal(result.status, 0, result.stderr); + assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.newSha); + assert.match(result.stdout, new RegExp(`main ${fx.oldSha}\\.\\.${fx.newSha}`)); + } finally { + fx.cleanup(); + } +});