Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/trailers.py: 7%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# trailers.py -- Git trailers parsing and manipulation
2# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
3#
4# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
5# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
6# General Public License as published by the Free Software Foundation; version 2.0
7# or (at your option) any later version. You can redistribute it and/or
8# modify it under the terms of either of these two licenses.
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16# You should have received a copy of the licenses; if not, see
17# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
18# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
19# License, Version 2.0.
20#
22"""Git trailers parsing and manipulation.
24This module provides functionality for parsing and manipulating Git trailers,
25which are structured information blocks appended to commit messages.
27Trailers follow the format:
28 Token: value
29 Token: value
31They are similar to RFC 822 email headers and appear at the end of commit
32messages after free-form content.
33"""
35__all__ = [
36 "Trailer",
37 "add_trailer_to_message",
38 "format_trailers",
39 "parse_trailers",
40]
43class Trailer:
44 """Represents a single Git trailer.
46 Args:
47 key: The trailer key/token (e.g., "Signed-off-by")
48 value: The trailer value
49 separator: The separator character used (default ':')
50 """
52 def __init__(self, key: str, value: str, separator: str = ":") -> None:
53 """Initialize a Trailer instance.
55 Args:
56 key: The trailer key/token
57 value: The trailer value
58 separator: The separator character (default ':')
59 """
60 self.key = key
61 self.value = value
62 self.separator = separator
64 def __eq__(self, other: object) -> bool:
65 """Compare two Trailer instances for equality.
67 Args:
68 other: The object to compare with
70 Returns:
71 True if trailers have the same key, value, and separator
72 """
73 if not isinstance(other, Trailer):
74 return NotImplemented
75 return (
76 self.key == other.key
77 and self.value == other.value
78 and self.separator == other.separator
79 )
81 def __repr__(self) -> str:
82 """Return a string representation suitable for debugging.
84 Returns:
85 A string showing the trailer's key, value, and separator
86 """
87 return f"Trailer(key={self.key!r}, value={self.value!r}, separator={self.separator!r})"
89 def __str__(self) -> str:
90 """Return the trailer formatted as it would appear in a commit message.
92 Returns:
93 The trailer in the format "key: value"
94 """
95 return f"{self.key}{self.separator} {self.value}"
98def parse_trailers(
99 message: bytes,
100 separators: str = ":",
101) -> tuple[bytes, list[Trailer]]:
102 """Parse trailers from a commit message.
104 Trailers are extracted from the input by looking for a group of one or more
105 lines that (i) is all trailers, or (ii) contains at least one Git-generated
106 or user-configured trailer and consists of at least 25% trailers.
108 The group must be preceded by one or more empty (or whitespace-only) lines.
109 The group must either be at the end of the input or be the last non-whitespace
110 lines before a line that starts with '---'.
112 Args:
113 message: The commit message as bytes
114 separators: Characters to recognize as trailer separators (default ':')
116 Returns:
117 A tuple of (message_without_trailers, list_of_trailers)
118 """
119 if not message:
120 return (b"", [])
122 # Decode message
123 try:
124 text = message.decode("utf-8")
125 except UnicodeDecodeError:
126 text = message.decode("latin-1")
128 lines = text.splitlines(keepends=True)
130 # Find the trailer block by searching backwards
131 # Look for a blank line followed by trailer-like lines
132 trailer_start = None
133 cutoff_line = None
135 # First, check if there's a "---" line that marks the end of the message
136 for i in range(len(lines) - 1, -1, -1):
137 if lines[i].lstrip().startswith("---"):
138 cutoff_line = i
139 break
141 # Determine the search range
142 search_end = cutoff_line if cutoff_line is not None else len(lines)
144 # Find the trailer block by scanning backward from the last non-blank line.
145 # A trailer block is a run of trailer/continuation/blank lines that contains
146 # at least one trailer and is preceded by a blank line. Because the block
147 # only grows as the scan moves up, a non-blank line that is neither a
148 # continuation nor a trailer invalidates every larger block, so the scan can
149 # stop at it. Doing this in one pass keeps parsing linear in the message
150 # size; the previous version re-sliced and re-checked the tail for every
151 # blank line, which is cubic on a message made mostly of blank lines.
152 content_end = search_end
153 while content_end > 0 and not lines[content_end - 1].strip():
154 content_end -= 1
156 has_trailer = False
157 for k in range(content_end - 1, 0, -1):
158 line = lines[k].rstrip()
159 if line and not line[0].isspace():
160 # A non-blank, non-continuation line must be a trailer, otherwise no
161 # block reaching this line (or starting above it) can be valid.
162 if not _is_trailer_line(line, separators):
163 break
164 has_trailer = True
165 # Blank and continuation lines are allowed inside the block. Stop at the
166 # first preceding blank line once a trailer has been seen.
167 if has_trailer and not lines[k - 1].strip():
168 trailer_start = k
169 break
171 if trailer_start is None:
172 # No trailer block found
173 return (message, [])
175 # Parse the trailers
176 trailer_lines = lines[trailer_start:search_end]
177 trailers = _parse_trailer_lines(trailer_lines, separators)
179 # Reconstruct the message without trailers
180 # Keep everything before the blank line that precedes the trailers
181 message_lines = lines[:trailer_start]
183 # Remove trailing blank lines from the message
184 while message_lines and not message_lines[-1].strip():
185 message_lines.pop()
187 message_without_trailers = "".join(message_lines)
188 if message_without_trailers and not message_without_trailers.endswith("\n"):
189 message_without_trailers += "\n"
191 return (message_without_trailers.encode("utf-8"), trailers)
194def _is_trailer_line(line: str, separators: str) -> bool:
195 """Check if a single line is a trailer line.
197 A trailer line contains one of the separator characters and the token
198 before the first separator is non-empty and free of whitespace.
200 Args:
201 line: The line to check (trailing whitespace already removed)
202 separators: Valid separator characters
204 Returns:
205 True if the line is a trailer line
206 """
207 for sep in separators:
208 if sep in line:
209 key_part = line.split(sep, 1)[0]
210 # Key must not contain whitespace
211 if key_part and not any(c.isspace() for c in key_part):
212 return True
213 return False
216def _parse_trailer_lines(lines: list[str], separators: str) -> list[Trailer]:
217 """Parse individual trailer lines.
219 Args:
220 lines: The trailer lines to parse
221 separators: Valid separator characters
223 Returns:
224 List of parsed Trailer objects
225 """
226 trailers: list[Trailer] = []
227 current_trailer: Trailer | None = None
229 for line in lines:
230 stripped = line.rstrip()
232 if not stripped:
233 # Empty line - finalize current trailer if any
234 if current_trailer:
235 trailers.append(current_trailer)
236 current_trailer = None
237 continue
239 # Check if this is a continuation line (starts with whitespace)
240 if stripped[0].isspace():
241 if current_trailer:
242 # Append to the current trailer value
243 continuation = stripped.lstrip()
244 current_trailer.value += " " + continuation
245 continue
247 # Finalize the previous trailer if any
248 if current_trailer:
249 trailers.append(current_trailer)
250 current_trailer = None
252 # Try to parse as a new trailer
253 for sep in separators:
254 if sep in stripped:
255 parts = stripped.split(sep, 1)
256 key = parts[0]
258 # Key must not contain whitespace
259 if key and not any(c.isspace() for c in key):
260 value = parts[1].strip() if len(parts) > 1 else ""
261 current_trailer = Trailer(key, value, sep)
262 break
264 # Don't forget the last trailer
265 if current_trailer:
266 trailers.append(current_trailer)
268 return trailers
271def format_trailers(trailers: list[Trailer]) -> bytes:
272 """Format a list of trailers as bytes.
274 Args:
275 trailers: List of Trailer objects
277 Returns:
278 Formatted trailers as bytes
279 """
280 if not trailers:
281 return b""
283 lines = [str(trailer) for trailer in trailers]
284 return "\n".join(lines).encode("utf-8") + b"\n"
287def add_trailer_to_message(
288 message: bytes,
289 key: str,
290 value: str,
291 separator: str = ":",
292 where: str = "end",
293 if_exists: str = "addIfDifferentNeighbor",
294 if_missing: str = "add",
295) -> bytes:
296 """Add a trailer to a commit message.
298 Args:
299 message: The original commit message
300 key: The trailer key
301 value: The trailer value
302 separator: The separator to use
303 where: Where to add the trailer ('end', 'start', 'after', 'before')
304 if_exists: How to handle existing trailers with the same key
305 - 'add': Always add
306 - 'replace': Replace all existing
307 - 'addIfDifferent': Add only if value is different from all existing
308 - 'addIfDifferentNeighbor': Add only if value differs from neighbors
309 - 'doNothing': Don't add if key exists
310 if_missing: What to do if the key doesn't exist
311 - 'add': Add the trailer
312 - 'doNothing': Don't add the trailer
314 Returns:
315 The message with the trailer added
316 """
317 message_body, existing_trailers = parse_trailers(message, separator)
319 new_trailer = Trailer(key, value, separator)
321 # Check if the key exists
322 key_exists = any(t.key == key for t in existing_trailers)
324 if not key_exists:
325 if if_missing == "doNothing":
326 return message
327 # Add the new trailer
328 updated_trailers = [*existing_trailers, new_trailer]
329 else:
330 # Key exists - apply if_exists logic
331 if if_exists == "doNothing":
332 return message
333 elif if_exists == "replace":
334 # Replace all trailers with this key
335 updated_trailers = [t for t in existing_trailers if t.key != key]
336 updated_trailers.append(new_trailer)
337 elif if_exists == "addIfDifferent":
338 # Add only if no existing trailer has the same value
339 has_same_value = any(
340 t.key == key and t.value == value for t in existing_trailers
341 )
342 if has_same_value:
343 return message
344 updated_trailers = [*existing_trailers, new_trailer]
345 elif if_exists == "addIfDifferentNeighbor":
346 # Add only if adjacent trailers with same key have different values
347 should_add = True
349 # Check if there's a neighboring trailer with the same key and value
350 for i, t in enumerate(existing_trailers):
351 if t.key == key and t.value == value:
352 # Check if it's a neighbor (last trailer with this key)
353 is_neighbor = True
354 for j in range(i + 1, len(existing_trailers)):
355 if existing_trailers[j].key == key:
356 is_neighbor = False
357 break
358 if is_neighbor:
359 should_add = False
360 break
362 if not should_add:
363 return message
364 updated_trailers = [*existing_trailers, new_trailer]
365 else: # 'add'
366 updated_trailers = [*existing_trailers, new_trailer]
368 # Apply where logic
369 if where == "start":
370 updated_trailers = [new_trailer] + [
371 t for t in updated_trailers if t != new_trailer
372 ]
373 elif where == "before":
374 # Insert before the first trailer with the same key
375 result = []
376 inserted = False
377 for t in updated_trailers:
378 if not inserted and t.key == key and t != new_trailer:
379 result.append(new_trailer)
380 inserted = True
381 if t != new_trailer:
382 result.append(t)
383 if not inserted:
384 result.append(new_trailer)
385 updated_trailers = result
386 elif where == "after":
387 # Insert after the last trailer with the same key
388 result = []
389 last_key_index = -1
390 for i, t in enumerate(updated_trailers):
391 if t.key == key and t != new_trailer:
392 last_key_index = len(result)
393 if t != new_trailer:
394 result.append(t)
396 if last_key_index >= 0:
397 result.insert(last_key_index + 1, new_trailer)
398 else:
399 result.append(new_trailer)
400 updated_trailers = result
401 # 'end' is the default - trailer is already at the end
403 # Reconstruct the message
404 result_message = message_body
405 if result_message and not result_message.endswith(b"\n"):
406 result_message += b"\n"
408 if updated_trailers:
409 result_message += b"\n"
410 result_message += format_trailers(updated_trailers)
412 return result_message