[PATCH b4] send: Add --force-cover-letter to send separate cover for single patch patch series

Breno Leitao
2026-01-27T12:53:28Z

By default, b4 mixes the cover letter content into the patch body
(under the --- cut line) when sending a single-patch series. Add
--force-cover-letter option to allow sending a separate cover letter
message (as 0/1) even for single-patch series.

This is useful when developers want to send a long cover letter with
extra information about an issue, and a commit. Having the cover-letter
"mixed-in" makes it hard to follow.

Example:
* https://lore.kernel.org/all/20260127-ipmi-v1-0-ba5cc90f516f@debian.org/

Signed-off-by: Breno Leitao <leitao@debian.org>
---
src/b4/command.py | 2 ++
src/b4/ez.py | 16 +++++++++-------
2 files changed, 11 insertions(+), 7 deletions(-)

diff --git a/src/b4/command.py b/src/b4/command.py
index 1f8b8f1..0e685cd 100644
--- a/src/b4/command.py
+++ b/src/b4/command.py
@@ -397,6 +397,8 @@ def setup_parser() -> argparse.ArgumentParser:
help='Resend a previously sent version of the series')
sp_send.add_argument('--no-sign', action='store_true', default=False,
help='Do not add the cryptographic attestation signature header')
+ sp_send.add_argument('--force-cover-letter', action='store_true', default=False,
+ help='Send a cover letter even for single-patch series')
sp_send.add_argument('--use-web-endpoint', dest='send_web', action='store_true', default=False,
help="Force going through the web endpoint")
ag_sendh = sp_send.add_argument_group('Web submission', 'Authenticate with the web submission endpoint')
diff --git a/src/b4/ez.py b/src/b4/ez.py
index 298e382..42379a7 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -1567,7 +1567,7 @@ def rethread(patches: List[Tuple[str, EmailMessage]]) -> None:

def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtracking: bool = True,
prefixes: Optional[List[str]] = None, usebranch: Optional[str] = None,
- expandprereqs: bool = True,
+ expandprereqs: bool = True, force_cover: bool = False,
) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]], str, List[Tuple[str, EmailMessage]]]:
cover, tracking = load_cover(strip_comments=True, usebranch=usebranch)

@@ -1735,7 +1735,7 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr
thdata = ''.join(wrapped).replace('X-B4-Tracking: ', '')

alltos, allccs, cbody = get_cover_dests(cover_letter)
- if len(patches) == 1:
+ if len(patches) == 1 and not force_cover:
mixin_cover(cbody, patches)
else:
add_cover(csubject, msgid_tpt, patches, cbody, seriests, thread=thread, presubject=presubject)
@@ -1767,7 +1767,7 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr
return alltos, allccs, tag_msg, patches

-def get_sent_tag_as_patches(tagname: str, revision: int, presubject: str = None) \
+def get_sent_tag_as_patches(tagname: str, revision: int, presubject: str = None, force_cover: bool = False) \
-> Tuple[List[Tuple[str, str]], List[Tuple[str, str]], List[Tuple[str, EmailMessage]]]:
cover, base_commit, change_id = get_base_changeid_from_tag(tagname)

@@ -1787,7 +1787,7 @@ def get_sent_tag_as_patches(tagname: str, revision: int, presubject: str = None)
presubject=presubject)

alltos, allccs, cbody = get_cover_dests(cbody)
- if len(patches) == 1:
+ if len(patches) == 1 and not force_cover:
mixin_cover(cbody, patches)
else:
add_cover(csubject, msgid_tpt, patches, cbody, seriests)
@@ -1942,8 +1942,9 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
presubject = tracking['series'].get('presubject', None)

try:
- todests, ccdests, patches = get_sent_tag_as_patches(tagname, revision=revision,
- presubject=presubject)
+ todests, ccdests, patches = get_sent_tag_as_patches(tagname, revision=revision,
+ presubject=presubject,
+ force_cover=cmdargs.force_cover_letter)
except RuntimeError as ex:
logger.critical('CRITICAL: Failed to convert tag to patches: %s', ex)
sys.exit(1)
@@ -1963,7 +1964,8 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
prefixes = None

try:
- todests, ccdests, tag_msg, patches = get_prep_branch_as_patches(prefixes=prefixes)
+ todests, ccdests, tag_msg, patches = get_prep_branch_as_patches(prefixes=prefixes,
+ force_cover=cmdargs.force_cover_letter)
except RuntimeError as ex:
logger.critical('CRITICAL: Failed to convert range to patches: %s', ex)
sys.exit(1)

---
base-commit: 477734000555ffc24bf873952e40367deee26f17
change-id: 20260127-force-cover-letter-e17dfceb28e4

Best regards,
--
Breno Leitao <leitao@debian.org>