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

21 

22"""Git trailers parsing and manipulation. 

23 

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

25which are structured information blocks appended to commit messages. 

26 

27Trailers follow the format: 

28 Token: value 

29 Token: value 

30 

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

32messages after free-form content. 

33""" 

34 

35__all__ = [ 

36 "Trailer", 

37 "add_trailer_to_message", 

38 "format_trailers", 

39 "parse_trailers", 

40] 

41 

42 

43class Trailer: 

44 """Represents a single Git trailer. 

45 

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

51 

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

53 """Initialize a Trailer instance. 

54 

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 

63 

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

65 """Compare two Trailer instances for equality. 

66 

67 Args: 

68 other: The object to compare with 

69 

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 ) 

80 

81 def __repr__(self) -> str: 

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

83 

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

88 

89 def __str__(self) -> str: 

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

91 

92 Returns: 

93 The trailer in the format "key: value" 

94 """ 

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

96 

97 

98def parse_trailers( 

99 message: bytes, 

100 separators: str = ":", 

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

102 """Parse trailers from a commit message. 

103 

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. 

107 

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

111 

112 Args: 

113 message: The commit message as bytes 

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

115 

116 Returns: 

117 A tuple of (message_without_trailers, list_of_trailers) 

118 """ 

119 if not message: 

120 return (b"", []) 

121 

122 # Decode message 

123 try: 

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

125 except UnicodeDecodeError: 

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

127 

128 lines = text.splitlines(keepends=True) 

129 

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 

134 

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 

140 

141 # Determine the search range 

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

143 

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

148 

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] 

153 

154 # Remove trailing blank lines from potential trailers 

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

156 potential_trailers = potential_trailers[:-1] 

157 

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 

168 

169 if not has_content_after: 

170 trailer_start = i + 1 

171 break 

172 

173 if trailer_start is None: 

174 # No trailer block found 

175 return (message, []) 

176 

177 # Parse the trailers 

178 trailer_lines = lines[trailer_start:search_end] 

179 trailers = _parse_trailer_lines(trailer_lines, separators) 

180 

181 # Reconstruct the message without trailers 

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

183 message_lines = lines[:trailer_start] 

184 

185 # Remove trailing blank lines from the message 

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

187 message_lines.pop() 

188 

189 message_without_trailers = "".join(message_lines) 

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

191 message_without_trailers += "\n" 

192 

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

194 

195 

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

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

198 

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. 

202 

203 Args: 

204 lines: The lines to check 

205 separators: Valid separator characters 

206 

207 Returns: 

208 True if the lines form a valid trailer block 

209 """ 

210 if not lines: 

211 return False 

212 

213 # Remove empty lines at the end 

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

215 lines = lines[:-1] 

216 

217 if not lines: 

218 return False 

219 

220 has_any_trailer = False 

221 

222 i = 0 

223 while i < len(lines): 

224 line = lines[i].rstrip() 

225 

226 if not line: 

227 # Empty lines are allowed within the trailer block 

228 i += 1 

229 continue 

230 

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 

236 

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 

247 

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

249 if not is_trailer: 

250 return False 

251 

252 i += 1 

253 

254 # Must have at least one trailer 

255 return has_any_trailer 

256 

257 

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

259 """Parse individual trailer lines. 

260 

261 Args: 

262 lines: The trailer lines to parse 

263 separators: Valid separator characters 

264 

265 Returns: 

266 List of parsed Trailer objects 

267 """ 

268 trailers: list[Trailer] = [] 

269 current_trailer: Trailer | None = None 

270 

271 for line in lines: 

272 stripped = line.rstrip() 

273 

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 

280 

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 

288 

289 # Finalize the previous trailer if any 

290 if current_trailer: 

291 trailers.append(current_trailer) 

292 current_trailer = None 

293 

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] 

299 

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 

305 

306 # Don't forget the last trailer 

307 if current_trailer: 

308 trailers.append(current_trailer) 

309 

310 return trailers 

311 

312 

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

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

315 

316 Args: 

317 trailers: List of Trailer objects 

318 

319 Returns: 

320 Formatted trailers as bytes 

321 """ 

322 if not trailers: 

323 return b"" 

324 

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

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

327 

328 

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. 

339 

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 

355 

356 Returns: 

357 The message with the trailer added 

358 """ 

359 message_body, existing_trailers = parse_trailers(message, separator) 

360 

361 new_trailer = Trailer(key, value, separator) 

362 

363 # Check if the key exists 

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

365 

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 

390 

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 

403 

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] 

409 

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) 

437 

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 

444 

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" 

449 

450 if updated_trailers: 

451 result_message += b"\n" 

452 result_message += format_trailers(updated_trailers) 

453 

454 return result_message