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
4 changes: 3 additions & 1 deletion docs/operations/upstream-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Normal commands compare against fetched `origin/main`, not the current branch. `

During initial catch-up, later upstream changes may already be present in the fork. Chronological intake removes prerequisite selection only when earlier changes are accounted for; it does not eliminate conflicts with those later imports or maintained fork differences.

To plan an early import, run `node scripts/upstream-queue.ts early <PR-number> --through upstream/main`. Pass several PR numbers to plan them together. The command applies the selected patches at the reconciled upstream boundary, replays intervening upstream integrations in order, and adds PRs or direct commits only when that replay encounters a textual conflict. It then checks whether the resulting selection applies to fetched fork `main`. `early --count 20 --through upstream/main` provides a quick file-overlap screen without replay. A clean replay establishes that moving the selected patches earlier does not change the upstream result under Git's merge rules; review semantic dependencies and validate behavior before importing. This check changes no refs or working-tree files; simulation objects live in an ignored directory that the command removes afterward. The normal intake and promotion order remains chronological unless maintainers deliberately change this policy.

## 2. Apply in order

Create the candidate from current `origin/main`, then apply the selected commits:
Expand Down Expand Up @@ -98,7 +100,7 @@ Commit the target change too. Neither command promotes code.
- `explain <PR-number-or-SHA-prefix>` shows sources and import evidence. SHA prefixes must be unambiguous and at least seven characters.
- `--fork-ref intake/<batch>` inspects a candidate; `--state` and `--upstream-ref` select alternate local inputs.
- `--json` provides structured output for queue commands.
- PR associations are cached in the primary checkout's ignored `.scratch/`, shared across worktrees and saved incrementally. Use `--refresh-metadata` to rebuild them.
- PR associations are cached in the primary checkout's ignored `.scratch/`, shared across worktrees and saved incrementally. New targets reuse settled associations from a previous cache and recheck direct or unmerged commits. Use `--refresh-metadata` to rebuild them.
- Recorded evidence includes ancestry, provenance trailers, cherry-pick references, and historical `Source PRs:` sections. Incomplete or ambiguous associations stop the queue.
- The old tracking issue is historical context only; its terminal markers are not completion evidence. The queue does not write to GitHub.

Expand Down
160 changes: 160 additions & 0 deletions scripts/upstream-early.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { assert, describe, it } from "@effect/vitest";
import {
assessEarlyCandidates,
resolveEarlyDependencies,
selectEarlySources,
} from "./upstream-early.ts";
import type { QueueEntry } from "./upstream-queue.ts";

const entry = (
n: number,
pr: number | null,
disposition: QueueEntry["disposition"] = "pending",
): QueueEntry => ({
sha: String(n),
parents: [String(n - 1)],
title: `change ${n}`,
empty: false,
pr,
evidence: [],
disposition,
});

describe("early upstream intake assessment", () => {
it("flags pending direct commits and PRs that touch a candidate's files", () => {
const entries = [
entry(0, 1, "recorded"),
entry(1, 10),
entry(2, null),
entry(3, 11),
entry(4, 12),
];
const paths = new Map([
["1", ["model.ts"]],
["2", ["settings.ts"]],
["3", ["model.ts", "settings.ts"]],
["4", ["mobile.ts"]],
]);
const result = assessEarlyCandidates(
entries,
"0",
10,
null,
(source) => paths.get(source.sha) ?? [],
(source) => source.pr !== 11,
);
assert.deepEqual(
result.map(({ pr, precedingPRs, overlappingPaths, cleanApply, fileDisjointAndClean }) => ({
pr,
precedingPRs,
overlappingPaths,
cleanApply,
fileDisjointAndClean,
})),
[
{
pr: 10,
precedingPRs: 0,
overlappingPaths: [],
cleanApply: true,
fileDisjointAndClean: true,
},
{
pr: 11,
precedingPRs: 1,
overlappingPaths: ["model.ts", "settings.ts"],
cleanApply: false,
fileDisjointAndClean: false,
},
{
pr: 12,
precedingPRs: 2,
overlappingPaths: [],
cleanApply: true,
fileDisjointAndClean: true,
},
],
);
});

it("does not claim clean application for a multi-commit PR", () => {
const entries = [entry(0, 1, "recorded"), entry(1, 10), entry(2, 10)];
const result = assessEarlyCandidates(
entries,
"0",
1,
10,
() => ["model.ts"],
() => {
throw new Error("should not simulate one commit in isolation");
},
);
assert.equal(result[0]!.cleanApply, null);
assert.match(result[0]!.reason!, /Multi-commit/);
assert.deepEqual(result[0]!.overlappingPaths, []);
});

it("rejects a PR outside the pending range", () => {
assert.throws(
() =>
assessEarlyCandidates(
[entry(0, 1, "recorded")],
"0",
10,
10,
() => [],
() => true,
),
"not pending",
);
});

it("flags a file and directory at the same path as overlapping", () => {
const result = assessEarlyCandidates(
[entry(0, 1, "recorded"), entry(1, 10), entry(2, 11)],
"0",
2,
null,
(source) => (source.pr === 10 ? ["assets"] : ["assets/logo.svg"]),
() => true,
);
assert.deepEqual(result[1]!.overlappingPaths, ["assets/logo.svg"]);
assert.equal(result[1]!.fileDisjointAndClean, false);
});

it("selects whole pending PRs and keeps intervening source history", () => {
const entries = [
entry(0, 1, "recorded"),
entry(1, 10),
entry(2, 11),
entry(3, null),
entry(4, 12),
entry(5, 13),
];
const selection = selectEarlySources(entries, "0", [12, 10]);
assert.deepEqual(
selection.selected.map((source) => source.sha),
["1", "4"],
);
assert.deepEqual(
selection.intervening.map((source) => source.sha),
["1", "2", "3", "4"],
);
});

it("adds only replay conflicts and retries their upstream-order selection", () => {
const entries = [entry(0, 1, "recorded"), entry(1, 10), entry(2, 11), entry(3, 12)];
const plan = resolveEarlyDependencies(entries, "0", [12], (selection) => {
const selected = new Set(selection.selected.map((source) => source.pr));
if (!selected.has(11)) return entries[2]!;
if (!selected.has(10)) return entries[1]!;
return null;
});
assert.deepEqual(plan.dependencyPRs, [11, 10]);
assert.deepEqual(
plan.selected.map((source) => source.sha),
["1", "2", "3"],
);
assert.equal(plan.attempts, 3);
});
});
156 changes: 156 additions & 0 deletions scripts/upstream-early.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import type { QueueEntry } from "./upstream-queue.ts";

export interface EarlyCandidate {
pr: number;
commits: string[];
precedingPRs: number;
overlappingPaths: string[];
cleanApply: boolean | null;
fileDisjointAndClean: boolean;
reason: string | null;
}

const pathsCollide = (left: string, right: string) =>
left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);

export interface EarlyImportSelection {
requestedPRs: number[];
selected: QueueEntry[];
intervening: QueueEntry[];
}

/** Select whole pending PRs and every upstream integration before the last source. */
export function selectEarlySources(
entries: QueueEntry[],
through: string,
requestedPRs: readonly number[],
directCommits: readonly string[] = [],
): EarlyImportSelection {
const start = entries.findIndex((entry) => entry.sha === through);
if (start < 0) throw new Error("Reconciled boundary is outside the upstream target.");
const outstanding = entries.slice(start + 1);
const requested = [...new Set(requestedPRs)];
for (const pr of requested) {
if (!outstanding.some((entry) => entry.pr === pr && entry.disposition === "pending"))
throw new Error(`PR #${pr} is not pending after the reconciled boundary.`);
}
for (const sha of directCommits) {
if (
!outstanding.some(
(entry) => entry.sha === sha && entry.pr === null && entry.disposition === "pending",
)
)
throw new Error(`Direct commit ${sha} is not pending after the reconciled boundary.`);
}
const isSelected = (entry: QueueEntry) =>
entry.disposition === "pending" &&
((entry.pr !== null && requested.includes(entry.pr)) || directCommits.includes(entry.sha));
const last = outstanding.findLastIndex(isSelected);
const selected = outstanding.filter((entry) => isSelected(entry));
return {
requestedPRs: requested,
selected,
intervening: outstanding.slice(0, last + 1),
};
}

/** Add only sources that actually conflict in an upstream-order replay. */
export function resolveEarlyDependencies(
entries: QueueEntry[],
through: string,
requestedPRs: readonly number[],
firstConflict: (selection: EarlyImportSelection) => QueueEntry | null,
) {
const dependencies = new Set<number>();
const directCommits = new Set<string>();
for (let attempt = 0; attempt <= entries.length; attempt++) {
const selection = selectEarlySources(
entries,
through,
[...requestedPRs, ...dependencies],
[...directCommits],
);
const conflict = firstConflict(selection);
if (conflict === null) {
return {
requestedPRs: [...new Set(requestedPRs)],
dependencyPRs: [...dependencies],
dependencyCommits: [...directCommits],
selected: selection.selected,
replayedCommits: selection.intervening.length,
attempts: attempt + 1,
};
}
if (conflict.disposition !== "pending")
throw new Error(`Replay conflicts with already recorded source ${conflict.sha}.`);
if (conflict.pr === null) {
if (directCommits.has(conflict.sha)) throw new Error(`Repeated conflict at ${conflict.sha}.`);
directCommits.add(conflict.sha);
} else {
if (requestedPRs.includes(conflict.pr) || dependencies.has(conflict.pr))
throw new Error(`Repeated conflict at PR #${conflict.pr}.`);
dependencies.add(conflict.pr);
}
}
throw new Error("Could not resolve early intake dependencies.");
}

/** A conservative file-level screen for taking a PR ahead of pending intake. */
export function assessEarlyCandidates(
entries: QueueEntry[],
through: string,
count: number,
selectedPR: number | null,
changedPaths: (entry: QueueEntry) => readonly string[],
appliesCleanly: (entry: QueueEntry) => boolean,
): EarlyCandidate[] {
const start = entries.findIndex((entry) => entry.sha === through);
if (start < 0) throw new Error("Reconciled boundary is outside the upstream target.");
const outstanding = entries.slice(start + 1).filter((entry) => entry.disposition === "pending");
const byPR = new Map<number, QueueEntry[]>();
for (const entry of outstanding) {
if (entry.pr === null) continue;
byPR.set(entry.pr, [...(byPR.get(entry.pr) ?? []), entry]);
}
if (selectedPR !== null && !byPR.has(selectedPR))
throw new Error(`PR #${selectedPR} is not pending after the reconciled boundary.`);

const earlierPaths = new Set<string>();
const earlierPRs = new Set<number>();
const results: EarlyCandidate[] = [];
const seen = new Set<number>();
for (const entry of outstanding) {
if (entry.pr !== null && !seen.has(entry.pr)) {
seen.add(entry.pr);
if (selectedPR === null || selectedPR === entry.pr) {
const sources = byPR.get(entry.pr)!;
const paths = new Set(sources.flatMap((source) => [...changedPaths(source)]));
const previousPaths = [...earlierPaths];
const overlappingPaths = [...paths]
.filter((path) => previousPaths.some((earlier) => pathsCollide(path, earlier)))
.sort();
const reason =
sources.length !== 1
? "Multi-commit PR: clean application requires sequential simulation."
: sources[0]!.empty
? "Empty first-parent diff: inspect source history."
: null;
const cleanApply = reason === null ? appliesCleanly(sources[0]!) : null;
results.push({
pr: entry.pr,
commits: sources.map((source) => source.sha),
precedingPRs: earlierPRs.size,
overlappingPaths,
cleanApply,
fileDisjointAndClean: cleanApply === true && overlappingPaths.length === 0,
reason,
});
}
if (selectedPR === null && results.length === count) break;
if (selectedPR !== null && selectedPR === entry.pr) break;
}
for (const path of changedPaths(entry)) earlierPaths.add(path);
if (entry.pr !== null) earlierPRs.add(entry.pr);
}
return results;
}
21 changes: 21 additions & 0 deletions scripts/upstream-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
readIntegrations,
reconcile,
reconciledThrough,
reusableAssociations,
shortenCommitSha,
} from "./upstream-queue.ts";
import type { Integration } from "./upstream-queue.ts";
Expand All @@ -27,6 +28,26 @@ const integration = (n: number, pr: number | null = null): Integration => ({
});

describe("chronological upstream queue", () => {
it("reuses merged PR metadata across targets and rechecks unsettled associations", () => {
const pr = {
number: 10,
mergedAt: "2026-01-01",
baseRefName: "main",
baseRepository: { nameWithOwner: "example/upstream" },
mergeCommit: { oid: sha(2) },
};
const cache = {
[sha(1)]: [pr],
[sha(2)]: [],
[sha(3)]: [{ ...pr, mergedAt: null }],
[sha(4)]: [{ ...pr, baseRepository: { nameWithOwner: "other/repo" } }],
};
assert.deepEqual(reusableAssociations(cache, "example/upstream", new Set([sha(2)])), {
[sha(1)]: [pr],
});
assert.deepEqual(reusableAssociations(cache, "example/upstream", new Set()), {});
});

it("asks Git for an unambiguous abbreviated commit SHA", () => {
const commit = sha(42);
const calls: [string, string[]][] = [];
Expand Down
Loading
Loading