fix: reconcile repository sync races
This commit is contained in:
parent
04e6ba60e8
commit
316bec5855
2 changed files with 209 additions and 28 deletions
102
src/repo-sync.js
102
src/repo-sync.js
|
|
@ -23,6 +23,17 @@ function remoteTags(url, { cwd, env }) {
|
||||||
return tags;
|
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({
|
function syncRepository({
|
||||||
forgeUrl,
|
forgeUrl,
|
||||||
upstreamUrl,
|
upstreamUrl,
|
||||||
|
|
@ -63,41 +74,98 @@ function syncRepository({
|
||||||
accept: [0, 1],
|
accept: [0, 1],
|
||||||
});
|
});
|
||||||
if (ancestry.status !== 0) {
|
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 newTags = [];
|
||||||
const movedTags = [];
|
const movedTags = [];
|
||||||
if (includeTags) {
|
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 forgeTags = remoteTags(forgeUrl, { cwd: directory, env });
|
||||||
const upstreamTags = remoteTags(upstreamUrl, { cwd: directory, env });
|
for (const [name, tag] of fetchedUpstreamTags) {
|
||||||
for (const [name, upstreamSha] of upstreamTags) {
|
|
||||||
const forgeSha = forgeTags.get(name);
|
const forgeSha = forgeTags.get(name);
|
||||||
if (!forgeSha) {
|
if (!forgeSha) {
|
||||||
const temporaryRef = `refs/stoke/upstream-tags/${name}`;
|
newTags.push(tag);
|
||||||
runGit(['fetch', '--no-tags', upstreamUrl, `refs/tags/${name}:${temporaryRef}`], {
|
} else if (forgeSha !== tag.sha) {
|
||||||
cwd: directory,
|
movedTags.push({ name, forgeSha, upstreamSha: tag.sha });
|
||||||
env,
|
|
||||||
});
|
|
||||||
newTags.push({ name, sha: upstreamSha, temporaryRef });
|
|
||||||
} else if (forgeSha !== upstreamSha) {
|
|
||||||
movedTags.push({ name, forgeSha, upstreamSha });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refspecs = [];
|
if (!dryRun && oldSha !== newSha) {
|
||||||
if (oldSha !== newSha) refspecs.push(`${upstreamRef}:refs/heads/${branch}`);
|
const branchPush = runGit(['push', forgeUrl, `${upstreamRef}:refs/heads/${branch}`], {
|
||||||
for (const tag of newTags) refspecs.push(`${tag.temporaryRef}:refs/tags/${tag.name}`);
|
cwd: directory,
|
||||||
if (!dryRun && refspecs.length > 0) {
|
env,
|
||||||
runGit(['push', forgeUrl, ...refspecs], { 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 {
|
return {
|
||||||
branch,
|
branch,
|
||||||
oldSha,
|
oldSha,
|
||||||
newSha,
|
newSha,
|
||||||
changed: oldSha !== newSha,
|
changed: oldSha !== newSha,
|
||||||
newTags,
|
newTags: reportedNewTags,
|
||||||
movedTags,
|
movedTags,
|
||||||
dryRun,
|
dryRun,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const path = require('node:path');
|
||||||
|
|
||||||
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
|
const CLI = path.join(__dirname, '..', 'src', 'cli.js');
|
||||||
const TOKEN = 'stoke-secret-token-for-sync-tests';
|
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();
|
const REAL_GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
|
||||||
|
|
||||||
function git(args, cwd) {
|
function git(args, cwd) {
|
||||||
|
|
@ -65,6 +66,15 @@ function refSha(repository, ref) {
|
||||||
return result.status === 0 ? result.stdout.trim() : null;
|
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 = {} } = {}) {
|
function runSync(fx, extra = [], { branch = 'main', env = {} } = {}) {
|
||||||
const args = [
|
const args = [
|
||||||
CLI,
|
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', () => {
|
test('repo sync keeps the token out of Git argv, output, remotes, and config', () => {
|
||||||
const fx = fixture();
|
const fx = fixture();
|
||||||
try {
|
try {
|
||||||
const wrapperDirectory = path.join(fx.root, 'bin');
|
|
||||||
const argvLog = path.join(fx.root, 'git-argv.log');
|
const argvLog = path.join(fx.root, 'git-argv.log');
|
||||||
const wrapper = path.join(wrapperDirectory, 'git');
|
const localConfigLog = path.join(fx.root, 'git-local-config.log');
|
||||||
fs.mkdirSync(wrapperDirectory);
|
const env = installGitWrapper(fx, `
|
||||||
fs.writeFileSync(wrapper, `#!/bin/sh\nprintf '%s\\n' "$@" >> "$STOKE_TEST_GIT_ARGV"\nexec "${REAL_GIT}" "$@"\n`);
|
printf '%s\\n' "$@" >> "$STOKE_TEST_GIT_ARGV"
|
||||||
fs.chmodSync(wrapper, 0o755);
|
if [ -f "$PWD/config" ]; then
|
||||||
|
sed -n '1,240p' "$PWD/config" >> "$STOKE_TEST_LOCAL_CONFIG"
|
||||||
const result = runSync(fx, [], {
|
"${REAL_GIT}" config --local --get-regexp '^remote\\..*\\.url$' >> "$STOKE_TEST_LOCAL_CONFIG" 2>/dev/null || true
|
||||||
env: {
|
fi`);
|
||||||
PATH: `${wrapperDirectory}:${process.env.PATH}`,
|
Object.assign(env, {
|
||||||
STOKE_TEST_GIT_ARGV: argvLog,
|
STOKE_TEST_GIT_ARGV: argvLog,
|
||||||
},
|
STOKE_TEST_LOCAL_CONFIG: localConfigLog,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = runSync(fx, [], { env });
|
||||||
|
|
||||||
assert.equal(result.status, 0, result.stderr);
|
assert.equal(result.status, 0, result.stderr);
|
||||||
for (const text of [
|
for (const text of [
|
||||||
result.stdout,
|
result.stdout,
|
||||||
result.stderr,
|
result.stderr,
|
||||||
fs.readFileSync(argvLog, 'utf8'),
|
fs.readFileSync(argvLog, 'utf8'),
|
||||||
|
fs.readFileSync(localConfigLog, 'utf8'),
|
||||||
fs.readFileSync(path.join(fx.forgeRepo, 'config'), 'utf8'),
|
fs.readFileSync(path.join(fx.forgeRepo, 'config'), 'utf8'),
|
||||||
fs.readFileSync(path.join(fx.upstreamRepo, '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(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 {
|
} finally {
|
||||||
fx.cleanup();
|
fx.cleanup();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue