@@ -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 ( / m e r g e c o m m i t [ 0 - 9 a - f ] { 7 , 40 } d e t e c t e d / ) ) ;
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