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# 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 # Search backwards for the trailer block
145 # A trailer block must be preceded by a blank line and extend to the end
146 for i in range(search_end - 1, -1, -1):
147 line = lines[i].rstrip()
149 # Check if this is a blank line
150 if not line:
151 # Check if the lines after this blank line are trailers
152 potential_trailers = lines[i + 1 : search_end]
154 # Remove trailing blank lines from potential trailers
155 while potential_trailers and not potential_trailers[-1].strip():
156 potential_trailers = potential_trailers[:-1]
158 # Check if these lines form a trailer block and extend to search_end
159 if potential_trailers and _is_trailer_block(potential_trailers, separators):
160 # Verify these trailers extend to the end (search_end)
161 # by checking there are no non-blank lines after them
162 last_trailer_index = i + 1 + len(potential_trailers)
163 has_content_after = False
164 for j in range(last_trailer_index, search_end):
165 if lines[j].strip():
166 has_content_after = True
167 break
169 if not has_content_after:
170 trailer_start = i + 1
171 break
173 if trailer_start is None:
174 # No trailer block found
175 return (message, [])
177 # Parse the trailers
178 trailer_lines = lines[trailer_start:search_end]
179 trailers = _parse_trailer_lines(trailer_lines, separators)
181 # Reconstruct the message without trailers
182 # Keep everything before the blank line that precedes the trailers
183 message_lines = lines[:trailer_start]
185 # Remove trailing blank lines from the message
186 while message_lines and not message_lines[-1].strip():
187 message_lines.pop()
189 message_without_trailers = "".join(message_lines)
190 if message_without_trailers and not message_without_trailers.endswith("\n"):
191 message_without_trailers += "\n"
193 return (message_without_trailers.encode("utf-8"), trailers)
196def _is_trailer_block(lines: list[str], separators: str) -> bool:
197 """Check if a group of lines forms a valid trailer block.
199 A trailer block must be composed entirely of trailer lines (with possible
200 blank lines and continuation lines). A single non-trailer line invalidates
201 the entire block.
203 Args:
204 lines: The lines to check
205 separators: Valid separator characters
207 Returns:
208 True if the lines form a valid trailer block
209 """
210 if not lines:
211 return False
213 # Remove empty lines at the end
214 while lines and not lines[-1].strip():
215 lines = lines[:-1]
217 if not lines:
218 return False
220 has_any_trailer = False
222 i = 0
223 while i < len(lines):
224 line = lines[i].rstrip()
226 if not line:
227 # Empty lines are allowed within the trailer block
228 i += 1
229 continue
231 # Check if this line is a continuation (starts with whitespace)
232 if line and line[0].isspace():
233 # This is a continuation of the previous line
234 i += 1
235 continue
237 # Check if this is a trailer line
238 is_trailer = False
239 for sep in separators:
240 if sep in line:
241 key_part = line.split(sep, 1)[0]
242 # Key must not contain whitespace
243 if key_part and not any(c.isspace() for c in key_part):
244 is_trailer = True
245 has_any_trailer = True
246 break
248 # If this is not a trailer line, the block is invalid
249 if not is_trailer:
250 return False
252 i += 1
254 # Must have at least one trailer
255 return has_any_trailer
258def _parse_trailer_lines(lines: list[str], separators: str) -> list[Trailer]:
259 """Parse individual trailer lines.
261 Args:
262 lines: The trailer lines to parse
263 separators: Valid separator characters
265 Returns:
266 List of parsed Trailer objects
267 """
268 trailers: list[Trailer] = []
269 current_trailer: Trailer | None = None
271 for line in lines:
272 stripped = line.rstrip()
274 if not stripped:
275 # Empty line - finalize current trailer if any
276 if current_trailer:
277 trailers.append(current_trailer)
278 current_trailer = None
279 continue
281 # Check if this is a continuation line (starts with whitespace)
282 if stripped[0].isspace():
283 if current_trailer:
284 # Append to the current trailer value
285 continuation = stripped.lstrip()
286 current_trailer.value += " " + continuation
287 continue
289 # Finalize the previous trailer if any
290 if current_trailer:
291 trailers.append(current_trailer)
292 current_trailer = None
294 # Try to parse as a new trailer
295 for sep in separators:
296 if sep in stripped:
297 parts = stripped.split(sep, 1)
298 key = parts[0]
300 # Key must not contain whitespace
301 if key and not any(c.isspace() for c in key):
302 value = parts[1].strip() if len(parts) > 1 else ""
303 current_trailer = Trailer(key, value, sep)
304 break
306 # Don't forget the last trailer
307 if current_trailer:
308 trailers.append(current_trailer)
310 return trailers
313def format_trailers(trailers: list[Trailer]) -> bytes:
314 """Format a list of trailers as bytes.
316 Args:
317 trailers: List of Trailer objects
319 Returns:
320 Formatted trailers as bytes
321 """
322 if not trailers:
323 return b""
325 lines = [str(trailer) for trailer in trailers]
326 return "\n".join(lines).encode("utf-8") + b"\n"
329def add_trailer_to_message(
330 message: bytes,
331 key: str,
332 value: str,
333 separator: str = ":",
334 where: str = "end",
335 if_exists: str = "addIfDifferentNeighbor",
336 if_missing: str = "add",
337) -> bytes:
338 """Add a trailer to a commit message.
340 Args:
341 message: The original commit message
342 key: The trailer key
343 value: The trailer value
344 separator: The separator to use
345 where: Where to add the trailer ('end', 'start', 'after', 'before')
346 if_exists: How to handle existing trailers with the same key
347 - 'add': Always add
348 - 'replace': Replace all existing
349 - 'addIfDifferent': Add only if value is different from all existing
350 - 'addIfDifferentNeighbor': Add only if value differs from neighbors
351 - 'doNothing': Don't add if key exists
352 if_missing: What to do if the key doesn't exist
353 - 'add': Add the trailer
354 - 'doNothing': Don't add the trailer
356 Returns:
357 The message with the trailer added
358 """
359 message_body, existing_trailers = parse_trailers(message, separator)
361 new_trailer = Trailer(key, value, separator)
363 # Check if the key exists
364 key_exists = any(t.key == key for t in existing_trailers)
366 if not key_exists:
367 if if_missing == "doNothing":
368 return message
369 # Add the new trailer
370 updated_trailers = [*existing_trailers, new_trailer]
371 else:
372 # Key exists - apply if_exists logic
373 if if_exists == "doNothing":
374 return message
375 elif if_exists == "replace":
376 # Replace all trailers with this key
377 updated_trailers = [t for t in existing_trailers if t.key != key]
378 updated_trailers.append(new_trailer)
379 elif if_exists == "addIfDifferent":
380 # Add only if no existing trailer has the same value
381 has_same_value = any(
382 t.key == key and t.value == value for t in existing_trailers
383 )
384 if has_same_value:
385 return message
386 updated_trailers = [*existing_trailers, new_trailer]
387 elif if_exists == "addIfDifferentNeighbor":
388 # Add only if adjacent trailers with same key have different values
389 should_add = True
391 # Check if there's a neighboring trailer with the same key and value
392 for i, t in enumerate(existing_trailers):
393 if t.key == key and t.value == value:
394 # Check if it's a neighbor (last trailer with this key)
395 is_neighbor = True
396 for j in range(i + 1, len(existing_trailers)):
397 if existing_trailers[j].key == key:
398 is_neighbor = False
399 break
400 if is_neighbor:
401 should_add = False
402 break
404 if not should_add:
405 return message
406 updated_trailers = [*existing_trailers, new_trailer]
407 else: # 'add'
408 updated_trailers = [*existing_trailers, new_trailer]
410 # Apply where logic
411 if where == "start":
412 updated_trailers = [new_trailer] + [
413 t for t in updated_trailers if t != new_trailer
414 ]
415 elif where == "before":
416 # Insert before the first trailer with the same key
417 result = []
418 inserted = False
419 for t in updated_trailers:
420 if not inserted and t.key == key and t != new_trailer:
421 result.append(new_trailer)
422 inserted = True
423 if t != new_trailer:
424 result.append(t)
425 if not inserted:
426 result.append(new_trailer)
427 updated_trailers = result
428 elif where == "after":
429 # Insert after the last trailer with the same key
430 result = []
431 last_key_index = -1
432 for i, t in enumerate(updated_trailers):
433 if t.key == key and t != new_trailer:
434 last_key_index = len(result)
435 if t != new_trailer:
436 result.append(t)
438 if last_key_index >= 0:
439 result.insert(last_key_index + 1, new_trailer)
440 else:
441 result.append(new_trailer)
442 updated_trailers = result
443 # 'end' is the default - trailer is already at the end
445 # Reconstruct the message
446 result_message = message_body
447 if result_message and not result_message.endswith(b"\n"):
448 result_message += b"\n"
450 if updated_trailers:
451 result_message += b"\n"
452 result_message += format_trailers(updated_trailers)
454 return result_message