mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 09:51:59 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user