@@ -11,8 +11,8 @@ import {
1111 extractBranchNameFromMergeMessage ,
1212 getCommitContext ,
1313 getCommitContextsBetweenShas ,
14+ getCommitParents ,
1415 getRepoInfo ,
15- isMergeCommit ,
1616 normalizePathspec ,
1717 parseRepoUrl ,
1818} from "./git" ;
@@ -422,6 +422,25 @@ type TempRepoWithMerge = {
422422 } ;
423423} ;
424424
425+ type TempRepoWithMultipleMerges = {
426+ cwd : string ;
427+ commits : {
428+ base : string ;
429+ merge100 : string ; // Merge of feature/LIN-100 (touches frontend/)
430+ merge200 : string ; // Merge of feature/LIN-200 (touches backend/)
431+ merge300 : string ; // Merge of feature/LIN-300 (touches infra/ — outside includePaths)
432+ headMerge : string ; // Merge of release branch into main
433+ } ;
434+ } ;
435+
436+ type TempRepoReleaseBranch = {
437+ cwd : string ;
438+ commits : {
439+ base : string ;
440+ headMerge : string ; // The rel-branch → main merge (HEAD)
441+ } ;
442+ } ;
443+
425444function runGit ( command : string , cwd : string ) : string {
426445 return execSync ( `git ${ command } ` , {
427446 cwd,
@@ -430,6 +449,31 @@ function runGit(command: string, cwd: string): string {
430449 } ) . trim ( ) ;
431450}
432451
452+ /**
453+ * Cuts a feature branch off `baseBranch`, lands one file change, merges it
454+ * back via `--no-ff` with a GitHub-style PR-merge message, then deletes the
455+ * branch (mirroring CI checkout state where merged feature branches are
456+ * gone). Returns the merge commit SHA.
457+ */
458+ function mergeFeatureBranch ( opts : {
459+ cwd : string ;
460+ baseBranch : string ;
461+ branch : string ;
462+ file : string ;
463+ prNumber : number ;
464+ } ) : string {
465+ const { cwd, baseBranch, branch, file, prNumber } = opts ;
466+ runGit ( `checkout -b ${ branch } ${ baseBranch } ` , cwd ) ;
467+ writeFileSync ( join ( cwd , file ) , "x" ) ;
468+ runGit ( "add ." , cwd ) ;
469+ runGit ( `commit -m "feature work on ${ branch } "` , cwd ) ;
470+ runGit ( `checkout ${ baseBranch } ` , cwd ) ;
471+ runGit ( `merge --no-ff ${ branch } -m "Merge pull request #${ prNumber } from owner/${ branch } "` , cwd ) ;
472+ const sha = runGit ( "rev-parse HEAD" , cwd ) ;
473+ runGit ( `branch -D ${ branch } ` , cwd ) ;
474+ return sha ;
475+ }
476+
433477/**
434478 * Build a deterministic git repo for integration tests.
435479 *
@@ -521,6 +565,113 @@ function createTempRepoWithMerge(): TempRepoWithMerge {
521565 return { cwd, commits : { base, featureBranch, mergeCommit } } ;
522566}
523567
568+ /**
569+ * Each feature branch is merged directly into main, then a release branch is
570+ * cut, gets one commit, and merges back. merge300 touches infra/ — outside
571+ * the includePaths used in tests, so it must not leak.
572+ */
573+ function createTempRepoWithMultipleMerges ( ) : TempRepoWithMultipleMerges {
574+ const cwd = mkdtempSync ( join ( tmpdir ( ) , "linear-release-multi-merge-" ) ) ;
575+ runGit ( "init" , cwd ) ;
576+ runGit ( 'config user.email "test@example.com"' , cwd ) ;
577+ runGit ( 'config user.name "Test User"' , cwd ) ;
578+
579+ mkdirSync ( join ( cwd , "frontend" ) , { recursive : true } ) ;
580+ mkdirSync ( join ( cwd , "backend" ) , { recursive : true } ) ;
581+ mkdirSync ( join ( cwd , "infra" ) , { recursive : true } ) ;
582+ writeFileSync ( join ( cwd , "frontend" , "seed.txt" ) , "seed" ) ;
583+ runGit ( "add ." , cwd ) ;
584+ runGit ( 'commit -m "Initial"' , cwd ) ;
585+ runGit ( "branch -M main" , cwd ) ;
586+ const base = runGit ( "rev-parse HEAD" , cwd ) ;
587+
588+ const merge100 = mergeFeatureBranch ( {
589+ cwd,
590+ baseBranch : "main" ,
591+ branch : "feature/LIN-100-add-foo" ,
592+ file : "frontend/foo.txt" ,
593+ prNumber : 100 ,
594+ } ) ;
595+ const merge200 = mergeFeatureBranch ( {
596+ cwd,
597+ baseBranch : "main" ,
598+ branch : "feature/LIN-200-fix-bar" ,
599+ file : "backend/bar.txt" ,
600+ prNumber : 200 ,
601+ } ) ;
602+ const merge300 = mergeFeatureBranch ( {
603+ cwd,
604+ baseBranch : "main" ,
605+ branch : "feature/LIN-300-infra" ,
606+ file : "infra/three.txt" ,
607+ prNumber : 300 ,
608+ } ) ;
609+
610+ // rel branch needs at least one of its own commits, otherwise --no-ff is a
611+ // no-op when the branches are identical.
612+ runGit ( "checkout -b rel/2026-05-06 main" , cwd ) ;
613+ writeFileSync ( join ( cwd , "frontend" , "release-notes.txt" ) , "notes" ) ;
614+ runGit ( "add ." , cwd ) ;
615+ runGit ( 'commit -m "release notes"' , cwd ) ;
616+ runGit ( "checkout main" , cwd ) ;
617+ runGit ( 'merge --no-ff rel/2026-05-06 -m "Merge pull request #324 from owner/rel/2026-05-06"' , cwd ) ;
618+ const headMerge = runGit ( "rev-parse HEAD" , cwd ) ;
619+ runGit ( "branch -D rel/2026-05-06" , cwd ) ;
620+
621+ return { cwd, commits : { base, merge100, merge200, merge300, headMerge } } ;
622+ }
623+
624+ /**
625+ * Customer's release-branch workflow: features are merged INTO a release
626+ * branch (not main), then rel is merged into main as HEAD. LIN-300 is mobile-
627+ * only — outside the path filter used in tests.
628+ */
629+ function createTempRepoReleaseBranch ( ) : TempRepoReleaseBranch {
630+ const cwd = mkdtempSync ( join ( tmpdir ( ) , "linear-release-rel-branch-" ) ) ;
631+ runGit ( "init" , cwd ) ;
632+ runGit ( 'config user.email "test@example.com"' , cwd ) ;
633+ runGit ( 'config user.name "Test User"' , cwd ) ;
634+
635+ mkdirSync ( join ( cwd , "frontend-nuxt3" ) , { recursive : true } ) ;
636+ mkdirSync ( join ( cwd , "backend" ) , { recursive : true } ) ;
637+ mkdirSync ( join ( cwd , "mobile-android" ) , { recursive : true } ) ;
638+ writeFileSync ( join ( cwd , "frontend-nuxt3" , "seed.ts" ) , "seed" ) ;
639+ runGit ( "add ." , cwd ) ;
640+ runGit ( 'commit -m "Initial"' , cwd ) ;
641+ runGit ( "branch -M main" , cwd ) ;
642+ const base = runGit ( "rev-parse HEAD" , cwd ) ;
643+
644+ runGit ( "checkout -b rel/2026-05-06 main" , cwd ) ;
645+ mergeFeatureBranch ( {
646+ cwd,
647+ baseBranch : "rel/2026-05-06" ,
648+ branch : "feature/LIN-100-foo" ,
649+ file : "frontend-nuxt3/foo.ts" ,
650+ prNumber : 100 ,
651+ } ) ;
652+ mergeFeatureBranch ( {
653+ cwd,
654+ baseBranch : "rel/2026-05-06" ,
655+ branch : "feature/LIN-200-bar" ,
656+ file : "backend/bar.ts" ,
657+ prNumber : 200 ,
658+ } ) ;
659+ mergeFeatureBranch ( {
660+ cwd,
661+ baseBranch : "rel/2026-05-06" ,
662+ branch : "feature/LIN-300-mobile" ,
663+ file : "mobile-android/m.kt" ,
664+ prNumber : 300 ,
665+ } ) ;
666+
667+ runGit ( "checkout main" , cwd ) ;
668+ runGit ( 'merge --no-ff rel/2026-05-06 -m "Merge pull request #324 from owner/rel/2026-05-06"' , cwd ) ;
669+ const headMerge = runGit ( "rev-parse HEAD" , cwd ) ;
670+ runGit ( "branch -D rel/2026-05-06" , cwd ) ;
671+
672+ return { cwd, commits : { base, headMerge } } ;
673+ }
674+
524675describe ( "getCommitContextsBetweenShas" , ( ) => {
525676 let repo : TempRepo ;
526677
@@ -697,21 +848,6 @@ describe("merge commit handling", () => {
697848 rmSync ( mergeRepo . cwd , { recursive : true , force : true } ) ;
698849 } ) ;
699850
700- describe ( "isMergeCommit" , ( ) => {
701- it ( "should return true for a merge commit" , ( ) => {
702- expect ( isMergeCommit ( mergeRepo . commits . mergeCommit , mergeRepo . cwd ) ) . toBe ( true ) ;
703- } ) ;
704-
705- it ( "should return false for a regular commit" , ( ) => {
706- expect ( isMergeCommit ( mergeRepo . commits . featureBranch , mergeRepo . cwd ) ) . toBe ( false ) ;
707- expect ( isMergeCommit ( mergeRepo . commits . base , mergeRepo . cwd ) ) . toBe ( false ) ;
708- } ) ;
709-
710- it ( "should return false for invalid SHA" , ( ) => {
711- expect ( isMergeCommit ( "invalid-sha" , mergeRepo . cwd ) ) . toBe ( false ) ;
712- } ) ;
713- } ) ;
714-
715851 describe ( "getCommitContext" , ( ) => {
716852 it ( "should return commit context for a valid SHA" , ( ) => {
717853 const context = getCommitContext ( mergeRepo . commits . mergeCommit , mergeRepo . cwd ) ;
@@ -733,6 +869,25 @@ describe("merge commit handling", () => {
733869 } ) ;
734870 } ) ;
735871
872+ describe ( "getCommitParents" , ( ) => {
873+ it ( "returns 2 parents for a merge commit" , ( ) => {
874+ const parents = getCommitParents ( mergeRepo . commits . mergeCommit , mergeRepo . cwd ) ;
875+ expect ( parents ) . toEqual ( [ mergeRepo . commits . base , mergeRepo . commits . featureBranch ] ) ;
876+ } ) ;
877+
878+ it ( "returns 1 parent for a regular commit" , ( ) => {
879+ expect ( getCommitParents ( mergeRepo . commits . featureBranch , mergeRepo . cwd ) ) . toEqual ( [ mergeRepo . commits . base ] ) ;
880+ } ) ;
881+
882+ it ( "returns [] for the root commit" , ( ) => {
883+ expect ( getCommitParents ( mergeRepo . commits . base , mergeRepo . cwd ) ) . toEqual ( [ ] ) ;
884+ } ) ;
885+
886+ it ( "returns [] for an unknown SHA" , ( ) => {
887+ expect ( getCommitParents ( "0000000000000000000000000000000000000000" , mergeRepo . cwd ) ) . toEqual ( [ ] ) ;
888+ } ) ;
889+ } ) ;
890+
736891 describe ( "getCommitContextsBetweenShas with merge commits" , ( ) => {
737892 it ( "should include merge commit when path filtering would exclude it" , ( ) => {
738893 // Without the fix, path filtering for "src/**" would only return the feature branch commit
@@ -768,6 +923,100 @@ describe("merge commit handling", () => {
768923 expect ( mergeCommitCount ) . toBe ( 1 ) ;
769924 } ) ;
770925 } ) ;
926+
927+ describe ( "getCommitContextsBetweenShas with multiple merges in range" , ( ) => {
928+ let multiRepo : TempRepoWithMultipleMerges ;
929+
930+ beforeAll ( ( ) => {
931+ multiRepo = createTempRepoWithMultipleMerges ( ) ;
932+ } ) ;
933+
934+ afterAll ( ( ) => {
935+ rmSync ( multiRepo . cwd , { recursive : true , force : true } ) ;
936+ } ) ;
937+
938+ it ( "should return in-path merges and drop out-of-path merges across a multi-merge range (LIN-69346)" , ( ) => {
939+ // Without the fix, git's history simplification drops every merge commit
940+ // because they're TREESAME to a parent within the paths, losing the
941+ // feature/LIN-XXX branch names that are the only carrier of issue keys.
942+ const result = getCommitContextsBetweenShas ( multiRepo . commits . base , multiRepo . commits . headMerge , {
943+ includePaths : [ "frontend/**" , "backend/**" ] ,
944+ cwd : multiRepo . cwd ,
945+ } ) ;
946+
947+ const shas = new Set ( result . map ( ( c ) => c . sha ) ) ;
948+ expect ( shas . has ( multiRepo . commits . merge100 ) ) . toBe ( true ) ;
949+ expect ( shas . has ( multiRepo . commits . merge200 ) ) . toBe ( true ) ;
950+ // merge300 only touched infra/ — kept by the merges-only scan, then dropped
951+ // by commitTouchesPaths so LIN-300 doesn't leak into a frontend release.
952+ expect ( shas . has ( multiRepo . commits . merge300 ) ) . toBe ( false ) ;
953+ expect ( shas . has ( multiRepo . commits . headMerge ) ) . toBe ( true ) ;
954+
955+ const branchNames = result . map ( ( c ) => c . branchName ) . filter ( ( b ) : b is string => ! ! b ) ;
956+ expect ( branchNames ) . toEqual (
957+ expect . arrayContaining ( [ "feature/LIN-100-add-foo" , "feature/LIN-200-fix-bar" , "rel/2026-05-06" ] ) ,
958+ ) ;
959+ expect ( branchNames ) . not . toContain ( "feature/LIN-300-infra" ) ;
960+ } ) ;
961+
962+ it ( "should return HEAD merge commit when fromSha === toSha and HEAD is a merge" , ( ) => {
963+ const result = getCommitContextsBetweenShas ( multiRepo . commits . headMerge , multiRepo . commits . headMerge , {
964+ includePaths : [ "frontend/**" , "backend/**" ] ,
965+ cwd : multiRepo . cwd ,
966+ } ) ;
967+
968+ const headResult = result . find ( ( c ) => c . sha === multiRepo . commits . headMerge ) ;
969+ expect ( headResult ) . toBeDefined ( ) ;
970+ expect ( headResult ?. branchName ) . toBe ( "rel/2026-05-06" ) ;
971+ } ) ;
972+
973+ it ( "should not drift to an unrelated ancestor when fromSha === toSha and HEAD is outside includePaths" , ( ) => {
974+ // The legacy `git log -1 <sha> -- <paths>` walked back from <sha> when it
975+ // didn't match, returning an unrelated ancestor. We use --no-walk semantics
976+ // instead: match the exact commit or return nothing.
977+ const result = getCommitContextsBetweenShas ( multiRepo . commits . merge300 , multiRepo . commits . merge300 , {
978+ includePaths : [ "frontend/**" ] ,
979+ cwd : multiRepo . cwd ,
980+ } ) ;
981+
982+ expect ( result ) . toEqual ( [ ] ) ;
983+ } ) ;
984+ } ) ;
985+
986+ describe ( "getCommitContextsBetweenShas with release-branch workflow (LIN-69346)" , ( ) => {
987+ // Mirrors the customer's first-sync scenario exactly: features are merged
988+ // INTO a release branch (not main), then rel is merged into main as HEAD.
989+ // With no prior release SHA on the pipeline, the CLI uses HEAD^1 as the
990+ // implicit boundary — without that boundary, scanning HEAD alone would
991+ // miss every issue key.
992+ let relRepo : TempRepoReleaseBranch ;
993+
994+ beforeAll ( ( ) => {
995+ relRepo = createTempRepoReleaseBranch ( ) ;
996+ } ) ;
997+
998+ afterAll ( ( ) => {
999+ rmSync ( relRepo . cwd , { recursive : true , force : true } ) ;
1000+ } ) ;
1001+
1002+ it ( "should surface feature merges from inside the rel branch when scanning HEAD^1..HEAD" , ( ) => {
1003+ const parents = getCommitParents ( relRepo . commits . headMerge , relRepo . cwd ) ;
1004+ expect ( parents . length ) . toBeGreaterThanOrEqual ( 1 ) ;
1005+ const parent = parents [ 0 ] ! ;
1006+
1007+ const result = getCommitContextsBetweenShas ( parent , relRepo . commits . headMerge , {
1008+ includePaths : [ "frontend-nuxt3/**" , "backend/**" ] ,
1009+ cwd : relRepo . cwd ,
1010+ } ) ;
1011+
1012+ const branchNames = result . map ( ( c ) => c . branchName ) . filter ( ( b ) : b is string => ! ! b ) ;
1013+ expect ( branchNames ) . toEqual (
1014+ expect . arrayContaining ( [ "feature/LIN-100-foo" , "feature/LIN-200-bar" , "rel/2026-05-06" ] ) ,
1015+ ) ;
1016+ // LIN-300 is mobile-only — outside the path filter — must not leak.
1017+ expect ( branchNames ) . not . toContain ( "feature/LIN-300-mobile" ) ;
1018+ } ) ;
1019+ } ) ;
7711020} ) ;
7721021
7731022describe ( "assertGitAvailable" , ( ) => {
0 commit comments