diff --git a/src/repo-sync.js b/src/repo-sync.js index fee2320..4f24807 100644 --- a/src/repo-sync.js +++ b/src/repo-sync.js @@ -23,6 +23,17 @@ function remoteTags(url, { cwd, env }) { return tags; } +function remoteRefSha(url, ref, { cwd, env }) { + const output = runGit(['ls-remote', '--refs', url, ref], { cwd, env }).stdout.trim(); + if (!output) return null; + const [sha, foundRef] = output.split(/\s+/, 2); + return foundRef === ref ? sha : null; +} + +function divergenceError(branch, forgeSha, upstreamSha) { + return new Error(`Refusing diverged branch ${branch}: forge ${forgeSha}, upstream ${upstreamSha}. Diverged trees are out of scope; follow ceremony docs/UPSTREAM-SYNC.md.`); +} + function syncRepository({ forgeUrl, upstreamUrl, @@ -63,41 +74,98 @@ function syncRepository({ 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.`); + throw divergenceError(branch, oldSha, newSha); } const newTags = []; const movedTags = []; if (includeTags) { + const discoveredUpstreamTags = remoteTags(upstreamUrl, { cwd: directory, env }); + const fetchedUpstreamTags = new Map(); + for (const name of discoveredUpstreamTags.keys()) { + const temporaryRef = `refs/stoke/upstream-tags/${name}`; + runGit(['fetch', '--no-tags', upstreamUrl, `refs/tags/${name}:${temporaryRef}`], { + cwd: directory, + env, + }); + const upstreamSha = runGit(['rev-parse', temporaryRef], { + cwd: directory, + env, + }).stdout.trim(); + fetchedUpstreamTags.set(name, { name, sha: upstreamSha, temporaryRef }); + } + const forgeTags = remoteTags(forgeUrl, { cwd: directory, env }); - const upstreamTags = remoteTags(upstreamUrl, { cwd: directory, env }); - for (const [name, upstreamSha] of upstreamTags) { + for (const [name, tag] of fetchedUpstreamTags) { 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 }); + newTags.push(tag); + } else if (forgeSha !== tag.sha) { + movedTags.push({ name, forgeSha, upstreamSha: tag.sha }); } } } - 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 }); + if (!dryRun && oldSha !== newSha) { + const branchPush = runGit(['push', forgeUrl, `${upstreamRef}:refs/heads/${branch}`], { + cwd: directory, + env, + accept: [0, 1], + }); + if (branchPush.status !== 0) { + const currentRef = 'refs/stoke/forge-current'; + runGit(['fetch', '--no-tags', forgeUrl, `refs/heads/${branch}:${currentRef}`], { + cwd: directory, + env, + }); + const currentSha = runGit(['rev-parse', currentRef], { cwd: directory, env }).stdout.trim(); + if (currentSha !== newSha) { + const currentAncestry = runGit(['merge-base', '--is-ancestor', currentSha, newSha], { + cwd: directory, + env, + accept: [0, 1], + }); + if (currentAncestry.status !== 0) throw divergenceError(branch, currentSha, newSha); + runGit(['push', forgeUrl, `${upstreamRef}:refs/heads/${branch}`], { + cwd: directory, + env, + }); + } + } + } + + const reportedNewTags = []; + for (const tag of newTags) { + if (dryRun) { + reportedNewTags.push(tag); + continue; + } + const tagPush = runGit(['push', forgeUrl, `${tag.temporaryRef}:refs/tags/${tag.name}`], { + cwd: directory, + env, + accept: [0, 1], + }); + if (tagPush.status === 0) { + reportedNewTags.push(tag); + continue; + } + const forgeSha = remoteRefSha(forgeUrl, `refs/tags/${tag.name}`, { + cwd: directory, + env, + }); + if (!forgeSha) { + throw new Error((tagPush.stderr || tagPush.stdout || `git exited ${tagPush.status}`).trim()); + } + if (forgeSha !== tag.sha) { + movedTags.push({ name: tag.name, forgeSha, upstreamSha: tag.sha }); + } } return { branch, oldSha, newSha, changed: oldSha !== newSha, - newTags, + newTags: reportedNewTags, movedTags, dryRun, }; diff --git a/test/sync.test.js b/test/sync.test.js index 775e45d..e5685f9 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -7,6 +7,7 @@ const path = require('node:path'); const CLI = path.join(__dirname, '..', 'src', 'cli.js'); const TOKEN = 'stoke-secret-token-for-sync-tests'; +const BASIC_CREDENTIAL = Buffer.from(`tester:${TOKEN}`).toString('base64'); const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim(); function git(args, cwd) { @@ -65,6 +66,15 @@ function refSha(repository, ref) { return result.status === 0 ? result.stdout.trim() : null; } +function installGitWrapper(fx, body) { + const wrapperDirectory = path.join(fx.root, 'bin'); + const wrapper = path.join(wrapperDirectory, 'git'); + fs.mkdirSync(wrapperDirectory); + fs.writeFileSync(wrapper, `#!/bin/sh\n${body}\nexec "${REAL_GIT}" "$@"\n`); + fs.chmodSync(wrapper, 0o755); + return { PATH: `${wrapperDirectory}:${process.env.PATH}` }; +} + function runSync(fx, extra = [], { branch = 'main', env = {} } = {}) { const args = [ CLI, @@ -181,32 +191,135 @@ test('repo sync --dry-run reports branch and tag moves without writing', () => { } }); +function runSourceTagRace({ dryRun }) { + const fx = fixture(); + git(['update-ref', 'refs/tags/race-tag', fx.oldSha], fx.upstreamRepo); + const env = installGitWrapper(fx, ` +case "$*" in + *"refs/tags/race-tag:refs/stoke/upstream-tags/race-tag"*) + "${REAL_GIT}" --git-dir="$STOKE_TEST_UPSTREAM_REPO" update-ref refs/tags/race-tag "$STOKE_TEST_NEW_SHA" + ;; +esac`); + Object.assign(env, { + STOKE_TEST_UPSTREAM_REPO: fx.upstreamRepo, + STOKE_TEST_NEW_SHA: fx.newSha, + }); + const options = ['--tags']; + if (dryRun) options.push('--dry-run'); + return { fx, result: runSync(fx, options, { env }) }; +} + +test('repo sync reports the fetched tag object when the source tag moves', () => { + const { fx, result } = runSourceTagRace({ dryRun: false }); + try { + assert.equal(result.status, 0, result.stderr); + assert.equal(refSha(fx.forgeRepo, 'refs/tags/race-tag'), fx.newSha); + const tagLine = result.stdout.split('\n').find((line) => line.startsWith('tag race-tag')); + assert.match(tagLine, new RegExp(fx.newSha)); + assert.ok(!tagLine.includes(fx.oldSha)); + } finally { + fx.cleanup(); + } +}); + +test('repo sync --dry-run reports the fetched tag object when the source tag moves', () => { + const { fx, result } = runSourceTagRace({ dryRun: true }); + try { + assert.equal(result.status, 0, result.stderr); + assert.equal(refSha(fx.forgeRepo, 'refs/tags/race-tag'), null); + const tagLine = result.stdout.split('\n').find((line) => line.startsWith('tag race-tag')); + assert.match(tagLine, new RegExp(fx.newSha)); + assert.ok(!tagLine.includes(fx.oldSha)); + } finally { + fx.cleanup(); + } +}); + +test('repo sync reclassifies a destination tag created during the push as moved', () => { + const fx = fixture(); + try { + git(['update-ref', 'refs/tags/race-tag', fx.newSha], fx.upstreamRepo); + const env = installGitWrapper(fx, ` +case "$*" in + *"refs/stoke/upstream-tags/race-tag:refs/tags/race-tag"*) + "${REAL_GIT}" --git-dir="$STOKE_TEST_FORGE_REPO" update-ref refs/tags/race-tag "$STOKE_TEST_OLD_SHA" + ;; +esac`); + Object.assign(env, { + STOKE_TEST_FORGE_REPO: fx.forgeRepo, + STOKE_TEST_OLD_SHA: fx.oldSha, + }); + + const result = runSync(fx, ['--tags'], { env }); + + assert.equal(result.status, 1); + assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), fx.newSha); + assert.equal(refSha(fx.forgeRepo, 'refs/tags/race-tag'), fx.oldSha); + assert.match(result.stderr, new RegExp(`race-tag.*${fx.oldSha}.*${fx.newSha}`)); + } finally { + fx.cleanup(); + } +}); + +test('repo sync reports a destination branch that diverges during the push', () => { + const fx = fixture(); + try { + const forgeWork = path.join(fx.root, 'forge-race-work'); + git(['clone', fx.forgeRepo, forgeWork], fx.root); + const racingSha = commit(forgeWork, 'racing forge change', 'racing-forge'); + git(['push', 'origin', 'HEAD:refs/race/forge-only'], forgeWork); + const env = installGitWrapper(fx, ` +case "$*" in + *"refs/stoke/upstream-branch:refs/heads/main"*) + "${REAL_GIT}" --git-dir="$STOKE_TEST_FORGE_REPO" update-ref refs/heads/main "$STOKE_TEST_RACING_SHA" + ;; +esac`); + Object.assign(env, { + STOKE_TEST_FORGE_REPO: fx.forgeRepo, + STOKE_TEST_RACING_SHA: racingSha, + }); + + const result = runSync(fx, [], { env }); + + assert.equal(result.status, 1); + assert.equal(git(['rev-parse', 'refs/heads/main'], fx.forgeRepo), racingSha); + assert.match(result.stderr, new RegExp(racingSha)); + assert.match(result.stderr, new RegExp(fx.newSha)); + assert.match(result.stderr, /Diverged trees are out of scope/); + } finally { + fx.cleanup(); + } +}); + test('repo sync keeps the token out of Git argv, output, remotes, and config', () => { const fx = fixture(); try { - const wrapperDirectory = path.join(fx.root, 'bin'); const argvLog = path.join(fx.root, 'git-argv.log'); - const wrapper = path.join(wrapperDirectory, 'git'); - fs.mkdirSync(wrapperDirectory); - fs.writeFileSync(wrapper, `#!/bin/sh\nprintf '%s\\n' "$@" >> "$STOKE_TEST_GIT_ARGV"\nexec "${REAL_GIT}" "$@"\n`); - fs.chmodSync(wrapper, 0o755); - - const result = runSync(fx, [], { - env: { - PATH: `${wrapperDirectory}:${process.env.PATH}`, - STOKE_TEST_GIT_ARGV: argvLog, - }, + const localConfigLog = path.join(fx.root, 'git-local-config.log'); + const env = installGitWrapper(fx, ` +printf '%s\\n' "$@" >> "$STOKE_TEST_GIT_ARGV" +if [ -f "$PWD/config" ]; then + sed -n '1,240p' "$PWD/config" >> "$STOKE_TEST_LOCAL_CONFIG" + "${REAL_GIT}" config --local --get-regexp '^remote\\..*\\.url$' >> "$STOKE_TEST_LOCAL_CONFIG" 2>/dev/null || true +fi`); + Object.assign(env, { + STOKE_TEST_GIT_ARGV: argvLog, + STOKE_TEST_LOCAL_CONFIG: localConfigLog, }); + const result = runSync(fx, [], { env }); + assert.equal(result.status, 0, result.stderr); for (const text of [ result.stdout, result.stderr, fs.readFileSync(argvLog, 'utf8'), + fs.readFileSync(localConfigLog, 'utf8'), fs.readFileSync(path.join(fx.forgeRepo, 'config'), 'utf8'), fs.readFileSync(path.join(fx.upstreamRepo, 'config'), 'utf8'), ]) { assert.ok(!text.includes(TOKEN), 'token leaked from the environment-only auth path'); + assert.ok(!text.includes(BASIC_CREDENTIAL), 'encoded credential leaked from the environment-only auth path'); } } finally { fx.cleanup();