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