From 64b3d9df940e046d3ec78d82ec115b315224efba Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 16:51:04 +0000 Subject: [PATCH 1/5] feat: fast-forward repository branches --- src/cli.js | 33 ++++++++++++++++++ src/repo-sync.js | 54 ++++++++++++++++++++++++++++++ test/sync.test.js | 85 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 src/repo-sync.js create mode 100644 test/sync.test.js 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(); + } +}); From ea6c1a4fe93fce22c2e9ed6ebdd0f2e36fa1efd7 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 16:54:34 +0000 Subject: [PATCH 2/5] feat: resolve repository default branch --- src/cli.js | 8 +++++--- src/repo-sync.js | 11 ++++++++++- test/sync.test.js | 38 +++++++++++++++++++++++++++++++++----- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/cli.js b/src/cli.js index addb70a..c1888d7 100755 --- a/src/cli.js +++ b/src/cli.js @@ -467,8 +467,6 @@ repo 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({ @@ -477,7 +475,11 @@ repo branch: options.branch, env: gitAuthEnv({ ...config, url: base }), }); - console.log(`${result.branch} ${result.oldSha}..${result.newSha}`); + if (result.changed) { + console.log(`${result.branch} ${result.oldSha}..${result.newSha}`); + } else { + console.log(`${result.branch} is up to date at ${result.newSha}`); + } } 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 43b5132..3b45379 100644 --- a/src/repo-sync.js +++ b/src/repo-sync.js @@ -19,6 +19,15 @@ function syncRepository({ forgeUrl, upstreamUrl, branch, env }) { try { runGit(['init', '--bare', directory], { cwd: directory, env }); + if (!branch) { + const symbolicHead = runGit(['ls-remote', '--symref', forgeUrl, 'HEAD'], { + cwd: directory, + env, + }).stdout; + const match = symbolicHead.match(/^ref:\s+refs\/heads\/(.+)\s+HEAD$/m); + if (!match) throw new Error('Could not resolve the forge repository default branch'); + branch = match[1]; + } runGit(['fetch', '--no-tags', forgeUrl, `refs/heads/${branch}:${forgeRef}`], { cwd: directory, env, @@ -45,7 +54,7 @@ function syncRepository({ forgeUrl, upstreamUrl, branch, env }) { env, }); } - return { branch, oldSha, newSha }; + return { branch, oldSha, newSha, changed: oldSha !== newSha }; } finally { fs.rmSync(directory, { recursive: true, force: true }); } diff --git a/test/sync.test.js b/test/sync.test.js index 4ef49c1..fd72c2c 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -55,17 +55,18 @@ function fixture() { }; } -function runSync(fx, extra = []) { - return spawnSync(process.execPath, [ +function runSync(fx, extra = [], { branch = 'main' } = {}) { + const args = [ CLI, 'repo', 'sync', '-o', 'o', '-r', 'r', '--from', `file://${fx.upstreamRepo}`, - '--branch', 'main', - ...extra, - ], { + ]; + if (branch) args.push('--branch', branch); + args.push(...extra); + return spawnSync(process.execPath, args, { encoding: 'utf8', env: { ...process.env, STOKE_CONFIG_FILE: fx.config }, }); @@ -83,3 +84,30 @@ test('repo sync fast-forwards an undiverged forge branch', () => { fx.cleanup(); } }); + +test('repo sync resolves an omitted branch from the forge symbolic HEAD', () => { + const fx = fixture(); + try { + const result = runSync(fx, [], { branch: null }); + + 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(); + } +}); + +test('repo sync reports an already-current branch as a no-op', () => { + const fx = fixture(); + try { + assert.equal(runSync(fx).status, 0); + 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 is up to date at ${fx.newSha}`)); + } finally { + fx.cleanup(); + } +}); From b21a1387a5023001b2591fd159857a0430a53876 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 16:57:02 +0000 Subject: [PATCH 3/5] 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(); + } +}); From 04e6ba60e8af294c3d204058ea683b9adda10529 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 16:59:10 +0000 Subject: [PATCH 4/5] docs: explain repository sync boundaries --- README.md | 25 +++++++++++++++++++++++++ changelog.d/23.md | 1 + test/sync.test.js | 37 +++++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 changelog.d/23.md diff --git a/README.md b/README.md index 79ba0a8..3e6d100 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,31 @@ stoke repo clone -o heavy-duty -r stoke ~/src/stoke --depth 1 The stored token is handed to git ephemerally through environment-based config (`GIT_CONFIG_*`): it never appears in the remote URL, on the command line, or in the cloned repository's `.git/config`. Git's output is streamed directly and its exit status is forwarded, so failures behave exactly like a plain `git clone`. +### `stoke repo sync` + +Fast-forward an undiverged imported repository from an upstream Git URL. + +```text +Options: + -o, --owner repository owner (required) + -r, --repo repository name (required) + --from upstream Git URL (required) + --branch branch to synchronize (default: the forge repository's default branch) + --tags also create new upstream tags + --dry-run report branch and tag moves without pushing +``` + +```bash +stoke repo sync -o heavy-duty -r box \ + --from https://github.com/heavy-duty/box.git +stoke repo sync -o heavy-duty -r box \ + --from https://github.com/heavy-duty/box.git --tags --dry-run +``` + +The command fetches both branch tips into an ephemeral bare repository and pushes only when the forge tip is an ancestor of the upstream tip. It refuses a diverged tree with both commit SHAs and never offers a force option. With `--tags`, new upstream tags are created; an existing forge tag that points elsewhere is reported and left untouched, and the command exits non-zero after applying any other safe moves. The stored Forgejo token uses the same environment-only Git authentication as `repo clone` and is never written to an argument, remote, or Git config. + +This verb deliberately does not merge diverged trees, configure Forgejo pull-mirrors, or copy releases. Follow ceremony's live `docs/UPSTREAM-SYNC.md` procedure for a diverged tree; import a scheduled read-only repository as a pull-mirror; compose release mirroring from `release create` and `release upload`. + ### `stoke repo create` Create a new repository for the authenticated user or an organization. diff --git a/changelog.d/23.md b/changelog.d/23.md new file mode 100644 index 0000000..121e76a --- /dev/null +++ b/changelog.d/23.md @@ -0,0 +1 @@ +- Added `repo sync` for credential-safe, fast-forward-only branch and tag updates with dry-run and divergence protection. (#23). diff --git a/test/sync.test.js b/test/sync.test.js index 1362c76..775e45d 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 REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim(); function git(args, cwd) { return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); @@ -64,7 +65,7 @@ function refSha(repository, ref) { return result.status === 0 ? result.stdout.trim() : null; } -function runSync(fx, extra = [], { branch = 'main' } = {}) { +function runSync(fx, extra = [], { branch = 'main', env = {} } = {}) { const args = [ CLI, 'repo', @@ -77,7 +78,7 @@ function runSync(fx, extra = [], { branch = 'main' } = {}) { args.push(...extra); return spawnSync(process.execPath, args, { encoding: 'utf8', - env: { ...process.env, STOKE_CONFIG_FILE: fx.config }, + env: { ...process.env, STOKE_CONFIG_FILE: fx.config, ...env }, }); } @@ -179,3 +180,35 @@ test('repo sync --dry-run reports branch and tag moves without writing', () => { 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, + }, + }); + + assert.equal(result.status, 0, result.stderr); + for (const text of [ + result.stdout, + result.stderr, + fs.readFileSync(argvLog, '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'); + } + } finally { + fx.cleanup(); + } +}); From 316bec5855b3d4f8f003df6f580b3d5cb4a34e38 Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl Date: Mon, 31 Aug 2026 17:14:13 +0000 Subject: [PATCH 5/5] fix: reconcile repository sync races --- src/repo-sync.js | 102 +++++++++++++++++++++++++++++------ test/sync.test.js | 135 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 209 insertions(+), 28 deletions(-) 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();