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(); + } +});