@@ -493,6 +493,15 @@ type TempRepoReleaseBranch = {
493493 } ;
494494} ;
495495
496+ type TempRepoStaleMerge = {
497+ cwd : string ;
498+ commits : {
499+ base : string ;
500+ staleMerge : string ; // Merge of feat/ABC-1-stale — edited app-a/ only, merged after app-b/ landed
501+ subjectMerge : string ; // Merge of feat/XYZ-2-impl — edited app-b/, key only on the merge subject (HEAD)
502+ } ;
503+ } ;
504+
496505function runGit ( command : string , cwd : string ) : string {
497506 return execSync ( `git ${ command } ` , {
498507 cwd,
@@ -729,6 +738,52 @@ function createTempRepoReleaseBranch(): TempRepoReleaseBranch {
729738 return { cwd, commits : { base, headMerge } } ;
730739}
731740
741+ /**
742+ * Two PR merges into main, each carrying its issue key only in the branch name
743+ * (no content commit carries a key):
744+ * - feat/ABC-1-stale is rooted at `base`, edits app-a/ only, and is merged
745+ * AFTER app-b/ appears on main — a stale branch never rebased. The merge
746+ * differs from its first parent for app-b/ only because app-b/ advanced on
747+ * main while the branch was open, so `--full-history` keeps it under an app-b
748+ * pathspec even though the branch delivered nothing to app-b/.
749+ * - feat/XYZ-2-impl is rooted at the stale merge and genuinely edits app-b/.
750+ * Its key lives only on the merge subject, so dropping the merge would lose
751+ * the key entirely even though the merge did deliver app-b/ changes.
752+ */
753+ function createTempRepoStaleMerge ( ) : TempRepoStaleMerge {
754+ const { cwd, base } = initTempRepo ( {
755+ prefix : "linear-release-stale-merge-" ,
756+ dirs : [ "app-a" , "app-b" ] ,
757+ seedFile : { path : "app-a/file.txt" , content : "a0" } ,
758+ } ) ;
759+
760+ runGit ( `checkout -b feat/ABC-1-stale ${ base } ` , cwd ) ;
761+ writeFileSync ( join ( cwd , "app-a" , "file.txt" ) , "a1" ) ;
762+ runGit ( "add ." , cwd ) ;
763+ runGit ( 'commit -m "rework app-a internals"' , cwd ) ;
764+
765+ runGit ( "checkout main" , cwd ) ;
766+ writeFileSync ( join ( cwd , "app-b" , "file.txt" ) , "b0" ) ;
767+ runGit ( "add ." , cwd ) ;
768+ runGit ( 'commit -m "add app-b on main"' , cwd ) ;
769+
770+ runGit ( 'merge --no-ff feat/ABC-1-stale -m "Merge pull request #1 from owner/feat/ABC-1-stale"' , cwd ) ;
771+ const staleMerge = runGit ( "rev-parse HEAD" , cwd ) ;
772+ runGit ( "branch -D feat/ABC-1-stale" , cwd ) ;
773+
774+ runGit ( `checkout -b feat/XYZ-2-impl ${ staleMerge } ` , cwd ) ;
775+ writeFileSync ( join ( cwd , "app-b" , "file.txt" ) , "b1" ) ;
776+ runGit ( "add ." , cwd ) ;
777+ runGit ( 'commit -m "implement the thing"' , cwd ) ;
778+
779+ runGit ( "checkout main" , cwd ) ;
780+ runGit ( 'merge --no-ff feat/XYZ-2-impl -m "Merge pull request #2 from owner/feat/XYZ-2-impl"' , cwd ) ;
781+ const subjectMerge = runGit ( "rev-parse HEAD" , cwd ) ;
782+ runGit ( "branch -D feat/XYZ-2-impl" , cwd ) ;
783+
784+ return { cwd, commits : { base, staleMerge, subjectMerge } } ;
785+ }
786+
732787describe ( "getCommitContextsBetweenShas" , ( ) => {
733788 let repo : TempRepo ;
734789
@@ -1044,8 +1099,9 @@ describe("merge commit handling", () => {
10441099 const shas = new Set ( result . map ( ( c ) => c . sha ) ) ;
10451100 expect ( shas . has ( multiRepo . commits . merge100 ) ) . toBe ( true ) ;
10461101 expect ( shas . has ( multiRepo . commits . merge200 ) ) . toBe ( true ) ;
1047- // merge300 only touched infra/ — kept by the merges-only scan, then dropped
1048- // by commitTouchesPaths so LIN-300 doesn't leak into a frontend release.
1102+ // merge300 only touched infra/, so under the frontend/backend pathspec it
1103+ // changed nothing relative to its parents and `--full-history` drops it
1104+ // natively — LIN-300 never reaches a frontend release.
10491105 expect ( shas . has ( multiRepo . commits . merge300 ) ) . toBe ( false ) ;
10501106 expect ( shas . has ( multiRepo . commits . headMerge ) ) . toBe ( true ) ;
10511107
@@ -1115,6 +1171,63 @@ describe("merge commit handling", () => {
11151171 expect ( branchNames ) . not . toContain ( "feature/LIN-300-mobile" ) ;
11161172 } ) ;
11171173 } ) ;
1174+
1175+ describe ( "getCommitContextsBetweenShas with stale-branch merges under a path filter" , ( ) => {
1176+ let repo : TempRepoStaleMerge ;
1177+
1178+ beforeAll ( ( ) => {
1179+ repo = createTempRepoStaleMerge ( ) ;
1180+ } ) ;
1181+
1182+ afterAll ( ( ) => {
1183+ rmSync ( repo . cwd , { recursive : true , force : true } ) ;
1184+ } ) ;
1185+
1186+ it ( "drops a stale-branch merge that delivered no change to the filtered paths" , ( ) => {
1187+ // feat/ABC-1-stale edited app-a/ only but merged after app-b/ landed, so
1188+ // `--full-history` keeps its merge under the app-b pathspec. The merge
1189+ // delivered nothing to app-b/, so its subject key must not be attributed.
1190+ const result = getCommitContextsBetweenShas ( repo . commits . base , repo . commits . subjectMerge , {
1191+ includePaths : [ "app-b/**" ] ,
1192+ cwd : repo . cwd ,
1193+ } ) ;
1194+
1195+ const shas = new Set ( result . map ( ( c ) => c . sha ) ) ;
1196+ expect ( shas . has ( repo . commits . staleMerge ) ) . toBe ( false ) ;
1197+
1198+ const branchNames = result . map ( ( c ) => c . branchName ) . filter ( ( b ) : b is string => ! ! b ) ;
1199+ expect ( branchNames ) . not . toContain ( "feat/ABC-1-stale" ) ;
1200+ } ) ;
1201+
1202+ it ( "retains a merge whose key lives only on the subject when it delivered the filtered paths" , ( ) => {
1203+ // feat/XYZ-2-impl genuinely edited app-b/ and carries its key only on the
1204+ // merge subject, so dropping the merge would lose the key entirely.
1205+ const result = getCommitContextsBetweenShas ( repo . commits . base , repo . commits . subjectMerge , {
1206+ includePaths : [ "app-b/**" ] ,
1207+ cwd : repo . cwd ,
1208+ } ) ;
1209+
1210+ const shas = new Set ( result . map ( ( c ) => c . sha ) ) ;
1211+ expect ( shas . has ( repo . commits . subjectMerge ) ) . toBe ( true ) ;
1212+
1213+ const branchNames = result . map ( ( c ) => c . branchName ) . filter ( ( b ) : b is string => ! ! b ) ;
1214+ expect ( branchNames ) . toContain ( "feat/XYZ-2-impl" ) ;
1215+ } ) ;
1216+
1217+ it ( "still attributes a stale merge to the surface it actually touched" , ( ) => {
1218+ // The same stale merge DID deliver app-a/ changes, so under an app-a filter
1219+ // its subject key is correctly retained — the fix discards leaks, not work.
1220+ // And the app-b-only merge must not leak into the app-a surface.
1221+ const result = getCommitContextsBetweenShas ( repo . commits . base , repo . commits . subjectMerge , {
1222+ includePaths : [ "app-a/**" ] ,
1223+ cwd : repo . cwd ,
1224+ } ) ;
1225+
1226+ const branchNames = result . map ( ( c ) => c . branchName ) . filter ( ( b ) : b is string => ! ! b ) ;
1227+ expect ( branchNames ) . toContain ( "feat/ABC-1-stale" ) ;
1228+ expect ( branchNames ) . not . toContain ( "feat/XYZ-2-impl" ) ;
1229+ } ) ;
1230+ } ) ;
11181231} ) ;
11191232
11201233describe ( "assertGitAvailable" , ( ) => {
0 commit comments