Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/ALAS_DOWNSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,11 @@ candidate commit at the point where the patch is replayed (stacked adaptation).
The candidate is rebuilt deterministically, so a stacked adaptation binds only
when every earlier replay reproduces that same parent; any drift fails closed.
Push adaptation commits where the workflow can fetch them before rerunning it;
once bound, they are ancestors of the integration commit.
once bound, they are ancestors of the integration commit. The advanced ledger
records the adaptation as the patch's `appliedCommit` and retires the original;
later syncs replay `appliedCommit` at the position of the first retired
original, so fork commits that build on the patch still replay after it. An
`appliedCommit` that is not in protected `alas` fails the build.

A fork-only commit outside the ledger is normally replayed as is. When it
conflicts with the new stable release, the review may name an adaptation in
Expand Down
28 changes: 19 additions & 9 deletions scripts/sync-candidate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,34 @@ function isRedundantPick(cwd, commit) {
return redundant;
}

// Replay order of the downstream commits. A reviewed adaptation of a non-ledger commit takes the
// place of its retired original (and of the original's retired copies), so fork commits that
// depend on it still replay after it.
function downstreamReplayOrder({downstreamCommits, patchIds, ledgerByPatchId, retiredCommits, transitions}) {
const replacementOf = new Map();
// Replay order of the downstream commits. A reviewed adaptation takes the place of its retired
// original (and of the original's retired copies), so fork commits that depend on it still replay
// after it. This holds for a non-ledger adaptation recorded in `downstreamTransitions` and for a
// ledger patch whose `appliedCommit` replaced its retired original.
function downstreamReplayOrder({downstreamCommits, patchIds, ledgerByPatchId, retiredCommits, transitions, ledgerPatches}) {
const transitionReplacementOf = new Map();
for (const transition of transitions) {
if (transition?.action !== "adapt" || !transition.replacementCommit) continue;
for (const commit of [transition.commit, ...(transition.equivalentCommits ?? [])]) {
replacementOf.set(commit, transition.replacementCommit);
transitionReplacementOf.set(commit, transition.replacementCommit);
}
}
// A ledger adaptation can keep its original's patch id (same change, same context), so ledger
// replacements do not depend on the retired commit's patch id.
const ledgerReplacementOf = new Map();
for (const patch of ledgerPatches) {
if (!patch.appliedCommit) continue;
for (const commit of patch.retiredCommits ?? []) ledgerReplacementOf.set(commit, patch.appliedCommit);
}
const order = [];
const placed = new Set();
for (const [index, commit] of downstreamCommits.entries()) {
const id = patchIds[index];
const replacement = replacementOf.get(commit);
if (replacement && retiredCommits.has(commit) && !(id && ledgerByPatchId.has(id))) {
const replacement = ledgerReplacementOf.get(commit) ??
(id && ledgerByPatchId.has(id) ? undefined : transitionReplacementOf.get(commit));
if (replacement && retiredCommits.has(commit)) {
if (!downstreamCommits.includes(replacement)) {
throw new Error(`Downstream transition replacement ${replacement} for ${commit} is not in protected alas`);
throw new Error(`Replacement ${replacement} for retired downstream commit ${commit} is not in protected alas`);
}
if (!placed.has(replacement)) order.push(replacement);
placed.add(replacement);
Expand Down Expand Up @@ -661,6 +670,7 @@ export function buildSyncCandidate({
ledgerByPatchId,
retiredCommits,
transitions: ledger.downstreamTransitions ?? [],
ledgerPatches: ledger.patches,
});
for (const commit of replayOrder) {
const id = patchIdByCommit.get(commit);
Expand Down
190 changes: 190 additions & 0 deletions scripts/sync-ledger-replacement-order.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// The stable sync after a reviewed ledger adaptation. The ledger patch's original stays in alas
// history, retired, and the adaptation reaches alas later through the sync merge. Fork commits that
// build on the patch sit between the two, so the replay must use the adaptation where the retired
// original was.
import assert from "node:assert/strict";
import {execFileSync} from "node:child_process";
import {mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {test} from "vitest";
import {buildSyncCandidate} from "./sync-candidate.mjs";
import {verifySyncSourceReview} from "./verify-sync-source-review.mjs";

let clock = 1_820_000_000;

function git(cwd, ...args) {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
env: {...process.env, GIT_AUTHOR_DATE: `${clock} +0000`, GIT_COMMITTER_DATE: `${clock} +0000`},
}).trim();
}

function commitFiles(cwd, files, message) {
clock += 60;
for (const [path, contents] of Object.entries(files)) {
const full = join(cwd, path);
mkdirSync(join(full, ".."), {recursive: true});
writeFileSync(full, contents);
git(cwd, "add", path);
}
git(cwd, "commit", "-m", message);
return git(cwd, "rev-parse", "HEAD");
}

function build(f, {tag, base, ledger, ...options}) {
return buildSyncCandidate({
cwd: f.cwd,
tagRef: tag,
baseRef: base,
upstreamRef: "upstream",
alasRef: "alas",
branch: `sync/upstream-${tag.slice(1)}`,
syncRefs: [],
ledger,
expectedPatchIdentities: ledger.patches,
...options,
});
}

const FEATURE = "one\ntwo\nthree\nfour\n";
const UPSTREAM_FEATURE = FEATURE.replace("one", "ONE");

function patchIdOf(cwd, commit) {
const patch = execFileSync("git", ["show", "--pretty=format:", "--binary", commit], {cwd, encoding: "utf8"});
return execFileSync("git", ["patch-id", "--stable"], {cwd, encoding: "utf8", input: `${patch}\n`}).split(/\s+/)[0];
}

// alas carries a ledger patch that adds a line to feature.txt and a later fork commit that edits
// that line. v1.1.0 changes another line of the same file, so the v1.1.0 sync adapts the patch onto
// the tag and is merged into alas like a protected PR. v1.2.0 then only changes an unrelated file.
// When `upstreamFeature` changes the patch's context, the adaptation gets another patch id.
function fixture({upstreamFeature = UPSTREAM_FEATURE} = {}) {
const root = mkdtempSync(join(tmpdir(), "alas-sync-ledger-order-"));
const cwd = join(root, "repo");
mkdirSync(cwd);
git(cwd, "init", "-b", "upstream", cwd);
git(cwd, "config", "user.name", "Fixture");
git(cwd, "config", "user.email", "fixture@example.test");
const base = commitFiles(cwd, {
"package.json": '{"version":"1.0.0"}\n',
"feature.txt": FEATURE,
"upstream.txt": "one\n",
}, "old stable");
git(cwd, "tag", "v1.0.0");

git(cwd, "checkout", "-b", "alas", base);
const patch = commitFiles(cwd, {"feature.txt": `${FEATURE}downstream\n`}, "feat: downstream feature");
const dependent = commitFiles(cwd, {"feature.txt": `${FEATURE}downstream, refined\n`}, "fix: refine downstream feature");
const ledger = {
schemaVersion: 2,
baseTag: "v1.0.0",
patches: [{
name: "downstream",
commit: patch,
upstreamPr: null,
files: ["feature.txt"],
tests: ["feature.test.ts"],
}],
retiredCommits: [],
preservedTransitions: [],
};
const recorded = commitFiles(cwd, {"docs/alas-downstream-patches.json": `${JSON.stringify(ledger, null, 2)}\n`}, "record ledger");
// Fork commits reach alas through PR merges. Dated like the last commit it merges, this merge makes
// the history walk list the original fix before the v1.1.0 adaptation, as on the real fork.
git(cwd, "reset", "--hard", git(cwd, "commit-tree", `${recorded}^{tree}`, "-p", recorded, "-p", base,
"-m", "Merge pull request #0 from nacho/downstream-feature"));

git(cwd, "checkout", "upstream");
const stable = commitFiles(cwd, {
"package.json": '{"version":"1.1.0"}\n',
"feature.txt": upstreamFeature,
}, "new stable");
git(cwd, "tag", "v1.1.0");
const f = {root, cwd, base, patch, dependent, stable, ledger};

// The reviewed v1.1.0 sync: the patch overlaps the upstream edit and is adapted onto the tag.
const first = {tag: "v1.1.0", base: "v1.0.0", ledger};
const unresolved = build(f, first);
assert.deepEqual(unresolved.syncReview.patches.map((entry) => entry.classification), ["overlap"]);
git(cwd, "checkout", "--detach", stable);
const adaptation = commitFiles(cwd, {"feature.txt": `${upstreamFeature}downstream\n`}, "feat: adapt downstream feature to v1.1.0");
const review = structuredClone(unresolved.syncReview);
review.patches[0].resolution = {
action: "adapt",
commit: adaptation,
automatic: false,
rationale: "Keeps the downstream line after the v1.1.0 context.",
tests: ["feature.test.ts"],
};
const synced = build(f, {...first, reviewOverride: review});
assert.deepEqual(synced.manualReviewReasons, []);
git(cwd, "checkout", "alas");
clock += 60;
git(cwd, "merge", "--no-ff", "-m", "Merge pull request #1 from sync/upstream-1.1.0", synced.integrationCommit);
const advanced = JSON.parse(readFileSync(join(cwd, "docs/alas-downstream-patches.json"), "utf8"));
assert.equal(advanced.patches[0].appliedCommit, adaptation);
assert.deepEqual(advanced.patches[0].retiredCommits, [patch]);

git(cwd, "checkout", "upstream");
const next = commitFiles(cwd, {"package.json": '{"version":"1.2.0"}\n', "upstream.txt": "two\n"}, "next stable");
git(cwd, "tag", "v1.2.0");
return {...f, upstreamFeature, adaptation, advanced, next};
}

for (const {variant, upstreamFeature, samePatchId} of [
{variant: "keeps its original's patch id", upstreamFeature: UPSTREAM_FEATURE, samePatchId: true},
{variant: "has another patch id", upstreamFeature: FEATURE.replace("three", "THREE"), samePatchId: false},
]) {
test(`replays an adapted ledger patch that ${variant} where its retired original was on the next stable sync`, () => {
const f = fixture({upstreamFeature});
try {
assert.equal(patchIdOf(f.cwd, f.adaptation) === patchIdOf(f.cwd, f.patch), samePatchId);
const report = build(f, {tag: "v1.2.0", base: "v1.1.0", ledger: f.advanced});
assert.deepEqual(report.conflicts, []);
assert.deepEqual(report.manualReviewReasons, []);
assert.deepEqual(report.appliedPatches, ["downstream"]);
assert.equal(git(f.cwd, "show", `${report.exactCandidateCommit}:feature.txt`), `${f.upstreamFeature}downstream, refined`);
const applied = report.appliedDownstreamCommits;
assert.ok(applied.indexOf(f.adaptation) >= 0 && applied.indexOf(f.adaptation) < applied.indexOf(f.dependent));
assert.equal(applied.filter((commit) => commit === f.adaptation).length, 1);
assert.ok(!applied.includes(f.patch));

const repeated = build(f, {tag: "v1.2.0", base: "v1.1.0", ledger: f.advanced});
assert.equal(repeated.integrationCommit, report.integrationCommit);

const wrapper = git(f.cwd, "commit-tree", `${report.integrationCommit}^{tree}`,
"-p", git(f.cwd, "rev-parse", "alas"), "-p", report.integrationCommit, "-m", "Merge pull request #2");
git(f.cwd, "branch", "alas-wrapper", wrapper);
const verified = verifySyncSourceReview({
cwd: f.cwd,
sourceCommit: wrapper,
alasRef: "alas-wrapper",
upstreamTag: "v1.2.0",
upstreamMainRef: "upstream",
packageVersion: "1.2.0",
review: report.syncReview,
ledger: report.advancedLedger,
expectedPatchIdentities: f.advanced.patches,
});
assert.equal(verified.reconstructedCandidateCommit, report.exactCandidateCommit);
assert.equal(verified.reconstructedIntegrationCommit, report.integrationCommit);
} finally {
rmSync(f.root, {recursive: true, force: true});
}
}, 60_000);
}

test("fails closed when an adapted ledger patch's applied commit is not in protected alas", () => {
const f = fixture();
try {
const ledger = structuredClone(f.advanced);
ledger.patches[0].appliedCommit = f.next;
ledger.patches[0].lastResolution.replacementCommit = f.next;
assert.throws(() => build(f, {tag: "v1.2.0", base: "v1.1.0", ledger}), /not in protected alas/);
} finally {
rmSync(f.root, {recursive: true, force: true});
}
}, 60_000);
Loading