Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/trailers.py: 6%
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# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
5# General Public License as published by the Free Software Foundation; version 2.0
6# or (at your option) any later version. You can redistribute it and/or
7# modify it under the terms of either of these two licenses.
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14#
15# You should have received a copy of the licenses; if not, see
16# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
17# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
18# License, Version 2.0.
19#
21"""Git trailers parsing and manipulation.
23This module provides functionality for parsing and manipulating Git trailers,
24which are structured information blocks appended to commit messages.
26Trailers follow the format:
27 Token: value
28 Token: value
30They are similar to RFC 822 email headers and appear at the end of commit
31messages after free-form content.
32"""
35class Trailer:
36 """Represents a single Git trailer.
38 Args:
39 key: The trailer key/token (e.g., "Signed-off-by")
40 value: The trailer value
41 separator: The separator character used (default ':')
42 """
44 def __init__(self, key: str, value: str, separator: str = ":") -> None:
45 """Initialize a Trailer instance.
47 Args:
48 key: The trailer key/token
49 value: The trailer value
50 separator: The separator character (default ':')
51 """
52 self.key = key
53 self.value = value
54 self.separator = separator
56 def __eq__(self, other: object) -> bool:
57 """Compare two Trailer instances for equality.
59 Args:
60 other: The object to compare with
62 Returns:
63 True if trailers have the same key, value, and separator
64 """
65 if not isinstance(other, Trailer):
66 return NotImplemented
67 return (
68 self.key == other.key
69 and self.value == other.value
70 and self.separator == other.separator
71 )
73 def __repr__(self) -> str:
74 """Return a string representation suitable for debugging.
76 Returns:
77 A string showing the trailer's key, value, and separator
78 """
79 return f"Trailer(key={self.key!r}, value={self.value!r}, separator={self.separator!r})"
81 def __str__(self) -> str:
82 """Return the trailer formatted as it would appear in a commit message.
84 Returns:
85 The trailer in the format "key: value"
86 """
87 return f"{self.key}{self.separator} {self.value}"
90def parse_trailers(
91 message: bytes,
92 separators: str = ":",
93) -> tuple[bytes, list[Trailer]]:
94 """Parse trailers from a commit message.
96 Trailers are extracted from the input by looking for a group of one or more
97 lines that (i) is all trailers, or (ii) contains at least one Git-generated
98 or user-configured trailer and consists of at least 25% trailers.
100 The group must be preceded by one or more empty (or whitespace-only) lines.
101 The group must either be at the end of the input or be the last non-whitespace
102 lines before a line that starts with '---'.
104 Args:
105 message: The commit message as bytes
106 separators: Characters to recognize as trailer separators (default ':')
108 Returns:
109 A tuple of (message_without_trailers, list_of_trailers)
110 """
111 if not message:
112 return (b"", [])
114 # Decode message
115 try:
116 text = message.decode("utf-8")
117 except UnicodeDecodeError:
118 text = message.decode("latin-1")
120 lines = text.splitlines(keepends=True)
122 # Find the trailer block by searching backwards
123 # Look for a blank line followed by trailer-like lines
124 trailer_start = None
125 cutoff_line = None
127 # First, check if there's a "---" line that marks the end of the message
128 for i in range(len(lines) - 1, -1, -1):
129 if lines[i].lstrip().startswith("---"):
130 cutoff_line = i
131 break
133 # Determine the search range
134 search_end = cutoff_line if cutoff_line is not None else len(lines)
136 # Search backwards for the trailer block
137 # A trailer block must be preceded by a blank line and extend to the end
138 for i in range(search_end - 1, -1, -1):
139 line = lines[i].rstrip()
141 # Check if this is a blank line
142 if not line:
143 # Check if the lines after this blank line are trailers
144 potential_trailers = lines[i + 1 : search_end]
146 # Remove trailing blank lines from potential trailers
147 while potential_trailers and not potential_trailers[-1].strip():
148 potential_trailers = potential_trailers[:-1]
150 # Check if these lines form a trailer block and extend to search_end
151 if potential_trailers and _is_trailer_block(potential_trailers, separators):
152 # Verify these trailers extend to the end (search_end)
153 # by checking there are no non-blank lines after them
154 last_trailer_index = i + 1 + len(potential_trailers)
155 has_content_after = False
156 for j in range(last_trailer_index, search_end):
157 if lines[j].strip():
158 has_content_after = True
159 break
161 if not has_content_after:
162 trailer_start = i + 1
163 break
165 if trailer_start is None:
166 # No trailer block found
167 return (message, [])
169 # Parse the trailers
170 trailer_lines = lines[trailer_start:search_end]
171 trailers = _parse_trailer_lines(trailer_lines, separators)
173 # Reconstruct the message without trailers
174 # Keep everything before the blank line that precedes the trailers
175 message_lines = lines[:trailer_start]
177 # Remove trailing blank lines from the message
178 while message_lines and not message_lines[-1].strip():
179 message_lines.pop()
181 message_without_trailers = "".join(message_lines)
182 if message_without_trailers and not message_without_trailers.endswith("\n"):
183 message_without_trailers += "\n"
185 return (message_without_trailers.encode("utf-8"), trailers)
188def _is_trailer_block(lines: list[str], separators: str) -> bool:
189 """Check if a group of lines forms a valid trailer block.
191 A trailer block must be composed entirely of trailer lines (with possible
192 blank lines and continuation lines). A single non-trailer line invalidates
193 the entire block.
195 Args:
196 lines: The lines to check
197 separators: Valid separator characters
199 Returns:
200 True if the lines form a valid trailer block
201 """
202 if not lines:
203 return False
205 # Remove empty lines at the end
206 while lines and not lines[-1].strip():
207 lines = lines[:-1]
209 if not lines:
210 return False
212 has_any_trailer = False
214 i = 0
215 while i < len(lines):
216 line = lines[i].rstrip()
218 if not line:
219 # Empty lines are allowed within the trailer block
220 i += 1
221 continue
223 # Check if this line is a continuation (starts with whitespace)
224 if line and line[0].isspace():
225 # This is a continuation of the previous line
226 i += 1
227 continue
229 # Check if this is a trailer line
230 is_trailer = False
231 for sep in separators:
232 if sep in line:
233 key_part = line.split(sep, 1)[0]
234 # Key must not contain whitespace
235 if key_part and not any(c.isspace() for c in key_part):
236 is_trailer = True
237 has_any_trailer = True
238 break
240 # If this is not a trailer line, the block is invalid
241 if not is_trailer:
242 return False
244 i += 1
246 # Must have at least one trailer
247 return has_any_trailer
250def _parse_trailer_lines(lines: list[str], separators: str) -> list[Trailer]:
251 """Parse individual trailer lines.
253 Args:
254 lines: The trailer lines to parse
255 separators: Valid separator characters
257 Returns:
258 List of parsed Trailer objects
259 """
260 trailers: list[Trailer] = []
261 current_trailer: Trailer | None = None
263 for line in lines:
264 stripped = line.rstrip()
266 if not stripped:
267 # Empty line - finalize current trailer if any
268 if current_trailer:
269 trailers.append(current_trailer)
270 current_trailer = None
271 continue
273 # Check if this is a continuation line (starts with whitespace)
274 if stripped[0].isspace():
275 if current_trailer:
276 # Append to the current trailer value
277 continuation = stripped.lstrip()
278 current_trailer.value += " " + continuation
279 continue
281 # Finalize the previous trailer if any
282 if current_trailer:
283 trailers.append(current_trailer)
284 current_trailer = None
286 # Try to parse as a new trailer
287 for sep in separators:
288 if sep in stripped:
289 parts = stripped.split(sep, 1)
290 key = parts[0]
292 # Key must not contain whitespace
293 if key and not any(c.isspace() for c in key):
294 value = parts[1].strip() if len(parts) > 1 else ""
295 current_trailer = Trailer(key, value, sep)
296 break
298 # Don't forget the last trailer
299 if current_trailer:
300 trailers.append(current_trailer)
302 return trailers
305def format_trailers(trailers: list[Trailer]) -> bytes:
306 """Format a list of trailers as bytes.
308 Args:
309 trailers: List of Trailer objects
311 Returns:
312 Formatted trailers as bytes
313 """
314 if not trailers:
315 return b""
317 lines = [str(trailer) for trailer in trailers]
318 return "\n".join(lines).encode("utf-8") + b"\n"
321def add_trailer_to_message(
322 message: bytes,
323 key: str,
324 value: str,
325 separator: str = ":",
326 where: str = "end",
327 if_exists: str = "addIfDifferentNeighbor",
328 if_missing: str = "add",
329) -> bytes:
330 """Add a trailer to a commit message.
332 Args:
333 message: The original commit message
334 key: The trailer key
335 value: The trailer value
336 separator: The separator to use
337 where: Where to add the trailer ('end', 'start', 'after', 'before')
338 if_exists: How to handle existing trailers with the same key
339 - 'add': Always add
340 - 'replace': Replace all existing
341 - 'addIfDifferent': Add only if value is different from all existing
342 - 'addIfDifferentNeighbor': Add only if value differs from neighbors
343 - 'doNothing': Don't add if key exists
344 if_missing: What to do if the key doesn't exist
345 - 'add': Add the trailer
346 - 'doNothing': Don't add the trailer
348 Returns:
349 The message with the trailer added
350 """
351 message_body, existing_trailers = parse_trailers(message, separator)
353 new_trailer = Trailer(key, value, separator)
355 # Check if the key exists
356 key_exists = any(t.key == key for t in existing_trailers)
358 if not key_exists:
359 if if_missing == "doNothing":
360 return message
361 # Add the new trailer
362 updated_trailers = [*existing_trailers, new_trailer]
363 else:
364 # Key exists - apply if_exists logic
365 if if_exists == "doNothing":
366 return message
367 elif if_exists == "replace":
368 # Replace all trailers with this key
369 updated_trailers = [t for t in existing_trailers if t.key != key]
370 updated_trailers.append(new_trailer)
371 elif if_exists == "addIfDifferent":
372 # Add only if no existing trailer has the same value
373 has_same_value = any(
374 t.key == key and t.value == value for t in existing_trailers
375 )
376 if has_same_value:
377 return message
378 updated_trailers = [*existing_trailers, new_trailer]
379 elif if_exists == "addIfDifferentNeighbor":
380 # Add only if adjacent trailers with same key have different values
381 should_add = True
383 # Check if there's a neighboring trailer with the same key and value
384 for i, t in enumerate(existing_trailers):
385 if t.key == key and t.value == value:
386 # Check if it's a neighbor (last trailer with this key)
387 is_neighbor = True
388 for j in range(i + 1, len(existing_trailers)):
389 if existing_trailers[j].key == key:
390 is_neighbor = False
391 break
392 if is_neighbor:
393 should_add = False
394 break
396 if not should_add:
397 return message
398 updated_trailers = [*existing_trailers, new_trailer]
399 else: # 'add'
400 updated_trailers = [*existing_trailers, new_trailer]
402 # Apply where logic
403 if where == "start":
404 updated_trailers = [new_trailer] + [
405 t for t in updated_trailers if t != new_trailer
406 ]
407 elif where == "before":
408 # Insert before the first trailer with the same key
409 result = []
410 inserted = False
411 for t in updated_trailers:
412 if not inserted and t.key == key and t != new_trailer:
413 result.append(new_trailer)
414 inserted = True
415 if t != new_trailer:
416 result.append(t)
417 if not inserted:
418 result.append(new_trailer)
419 updated_trailers = result
420 elif where == "after":
421 # Insert after the last trailer with the same key
422 result = []
423 last_key_index = -1
424 for i, t in enumerate(updated_trailers):
425 if t.key == key and t != new_trailer:
426 last_key_index = len(result)
427 if t != new_trailer:
428 result.append(t)
430 if last_key_index >= 0:
431 result.insert(last_key_index + 1, new_trailer)
432 else:
433 result.append(new_trailer)
434 updated_trailers = result
435 # 'end' is the default - trailer is already at the end
437 # Reconstruct the message
438 result_message = message_body
439 if result_message and not result_message.endswith(b"\n"):
440 result_message += b"\n"
442 if updated_trailers:
443 result_message += b"\n"
444 result_message += format_trailers(updated_trailers)
446 return result_message