feat: fast-forward repository branches
This commit is contained in:
parent
fb5cb4746b
commit
64b3d9df94
3 changed files with 172 additions and 0 deletions
33
src/cli.js
33
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 <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('--from <upstream-url>', 'upstream Git URL')
|
||||
.option('--branch <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')
|
||||
|
|
|
|||
54
src/repo-sync.js
Normal file
54
src/repo-sync.js
Normal file
|
|
@ -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 };
|
||||
85
test/sync.test.js
Normal file
85
test/sync.test.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue