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

188 statements  

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# 

20 

21"""Git trailers parsing and manipulation. 

22 

23This module provides functionality for parsing and manipulating Git trailers, 

24which are structured information blocks appended to commit messages. 

25 

26Trailers follow the format: 

27 Token: value 

28 Token: value 

29 

30They are similar to RFC 822 email headers and appear at the end of commit 

31messages after free-form content. 

32""" 

33 

34__all__ = [ 

35 "Trailer", 

36 "add_trailer_to_message", 

37 "format_trailers", 

38 "parse_trailers", 

39] 

40 

41 

42class Trailer: 

43 """Represents a single Git trailer. 

44 

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 """ 

50 

51 def __init__(self, key: str, value: str, separator: str = ":") -> None: 

52 """Initialize a Trailer instance. 

53 

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 

62 

63 def __eq__(self, other: object) -> bool: 

64 """Compare two Trailer instances for equality. 

65 

66 Args: 

67 other: The object to compare with 

68 

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 ) 

79 

80 def __repr__(self) -> str: 

81 """Return a string representation suitable for debugging. 

82 

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})" 

87 

88 def __str__(self) -> str: 

89 """Return the trailer formatted as it would appear in a commit message. 

90 

91 Returns: 

92 The trailer in the format "key: value" 

93 """ 

94 return f"{self.key}{self.separator} {self.value}" 

95 

96 

97def parse_trailers( 

98 message: bytes, 

99 separators: str = ":", 

100) -> tuple[bytes, list[Trailer]]: 

101 """Parse trailers from a commit message. 

102 

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. 

106 

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 '---'. 

110 

111 Args: 

112 message: The commit message as bytes 

113 separators: Characters to recognize as trailer separators (default ':') 

114 

115 Returns: 

116 A tuple of (message_without_trailers, list_of_trailers) 

117 """ 

118 if not message: 

119 return (b"", []) 

120 

121 # Decode message 

122 try: 

123 text = message.decode("utf-8") 

124 except UnicodeDecodeError: 

125 text = message.decode("latin-1") 

126 

127 lines = text.splitlines(keepends=True) 

128 

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 

133 

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 

139 

140 # Determine the search range 

141 search_end = cutoff_line if cutoff_line is not None else len(lines) 

142 

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() 

147 

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] 

152 

153 # Remove trailing blank lines from potential trailers 

154 while potential_trailers and not potential_trailers[-1].strip(): 

155 potential_trailers = potential_trailers[:-1] 

156 

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 

167 

168 if not has_content_after: 

169 trailer_start = i + 1 

170 break 

171 

172 if trailer_start is None: 

173 # No trailer block found 

174 return (message, []) 

175 

176 # Parse the trailers 

177 trailer_lines = lines[trailer_start:search_end] 

178 trailers = _parse_trailer_lines(trailer_lines, separators) 

179 

180 # Reconstruct the message without trailers 

181 # Keep everything before the blank line that precedes the trailers 

182 message_lines = lines[:trailer_start] 

183 

184 # Remove trailing blank lines from the message 

185 while message_lines and not message_lines[-1].strip(): 

186 message_lines.pop() 

187 

188 message_without_trailers = "".join(message_lines) 

189 if message_without_trailers and not message_without_trailers.endswith("\n"): 

190 message_without_trailers += "\n" 

191 

192 return (message_without_trailers.encode("utf-8"), trailers) 

193 

194 

195def _is_trailer_block(lines: list[str], separators: str) -> bool: 

196 """Check if a group of lines forms a valid trailer block. 

197 

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. 

201 

202 Args: 

203 lines: The lines to check 

204 separators: Valid separator characters 

205 

206 Returns: 

207 True if the lines form a valid trailer block 

208 """ 

209 if not lines: 

210 return False 

211 

212 # Remove empty lines at the end 

213 while lines and not lines[-1].strip(): 

214 lines = lines[:-1] 

215 

216 if not lines: 

217 return False 

218 

219 has_any_trailer = False 

220 

221 i = 0 

222 while i < len(lines): 

223 line = lines[i].rstrip() 

224 

225 if not line: 

226 # Empty lines are allowed within the trailer block 

227 i += 1 

228 continue 

229 

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 

235 

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 

246 

247 # If this is not a trailer line, the block is invalid 

248 if not is_trailer: 

249 return False 

250 

251 i += 1 

252 

253 # Must have at least one trailer 

254 return has_any_trailer 

255 

256 

257def _parse_trailer_lines(lines: list[str], separators: str) -> list[Trailer]: 

258 """Parse individual trailer lines. 

259 

260 Args: 

261 lines: The trailer lines to parse 

262 separators: Valid separator characters 

263 

264 Returns: 

265 List of parsed Trailer objects 

266 """ 

267 trailers: list[Trailer] = [] 

268 current_trailer: Trailer | None = None 

269 

270 for line in lines: 

271 stripped = line.rstrip() 

272 

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 

279 

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 

287 

288 # Finalize the previous trailer if any 

289 if current_trailer: 

290 trailers.append(current_trailer) 

291 current_trailer = None 

292 

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] 

298 

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 

304 

305 # Don't forget the last trailer 

306 if current_trailer: 

307 trailers.append(current_trailer) 

308 

309 return trailers 

310 

311 

312def format_trailers(trailers: list[Trailer]) -> bytes: 

313 """Format a list of trailers as bytes. 

314 

315 Args: 

316 trailers: List of Trailer objects 

317 

318 Returns: 

319 Formatted trailers as bytes 

320 """ 

321 if not trailers: 

322 return b"" 

323 

324 lines = [str(trailer) for trailer in trailers] 

325 return "\n".join(lines).encode("utf-8") + b"\n" 

326 

327 

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. 

338 

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 

354 

355 Returns: 

356 The message with the trailer added 

357 """ 

358 message_body, existing_trailers = parse_trailers(message, separator) 

359 

360 new_trailer = Trailer(key, value, separator) 

361 

362 # Check if the key exists 

363 key_exists = any(t.key == key for t in existing_trailers) 

364 

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 

389 

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 

402 

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] 

408 

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) 

436 

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 

443 

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" 

448 

449 if updated_trailers: 

450 result_message += b"\n" 

451 result_message += format_trailers(updated_trailers) 

452 

453 return result_message