Merge pull request 'feat: add fast-forward repo sync' (#41) from build/23-repo-sync into main
All checks were successful
ci / test (push) Successful in 28s
All checks were successful
ci / test (push) Successful in 28s
Reviewed-on: #41 Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders> Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders> Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
This commit is contained in:
commit
c34a8b04d2
5 changed files with 574 additions and 0 deletions
25
README.md
25
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 <owner> repository owner (required)
|
||||
-r, --repo <repo> repository name (required)
|
||||
--from <upstream-url> upstream Git URL (required)
|
||||
--branch <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.
|
||||
|
|
|
|||
1
changelog.d/23.md
Normal file
1
changelog.d/23.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added `repo sync` for credential-safe, fast-forward-only branch and tag updates with dry-run and divergence protection. (#23).
|
||||
44
src/cli.js
44
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,49 @@ repo
|
|||
}
|
||||
});
|
||||
|
||||
repo
|
||||
.command('sync')
|
||||
.description('Fast-forward an imported repository from its upstream')
|
||||
.requiredOption('-o, --owner <owner>', 'repository owner')
|
||||
.requiredOption('-r, --repo <repo>', 'repository name')
|
||||
.requiredOption('--from <upstream-url>', 'upstream Git URL')
|
||||
.option('--branch <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');
|
||||
}
|
||||
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,
|
||||
includeTags: options.tags,
|
||||
dryRun: options.dryRun,
|
||||
env: gitAuthEnv({ ...config, url: base }),
|
||||
});
|
||||
if (result.changed) {
|
||||
console.log(`${result.branch} ${result.oldSha}..${result.newSha}`);
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
repo
|
||||
.command('create')
|
||||
.description('Create a new repository for the authenticated user or an organization')
|
||||
|
|
|
|||
177
src/repo-sync.js
Normal file
177
src/repo-sync.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
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 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 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,
|
||||
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';
|
||||
|
||||
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,
|
||||
});
|
||||
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 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 });
|
||||
for (const [name, tag] of fetchedUpstreamTags) {
|
||||
const forgeSha = forgeTags.get(name);
|
||||
if (!forgeSha) {
|
||||
newTags.push(tag);
|
||||
} else if (forgeSha !== tag.sha) {
|
||||
movedTags.push({ name, forgeSha, upstreamSha: tag.sha });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: reportedNewTags,
|
||||
movedTags,
|
||||
dryRun,
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { syncRepository };
|
||||
327
test/sync.test.js
Normal file
327
test/sync.test.js
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
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';
|
||||
const BASIC_CREDENTIAL = Buffer.from(`tester:${TOKEN}`).toString('base64');
|
||||
const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
|
||||
|
||||
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,
|
||||
upstreamWork,
|
||||
upstreamRepo,
|
||||
config,
|
||||
oldSha,
|
||||
newSha,
|
||||
cleanup() {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 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,
|
||||
'repo',
|
||||
'sync',
|
||||
'-o', 'o',
|
||||
'-r', 'r',
|
||||
'--from', `file://${fx.upstreamRepo}`,
|
||||
];
|
||||
if (branch) args.push('--branch', branch);
|
||||
args.push(...extra);
|
||||
return spawnSync(process.execPath, args, {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, STOKE_CONFIG_FILE: fx.config, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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 argvLog = path.join(fx.root, 'git-argv.log');
|
||||
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();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue