forked from heavy-duty/stoke
feat: sync safe tags and support dry runs
This commit is contained in:
parent
ea6c1a4fe9
commit
b21a1387a5
3 changed files with 130 additions and 7 deletions
|
|
@ -473,6 +473,8 @@ repo
|
||||||
forgeUrl,
|
forgeUrl,
|
||||||
upstreamUrl: options.from,
|
upstreamUrl: options.from,
|
||||||
branch: options.branch,
|
branch: options.branch,
|
||||||
|
includeTags: options.tags,
|
||||||
|
dryRun: options.dryRun,
|
||||||
env: gitAuthEnv({ ...config, url: base }),
|
env: gitAuthEnv({ ...config, url: base }),
|
||||||
});
|
});
|
||||||
if (result.changed) {
|
if (result.changed) {
|
||||||
|
|
@ -480,6 +482,13 @@ repo
|
||||||
} else {
|
} else {
|
||||||
console.log(`${result.branch} is up to date at ${result.newSha}`);
|
console.log(`${result.branch} is up to date at ${result.newSha}`);
|
||||||
}
|
}
|
||||||
|
for (const tag of result.newTags) {
|
||||||
|
console.log(`tag ${tag.name} create ${tag.sha}`);
|
||||||
|
}
|
||||||
|
for (const tag of result.movedTags) {
|
||||||
|
console.error(`tag ${tag.name} moved upstream: forge ${tag.forgeSha}, upstream ${tag.upstreamSha}; skipped`);
|
||||||
|
}
|
||||||
|
if (result.movedTags.length > 0) process.exitCode = 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Repository sync failed: ${err.message}`);
|
console.error(`Repository sync failed: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,25 @@ function runGit(args, { cwd, env, accept = [0] }) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncRepository({ forgeUrl, upstreamUrl, branch, env }) {
|
function remoteTags(url, { cwd, env }) {
|
||||||
|
const output = runGit(['ls-remote', '--tags', '--refs', url], { cwd, env }).stdout;
|
||||||
|
const tags = new Map();
|
||||||
|
for (const line of output.trim().split('\n')) {
|
||||||
|
if (!line) continue;
|
||||||
|
const [sha, ref] = line.split(/\s+/, 2);
|
||||||
|
tags.set(ref.slice('refs/tags/'.length), sha);
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRepository({
|
||||||
|
forgeUrl,
|
||||||
|
upstreamUrl,
|
||||||
|
branch,
|
||||||
|
includeTags = false,
|
||||||
|
dryRun = false,
|
||||||
|
env,
|
||||||
|
}) {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-repo-sync-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-repo-sync-'));
|
||||||
const forgeRef = 'refs/stoke/forge-branch';
|
const forgeRef = 'refs/stoke/forge-branch';
|
||||||
const upstreamRef = 'refs/stoke/upstream-branch';
|
const upstreamRef = 'refs/stoke/upstream-branch';
|
||||||
|
|
@ -48,13 +66,41 @@ function syncRepository({ forgeUrl, upstreamUrl, branch, env }) {
|
||||||
throw new Error(`Refusing diverged branch ${branch}: forge ${oldSha}, upstream ${newSha}. Diverged trees are out of scope; follow ceremony docs/UPSTREAM-SYNC.md.`);
|
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) {
|
const newTags = [];
|
||||||
runGit(['push', forgeUrl, `${upstreamRef}:refs/heads/${branch}`], {
|
const movedTags = [];
|
||||||
cwd: directory,
|
if (includeTags) {
|
||||||
env,
|
const forgeTags = remoteTags(forgeUrl, { cwd: directory, env });
|
||||||
});
|
const upstreamTags = remoteTags(upstreamUrl, { cwd: directory, env });
|
||||||
|
for (const [name, upstreamSha] of upstreamTags) {
|
||||||
|
const forgeSha = forgeTags.get(name);
|
||||||
|
if (!forgeSha) {
|
||||||
|
const temporaryRef = `refs/stoke/upstream-tags/${name}`;
|
||||||
|
runGit(['fetch', '--no-tags', upstreamUrl, `refs/tags/${name}:${temporaryRef}`], {
|
||||||
|
cwd: directory,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
newTags.push({ name, sha: upstreamSha, temporaryRef });
|
||||||
|
} else if (forgeSha !== upstreamSha) {
|
||||||
|
movedTags.push({ name, forgeSha, upstreamSha });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { branch, oldSha, newSha, changed: oldSha !== newSha };
|
|
||||||
|
const refspecs = [];
|
||||||
|
if (oldSha !== newSha) refspecs.push(`${upstreamRef}:refs/heads/${branch}`);
|
||||||
|
for (const tag of newTags) refspecs.push(`${tag.temporaryRef}:refs/tags/${tag.name}`);
|
||||||
|
if (!dryRun && refspecs.length > 0) {
|
||||||
|
runGit(['push', forgeUrl, ...refspecs], { cwd: directory, env });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
branch,
|
||||||
|
oldSha,
|
||||||
|
newSha,
|
||||||
|
changed: oldSha !== newSha,
|
||||||
|
newTags,
|
||||||
|
movedTags,
|
||||||
|
dryRun,
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(directory, { recursive: true, force: true });
|
fs.rmSync(directory, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ function fixture() {
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
forgeRepo,
|
forgeRepo,
|
||||||
|
upstreamWork,
|
||||||
upstreamRepo,
|
upstreamRepo,
|
||||||
config,
|
config,
|
||||||
oldSha,
|
oldSha,
|
||||||
|
|
@ -55,6 +56,14 @@ function fixture() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refSha(repository, ref) {
|
||||||
|
const result = spawnSync('git', ['rev-parse', '--verify', ref], {
|
||||||
|
cwd: repository,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
return result.status === 0 ? result.stdout.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
function runSync(fx, extra = [], { branch = 'main' } = {}) {
|
function runSync(fx, extra = [], { branch = 'main' } = {}) {
|
||||||
const args = [
|
const args = [
|
||||||
CLI,
|
CLI,
|
||||||
|
|
@ -111,3 +120,62 @@ test('repo sync reports an already-current branch as a no-op', () => {
|
||||||
fx.cleanup();
|
fx.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('repo sync refuses a diverged forge branch without changing it', () => {
|
||||||
|
const fx = fixture();
|
||||||
|
try {
|
||||||
|
const forgeWork = path.join(fx.root, 'forge-work');
|
||||||
|
git(['clone', fx.forgeRepo, forgeWork], fx.root);
|
||||||
|
const forgeSha = commit(forgeWork, 'forge-only change', 'forge-only');
|
||||||
|
git(['push', 'origin', 'main'], forgeWork);
|
||||||
|
|
||||||
|
const result = runSync(fx);
|
||||||
|
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stderr, new RegExp(forgeSha));
|
||||||
|
assert.match(result.stderr, new RegExp(fx.newSha));
|
||||||
|
assert.match(result.stderr, /Diverged trees are out of scope/);
|
||||||
|
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), forgeSha);
|
||||||
|
} finally {
|
||||||
|
fx.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo sync --tags creates new tags but skips a moved upstream tag', () => {
|
||||||
|
const fx = fixture();
|
||||||
|
try {
|
||||||
|
git(['update-ref', 'refs/tags/stable', fx.oldSha], fx.forgeRepo);
|
||||||
|
git(['update-ref', 'refs/tags/moved', fx.oldSha], fx.forgeRepo);
|
||||||
|
git(['update-ref', 'refs/tags/stable', fx.oldSha], fx.upstreamRepo);
|
||||||
|
git(['update-ref', 'refs/tags/moved', fx.newSha], fx.upstreamRepo);
|
||||||
|
git(['update-ref', 'refs/tags/new-tag', fx.newSha], fx.upstreamRepo);
|
||||||
|
|
||||||
|
const result = runSync(fx, ['--tags']);
|
||||||
|
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.newSha);
|
||||||
|
assert.equal(refSha(fx.forgeRepo, 'refs/tags/stable'), fx.oldSha);
|
||||||
|
assert.equal(refSha(fx.forgeRepo, 'refs/tags/moved'), fx.oldSha);
|
||||||
|
assert.equal(refSha(fx.forgeRepo, 'refs/tags/new-tag'), fx.newSha);
|
||||||
|
assert.match(result.stderr, new RegExp(`moved.*${fx.oldSha}.*${fx.newSha}`));
|
||||||
|
} finally {
|
||||||
|
fx.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repo sync --dry-run reports branch and tag moves without writing', () => {
|
||||||
|
const fx = fixture();
|
||||||
|
try {
|
||||||
|
git(['update-ref', 'refs/tags/new-tag', fx.newSha], fx.upstreamRepo);
|
||||||
|
|
||||||
|
const result = runSync(fx, ['--tags', '--dry-run']);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.oldSha);
|
||||||
|
assert.equal(refSha(fx.forgeRepo, 'refs/tags/new-tag'), null);
|
||||||
|
assert.match(result.stdout, new RegExp(`main ${fx.oldSha}\\.\\.${fx.newSha}`));
|
||||||
|
assert.match(result.stdout, new RegExp(`new-tag .*${fx.newSha}`));
|
||||||
|
} finally {
|
||||||
|
fx.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue