ci: Resolve replay stalls toward 3.x in the master sync (no-changelog) (#35951)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matsu
2026-08-10 15:59:28 +03:00
committed by GitHub
co-authored by Claude Fable 5
parent aa03666bc6
commit f0c507d638
4 changed files with 312 additions and 12 deletions
+96 -7
View File
@@ -21,7 +21,9 @@
* 2. The replay stalls, but 3.x's content still reconciles with master → an earlier
* conflict was resolved in a merge, which leaves no patch to replay. Replay again
* favouring the 3.x side (`-X theirs`), mirroring the side the resolved merge kept;
* the human's fix commit is in the queue and does the real work. Tree is then proven.
* the human's fix commit is in the queue and does the real work. Stalls the strategy
* option cannot settle (modify/delete — `-X` never resolves those) are resolved in
* place toward the queue commit's side. Tree is then proven.
* 3. master conflicts with 3.x, but ONLY on mechanical files — tool-generated content
* with a deterministic resolution (the pnpm lockfile, bot-maintained data files).
* These are resolved in place while the replay is stopped, exactly as a human
@@ -195,11 +197,35 @@ export function resolveMechanicalPath({ git, pnpm, path, masterSha, log = consol
}
}
/**
* Resolve one stalled path by taking the side of the queue commit being replayed
* ("theirs" while a rebase is stopped). Exists for the stalls `-X theirs` cannot settle
* itself — modify/delete, which strategy options never resolve: when the queue commit
* deleted the file there is no stage 3, so the deletion wins. Only meaningful on the
* favoured replay passes, where the callers' tree guard proves the final content — a
* wrong pick here fails the run instead of pushing.
*/
export function resolveQueueSidePath({ git, path, log = console.log }) {
// `ls-files -u` lines are `<mode> <oid> <stage>\t<path>`; stage 3 is the queue side.
const stages = git(['ls-files', '-u', '--', path]);
const queueSideExists = stages.split('\n').some((line) => /^\S+ \S+ 3\t/.test(line));
if (queueSideExists) {
log(`Resolving ${path} with the replayed commit's version...`);
git(['checkout', '--theirs', '--', path]);
git(['add', '--', path]);
} else {
log(`Resolving ${path} with the replayed commit's deletion...`);
git(['rm', '--force', '--', path]);
}
}
/**
* Replay the 3.x-only commits onto master, resolving stalls that involve ONLY mechanical
* paths in place — folded into the stalled commit exactly as a human's `rebase --continue`
* would, so no commit is added. Bails (leaving the rebase stopped, for the caller to
* abort) as soon as a stall touches a real code path.
* abort) as soon as a stall touches a real code path — unless `favourQueue` is set, in
* which case code-path stalls are resolved with the queue commit's side too. That flag
* belongs only on the favoured (`-X theirs`) passes, whose tree guard proves the outcome.
*
* Merge commits in the range (breaking PR merges, past conflict-PR merges) are flattened;
* commits already applied to master — or fully absorbed by a mechanical resolution — are
@@ -211,6 +237,7 @@ export function rebaseResolvingMechanical({
masterSha,
log = console.log,
extraArgs = [],
favourQueue = false,
maxStalls = 50,
}) {
const resolved = new Set();
@@ -223,7 +250,7 @@ export function rebaseResolvingMechanical({
}
const { mechanical, code } = classifyPaths(conflictedFiles(git));
// No conflicted files means the rebase failed for some other reason — don't guess.
if (mechanical.length === 0 || code.length > 0) {
if (mechanical.length + code.length === 0 || (code.length > 0 && !favourQueue)) {
if (res.out) log(res.out);
return { ok: false, resolved: [...resolved], conflictedCode: code };
}
@@ -231,6 +258,9 @@ export function rebaseResolvingMechanical({
resolveMechanicalPath({ git, pnpm, path, masterSha, log });
resolved.add(path);
}
for (const path of code) {
resolveQueueSidePath({ git, path, log });
}
// The resolution may have absorbed the whole commit — skip it rather than
// letting `--continue` refuse an empty commit.
const empty = attempt(git, ['diff-index', '--cached', '--quiet', 'HEAD']).ok;
@@ -273,6 +303,42 @@ export function assertNoMarkers(git, rev = 'HEAD') {
throw new Error(`Could not scan ${rev} for conflict markers:\n${found.out}`);
}
/**
* Fold any residual deviation between the replayed tip and the proven merge tree into the
* tip commit. `-X theirs` resolves overlapping hunks toward the queue side without
* stalling — silently dropping master-side changes (lockfile hunks are the usual case)
* that no fix commit in the queue reasserts. The merge tree is the definition of the
* correct content, so its blobs are taken verbatim. `excludePaths` names files the merge
* tree itself could not resolve (marker-carrying mechanical blobs), which get their own
* reconciliation. Never a commit of its own, and never a rewrite of a master commit.
*/
export function reconcileWithMergeTreeAtTip({
git,
mergedTree,
masterSha,
excludePaths = [],
log = console.log,
}) {
if (git(['rev-parse', 'HEAD^{tree}']) === mergedTree) return [];
const out = git(['diff-tree', '-r', '--name-only', '--no-renames', mergedTree, 'HEAD']);
const excluded = new Set(excludePaths);
const paths = (out ? out.split('\n') : []).filter((p) => p && !excluded.has(p));
if (paths.length === 0) return [];
if (git(['rev-parse', 'HEAD']) === masterSha) {
throw new Error('Merge-tree reconciliation would amend a master commit; refusing.');
}
log(`Folding the merge tree's content for ${paths.join(', ')} into the tip commit.`);
for (const path of paths) {
if (attempt(git, ['cat-file', '-e', `${mergedTree}:${path}`]).ok) {
git(['checkout', mergedTree, '--', path]);
} else {
git(['rm', '--force', '--', path]); // the merge deleted it
}
}
git(['commit', '--amend', '--no-edit', '--no-verify']);
return paths;
}
/**
* Make the lockfile at the replayed tip consistent with the tip's own manifests. A regen
* at a stall usually already did this, but the `-X theirs` route resolves lockfile hunks
@@ -549,9 +615,19 @@ export async function sync({
masterSha,
log,
extraArgs: ['-X', 'theirs'],
favourQueue: true,
});
}
if (replay.ok) {
// The favoured retry may have drifted from the merge tree on cleanly-merging
// paths; fold those back before the mechanical files get their own treatment.
reconcileWithMergeTreeAtTip({
git,
mergedTree: merged.tree,
masterSha,
excludePaths: mechanical,
log,
});
if (mechanical.includes(LOCKFILE)) reconcileLockfileAtTip({ git, pnpm, masterSha, log });
assertTreeMatches(git, merged.tree, mechanical);
assertNoMarkers(git);
@@ -607,18 +683,31 @@ export async function sync({
// The content reconciles but the patches no longer apply on their own: an earlier
// conflict was resolved in a merge, and a merge resolution leaves no patch to replay.
// Replay favouring the 3.x side — the same side that resolved merge kept — and let the
// resolver's own fix commit, which is in the queue, do the real work. Mechanical stalls
// the strategy cannot settle (e.g. modify/delete) are resolved in place. Nothing is
// squashed; the tree guard below proves the outcome exactly, since the merge was clean.
// resolver's own fix commit, which is in the queue, do the real work. Stalls the
// strategy option cannot settle (modify/delete — `-X` never resolves those) are
// resolved in place toward the queue commit's side. Nothing is squashed; the tree
// guard below proves the outcome exactly, since the merge was clean.
git(['rebase', '--abort']);
log(`Patches no longer apply individually; replaying with ${target}'s side favoured.`);
if (!rebaseResolvingMechanical({ git, pnpm, masterSha, log, extraArgs: ['-X', 'theirs'] }).ok) {
const favoured = rebaseResolvingMechanical({
git,
pnpm,
masterSha,
log,
extraArgs: ['-X', 'theirs'],
favourQueue: true,
});
if (!favoured.ok) {
git(['rebase', '--abort']);
throw new Error(
`Could not replay ${target} onto master even with its own side favoured; needs a human.`,
);
}
// Favouring resolves overlapping hunks toward 3.x without stalling, which can drop
// master-side changes no fix commit reasserts; the merge was clean, so the merge tree
// is the correct content — fold any drift back into the tip.
reconcileWithMergeTreeAtTip({ git, mergedTree: merged.tree, masterSha, log });
assertTreeMatches(git, merged.tree);
assertNoMarkers(git);
pushReplay(preHead);
+210 -3
View File
@@ -7,7 +7,9 @@ import {
classifyPaths,
blocksLockfileRegen,
resolveMechanicalPath,
resolveQueueSidePath,
rebaseResolvingMechanical,
reconcileWithMergeTreeAtTip,
reconcileLockfileAtTip,
recentAbandonedConflictPrs,
assertTreeMatches,
@@ -223,6 +225,53 @@ test('rebaseResolvingMechanical bails as soon as a stall touches a code path', (
);
});
test('resolveQueueSidePath takes the queue commit side, or its deletion when there is no stage 3', () => {
const FILE = 'packages/cli/x.ts';
const bothSides = makeStub([
[(a) => a[0] === 'ls-files', `100644 aaa 2\t${FILE}\n100644 bbb 3\t${FILE}`],
]);
resolveQueueSidePath({ git: bothSides, path: FILE, log: () => {} });
assert.ok(bothSides.calls.some((a) => a[0] === 'checkout' && a[1] === '--theirs'));
assert.ok(bothSides.calls.some((a) => a[0] === 'add' && a.includes(FILE)));
// modify/delete with the queue commit deleting: stages 1 and 2 only.
const queueDeleted = makeStub([
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
]);
resolveQueueSidePath({ git: queueDeleted, path: FILE, log: () => {} });
assert.ok(queueDeleted.calls.some((a) => a[0] === 'rm' && a.includes(FILE)));
assert.equal(
queueDeleted.calls.some((a) => a[0] === 'checkout'),
false,
);
});
test('rebaseResolvingMechanical with favourQueue resolves a modify/delete code stall and continues', () => {
const FILE = 'packages/nodes-base/nodes/Function/Function.node.ts';
const git = makeStub([
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (modify/delete): ${FILE} deleted in 0ff923a066`)],
[isConflictedFiles, FILE],
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
[(a) => a[0] === 'diff-index', fail()], // staged deletion -> continue, not skip
]);
const pnpm = makeStub();
const res = rebaseResolvingMechanical({
git,
pnpm,
masterSha: MASTER,
favourQueue: true,
log: () => {},
});
assert.equal(res.ok, true);
assert.ok(git.calls.some((a) => a[0] === 'rm' && a.includes(FILE)));
assert.ok(git.calls.some((a) => a[0] === 'rebase' && a[1] === '--continue'));
// The stall carried no mechanical file, so nothing mechanical may be touched.
assert.equal(pnpm.calls.length, 0);
});
test('assertTreeMatches is exact by default and scoped to the allowed paths otherwise', () => {
assert.doesNotThrow(() => assertTreeMatches(makeStub([[() => true, MERGE_TREE]]), MERGE_TREE));
assert.throws(
@@ -256,6 +305,97 @@ test('assertNoMarkers is a push guard', () => {
);
});
test('reconcileWithMergeTreeAtTip folds favoured-replay drift into the tip commit, never a master commit', () => {
// No drift: nothing to do.
const clean = makeStub([[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', MERGE_TREE]]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: clean,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
[],
);
assert.equal(
clean.calls.some((a) => a[0] === 'commit'),
false,
);
// Lockfile drift: take the merge tree's blob and amend the tip.
const drifted = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
[(a) => a[0] === 'diff-tree', LOCKFILE],
[(a) => a[0] === 'cat-file', ''],
]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: drifted,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
[LOCKFILE],
);
assert.ok(
drifted.calls.some((a) => a[0] === 'checkout' && a[1] === MERGE_TREE && a.includes(LOCKFILE)),
);
assert.ok(drifted.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
// Excluded (mechanical) paths are left to their own reconciliation.
const excluded = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'diff-tree', LOCKFILE],
]);
assert.deepEqual(
reconcileWithMergeTreeAtTip({
git: excluded,
mergedTree: MERGE_TREE,
masterSha: MASTER,
excludePaths: [LOCKFILE],
log: () => {},
}),
[],
);
assert.equal(
excluded.calls.some((a) => a[0] === 'commit'),
false,
);
// A path the merge deleted is removed, not checked out.
const deleted = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', PRE_HEAD],
[(a) => a[0] === 'diff-tree', 'packages/cli/gone.ts'],
[(a) => a[0] === 'cat-file', fail()],
]);
reconcileWithMergeTreeAtTip({
git: deleted,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
});
assert.ok(deleted.calls.some((a) => a[0] === 'rm' && a.includes('packages/cli/gone.ts')));
// Never rewrite a master commit.
const atMasterTip = makeStub([
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}', 'OTHER'],
[(a) => a[0] === 'rev-parse' && a[1] === 'HEAD', MASTER],
[(a) => a[0] === 'diff-tree', LOCKFILE],
]);
assert.throws(
() =>
reconcileWithMergeTreeAtTip({
git: atMasterTip,
mergedTree: MERGE_TREE,
masterSha: MASTER,
log: () => {},
}),
/amend a master commit/,
);
});
test('reconcileLockfileAtTip folds an inconsistent lockfile into the tip commit, never a master commit', () => {
const consistent = makeStub([[(a) => a[0] === 'diff', '']]);
reconcileLockfileAtTip({ git: consistent, pnpm: makeStub(), masterSha: MASTER, log: () => {} });
@@ -441,12 +581,79 @@ test('sync replays favouring 3.x when the patches no longer apply on their own',
);
});
test('sync recovers when the favoured replay stalls on a modify/delete conflict', async () => {
// The endpoints reconcile (merge-tree is clean) because a fix commit in the queue
// re-deletes the file — but replaying the deleting commit itself stalls on
// modify/delete, which `-X theirs` never settles on its own.
const FILE = 'packages/nodes-base/nodes/Function/Function.node.ts';
const git = makeStub([
...baseGitRoutes,
[(a) => a[0] === 'rebase' && a[1] === '--continue', ''],
[isRebase, fail(`CONFLICT (modify/delete): ${FILE} deleted in 0ff923a066`)],
[isConflictedFiles, FILE],
[(a) => a[0] === 'ls-files', `100644 aaa 1\t${FILE}\n100644 bbb 2\t${FILE}`],
[(a) => a[0] === 'diff-index', fail()],
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.ok(
git.calls.some((a) => a[0] === 'rm' && a.includes(FILE)),
'expected the queue-side deletion to be taken',
);
assert.ok(
git.calls.some((a) => a[0] === 'push'),
'expected the replay to be pushed',
);
// Still no new commit and no PR — the fix commit in the queue does the real work.
assert.equal(
git.calls.some((a) => a[0] === 'commit'),
false,
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync folds favoured-replay drift back to the merge tree before pushing', async () => {
// The favoured replay finishes but its tip drifts from the merge tree (the lockfile
// hunks `-X theirs` resolved toward 3.x): the drift is folded back, then pushed.
let treeReads = 0;
const git = makeStub([
...baseGitRoutes.filter((r) => !r[0](['rev-parse', 'HEAD^{tree}'])),
[
(a) => a[0] === 'rev-parse' && a[1] === 'HEAD^{tree}',
() => (treeReads++ === 0 ? 'DRIFTED' : MERGE_TREE),
],
[(a) => a[0] === 'diff-tree', LOCKFILE],
[(a) => a[0] === 'cat-file', ''],
[favouringOwnSide, ''],
[isRebase, fail('CONFLICT (content): packages/cli/x.ts')],
]);
const gh = makeStub(noOpenPr);
await sync({ git, gh, pnpm: makeStub(), env, log: () => {} });
assert.ok(git.calls.some((a) => a[0] === 'checkout' && a[1] === MERGE_TREE));
assert.ok(git.calls.some((a) => a[0] === 'commit' && a.includes('--amend')));
assert.ok(
git.calls.some((a) => a[0] === 'push'),
'expected the reconciled replay to be pushed',
);
assert.equal(
gh.calls.some((a) => a[0] === 'pr' && a[1] === 'create'),
false,
);
});
test('sync fails without pushing when even the favoured replay cannot finish', async () => {
const git = makeStub([
...baseGitRoutes,
[isRebase, fail('CONFLICT')],
// The favoured replay stalls on a real code file, so the driver must not resolve it.
[isConflictedFiles, 'packages/cli/x.ts'],
[isRebase, fail('rebase failed for a non-conflict reason')],
// No conflicted files: nothing the driver may resolve — don't guess.
[isConflictedFiles, ''],
]);
const gh = makeStub(noOpenPr);