ソースを参照

Add interpret-trailers command

Fixes #1826
Jelmer Vernooij 2 ヶ月 前
コミット
3825a6c368
8 ファイル変更1091 行追加0 行削除
  1. 6 0
      NEWS
  2. 111 0
      dulwich/cli.py
  3. 106 0
      dulwich/porcelain.py
  4. 448 0
      dulwich/trailers.py
  5. 37 0
      dulwich/worktree.py
  6. 1 0
      tests/__init__.py
  7. 108 0
      tests/test_cli.py
  8. 274 0
      tests/test_trailers.py

+ 6 - 0
NEWS

@@ -8,6 +8,12 @@
    in CLI commands. When GIT_FLUSH=1, output is flushed after each write for
    real-time visibility. (Jelmer Vernooij, #1810)
 
+ * Implement ``dulwich interpret-trailers`` functionality to parse and manipulate
+   structured metadata (trailers) in commit messages. Adds ``porcelain.interpret_trailers()``
+   with support for parsing, adding, replacing, and formatting trailers. Also fixes
+   the ``signoff`` parameter in ``porcelain.commit()`` to add ``Signed-off-by`` trailers.
+   (Jelmer Vernooij, #1826)
+
  * Add support for recursive submodule updates via ``--recursive`` flag in
    ``dulwich submodule update`` command and ``recursive`` parameter in
    ``porcelain.submodule_update()``.

+ 111 - 0
dulwich/cli.py

@@ -1514,6 +1514,116 @@ class cmd_dump_index(Command):
             logger.info("%s %s", o, idx[o])
 
 
+class cmd_interpret_trailers(Command):
+    """Add or parse structured information in commit messages."""
+
+    def run(self, args: Sequence[str]) -> None:
+        """Execute the interpret-trailers command.
+
+        Args:
+            args: Command line arguments
+        """
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "file",
+            nargs="?",
+            help="File to read message from. If not specified, reads from stdin.",
+        )
+        parser.add_argument(
+            "--trailer",
+            action="append",
+            dest="trailers",
+            metavar="<token>[(=|:)<value>]",
+            help="Trailer to add. Can be specified multiple times.",
+        )
+        parser.add_argument(
+            "--trim-empty",
+            action="store_true",
+            help="Remove trailers with empty values",
+        )
+        parser.add_argument(
+            "--only-trailers",
+            action="store_true",
+            help="Output only the trailers, not the message body",
+        )
+        parser.add_argument(
+            "--only-input",
+            action="store_true",
+            help="Don't add new trailers, only parse existing ones",
+        )
+        parser.add_argument(
+            "--unfold", action="store_true", help="Join multiline values into one line"
+        )
+        parser.add_argument(
+            "--parse",
+            action="store_true",
+            help="Shorthand for --only-trailers --only-input --unfold",
+        )
+        parser.add_argument(
+            "--where",
+            choices=["end", "start", "after", "before"],
+            default="end",
+            help="Where to place new trailers",
+        )
+        parser.add_argument(
+            "--if-exists",
+            choices=[
+                "add",
+                "replace",
+                "addIfDifferent",
+                "addIfDifferentNeighbor",
+                "doNothing",
+            ],
+            default="addIfDifferentNeighbor",
+            help="Action if trailer already exists",
+        )
+        parser.add_argument(
+            "--if-missing",
+            choices=["add", "doNothing"],
+            default="add",
+            help="Action if trailer is missing",
+        )
+        parsed_args = parser.parse_args(args)
+
+        # Read message from file or stdin
+        if parsed_args.file:
+            with open(parsed_args.file, "rb") as f:
+                message = f.read()
+        else:
+            message = sys.stdin.buffer.read()
+
+        # Parse trailer arguments
+        trailer_list = []
+        if parsed_args.trailers:
+            for trailer_spec in parsed_args.trailers:
+                # Parse "key:value" or "key=value" or just "key"
+                if ":" in trailer_spec:
+                    key, value = trailer_spec.split(":", 1)
+                elif "=" in trailer_spec:
+                    key, value = trailer_spec.split("=", 1)
+                else:
+                    key = trailer_spec
+                    value = ""
+                trailer_list.append((key.strip(), value.strip()))
+
+        # Call interpret_trailers
+        result = porcelain.interpret_trailers(
+            message,
+            trailers=trailer_list if trailer_list else None,
+            trim_empty=parsed_args.trim_empty,
+            only_trailers=parsed_args.only_trailers,
+            only_input=parsed_args.only_input,
+            unfold=parsed_args.unfold,
+            parse=parsed_args.parse,
+            where=parsed_args.where,
+            if_exists=parsed_args.if_exists,
+            if_missing=parsed_args.if_missing,
+        )
+
+        # Output result
+        sys.stdout.buffer.write(result)
+
+
 class cmd_init(Command):
     """Create an empty Git repository or reinitialize an existing one."""
 
@@ -5898,6 +6008,7 @@ commands = {
     "grep": cmd_grep,
     "help": cmd_help,
     "init": cmd_init,
+    "interpret-trailers": cmd_interpret_trailers,
     "lfs": cmd_lfs,
     "log": cmd_log,
     "ls-files": cmd_ls_files,

+ 106 - 0
dulwich/porcelain.py

@@ -41,6 +41,7 @@ Currently implemented:
  * for_each_ref
  * grep
  * init
+ * interpret_trailers
  * ls_files
  * ls_remote
  * ls_tree
@@ -213,6 +214,7 @@ from .sparse_patterns import (
     apply_included_paths,
     determine_included_paths,
 )
+from .trailers import add_trailer_to_message, format_trailers, parse_trailers
 
 # Module level tuple definition for status output
 GitStatus = namedtuple("GitStatus", "staged unstaged untracked")
@@ -817,6 +819,7 @@ def commit(
                 encoding=encoding,
                 no_verify=no_verify,
                 sign=sign,
+                signoff=signoff,
                 merge_heads=merge_heads,
                 ref=None,
             )
@@ -833,6 +836,7 @@ def commit(
                 encoding=encoding,
                 no_verify=no_verify,
                 sign=sign,
+                signoff=signoff,
                 merge_heads=merge_heads,
             )
 
@@ -861,6 +865,108 @@ def commit_tree(
         )
 
 
+def interpret_trailers(
+    message: Union[str, bytes],
+    *,
+    trailers: Optional[list[tuple[str, str]]] = None,
+    trim_empty: bool = False,
+    only_trailers: bool = False,
+    only_input: bool = False,
+    unfold: bool = False,
+    parse: bool = False,
+    where: str = "end",
+    if_exists: str = "addIfDifferentNeighbor",
+    if_missing: str = "add",
+    separators: str = ":",
+) -> bytes:
+    r"""Parse and manipulate trailers in a commit message.
+
+    This function implements the functionality of `git interpret-trailers`,
+    allowing parsing and manipulation of structured metadata (trailers) in
+    commit messages.
+
+    Trailers are key-value pairs at the end of commit messages, formatted like:
+        Signed-off-by: Alice <alice@example.com>
+        Reviewed-by: Bob <bob@example.com>
+
+    Args:
+        message: The commit message (string or bytes)
+        trailers: List of (key, value) tuples to add as new trailers
+        trim_empty: Remove trailers with empty values
+        only_trailers: Output only the trailers, not the message body
+        only_input: Don't add new trailers, only parse existing ones
+        unfold: Join multiline trailer values into a single line
+        parse: Shorthand for --only-trailers --only-input --unfold
+        where: Where to add new trailers ('end', 'start', 'after', 'before')
+        if_exists: How to handle duplicate keys
+            - 'add': Always add
+            - 'replace': Replace all existing
+            - 'addIfDifferent': Add only if value differs from all existing
+            - 'addIfDifferentNeighbor': Add only if value differs from neighbors
+            - 'doNothing': Don't add if key exists
+        if_missing: What to do if key doesn't exist ('add' or 'doNothing')
+        separators: Valid separator characters (default ':')
+
+    Returns:
+        The processed message as bytes
+
+    Examples:
+        >>> msg = b"Subject\\n\\nBody text\\n"
+        >>> interpret_trailers(msg, trailers=[("Signed-off-by", "Alice <alice@example.com>")])
+        b'Subject\\n\\nBody text\\n\\nSigned-off-by: Alice <alice@example.com>\\n'
+
+        >>> msg = b"Subject\\n\\nSigned-off-by: Alice\\n"
+        >>> interpret_trailers(msg, only_trailers=True)
+        b'Signed-off-by: Alice\\n'
+    """
+    # Handle --parse shorthand
+    if parse:
+        only_trailers = True
+        only_input = True
+        unfold = True
+
+    # Convert message to bytes
+    if isinstance(message, str):
+        message_bytes = message.encode("utf-8")
+    else:
+        message_bytes = message
+
+    # Parse existing trailers
+    _message_body, parsed_trailers = parse_trailers(message_bytes, separators)
+
+    # Apply unfold if requested
+    if unfold:
+        for trailer in parsed_trailers:
+            # Replace newlines and multiple spaces with single space
+            trailer.value = " ".join(trailer.value.split())
+
+    # Apply trim_empty if requested
+    if trim_empty:
+        parsed_trailers = [t for t in parsed_trailers if t.value.strip()]
+
+    # Add new trailers if requested and not only_input
+    if not only_input and trailers:
+        for key, value in trailers:
+            message_bytes = add_trailer_to_message(
+                message_bytes,
+                key,
+                value,
+                separators[0],  # Use first separator as default
+                where=where,
+                if_exists=if_exists,
+                if_missing=if_missing,
+            )
+        # Re-parse to get updated trailers for output
+        if only_trailers:
+            _message_body, parsed_trailers = parse_trailers(message_bytes, separators)
+
+    # Return based on only_trailers flag
+    if only_trailers:
+        return format_trailers(parsed_trailers)
+    else:
+        return message_bytes
+
+
 def init(
     path: Union[str, os.PathLike[str]] = ".",
     *,

+ 448 - 0
dulwich/trailers.py

@@ -0,0 +1,448 @@
+# trailers.py -- Git trailers parsing and manipulation
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Git trailers parsing and manipulation.
+
+This module provides functionality for parsing and manipulating Git trailers,
+which are structured information blocks appended to commit messages.
+
+Trailers follow the format:
+    Token: value
+    Token: value
+
+They are similar to RFC 822 email headers and appear at the end of commit
+messages after free-form content.
+"""
+
+from typing import Optional
+
+
+class Trailer:
+    """Represents a single Git trailer.
+
+    Args:
+        key: The trailer key/token (e.g., "Signed-off-by")
+        value: The trailer value
+        separator: The separator character used (default ':')
+    """
+
+    def __init__(self, key: str, value: str, separator: str = ":") -> None:
+        """Initialize a Trailer instance.
+
+        Args:
+            key: The trailer key/token
+            value: The trailer value
+            separator: The separator character (default ':')
+        """
+        self.key = key
+        self.value = value
+        self.separator = separator
+
+    def __eq__(self, other: object) -> bool:
+        """Compare two Trailer instances for equality.
+
+        Args:
+            other: The object to compare with
+
+        Returns:
+            True if trailers have the same key, value, and separator
+        """
+        if not isinstance(other, Trailer):
+            return NotImplemented
+        return (
+            self.key == other.key
+            and self.value == other.value
+            and self.separator == other.separator
+        )
+
+    def __repr__(self) -> str:
+        """Return a string representation suitable for debugging.
+
+        Returns:
+            A string showing the trailer's key, value, and separator
+        """
+        return f"Trailer(key={self.key!r}, value={self.value!r}, separator={self.separator!r})"
+
+    def __str__(self) -> str:
+        """Return the trailer formatted as it would appear in a commit message.
+
+        Returns:
+            The trailer in the format "key: value"
+        """
+        return f"{self.key}{self.separator} {self.value}"
+
+
+def parse_trailers(
+    message: bytes,
+    separators: str = ":",
+) -> tuple[bytes, list[Trailer]]:
+    """Parse trailers from a commit message.
+
+    Trailers are extracted from the input by looking for a group of one or more
+    lines that (i) is all trailers, or (ii) contains at least one Git-generated
+    or user-configured trailer and consists of at least 25% trailers.
+
+    The group must be preceded by one or more empty (or whitespace-only) lines.
+    The group must either be at the end of the input or be the last non-whitespace
+    lines before a line that starts with '---'.
+
+    Args:
+        message: The commit message as bytes
+        separators: Characters to recognize as trailer separators (default ':')
+
+    Returns:
+        A tuple of (message_without_trailers, list_of_trailers)
+    """
+    if not message:
+        return (b"", [])
+
+    # Decode message
+    try:
+        text = message.decode("utf-8")
+    except UnicodeDecodeError:
+        text = message.decode("latin-1")
+
+    lines = text.splitlines(keepends=True)
+
+    # Find the trailer block by searching backwards
+    # Look for a blank line followed by trailer-like lines
+    trailer_start = None
+    cutoff_line = None
+
+    # First, check if there's a "---" line that marks the end of the message
+    for i in range(len(lines) - 1, -1, -1):
+        if lines[i].lstrip().startswith("---"):
+            cutoff_line = i
+            break
+
+    # Determine the search range
+    search_end = cutoff_line if cutoff_line is not None else len(lines)
+
+    # Search backwards for the trailer block
+    # A trailer block must be preceded by a blank line and extend to the end
+    for i in range(search_end - 1, -1, -1):
+        line = lines[i].rstrip()
+
+        # Check if this is a blank line
+        if not line:
+            # Check if the lines after this blank line are trailers
+            potential_trailers = lines[i + 1 : search_end]
+
+            # Remove trailing blank lines from potential trailers
+            while potential_trailers and not potential_trailers[-1].strip():
+                potential_trailers = potential_trailers[:-1]
+
+            # Check if these lines form a trailer block and extend to search_end
+            if potential_trailers and _is_trailer_block(potential_trailers, separators):
+                # Verify these trailers extend to the end (search_end)
+                # by checking there are no non-blank lines after them
+                last_trailer_index = i + 1 + len(potential_trailers)
+                has_content_after = False
+                for j in range(last_trailer_index, search_end):
+                    if lines[j].strip():
+                        has_content_after = True
+                        break
+
+                if not has_content_after:
+                    trailer_start = i + 1
+                    break
+
+    if trailer_start is None:
+        # No trailer block found
+        return (message, [])
+
+    # Parse the trailers
+    trailer_lines = lines[trailer_start:search_end]
+    trailers = _parse_trailer_lines(trailer_lines, separators)
+
+    # Reconstruct the message without trailers
+    # Keep everything before the blank line that precedes the trailers
+    message_lines = lines[:trailer_start]
+
+    # Remove trailing blank lines from the message
+    while message_lines and not message_lines[-1].strip():
+        message_lines.pop()
+
+    message_without_trailers = "".join(message_lines)
+    if message_without_trailers and not message_without_trailers.endswith("\n"):
+        message_without_trailers += "\n"
+
+    return (message_without_trailers.encode("utf-8"), trailers)
+
+
+def _is_trailer_block(lines: list[str], separators: str) -> bool:
+    """Check if a group of lines forms a valid trailer block.
+
+    A trailer block must be composed entirely of trailer lines (with possible
+    blank lines and continuation lines). A single non-trailer line invalidates
+    the entire block.
+
+    Args:
+        lines: The lines to check
+        separators: Valid separator characters
+
+    Returns:
+        True if the lines form a valid trailer block
+    """
+    if not lines:
+        return False
+
+    # Remove empty lines at the end
+    while lines and not lines[-1].strip():
+        lines = lines[:-1]
+
+    if not lines:
+        return False
+
+    has_any_trailer = False
+
+    i = 0
+    while i < len(lines):
+        line = lines[i].rstrip()
+
+        if not line:
+            # Empty lines are allowed within the trailer block
+            i += 1
+            continue
+
+        # Check if this line is a continuation (starts with whitespace)
+        if line and line[0].isspace():
+            # This is a continuation of the previous line
+            i += 1
+            continue
+
+        # Check if this is a trailer line
+        is_trailer = False
+        for sep in separators:
+            if sep in line:
+                key_part = line.split(sep, 1)[0]
+                # Key must not contain whitespace
+                if key_part and not any(c.isspace() for c in key_part):
+                    is_trailer = True
+                    has_any_trailer = True
+                    break
+
+        # If this is not a trailer line, the block is invalid
+        if not is_trailer:
+            return False
+
+        i += 1
+
+    # Must have at least one trailer
+    return has_any_trailer
+
+
+def _parse_trailer_lines(lines: list[str], separators: str) -> list[Trailer]:
+    """Parse individual trailer lines.
+
+    Args:
+        lines: The trailer lines to parse
+        separators: Valid separator characters
+
+    Returns:
+        List of parsed Trailer objects
+    """
+    trailers: list[Trailer] = []
+    current_trailer: Optional[Trailer] = None
+
+    for line in lines:
+        stripped = line.rstrip()
+
+        if not stripped:
+            # Empty line - finalize current trailer if any
+            if current_trailer:
+                trailers.append(current_trailer)
+                current_trailer = None
+            continue
+
+        # Check if this is a continuation line (starts with whitespace)
+        if stripped[0].isspace():
+            if current_trailer:
+                # Append to the current trailer value
+                continuation = stripped.lstrip()
+                current_trailer.value += " " + continuation
+            continue
+
+        # Finalize the previous trailer if any
+        if current_trailer:
+            trailers.append(current_trailer)
+            current_trailer = None
+
+        # Try to parse as a new trailer
+        for sep in separators:
+            if sep in stripped:
+                parts = stripped.split(sep, 1)
+                key = parts[0]
+
+                # Key must not contain whitespace
+                if key and not any(c.isspace() for c in key):
+                    value = parts[1].strip() if len(parts) > 1 else ""
+                    current_trailer = Trailer(key, value, sep)
+                    break
+
+    # Don't forget the last trailer
+    if current_trailer:
+        trailers.append(current_trailer)
+
+    return trailers
+
+
+def format_trailers(trailers: list[Trailer]) -> bytes:
+    """Format a list of trailers as bytes.
+
+    Args:
+        trailers: List of Trailer objects
+
+    Returns:
+        Formatted trailers as bytes
+    """
+    if not trailers:
+        return b""
+
+    lines = [str(trailer) for trailer in trailers]
+    return "\n".join(lines).encode("utf-8") + b"\n"
+
+
+def add_trailer_to_message(
+    message: bytes,
+    key: str,
+    value: str,
+    separator: str = ":",
+    where: str = "end",
+    if_exists: str = "addIfDifferentNeighbor",
+    if_missing: str = "add",
+) -> bytes:
+    """Add a trailer to a commit message.
+
+    Args:
+        message: The original commit message
+        key: The trailer key
+        value: The trailer value
+        separator: The separator to use
+        where: Where to add the trailer ('end', 'start', 'after', 'before')
+        if_exists: How to handle existing trailers with the same key
+            - 'add': Always add
+            - 'replace': Replace all existing
+            - 'addIfDifferent': Add only if value is different from all existing
+            - 'addIfDifferentNeighbor': Add only if value differs from neighbors
+            - 'doNothing': Don't add if key exists
+        if_missing: What to do if the key doesn't exist
+            - 'add': Add the trailer
+            - 'doNothing': Don't add the trailer
+
+    Returns:
+        The message with the trailer added
+    """
+    message_body, existing_trailers = parse_trailers(message, separator)
+
+    new_trailer = Trailer(key, value, separator)
+
+    # Check if the key exists
+    key_exists = any(t.key == key for t in existing_trailers)
+
+    if not key_exists:
+        if if_missing == "doNothing":
+            return message
+        # Add the new trailer
+        updated_trailers = [*existing_trailers, new_trailer]
+    else:
+        # Key exists - apply if_exists logic
+        if if_exists == "doNothing":
+            return message
+        elif if_exists == "replace":
+            # Replace all trailers with this key
+            updated_trailers = [t for t in existing_trailers if t.key != key]
+            updated_trailers.append(new_trailer)
+        elif if_exists == "addIfDifferent":
+            # Add only if no existing trailer has the same value
+            has_same_value = any(
+                t.key == key and t.value == value for t in existing_trailers
+            )
+            if has_same_value:
+                return message
+            updated_trailers = [*existing_trailers, new_trailer]
+        elif if_exists == "addIfDifferentNeighbor":
+            # Add only if adjacent trailers with same key have different values
+            should_add = True
+
+            # Check if there's a neighboring trailer with the same key and value
+            for i, t in enumerate(existing_trailers):
+                if t.key == key and t.value == value:
+                    # Check if it's a neighbor (last trailer with this key)
+                    is_neighbor = True
+                    for j in range(i + 1, len(existing_trailers)):
+                        if existing_trailers[j].key == key:
+                            is_neighbor = False
+                            break
+                    if is_neighbor:
+                        should_add = False
+                        break
+
+            if not should_add:
+                return message
+            updated_trailers = [*existing_trailers, new_trailer]
+        else:  # 'add'
+            updated_trailers = [*existing_trailers, new_trailer]
+
+    # Apply where logic
+    if where == "start":
+        updated_trailers = [new_trailer] + [
+            t for t in updated_trailers if t != new_trailer
+        ]
+    elif where == "before":
+        # Insert before the first trailer with the same key
+        result = []
+        inserted = False
+        for t in updated_trailers:
+            if not inserted and t.key == key and t != new_trailer:
+                result.append(new_trailer)
+                inserted = True
+            if t != new_trailer:
+                result.append(t)
+        if not inserted:
+            result.append(new_trailer)
+        updated_trailers = result
+    elif where == "after":
+        # Insert after the last trailer with the same key
+        result = []
+        last_key_index = -1
+        for i, t in enumerate(updated_trailers):
+            if t.key == key and t != new_trailer:
+                last_key_index = len(result)
+            if t != new_trailer:
+                result.append(t)
+
+        if last_key_index >= 0:
+            result.insert(last_key_index + 1, new_trailer)
+        else:
+            result.append(new_trailer)
+        updated_trailers = result
+    # 'end' is the default - trailer is already at the end
+
+    # Reconstruct the message
+    result_message = message_body
+    if result_message and not result_message.endswith(b"\n"):
+        result_message += b"\n"
+
+    if updated_trailers:
+        result_message += b"\n"
+        result_message += format_trailers(updated_trailers)
+
+    return result_message

+ 37 - 0
dulwich/worktree.py

@@ -46,6 +46,7 @@ from .repo import (
     check_user_identity,
     get_user_identity,
 )
+from .trailers import add_trailer_to_message
 
 
 class WorkTreeInfo:
@@ -428,6 +429,7 @@ class WorkTree:
         merge_heads: Sequence[ObjectID] | None = None,
         no_verify: bool = False,
         sign: bool | None = None,
+        signoff: bool | None = None,
     ) -> ObjectID:
         """Create a new commit.
 
@@ -456,6 +458,8 @@ class WorkTree:
           sign: GPG Sign the commit (bool, defaults to False,
             pass True to use default GPG key,
             pass a str containing Key ID to use a specific GPG key)
+          signoff: Add Signed-off-by line (DCO) to commit message.
+            If None, uses format.signoff config.
 
         Returns:
           New commit SHA1
@@ -557,6 +561,39 @@ class WorkTree:
             # FIXME: Try to read commit message from .git/MERGE_MSG
             raise ValueError("No commit message specified")
 
+        # Handle signoff
+        should_signoff = signoff
+        if should_signoff is None:
+            # Check format.signOff configuration
+            try:
+                should_signoff = config.get_boolean(
+                    (b"format",), b"signoff", default=False
+                )
+            except KeyError:
+                should_signoff = False
+
+        if should_signoff:
+            # Add Signed-off-by trailer
+            # Get the committer identity for the signoff
+            signoff_identity = committer
+            if isinstance(message, bytes):
+                message_bytes = message
+            else:
+                message_bytes = message.encode("utf-8")
+
+            message_bytes = add_trailer_to_message(
+                message_bytes,
+                "Signed-off-by",
+                signoff_identity.decode("utf-8")
+                if isinstance(signoff_identity, bytes)
+                else signoff_identity,
+                separator=":",
+                where="end",
+                if_exists="addIfDifferentNeighbor",
+                if_missing="add",
+            )
+            message = message_bytes
+
         try:
             if no_verify:
                 c.message = message

+ 1 - 0
tests/__init__.py

@@ -184,6 +184,7 @@ def self_test_suite() -> unittest.TestSuite:
         "sparse_patterns",
         "stash",
         "submodule",
+        "trailers",
         "utils",
         "walk",
         "web",

+ 108 - 0
tests/test_cli.py

@@ -3850,5 +3850,113 @@ class MaintenanceCommandTest(DulwichCliTestCase):
             self.assertEqual(result, 1)
 
 
+class InterpretTrailersCommandTest(DulwichCliTestCase):
+    """Tests for interpret-trailers command."""
+
+    def test_parse_trailers_from_file(self):
+        """Test parsing trailers from a file."""
+        # Create a message file with trailers
+        msg_file = os.path.join(self.test_dir, "message.txt")
+        with open(msg_file, "wb") as f:
+            f.write(b"Subject\n\nBody\n\nSigned-off-by: Alice <alice@example.com>\n")
+
+        result, stdout, _stderr = self._run_cli(
+            "interpret-trailers", "--only-trailers", msg_file
+        )
+        self.assertIsNone(result)
+        self.assertIn("Signed-off-by: Alice <alice@example.com>", stdout)
+
+    def test_add_trailer_to_message(self):
+        """Test adding a trailer to a message."""
+        msg_file = os.path.join(self.test_dir, "message.txt")
+        with open(msg_file, "wb") as f:
+            f.write(b"Subject\n\nBody text\n")
+
+        result, stdout, _stderr = self._run_cli(
+            "interpret-trailers",
+            "--trailer",
+            "Signed-off-by:Alice <alice@example.com>",
+            msg_file,
+        )
+        self.assertIsNone(result)
+        self.assertIn("Signed-off-by: Alice <alice@example.com>", stdout)
+        self.assertIn("Subject", stdout)
+        self.assertIn("Body text", stdout)
+
+    def test_add_multiple_trailers(self):
+        """Test adding multiple trailers."""
+        msg_file = os.path.join(self.test_dir, "message.txt")
+        with open(msg_file, "wb") as f:
+            f.write(b"Subject\n\nBody\n")
+
+        result, stdout, _stderr = self._run_cli(
+            "interpret-trailers",
+            "--trailer",
+            "Signed-off-by:Alice",
+            "--trailer",
+            "Reviewed-by:Bob",
+            msg_file,
+        )
+        self.assertIsNone(result)
+        self.assertIn("Signed-off-by: Alice", stdout)
+        self.assertIn("Reviewed-by: Bob", stdout)
+
+    def test_parse_shorthand(self):
+        """Test --parse shorthand option."""
+        msg_file = os.path.join(self.test_dir, "message.txt")
+        with open(msg_file, "wb") as f:
+            f.write(b"Subject\n\nBody\n\nSigned-off-by: Alice\n")
+
+        result, stdout, _stderr = self._run_cli(
+            "interpret-trailers", "--parse", msg_file
+        )
+        self.assertIsNone(result)
+        # --parse is shorthand for --only-trailers --only-input --unfold
+        self.assertEqual(stdout, "Signed-off-by: Alice\n")
+
+    def test_trim_empty(self):
+        """Test --trim-empty option."""
+        msg_file = os.path.join(self.test_dir, "message.txt")
+        with open(msg_file, "wb") as f:
+            f.write(b"Subject\n\nBody\n\nSigned-off-by: Alice\nReviewed-by: \n")
+
+        result, stdout, _stderr = self._run_cli(
+            "interpret-trailers", "--trim-empty", "--only-trailers", msg_file
+        )
+        self.assertIsNone(result)
+        self.assertIn("Signed-off-by: Alice", stdout)
+        self.assertNotIn("Reviewed-by:", stdout)
+
+    def test_if_exists_replace(self):
+        """Test --if-exists replace option."""
+        msg_file = os.path.join(self.test_dir, "message.txt")
+        with open(msg_file, "wb") as f:
+            f.write(b"Subject\n\nBody\n\nSigned-off-by: Alice\n")
+
+        result, stdout, _stderr = self._run_cli(
+            "interpret-trailers",
+            "--if-exists",
+            "replace",
+            "--trailer",
+            "Signed-off-by:Bob",
+            msg_file,
+        )
+        self.assertIsNone(result)
+        self.assertIn("Signed-off-by: Bob", stdout)
+        self.assertNotIn("Alice", stdout)
+
+    def test_trailer_with_equals(self):
+        """Test trailer with equals separator."""
+        msg_file = os.path.join(self.test_dir, "message.txt")
+        with open(msg_file, "wb") as f:
+            f.write(b"Subject\n\nBody\n")
+
+        result, stdout, _stderr = self._run_cli(
+            "interpret-trailers", "--trailer", "Bug=12345", msg_file
+        )
+        self.assertIsNone(result)
+        self.assertIn("Bug: 12345", stdout)
+
+
 if __name__ == "__main__":
     unittest.main()

+ 274 - 0
tests/test_trailers.py

@@ -0,0 +1,274 @@
+# test_trailers.py -- tests for git trailers
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as published by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Tests for dulwich.trailers."""
+
+import unittest
+
+from dulwich.trailers import (
+    Trailer,
+    add_trailer_to_message,
+    format_trailers,
+    parse_trailers,
+)
+
+
+class TestTrailer(unittest.TestCase):
+    """Tests for the Trailer class."""
+
+    def test_init(self) -> None:
+        """Test Trailer initialization."""
+        trailer = Trailer("Signed-off-by", "Alice <alice@example.com>")
+        self.assertEqual(trailer.key, "Signed-off-by")
+        self.assertEqual(trailer.value, "Alice <alice@example.com>")
+        self.assertEqual(trailer.separator, ":")
+
+    def test_str(self) -> None:
+        """Test Trailer string representation."""
+        trailer = Trailer("Signed-off-by", "Alice <alice@example.com>")
+        self.assertEqual(str(trailer), "Signed-off-by: Alice <alice@example.com>")
+
+    def test_equality(self) -> None:
+        """Test Trailer equality."""
+        t1 = Trailer("Signed-off-by", "Alice")
+        t2 = Trailer("Signed-off-by", "Alice")
+        t3 = Trailer("Signed-off-by", "Bob")
+        self.assertEqual(t1, t2)
+        self.assertNotEqual(t1, t3)
+
+
+class TestParseTrailers(unittest.TestCase):
+    """Tests for parse_trailers function."""
+
+    def test_no_trailers(self) -> None:
+        """Test parsing a message with no trailers."""
+        message = b"Subject\n\nBody text\n"
+        body, trailers = parse_trailers(message)
+        self.assertEqual(body, b"Subject\n\nBody text\n")
+        self.assertEqual(trailers, [])
+
+    def test_simple_trailer(self) -> None:
+        """Test parsing a message with a single trailer."""
+        message = b"Subject\n\nBody text\n\nSigned-off-by: Alice <alice@example.com>\n"
+        body, trailers = parse_trailers(message)
+        self.assertEqual(body, b"Subject\n\nBody text\n")
+        self.assertEqual(len(trailers), 1)
+        self.assertEqual(trailers[0].key, "Signed-off-by")
+        self.assertEqual(trailers[0].value, "Alice <alice@example.com>")
+
+    def test_multiple_trailers(self) -> None:
+        """Test parsing a message with multiple trailers."""
+        message = b"Subject\n\nBody text\n\nSigned-off-by: Alice <alice@example.com>\nReviewed-by: Bob <bob@example.com>\n"
+        body, trailers = parse_trailers(message)
+        self.assertEqual(body, b"Subject\n\nBody text\n")
+        self.assertEqual(len(trailers), 2)
+        self.assertEqual(trailers[0].key, "Signed-off-by")
+        self.assertEqual(trailers[0].value, "Alice <alice@example.com>")
+        self.assertEqual(trailers[1].key, "Reviewed-by")
+        self.assertEqual(trailers[1].value, "Bob <bob@example.com>")
+
+    def test_trailer_with_multiline_value(self) -> None:
+        """Test parsing a trailer with multiline value."""
+        message = b"Subject\n\nBody\n\nTrailer: line1\n line2\n line3\n"
+        _body, trailers = parse_trailers(message)
+        self.assertEqual(len(trailers), 1)
+        self.assertEqual(trailers[0].key, "Trailer")
+        self.assertEqual(trailers[0].value, "line1 line2 line3")
+
+    def test_no_blank_line_before_trailer(self) -> None:
+        """Test that trailers without preceding blank line are not parsed."""
+        message = b"Subject\nBody\nSigned-off-by: Alice\n"
+        body, trailers = parse_trailers(message)
+        self.assertEqual(body, message)
+        self.assertEqual(trailers, [])
+
+    def test_trailer_at_end_only(self) -> None:
+        """Test that trailers must be at the end of the message."""
+        message = b"Subject\n\nSigned-off-by: Alice\n\nMore body text\n"
+        body, trailers = parse_trailers(message)
+        # The "Signed-off-by" is not at the end, so it shouldn't be parsed as a trailer
+        self.assertEqual(body, message)
+        self.assertEqual(trailers, [])
+
+    def test_different_separators(self) -> None:
+        """Test parsing trailers with different separators."""
+        message = b"Subject\n\nBody\n\nKey= value\n"
+        _body, trailers = parse_trailers(message, separators="=")
+        self.assertEqual(len(trailers), 1)
+        self.assertEqual(trailers[0].key, "Key")
+        self.assertEqual(trailers[0].value, "value")
+        self.assertEqual(trailers[0].separator, "=")
+
+    def test_empty_message(self) -> None:
+        """Test parsing an empty message."""
+        body, trailers = parse_trailers(b"")
+        self.assertEqual(body, b"")
+        self.assertEqual(trailers, [])
+
+
+class TestFormatTrailers(unittest.TestCase):
+    """Tests for format_trailers function."""
+
+    def test_empty_list(self) -> None:
+        """Test formatting an empty list of trailers."""
+        result = format_trailers([])
+        self.assertEqual(result, b"")
+
+    def test_single_trailer(self) -> None:
+        """Test formatting a single trailer."""
+        trailers = [Trailer("Signed-off-by", "Alice <alice@example.com>")]
+        result = format_trailers(trailers)
+        self.assertEqual(result, b"Signed-off-by: Alice <alice@example.com>\n")
+
+    def test_multiple_trailers(self) -> None:
+        """Test formatting multiple trailers."""
+        trailers = [
+            Trailer("Signed-off-by", "Alice <alice@example.com>"),
+            Trailer("Reviewed-by", "Bob <bob@example.com>"),
+        ]
+        result = format_trailers(trailers)
+        expected = b"Signed-off-by: Alice <alice@example.com>\nReviewed-by: Bob <bob@example.com>\n"
+        self.assertEqual(result, expected)
+
+
+class TestAddTrailerToMessage(unittest.TestCase):
+    """Tests for add_trailer_to_message function."""
+
+    def test_add_to_empty_message(self) -> None:
+        """Test adding a trailer to an empty message."""
+        message = b""
+        result = add_trailer_to_message(message, "Signed-off-by", "Alice")
+        # Empty messages should get a trailer added
+        self.assertIn(b"Signed-off-by: Alice", result)
+
+    def test_add_to_message_without_trailers(self) -> None:
+        """Test adding a trailer to a message without existing trailers."""
+        message = b"Subject\n\nBody text\n"
+        result = add_trailer_to_message(message, "Signed-off-by", "Alice")
+        expected = b"Subject\n\nBody text\n\nSigned-off-by: Alice\n"
+        self.assertEqual(result, expected)
+
+    def test_add_to_message_with_existing_trailers(self) -> None:
+        """Test adding a trailer to a message with existing trailers."""
+        message = b"Subject\n\nBody\n\nSigned-off-by: Alice\n"
+        result = add_trailer_to_message(message, "Reviewed-by", "Bob")
+        self.assertIn(b"Signed-off-by: Alice", result)
+        self.assertIn(b"Reviewed-by: Bob", result)
+
+    def test_add_duplicate_trailer_default(self) -> None:
+        """Test adding a duplicate trailer with default if_exists."""
+        message = b"Subject\n\nBody\n\nSigned-off-by: Alice\n"
+        result = add_trailer_to_message(
+            message, "Signed-off-by", "Alice", if_exists="addIfDifferentNeighbor"
+        )
+        # Should not add duplicate
+        self.assertEqual(result, message)
+
+    def test_add_duplicate_trailer_add(self) -> None:
+        """Test adding a duplicate trailer with if_exists=add."""
+        message = b"Subject\n\nBody\n\nSigned-off-by: Alice\n"
+        result = add_trailer_to_message(
+            message, "Signed-off-by", "Alice", if_exists="add"
+        )
+        # Should add duplicate
+        self.assertEqual(result.count(b"Signed-off-by: Alice"), 2)
+
+    def test_add_different_value(self) -> None:
+        """Test adding a trailer with same key but different value."""
+        message = b"Subject\n\nBody\n\nSigned-off-by: Alice\n"
+        result = add_trailer_to_message(message, "Signed-off-by", "Bob")
+        self.assertIn(b"Signed-off-by: Alice", result)
+        self.assertIn(b"Signed-off-by: Bob", result)
+
+    def test_replace_existing(self) -> None:
+        """Test replacing existing trailers with if_exists=replace."""
+        message = b"Subject\n\nBody\n\nSigned-off-by: Alice\nSigned-off-by: Bob\n"
+        result = add_trailer_to_message(
+            message, "Signed-off-by", "Charlie", if_exists="replace"
+        )
+        self.assertNotIn(b"Alice", result)
+        self.assertNotIn(b"Bob", result)
+        self.assertIn(b"Signed-off-by: Charlie", result)
+
+    def test_do_nothing_if_exists(self) -> None:
+        """Test if_exists=doNothing."""
+        message = b"Subject\n\nBody\n\nSigned-off-by: Alice\n"
+        result = add_trailer_to_message(
+            message, "Signed-off-by", "Bob", if_exists="doNothing"
+        )
+        # Should not modify the message
+        self.assertEqual(result, message)
+
+    def test_if_missing_do_nothing(self) -> None:
+        """Test if_missing=doNothing."""
+        message = b"Subject\n\nBody\n"
+        result = add_trailer_to_message(
+            message, "Signed-off-by", "Alice", if_missing="doNothing"
+        )
+        # Should not add the trailer
+        self.assertNotIn(b"Signed-off-by", result)
+
+    def test_where_start(self) -> None:
+        """Test adding trailer at start."""
+        message = b"Subject\n\nBody\n\nReviewed-by: Bob\n"
+        result = add_trailer_to_message(
+            message, "Signed-off-by", "Alice", where="start"
+        )
+        # Parse to check order
+        _, trailers = parse_trailers(result)
+        self.assertEqual(len(trailers), 2)
+        self.assertEqual(trailers[0].key, "Signed-off-by")
+        self.assertEqual(trailers[1].key, "Reviewed-by")
+
+    def test_custom_separator(self) -> None:
+        """Test adding trailer with custom separator."""
+        message = b"Subject\n\nBody\n"
+        result = add_trailer_to_message(message, "Key", "value", separator="=")
+        self.assertIn(b"Key= value", result)
+
+
+class TestIntegration(unittest.TestCase):
+    """Integration tests for trailers."""
+
+    def test_parse_and_format_roundtrip(self) -> None:
+        """Test that parse and format are inverse operations."""
+        original = b"Subject\n\nBody\n\nSigned-off-by: Alice\nReviewed-by: Bob\n"
+        body, trailers = parse_trailers(original)
+        formatted = body
+        if body and not body.endswith(b"\n"):
+            formatted += b"\n"
+        if trailers:
+            formatted += b"\n"
+            formatted += format_trailers(trailers)
+        self.assertEqual(formatted, original)
+
+    def test_add_multiple_trailers(self) -> None:
+        """Test adding multiple trailers in sequence."""
+        message = b"Subject\n\nBody\n"
+        message = add_trailer_to_message(message, "Signed-off-by", "Alice")
+        message = add_trailer_to_message(message, "Reviewed-by", "Bob")
+        message = add_trailer_to_message(message, "Tested-by", "Charlie")
+
+        _, trailers = parse_trailers(message)
+        self.assertEqual(len(trailers), 3)
+        self.assertEqual(trailers[0].key, "Signed-off-by")
+        self.assertEqual(trailers[1].key, "Reviewed-by")
+        self.assertEqual(trailers[2].key, "Tested-by")