[PATCH b4] prep: keep auto-to-cc trailers before cover changelogs

Akihiko Odaki <[email protected]> Sun, 28 Jun 2026 15:03:21 +0900
Newsgroups org.kernel.linux.tools
Message-ID <[email protected]>
After get_body_parts() stopped splitting free-form messages on every
"---" line, prep --auto-to-cc no longer saw existing cover trailers
when they were followed by the generated changelog block. That made it
append duplicate To:/Cc: trailers below the changelog.

Teach auto-to-cc to collect existing cover recipients with the same
broad trailer scan used by the send path. Then let fix_trailers()
treat the cover's "---" line as a separator even when the cover has no
diff, so new To:/Cc: trailers are inserted before the changelog instead
of being appended below it.

Add a regression test for a rerolled cover whose existing To: trailer is
above "Changes in v2:".

Closes: https://github.com/mricon/b4/issues/78
Signed-off-by: Akihiko Odaki <[email protected]>
Assisted-by: Codex gpt-5.5
---
 src/b4/__init__.py   |  6 ++++--
 src/b4/ez.py         |  7 ++++---
 src/tests/test_ez.py | 42 ++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 50 insertions(+), 5 deletions(-)

diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index d7b41ee..5ad1c39 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -2896,6 +2896,7 @@ class LoreMessage:
     @staticmethod
     def get_body_parts(
         body: str,
+        force_patch_separator: bool = False,
     ) -> Tuple[List[LoreTrailer], str, List[LoreTrailer], str, str]:
         # remove any starting/trailing blank lines
         body = body.replace('\r', '')
@@ -2917,7 +2918,7 @@ class LoreMessage:
         # diff content. Free-form replies should not be split on '---', which
         # may appear as a visual separator unrelated to any patch.
         parts = [body]
-        if DIFF_RE.search(body) or DIFFSTAT_RE.search(body):
+        if force_patch_separator or DIFF_RE.search(body) or DIFFSTAT_RE.search(body):
             parts = re.split(r'^---\s*\n', body, maxsplit=1, flags=re.M)
             if len(parts) == 2:
                 basement = parts[1]
@@ -2972,6 +2973,7 @@ class LoreMessage:
 
     def fix_trailers(
         self,
+        force_patch_separator: bool = False,
         extras: Optional[List[LoreTrailer]] = None,
         copyccs: bool = False,
         addmysob: bool = False,
@@ -2982,7 +2984,7 @@ class LoreMessage:
         config = get_main_config()
 
         bheaders, message, btrailers, basement, signature = LoreMessage.get_body_parts(
-            self.body
+            self.body, force_patch_separator=force_patch_separator
         )
 
         sobtr = LoreTrailer()
diff --git a/src/b4/ez.py b/src/b4/ez.py
index 268d783..3f43176 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -3865,9 +3865,8 @@ def auto_to_cc() -> None:
 
     logger.debug('Getting addresses from cover letter')
     cover, tracking = load_cover(strip_comments=False)
-    parts = b4.LoreMessage.get_body_parts(cover)
     seen = set()
-    for ltr in parts[2]:
+    for ltr in b4.LoreMessage.find_trailers(cover)[0]:
         if not ltr.addr:
             continue
         seen.add(ltr.addr[1])
@@ -3922,7 +3921,9 @@ def auto_to_cc() -> None:
         cmsg.set_payload(cover, charset='utf-8')
         clm = b4.LoreMessage(cmsg)
         fallback_order = str(config.get('send-trailer-order', 'To,Cc,*'))
-        clm.fix_trailers(extras=extras, fallback_order=fallback_order)
+        clm.fix_trailers(
+            force_patch_separator=True, extras=extras, fallback_order=fallback_order
+        )
         logger.info('---')
         logger.info('You can trim/expand this list with: b4 prep --edit-cover')
         store_cover(clm.body, tracking)
diff --git a/src/tests/test_ez.py b/src/tests/test_ez.py
index 192b000..b0b8570 100644
--- a/src/tests/test_ez.py
+++ b/src/tests/test_ez.py
@@ -635,6 +635,48 @@ def test_mixin_cover_keeps_notes_with_midsection_trailer_line() -> None:
     assert body.index('base-commit:') > body.index('diff --git')
 
 
+def test_auto_to_cc_sees_cover_trailers_before_changelog(prepdir: str) -> None:
+    fname = os.path.join(prepdir, 'auto-to-cc.txt')
+    with open(fname, 'w') as fh:
+        fh.write('content\n')
+    ecode, out = b4.git_run_command(None, ['add', fname], logstderr=True)
+    assert ecode == 0, f'git add failed: {out}'
+    ecode, out = b4.git_run_command(
+        None, ['commit', '-m', 'feat: exercise auto-to-cc'], logstderr=True
+    )
+    assert ecode == 0, f'git commit failed: {out}'
+
+    _cover, tracking = b4.ez.load_cover()
+    cover = (
+        'Cover title\n'
+        '\n'
+        'Cover body text.\n'
+        '\n'
+        'To: [email protected]\n'
+        '\n'
+        '---\n'
+        'Changes in v2:\n'
+        '- some change\n'
+        '- Link to v1: https://example.com/r/msgid%40example.com\n'
+    )
+    b4.ez.store_cover(cover, tracking)
+    b4.MAIN_CONFIG.update(
+        {
+            'send-series-to': '[email protected]',
+            'send-series-cc': '[email protected]',
+        }
+    )
+
+    b4.ez.auto_to_cc()
+
+    new_cover, _tracking = b4.ez.load_cover()
+    assert new_cover.count('To: [email protected]') == 1
+    assert new_cover.count('Cc: [email protected]') == 1
+    assert new_cover.index('Cc: [email protected]') < new_cover.index(
+        'Changes in v2:'
+    )
+
+
 # A single patch whose commit message body is empty: the payload jumps straight
 # from the (header-borne) subject to the '---' cutline. This is what b4 emits
 # when the author leaves the commit message blank.

---
base-commit: 4217c3e5d3e1eb259626142fd71b91ec6d5e3d1e
change-id: 20260628-prep-2c600976cfe6

Best regards,
--  
Akihiko Odaki <[email protected]>