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 public 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"""
34from typing import Optional
37class Trailer:
38 """Represents a single Git trailer.
40 Args:
41 key: The trailer key/token (e.g., "Signed-off-by")
42 value: The trailer value
43 separator: The separator character used (default ':')
44 """
46 def __init__(self, key: str, value: str, separator: str = ":") -> None:
47 """Initialize a Trailer instance.
49 Args:
50 key: The trailer key/token
51 value: The trailer value
52 separator: The separator character (default ':')
53 """
54 self.key = key
55 self.value = value
56 self.separator = separator
58 def __eq__(self, other: object) -> bool:
59 """Compare two Trailer instances for equality.
61 Args:
62 other: The object to compare with
64 Returns:
65 True if trailers have the same key, value, and separator
66 """
67 if not isinstance(other, Trailer):
68 return NotImplemented
69 return (
70 self.key == other.key
71 and self.value == other.value
72 and self.separator == other.separator
73 )
75 def __repr__(self) -> str:
76 """Return a string representation suitable for debugging.
78 Returns:
79 A string showing the trailer's key, value, and separator
80 """
81 return f"Trailer(key={self.key!r}, value={self.value!r}, separator={self.separator!r})"
83 def __str__(self) -> str:
84 """Return the trailer formatted as it would appear in a commit message.
86 Returns:
87 The trailer in the format "key: value"
88 """
89 return f"{self.key}{self.separator} {self.value}"
92def parse_trailers(
93 message: bytes,
94 separators: str = ":",
95) -> tuple[bytes, list[Trailer]]:
96 """Parse trailers from a commit message.
98 Trailers are extracted from the input by looking for a group of one or more
99 lines that (i) is all trailers, or (ii) contains at least one Git-generated
100 or user-configured trailer and consists of at least 25% trailers.
102 The group must be preceded by one or more empty (or whitespace-only) lines.
103 The group must either be at the end of the input or be the last non-whitespace
104 lines before a line that starts with '---'.
106 Args:
107 message: The commit message as bytes
108 separators: Characters to recognize as trailer separators (default ':')
110 Returns:
111 A tuple of (message_without_trailers, list_of_trailers)
112 """
113 if not message:
114 return (b"", [])
116 # Decode message
117 try:
118 text = message.decode("utf-8")
119 except UnicodeDecodeError:
120 text = message.decode("latin-1")
122 lines = text.splitlines(keepends=True)
124 # Find the trailer block by searching backwards
125 # Look for a blank line followed by trailer-like lines
126 trailer_start = None
127 cutoff_line = None
129 # First, check if there's a "---" line that marks the end of the message
130 for i in range(len(lines) - 1, -1, -1):
131 if lines[i].lstrip().startswith("---"):
132 cutoff_line = i
133 break
135 # Determine the search range
136 search_end = cutoff_line if cutoff_line is not None else len(lines)
138 # Search backwards for the trailer block
139 # A trailer block must be preceded by a blank line and extend to the end
140 for i in range(search_end - 1, -1, -1):
141 line = lines[i].rstrip()
143 # Check if this is a blank line
144 if not line:
145 # Check if the lines after this blank line are trailers
146 potential_trailers = lines[i + 1 : search_end]
148 # Remove trailing blank lines from potential trailers
149 while potential_trailers and not potential_trailers[-1].strip():
150 potential_trailers = potential_trailers[:-1]
152 # Check if these lines form a trailer block and extend to search_end
153 if potential_trailers and _is_trailer_block(potential_trailers, separators):
154 # Verify these trailers extend to the end (search_end)
155 # by checking there are no non-blank lines after them
156 last_trailer_index = i + 1 + len(potential_trailers)
157 has_content_after = False
158 for j in range(last_trailer_index, search_end):
159 if lines[j].strip():
160 has_content_after = True
161 break
163 if not has_content_after:
164 trailer_start = i + 1
165 break
167 if trailer_start is None:
168 # No trailer block found
169 return (message, [])
171 # Parse the trailers
172 trailer_lines = lines[trailer_start:search_end]
173 trailers = _parse_trailer_lines(trailer_lines, separators)
175 # Reconstruct the message without trailers
176 # Keep everything before the blank line that precedes the trailers
177 message_lines = lines[:trailer_start]
179 # Remove trailing blank lines from the message
180 while message_lines and not message_lines[-1].strip():
181 message_lines.pop()
183 message_without_trailers = "".join(message_lines)
184 if message_without_trailers and not message_without_trailers.endswith("\n"):
185 message_without_trailers += "\n"
187 return (message_without_trailers.encode("utf-8"), trailers)
190def _is_trailer_block(lines: list[str], separators: str) -> bool:
191 """Check if a group of lines forms a valid trailer block.
193 A trailer block must be composed entirely of trailer lines (with possible
194 blank lines and continuation lines). A single non-trailer line invalidates
195 the entire block.
197 Args:
198 lines: The lines to check
199 separators: Valid separator characters
201 Returns:
202 True if the lines form a valid trailer block
203 """
204 if not lines:
205 return False
207 # Remove empty lines at the end
208 while lines and not lines[-1].strip():
209 lines = lines[:-1]
211 if not lines:
212 return False
214 has_any_trailer = False
216 i = 0
217 while i < len(lines):
218 line = lines[i].rstrip()
220 if not line:
221 # Empty lines are allowed within the trailer block
222 i += 1
223 continue
225 # Check if this line is a continuation (starts with whitespace)
226 if line and line[0].isspace():
227 # This is a continuation of the previous line
228 i += 1
229 continue
231 # Check if this is a trailer line
232 is_trailer = False
233 for sep in separators:
234 if sep in line:
235 key_part = line.split(sep, 1)[0]
236 # Key must not contain whitespace
237 if key_part and not any(c.isspace() for c in key_part):
238 is_trailer = True
239 has_any_trailer = True
240 break
242 # If this is not a trailer line, the block is invalid
243 if not is_trailer:
244 return False
246 i += 1
248 # Must have at least one trailer
249 return has_any_trailer
252def _parse_trailer_lines(lines: list[str], separators: str) -> list[Trailer]:
253 """Parse individual trailer lines.
255 Args:
256 lines: The trailer lines to parse
257 separators: Valid separator characters
259 Returns:
260 List of parsed Trailer objects
261 """
262 trailers: list[Trailer] = []
263 current_trailer: Optional[Trailer] = None
265 for line in lines:
266 stripped = line.rstrip()
268 if not stripped:
269 # Empty line - finalize current trailer if any
270 if current_trailer:
271 trailers.append(current_trailer)
272 current_trailer = None
273 continue
275 # Check if this is a continuation line (starts with whitespace)
276 if stripped[0].isspace():
277 if current_trailer:
278 # Append to the current trailer value
279 continuation = stripped.lstrip()
280 current_trailer.value += " " + continuation
281 continue
283 # Finalize the previous trailer if any
284 if current_trailer:
285 trailers.append(current_trailer)
286 current_trailer = None
288 # Try to parse as a new trailer
289 for sep in separators:
290 if sep in stripped:
291 parts = stripped.split(sep, 1)
292 key = parts[0]
294 # Key must not contain whitespace
295 if key and not any(c.isspace() for c in key):
296 value = parts[1].strip() if len(parts) > 1 else ""
297 current_trailer = Trailer(key, value, sep)
298 break
300 # Don't forget the last trailer
301 if current_trailer:
302 trailers.append(current_trailer)
304 return trailers
307def format_trailers(trailers: list[Trailer]) -> bytes:
308 """Format a list of trailers as bytes.
310 Args:
311 trailers: List of Trailer objects
313 Returns:
314 Formatted trailers as bytes
315 """
316 if not trailers:
317 return b""
319 lines = [str(trailer) for trailer in trailers]
320 return "\n".join(lines).encode("utf-8") + b"\n"
323def add_trailer_to_message(
324 message: bytes,
325 key: str,
326 value: str,
327 separator: str = ":",
328 where: str = "end",
329 if_exists: str = "addIfDifferentNeighbor",
330 if_missing: str = "add",
331) -> bytes:
332 """Add a trailer to a commit message.
334 Args:
335 message: The original commit message
336 key: The trailer key
337 value: The trailer value
338 separator: The separator to use
339 where: Where to add the trailer ('end', 'start', 'after', 'before')
340 if_exists: How to handle existing trailers with the same key
341 - 'add': Always add
342 - 'replace': Replace all existing
343 - 'addIfDifferent': Add only if value is different from all existing
344 - 'addIfDifferentNeighbor': Add only if value differs from neighbors
345 - 'doNothing': Don't add if key exists
346 if_missing: What to do if the key doesn't exist
347 - 'add': Add the trailer
348 - 'doNothing': Don't add the trailer
350 Returns:
351 The message with the trailer added
352 """
353 message_body, existing_trailers = parse_trailers(message, separator)
355 new_trailer = Trailer(key, value, separator)
357 # Check if the key exists
358 key_exists = any(t.key == key for t in existing_trailers)
360 if not key_exists:
361 if if_missing == "doNothing":
362 return message
363 # Add the new trailer
364 updated_trailers = [*existing_trailers, new_trailer]
365 else:
366 # Key exists - apply if_exists logic
367 if if_exists == "doNothing":
368 return message
369 elif if_exists == "replace":
370 # Replace all trailers with this key
371 updated_trailers = [t for t in existing_trailers if t.key != key]
372 updated_trailers.append(new_trailer)
373 elif if_exists == "addIfDifferent":
374 # Add only if no existing trailer has the same value
375 has_same_value = any(
376 t.key == key and t.value == value for t in existing_trailers
377 )
378 if has_same_value:
379 return message
380 updated_trailers = [*existing_trailers, new_trailer]
381 elif if_exists == "addIfDifferentNeighbor":
382 # Add only if adjacent trailers with same key have different values
383 should_add = True
385 # Check if there's a neighboring trailer with the same key and value
386 for i, t in enumerate(existing_trailers):
387 if t.key == key and t.value == value:
388 # Check if it's a neighbor (last trailer with this key)
389 is_neighbor = True
390 for j in range(i + 1, len(existing_trailers)):
391 if existing_trailers[j].key == key:
392 is_neighbor = False
393 break
394 if is_neighbor:
395 should_add = False
396 break
398 if not should_add:
399 return message
400 updated_trailers = [*existing_trailers, new_trailer]
401 else: # 'add'
402 updated_trailers = [*existing_trailers, new_trailer]
404 # Apply where logic
405 if where == "start":
406 updated_trailers = [new_trailer] + [
407 t for t in updated_trailers if t != new_trailer
408 ]
409 elif where == "before":
410 # Insert before the first trailer with the same key
411 result = []
412 inserted = False
413 for t in updated_trailers:
414 if not inserted and t.key == key and t != new_trailer:
415 result.append(new_trailer)
416 inserted = True
417 if t != new_trailer:
418 result.append(t)
419 if not inserted:
420 result.append(new_trailer)
421 updated_trailers = result
422 elif where == "after":
423 # Insert after the last trailer with the same key
424 result = []
425 last_key_index = -1
426 for i, t in enumerate(updated_trailers):
427 if t.key == key and t != new_trailer:
428 last_key_index = len(result)
429 if t != new_trailer:
430 result.append(t)
432 if last_key_index >= 0:
433 result.insert(last_key_index + 1, new_trailer)
434 else:
435 result.append(new_trailer)
436 updated_trailers = result
437 # 'end' is the default - trailer is already at the end
439 # Reconstruct the message
440 result_message = message_body
441 if result_message and not result_message.endswith(b"\n"):
442 result_message += b"\n"
444 if updated_trailers:
445 result_message += b"\n"
446 result_message += format_trailers(updated_trailers)
448 return result_message