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

187 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 

35class Trailer: 

36 """Represents a single Git trailer. 

37 

38 Args: 

39 key: The trailer key/token (e.g., "Signed-off-by") 

40 value: The trailer value 

41 separator: The separator character used (default ':') 

42 """ 

43 

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

45 """Initialize a Trailer instance. 

46 

47 Args: 

48 key: The trailer key/token 

49 value: The trailer value 

50 separator: The separator character (default ':') 

51 """ 

52 self.key = key 

53 self.value = value 

54 self.separator = separator 

55 

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

57 """Compare two Trailer instances for equality. 

58 

59 Args: 

60 other: The object to compare with 

61 

62 Returns: 

63 True if trailers have the same key, value, and separator 

64 """ 

65 if not isinstance(other, Trailer): 

66 return NotImplemented 

67 return ( 

68 self.key == other.key 

69 and self.value == other.value 

70 and self.separator == other.separator 

71 ) 

72 

73 def __repr__(self) -> str: 

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

75 

76 Returns: 

77 A string showing the trailer's key, value, and separator 

78 """ 

79 return f"Trailer(key={self.key!r}, value={self.value!r}, separator={self.separator!r})" 

80 

81 def __str__(self) -> str: 

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

83 

84 Returns: 

85 The trailer in the format "key: value" 

86 """ 

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

88 

89 

90def parse_trailers( 

91 message: bytes, 

92 separators: str = ":", 

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

94 """Parse trailers from a commit message. 

95 

96 Trailers are extracted from the input by looking for a group of one or more 

97 lines that (i) is all trailers, or (ii) contains at least one Git-generated 

98 or user-configured trailer and consists of at least 25% trailers. 

99 

100 The group must be preceded by one or more empty (or whitespace-only) lines. 

101 The group must either be at the end of the input or be the last non-whitespace 

102 lines before a line that starts with '---'. 

103 

104 Args: 

105 message: The commit message as bytes 

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

107 

108 Returns: 

109 A tuple of (message_without_trailers, list_of_trailers) 

110 """ 

111 if not message: 

112 return (b"", []) 

113 

114 # Decode message 

115 try: 

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

117 except UnicodeDecodeError: 

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

119 

120 lines = text.splitlines(keepends=True) 

121 

122 # Find the trailer block by searching backwards 

123 # Look for a blank line followed by trailer-like lines 

124 trailer_start = None 

125 cutoff_line = None 

126 

127 # First, check if there's a "---" line that marks the end of the message 

128 for i in range(len(lines) - 1, -1, -1): 

129 if lines[i].lstrip().startswith("---"): 

130 cutoff_line = i 

131 break 

132 

133 # Determine the search range 

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

135 

136 # Search backwards for the trailer block 

137 # A trailer block must be preceded by a blank line and extend to the end 

138 for i in range(search_end - 1, -1, -1): 

139 line = lines[i].rstrip() 

140 

141 # Check if this is a blank line 

142 if not line: 

143 # Check if the lines after this blank line are trailers 

144 potential_trailers = lines[i + 1 : search_end] 

145 

146 # Remove trailing blank lines from potential trailers 

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

148 potential_trailers = potential_trailers[:-1] 

149 

150 # Check if these lines form a trailer block and extend to search_end 

151 if potential_trailers and _is_trailer_block(potential_trailers, separators): 

152 # Verify these trailers extend to the end (search_end) 

153 # by checking there are no non-blank lines after them 

154 last_trailer_index = i + 1 + len(potential_trailers) 

155 has_content_after = False 

156 for j in range(last_trailer_index, search_end): 

157 if lines[j].strip(): 

158 has_content_after = True 

159 break 

160 

161 if not has_content_after: 

162 trailer_start = i + 1 

163 break 

164 

165 if trailer_start is None: 

166 # No trailer block found 

167 return (message, []) 

168 

169 # Parse the trailers 

170 trailer_lines = lines[trailer_start:search_end] 

171 trailers = _parse_trailer_lines(trailer_lines, separators) 

172 

173 # Reconstruct the message without trailers 

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

175 message_lines = lines[:trailer_start] 

176 

177 # Remove trailing blank lines from the message 

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

179 message_lines.pop() 

180 

181 message_without_trailers = "".join(message_lines) 

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

183 message_without_trailers += "\n" 

184 

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

186 

187 

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

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

190 

191 A trailer block must be composed entirely of trailer lines (with possible 

192 blank lines and continuation lines). A single non-trailer line invalidates 

193 the entire block. 

194 

195 Args: 

196 lines: The lines to check 

197 separators: Valid separator characters 

198 

199 Returns: 

200 True if the lines form a valid trailer block 

201 """ 

202 if not lines: 

203 return False 

204 

205 # Remove empty lines at the end 

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

207 lines = lines[:-1] 

208 

209 if not lines: 

210 return False 

211 

212 has_any_trailer = False 

213 

214 i = 0 

215 while i < len(lines): 

216 line = lines[i].rstrip() 

217 

218 if not line: 

219 # Empty lines are allowed within the trailer block 

220 i += 1 

221 continue 

222 

223 # Check if this line is a continuation (starts with whitespace) 

224 if line and line[0].isspace(): 

225 # This is a continuation of the previous line 

226 i += 1 

227 continue 

228 

229 # Check if this is a trailer line 

230 is_trailer = False 

231 for sep in separators: 

232 if sep in line: 

233 key_part = line.split(sep, 1)[0] 

234 # Key must not contain whitespace 

235 if key_part and not any(c.isspace() for c in key_part): 

236 is_trailer = True 

237 has_any_trailer = True 

238 break 

239 

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

241 if not is_trailer: 

242 return False 

243 

244 i += 1 

245 

246 # Must have at least one trailer 

247 return has_any_trailer 

248 

249 

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

251 """Parse individual trailer lines. 

252 

253 Args: 

254 lines: The trailer lines to parse 

255 separators: Valid separator characters 

256 

257 Returns: 

258 List of parsed Trailer objects 

259 """ 

260 trailers: list[Trailer] = [] 

261 current_trailer: Trailer | None = None 

262 

263 for line in lines: 

264 stripped = line.rstrip() 

265 

266 if not stripped: 

267 # Empty line - finalize current trailer if any 

268 if current_trailer: 

269 trailers.append(current_trailer) 

270 current_trailer = None 

271 continue 

272 

273 # Check if this is a continuation line (starts with whitespace) 

274 if stripped[0].isspace(): 

275 if current_trailer: 

276 # Append to the current trailer value 

277 continuation = stripped.lstrip() 

278 current_trailer.value += " " + continuation 

279 continue 

280 

281 # Finalize the previous trailer if any 

282 if current_trailer: 

283 trailers.append(current_trailer) 

284 current_trailer = None 

285 

286 # Try to parse as a new trailer 

287 for sep in separators: 

288 if sep in stripped: 

289 parts = stripped.split(sep, 1) 

290 key = parts[0] 

291 

292 # Key must not contain whitespace 

293 if key and not any(c.isspace() for c in key): 

294 value = parts[1].strip() if len(parts) > 1 else "" 

295 current_trailer = Trailer(key, value, sep) 

296 break 

297 

298 # Don't forget the last trailer 

299 if current_trailer: 

300 trailers.append(current_trailer) 

301 

302 return trailers 

303 

304 

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

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

307 

308 Args: 

309 trailers: List of Trailer objects 

310 

311 Returns: 

312 Formatted trailers as bytes 

313 """ 

314 if not trailers: 

315 return b"" 

316 

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

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

319 

320 

321def add_trailer_to_message( 

322 message: bytes, 

323 key: str, 

324 value: str, 

325 separator: str = ":", 

326 where: str = "end", 

327 if_exists: str = "addIfDifferentNeighbor", 

328 if_missing: str = "add", 

329) -> bytes: 

330 """Add a trailer to a commit message. 

331 

332 Args: 

333 message: The original commit message 

334 key: The trailer key 

335 value: The trailer value 

336 separator: The separator to use 

337 where: Where to add the trailer ('end', 'start', 'after', 'before') 

338 if_exists: How to handle existing trailers with the same key 

339 - 'add': Always add 

340 - 'replace': Replace all existing 

341 - 'addIfDifferent': Add only if value is different from all existing 

342 - 'addIfDifferentNeighbor': Add only if value differs from neighbors 

343 - 'doNothing': Don't add if key exists 

344 if_missing: What to do if the key doesn't exist 

345 - 'add': Add the trailer 

346 - 'doNothing': Don't add the trailer 

347 

348 Returns: 

349 The message with the trailer added 

350 """ 

351 message_body, existing_trailers = parse_trailers(message, separator) 

352 

353 new_trailer = Trailer(key, value, separator) 

354 

355 # Check if the key exists 

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

357 

358 if not key_exists: 

359 if if_missing == "doNothing": 

360 return message 

361 # Add the new trailer 

362 updated_trailers = [*existing_trailers, new_trailer] 

363 else: 

364 # Key exists - apply if_exists logic 

365 if if_exists == "doNothing": 

366 return message 

367 elif if_exists == "replace": 

368 # Replace all trailers with this key 

369 updated_trailers = [t for t in existing_trailers if t.key != key] 

370 updated_trailers.append(new_trailer) 

371 elif if_exists == "addIfDifferent": 

372 # Add only if no existing trailer has the same value 

373 has_same_value = any( 

374 t.key == key and t.value == value for t in existing_trailers 

375 ) 

376 if has_same_value: 

377 return message 

378 updated_trailers = [*existing_trailers, new_trailer] 

379 elif if_exists == "addIfDifferentNeighbor": 

380 # Add only if adjacent trailers with same key have different values 

381 should_add = True 

382 

383 # Check if there's a neighboring trailer with the same key and value 

384 for i, t in enumerate(existing_trailers): 

385 if t.key == key and t.value == value: 

386 # Check if it's a neighbor (last trailer with this key) 

387 is_neighbor = True 

388 for j in range(i + 1, len(existing_trailers)): 

389 if existing_trailers[j].key == key: 

390 is_neighbor = False 

391 break 

392 if is_neighbor: 

393 should_add = False 

394 break 

395 

396 if not should_add: 

397 return message 

398 updated_trailers = [*existing_trailers, new_trailer] 

399 else: # 'add' 

400 updated_trailers = [*existing_trailers, new_trailer] 

401 

402 # Apply where logic 

403 if where == "start": 

404 updated_trailers = [new_trailer] + [ 

405 t for t in updated_trailers if t != new_trailer 

406 ] 

407 elif where == "before": 

408 # Insert before the first trailer with the same key 

409 result = [] 

410 inserted = False 

411 for t in updated_trailers: 

412 if not inserted and t.key == key and t != new_trailer: 

413 result.append(new_trailer) 

414 inserted = True 

415 if t != new_trailer: 

416 result.append(t) 

417 if not inserted: 

418 result.append(new_trailer) 

419 updated_trailers = result 

420 elif where == "after": 

421 # Insert after the last trailer with the same key 

422 result = [] 

423 last_key_index = -1 

424 for i, t in enumerate(updated_trailers): 

425 if t.key == key and t != new_trailer: 

426 last_key_index = len(result) 

427 if t != new_trailer: 

428 result.append(t) 

429 

430 if last_key_index >= 0: 

431 result.insert(last_key_index + 1, new_trailer) 

432 else: 

433 result.append(new_trailer) 

434 updated_trailers = result 

435 # 'end' is the default - trailer is already at the end 

436 

437 # Reconstruct the message 

438 result_message = message_body 

439 if result_message and not result_message.endswith(b"\n"): 

440 result_message += b"\n" 

441 

442 if updated_trailers: 

443 result_message += b"\n" 

444 result_message += format_trailers(updated_trailers) 

445 

446 return result_message