From b21a1387a5023001b2591fd159857a0430a53876 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 16:57:02 +0000 Subject: [PATCH] feat: sync safe tags and support dry runs --- src/cli.js | 9 +++++++ src/repo-sync.js | 60 ++++++++++++++++++++++++++++++++++++----- test/sync.test.js | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/src/cli.js b/src/cli.js index c1888d7..8691029 100755 --- a/src/cli.js +++ b/src/cli.js @@ -473,6 +473,8 @@ repo forgeUrl, upstreamUrl: options.from, branch: options.branch, + includeTags: options.tags, + dryRun: options.dryRun, env: gitAuthEnv({ ...config, url: base }), }); if (result.changed) { @@ -480,6 +482,13 @@ repo } else { 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) { console.error(`Repository sync failed: ${err.message}`); process.exit(1); diff --git a/src/repo-sync.js b/src/repo-sync.js index 3b45379..fee2320 100644 --- a/src/repo-sync.js +++ b/src/repo-sync.js @@ -12,7 +12,25 @@ function runGit(args, { cwd, env, accept = [0] }) { 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 forgeRef = 'refs/stoke/forge-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.`); } - if (oldSha !== newSha) { - runGit(['push', forgeUrl, `${upstreamRef}:refs/heads/${branch}`], { - cwd: directory, - env, - }); + const newTags = []; + const movedTags = []; + if (includeTags) { + 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 { fs.rmSync(directory, { recursive: true, force: true }); } diff --git a/test/sync.test.js b/test/sync.test.js index fd72c2c..1362c76 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -45,6 +45,7 @@ function fixture() { return { root, forgeRepo, + upstreamWork, upstreamRepo, config, 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' } = {}) { const args = [ CLI, @@ -111,3 +120,62 @@ test('repo sync reports an already-current branch as a no-op', () => { 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(); + } +});