2026-08-31 16:51:04 +00:00
|
|
|
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';
|
2026-08-31 17:14:13 +00:00
|
|
|
const BASIC_CREDENTIAL = Buffer.from(`tester:${TOKEN}`).toString('base64');
|
2026-08-31 16:59:10 +00:00
|
|
|
const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
|
2026-08-31 16:51:04 +00:00
|
|
|
|
|
|
|
|
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,
|
2026-08-31 16:57:02 +00:00
|
|
|
upstreamWork,
|
2026-08-31 16:51:04 +00:00
|
|
|
upstreamRepo,
|
|
|
|
|
config,
|
|
|
|
|
oldSha,
|
|
|
|
|
newSha,
|
|
|
|
|
cleanup() {
|
|
|
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 16:57:02 +00:00
|
|
|
function refSha(repository, ref) {
|
|
|
|
|
const result = spawnSync('git', ['rev-parse', '--verify', ref], {
|
|
|
|
|
cwd: repository,
|
|
|
|
|
encoding: 'utf8',
|
|
|
|
|
});
|
|
|
|
|
return result.status === 0 ? result.stdout.trim() : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 17:14:13 +00:00
|
|
|
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}` };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 16:59:10 +00:00
|
|
|
function runSync(fx, extra = [], { branch = 'main', env = {} } = {}) {
|
2026-08-31 16:54:34 +00:00
|
|
|
const args = [
|
2026-08-31 16:51:04 +00:00
|
|
|
CLI,
|
|
|
|
|
'repo',
|
|
|
|
|
'sync',
|
|
|
|
|
'-o', 'o',
|
|
|
|
|
'-r', 'r',
|
|
|
|
|
'--from', `file://${fx.upstreamRepo}`,
|
2026-08-31 16:54:34 +00:00
|
|
|
];
|
|
|
|
|
if (branch) args.push('--branch', branch);
|
|
|
|
|
args.push(...extra);
|
|
|
|
|
return spawnSync(process.execPath, args, {
|
2026-08-31 16:51:04 +00:00
|
|
|
encoding: 'utf8',
|
2026-08-31 16:59:10 +00:00
|
|
|
env: { ...process.env, STOKE_CONFIG_FILE: fx.config, ...env },
|
2026-08-31 16:51:04 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-31 16:54:34 +00:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-31 16:57:02 +00:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-08-31 16:59:10 +00:00
|
|
|
|
2026-08-31 17:14:13 +00:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-31 16:59:10 +00:00
|
|
|
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');
|
2026-08-31 17:14:13 +00:00
|
|
|
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,
|
2026-08-31 16:59:10 +00:00
|
|
|
});
|
|
|
|
|
|
2026-08-31 17:14:13 +00:00
|
|
|
const result = runSync(fx, [], { env });
|
|
|
|
|
|
2026-08-31 16:59:10 +00:00
|
|
|
assert.equal(result.status, 0, result.stderr);
|
|
|
|
|
for (const text of [
|
|
|
|
|
result.stdout,
|
|
|
|
|
result.stderr,
|
|
|
|
|
fs.readFileSync(argvLog, 'utf8'),
|
2026-08-31 17:14:13 +00:00
|
|
|
fs.readFileSync(localConfigLog, 'utf8'),
|
2026-08-31 16:59:10 +00:00
|
|
|
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');
|
2026-08-31 17:14:13 +00:00
|
|
|
assert.ok(!text.includes(BASIC_CREDENTIAL), 'encoded credential leaked from the environment-only auth path');
|
2026-08-31 16:59:10 +00:00
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
fx.cleanup();
|
|
|
|
|
}
|
|
|
|
|
});
|