Skip to content

Commit c25673e

Browse files
authored
fix: --topo-order and merge commit fallback in push_signed_commits.cjs (#26306)
1 parent d37c7c6 commit c25673e

File tree

3 files changed

+173
-3
lines changed

3 files changed

+173
-3
lines changed

.changeset/patch-fix-topo-order-merge-fallback.md

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/push_signed_commits.cjs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,13 @@ async function readBlobAsBase64(sha, filePath, cwd) {
132132
* @returns {Promise<void>}
133133
*/
134134
async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, cwd, gitAuthEnv }) {
135-
// Collect the commits introduced (oldest-first)
136-
const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--reverse", `${baseRef}..HEAD`], { cwd });
137-
const shas = revListOut.trim().split("\n").filter(Boolean);
135+
// Collect the commits introduced (oldest-first) using topological order to ensure
136+
// correct sequencing even when commit dates are out of sync (e.g. after rebase --committer-date-is-author-date).
137+
// Using --parents emits each line as "<sha> <parent1> [<parent2> ...]", which lets us detect merge commits
138+
// (more than one parent) in a single subprocess call without iterating each SHA individually.
139+
const { stdout: revListOut } = await exec.getExecOutput("git", ["rev-list", "--parents", "--topo-order", "--reverse", `${baseRef}..HEAD`], { cwd });
140+
const revListLines = revListOut.trim().split("\n").filter(Boolean);
141+
const shas = revListLines.map(line => line.split(" ")[0]);
138142

139143
if (shas.length === 0) {
140144
core.info("pushSignedCommits: no new commits to push via GraphQL");
@@ -144,6 +148,19 @@ async function pushSignedCommits({ githubClient, owner, repo, branch, baseRef, c
144148
core.info(`pushSignedCommits: replaying ${shas.length} commit(s) via GraphQL createCommitOnBranch (branch: ${branch}, repo: ${owner}/${repo})`);
145149

146150
try {
151+
// Pre-flight check: detect merge commits. Each --parents output line is "<sha> <parent1> [<parent2> ...]".
152+
// A line with 3+ space-separated fields means the commit has 2+ parents (i.e. a merge commit).
153+
// The GitHub GraphQL createCommitOnBranch mutation does not support multiple parents, so fall back
154+
// to git push for the entire series if any merge commit is found.
155+
for (const line of revListLines) {
156+
const fields = line.split(" ");
157+
if (fields.length > 2) {
158+
const sha = fields[0];
159+
core.warning(`pushSignedCommits: merge commit ${sha} detected, falling back to git push`);
160+
throw new Error("merge commit detected");
161+
}
162+
}
163+
147164
// Pre-scan ALL commits: collect file changes and check for unsupported file modes
148165
// BEFORE starting any GraphQL mutations. If a symlink is found mid-loop after some
149166
// commits have already been signed, the remote branch diverges and the git push

actions/setup/js/push_signed_commits.test.cjs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,4 +968,152 @@ describe("push_signed_commits integration tests", () => {
968968
expect(Buffer.from(callArg.fileChanges.additions[0].contents, "base64").toString()).toBe("copy source\n");
969969
});
970970
});
971+
972+
// ──────────────────────────────────────────────────────
973+
// Topological ordering (--topo-order)
974+
// ──────────────────────────────────────────────────────
975+
976+
describe("topological commit ordering", () => {
977+
it("should replay commits in DAG order even when commit dates are out of sync", async () => {
978+
execGit(["checkout", "-b", "topo-order-branch"], { cwd: workDir });
979+
980+
// Create two commits where the second commit has an earlier author/committer date
981+
// than the first, simulating the situation after `git rebase --committer-date-is-author-date`
982+
// or manual date manipulation. Without --topo-order git would return them in wrong order.
983+
const laterDate = "2020-01-02T00:00:00+00:00";
984+
const earlierDate = "2020-01-01T00:00:00+00:00";
985+
986+
fs.writeFileSync(path.join(workDir, "first.txt"), "first\n");
987+
execGit(["add", "first.txt"], { cwd: workDir });
988+
// Commit A has a later date (chronologically second)
989+
spawnSync("git", ["commit", "-m", "First commit (later date)"], {
990+
cwd: workDir,
991+
encoding: "utf8",
992+
env: {
993+
...process.env,
994+
GIT_CONFIG_NOSYSTEM: "1",
995+
HOME: os.tmpdir(),
996+
GIT_AUTHOR_DATE: laterDate,
997+
GIT_COMMITTER_DATE: laterDate,
998+
},
999+
});
1000+
1001+
fs.writeFileSync(path.join(workDir, "second.txt"), "second\n");
1002+
execGit(["add", "second.txt"], { cwd: workDir });
1003+
// Commit B has an earlier date (chronologically first) – without --topo-order
1004+
// a date-based sort would put this before commit A, which would be wrong.
1005+
spawnSync("git", ["commit", "-m", "Second commit (earlier date)"], {
1006+
cwd: workDir,
1007+
encoding: "utf8",
1008+
env: {
1009+
...process.env,
1010+
GIT_CONFIG_NOSYSTEM: "1",
1011+
HOME: os.tmpdir(),
1012+
GIT_AUTHOR_DATE: earlierDate,
1013+
GIT_COMMITTER_DATE: earlierDate,
1014+
},
1015+
});
1016+
1017+
execGit(["push", "-u", "origin", "topo-order-branch"], { cwd: workDir });
1018+
1019+
global.exec = makeRealExec(workDir);
1020+
const githubClient = makeMockGithubClient();
1021+
1022+
await pushSignedCommits({
1023+
githubClient,
1024+
owner: "test-owner",
1025+
repo: "test-repo",
1026+
branch: "topo-order-branch",
1027+
baseRef: "origin/main",
1028+
cwd: workDir,
1029+
});
1030+
1031+
// Both commits must be replayed via GraphQL in DAG order: first → second
1032+
expect(githubClient.graphql).toHaveBeenCalledTimes(2);
1033+
const headlines = githubClient.graphql.mock.calls.map(c => c[1].input.message.headline);
1034+
expect(headlines).toEqual(["First commit (later date)", "Second commit (earlier date)"]);
1035+
});
1036+
});
1037+
1038+
// ──────────────────────────────────────────────────────
1039+
// Merge commit fallback
1040+
// ──────────────────────────────────────────────────────
1041+
1042+
describe("merge commit fallback", () => {
1043+
it("should fall back to git push and warn when the commit range contains a merge commit", async () => {
1044+
// Set up: main already has an initial commit. Create a side branch with an extra commit,
1045+
// then merge it back into a feature branch to produce a merge commit in the range.
1046+
execGit(["checkout", "-b", "side-branch"], { cwd: workDir });
1047+
fs.writeFileSync(path.join(workDir, "side.txt"), "side branch content\n");
1048+
execGit(["add", "side.txt"], { cwd: workDir });
1049+
execGit(["commit", "-m", "Side branch commit"], { cwd: workDir });
1050+
1051+
// Back to main, create feature branch, and merge side-branch into it
1052+
execGit(["checkout", "main"], { cwd: workDir });
1053+
execGit(["checkout", "-b", "merge-test-branch"], { cwd: workDir });
1054+
fs.writeFileSync(path.join(workDir, "feature.txt"), "feature content\n");
1055+
execGit(["add", "feature.txt"], { cwd: workDir });
1056+
execGit(["commit", "-m", "Feature commit"], { cwd: workDir });
1057+
1058+
// Merge side-branch – this creates a merge commit with two parents
1059+
execGit(["merge", "--no-ff", "side-branch", "-m", "Merge side-branch into merge-test-branch"], { cwd: workDir });
1060+
1061+
// Push so ls-remote can resolve the OID
1062+
execGit(["push", "-u", "origin", "merge-test-branch"], { cwd: workDir });
1063+
1064+
global.exec = makeRealExec(workDir);
1065+
const githubClient = makeMockGithubClient();
1066+
1067+
await pushSignedCommits({
1068+
githubClient,
1069+
owner: "test-owner",
1070+
repo: "test-repo",
1071+
branch: "merge-test-branch",
1072+
baseRef: "origin/main",
1073+
cwd: workDir,
1074+
});
1075+
1076+
// GraphQL must NOT have been called – merge commit triggers git push fallback
1077+
expect(githubClient.graphql).not.toHaveBeenCalled();
1078+
1079+
// Warning about the merge commit must be emitted
1080+
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringMatching(/merge commit [0-9a-f]{7,40} detected/));
1081+
expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("falling back to git push"));
1082+
1083+
// All commits (including the merge commit) must be present on the remote via git push
1084+
const lsRemote = execGit(["ls-remote", bareDir, "refs/heads/merge-test-branch"], { cwd: workDir });
1085+
const remoteOid = lsRemote.stdout.trim().split(/\s+/)[0];
1086+
const localOid = execGit(["rev-parse", "HEAD"], { cwd: workDir }).stdout.trim();
1087+
expect(remoteOid).toBe(localOid);
1088+
});
1089+
1090+
it("should not trigger merge-commit fallback for a commit message that starts with 'parent '", async () => {
1091+
// Regression test: a commit whose message body starts with "parent " must not be misidentified
1092+
// as a merge commit. The old cat-file approach would have counted this as an extra parent.
1093+
execGit(["checkout", "-b", "tricky-message-branch"], { cwd: workDir });
1094+
fs.writeFileSync(path.join(workDir, "tricky.txt"), "tricky content\n");
1095+
execGit(["add", "tricky.txt"], { cwd: workDir });
1096+
// Write the multi-line commit message to a file to avoid shell interpretation issues
1097+
const msgFile = path.join(workDir, ".git", "TRICKY_MSG");
1098+
fs.writeFileSync(msgFile, "Normal headline\n\nparent this line starts with parent but is not a git parent header\n");
1099+
execGit(["commit", "-F", msgFile], { cwd: workDir });
1100+
execGit(["push", "-u", "origin", "tricky-message-branch"], { cwd: workDir });
1101+
1102+
global.exec = makeRealExec(workDir);
1103+
const githubClient = makeMockGithubClient();
1104+
1105+
await pushSignedCommits({
1106+
githubClient,
1107+
owner: "test-owner",
1108+
repo: "test-repo",
1109+
branch: "tricky-message-branch",
1110+
baseRef: "origin/main",
1111+
cwd: workDir,
1112+
});
1113+
1114+
// Must proceed via GraphQL – not incorrectly fallen back to git push
1115+
expect(githubClient.graphql).toHaveBeenCalledTimes(1);
1116+
expect(mockCore.warning).not.toHaveBeenCalledWith(expect.stringContaining("merge commit"));
1117+
});
1118+
});
9711119
});

0 commit comments

Comments
 (0)