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

34from typing import Optional 

35 

36 

37class Trailer: 

38 """Represents a single Git trailer. 

39 

40 Args: 

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

42 value: The trailer value 

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

44 """ 

45 

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

47 """Initialize a Trailer instance. 

48 

49 Args: 

50 key: The trailer key/token 

51 value: The trailer value 

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

53 """ 

54 self.key = key 

55 self.value = value 

56 self.separator = separator 

57 

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

59 """Compare two Trailer instances for equality. 

60 

61 Args: 

62 other: The object to compare with 

63 

64 Returns: 

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

66 """ 

67 if not isinstance(other, Trailer): 

68 return NotImplemented 

69 return ( 

70 self.key == other.key 

71 and self.value == other.value 

72 and self.separator == other.separator 

73 ) 

74 

75 def __repr__(self) -> str: 

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

77 

78 Returns: 

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

80 """ 

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

82 

83 def __str__(self) -> str: 

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

85 

86 Returns: 

87 The trailer in the format "key: value" 

88 """ 

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

90 

91 

92def parse_trailers( 

93 message: bytes, 

94 separators: str = ":", 

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

96 """Parse trailers from a commit message. 

97 

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

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

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

101 

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

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

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

105 

106 Args: 

107 message: The commit message as bytes 

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

109 

110 Returns: 

111 A tuple of (message_without_trailers, list_of_trailers) 

112 """ 

113 if not message: 

114 return (b"", []) 

115 

116 # Decode message 

117 try: 

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

119 except UnicodeDecodeError: 

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

121 

122 lines = text.splitlines(keepends=True) 

123 

124 # Find the trailer block by searching backwards 

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

126 trailer_start = None 

127 cutoff_line = None 

128 

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

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

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

132 cutoff_line = i 

133 break 

134 

135 # Determine the search range 

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

137 

138 # Search backwards for the trailer block 

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

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

141 line = lines[i].rstrip() 

142 

143 # Check if this is a blank line 

144 if not line: 

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

146 potential_trailers = lines[i + 1 : search_end] 

147 

148 # Remove trailing blank lines from potential trailers 

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

150 potential_trailers = potential_trailers[:-1] 

151 

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

153 if potential_trailers and _is_trailer_block(potential_trailers, separators): 

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

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

156 last_trailer_index = i + 1 + len(potential_trailers) 

157 has_content_after = False 

158 for j in range(last_trailer_index, search_end): 

159 if lines[j].strip(): 

160 has_content_after = True 

161 break 

162 

163 if not has_content_after: 

164 trailer_start = i + 1 

165 break 

166 

167 if trailer_start is None: 

168 # No trailer block found 

169 return (message, []) 

170 

171 # Parse the trailers 

172 trailer_lines = lines[trailer_start:search_end] 

173 trailers = _parse_trailer_lines(trailer_lines, separators) 

174 

175 # Reconstruct the message without trailers 

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

177 message_lines = lines[:trailer_start] 

178 

179 # Remove trailing blank lines from the message 

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

181 message_lines.pop() 

182 

183 message_without_trailers = "".join(message_lines) 

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

185 message_without_trailers += "\n" 

186 

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

188 

189 

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

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

192 

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

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

195 the entire block. 

196 

197 Args: 

198 lines: The lines to check 

199 separators: Valid separator characters 

200 

201 Returns: 

202 True if the lines form a valid trailer block 

203 """ 

204 if not lines: 

205 return False 

206 

207 # Remove empty lines at the end 

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

209 lines = lines[:-1] 

210 

211 if not lines: 

212 return False 

213 

214 has_any_trailer = False 

215 

216 i = 0 

217 while i < len(lines): 

218 line = lines[i].rstrip() 

219 

220 if not line: 

221 # Empty lines are allowed within the trailer block 

222 i += 1 

223 continue 

224 

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

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

227 # This is a continuation of the previous line 

228 i += 1 

229 continue 

230 

231 # Check if this is a trailer line 

232 is_trailer = False 

233 for sep in separators: 

234 if sep in line: 

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

236 # Key must not contain whitespace 

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

238 is_trailer = True 

239 has_any_trailer = True 

240 break 

241 

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

243 if not is_trailer: 

244 return False 

245 

246 i += 1 

247 

248 # Must have at least one trailer 

249 return has_any_trailer 

250 

251 

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

253 """Parse individual trailer lines. 

254 

255 Args: 

256 lines: The trailer lines to parse 

257 separators: Valid separator characters 

258 

259 Returns: 

260 List of parsed Trailer objects 

261 """ 

262 trailers: list[Trailer] = [] 

263 current_trailer: Optional[Trailer] = None 

264 

265 for line in lines: 

266 stripped = line.rstrip() 

267 

268 if not stripped: 

269 # Empty line - finalize current trailer if any 

270 if current_trailer: 

271 trailers.append(current_trailer) 

272 current_trailer = None 

273 continue 

274 

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

276 if stripped[0].isspace(): 

277 if current_trailer: 

278 # Append to the current trailer value 

279 continuation = stripped.lstrip() 

280 current_trailer.value += " " + continuation 

281 continue 

282 

283 # Finalize the previous trailer if any 

284 if current_trailer: 

285 trailers.append(current_trailer) 

286 current_trailer = None 

287 

288 # Try to parse as a new trailer 

289 for sep in separators: 

290 if sep in stripped: 

291 parts = stripped.split(sep, 1) 

292 key = parts[0] 

293 

294 # Key must not contain whitespace 

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

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

297 current_trailer = Trailer(key, value, sep) 

298 break 

299 

300 # Don't forget the last trailer 

301 if current_trailer: 

302 trailers.append(current_trailer) 

303 

304 return trailers 

305 

306 

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

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

309 

310 Args: 

311 trailers: List of Trailer objects 

312 

313 Returns: 

314 Formatted trailers as bytes 

315 """ 

316 if not trailers: 

317 return b"" 

318 

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

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

321 

322 

323def add_trailer_to_message( 

324 message: bytes, 

325 key: str, 

326 value: str, 

327 separator: str = ":", 

328 where: str = "end", 

329 if_exists: str = "addIfDifferentNeighbor", 

330 if_missing: str = "add", 

331) -> bytes: 

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

333 

334 Args: 

335 message: The original commit message 

336 key: The trailer key 

337 value: The trailer value 

338 separator: The separator to use 

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

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

341 - 'add': Always add 

342 - 'replace': Replace all existing 

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

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

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

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

347 - 'add': Add the trailer 

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

349 

350 Returns: 

351 The message with the trailer added 

352 """ 

353 message_body, existing_trailers = parse_trailers(message, separator) 

354 

355 new_trailer = Trailer(key, value, separator) 

356 

357 # Check if the key exists 

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

359 

360 if not key_exists: 

361 if if_missing == "doNothing": 

362 return message 

363 # Add the new trailer 

364 updated_trailers = [*existing_trailers, new_trailer] 

365 else: 

366 # Key exists - apply if_exists logic 

367 if if_exists == "doNothing": 

368 return message 

369 elif if_exists == "replace": 

370 # Replace all trailers with this key 

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

372 updated_trailers.append(new_trailer) 

373 elif if_exists == "addIfDifferent": 

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

375 has_same_value = any( 

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

377 ) 

378 if has_same_value: 

379 return message 

380 updated_trailers = [*existing_trailers, new_trailer] 

381 elif if_exists == "addIfDifferentNeighbor": 

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

383 should_add = True 

384 

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

386 for i, t in enumerate(existing_trailers): 

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

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

389 is_neighbor = True 

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

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

392 is_neighbor = False 

393 break 

394 if is_neighbor: 

395 should_add = False 

396 break 

397 

398 if not should_add: 

399 return message 

400 updated_trailers = [*existing_trailers, new_trailer] 

401 else: # 'add' 

402 updated_trailers = [*existing_trailers, new_trailer] 

403 

404 # Apply where logic 

405 if where == "start": 

406 updated_trailers = [new_trailer] + [ 

407 t for t in updated_trailers if t != new_trailer 

408 ] 

409 elif where == "before": 

410 # Insert before the first trailer with the same key 

411 result = [] 

412 inserted = False 

413 for t in updated_trailers: 

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

415 result.append(new_trailer) 

416 inserted = True 

417 if t != new_trailer: 

418 result.append(t) 

419 if not inserted: 

420 result.append(new_trailer) 

421 updated_trailers = result 

422 elif where == "after": 

423 # Insert after the last trailer with the same key 

424 result = [] 

425 last_key_index = -1 

426 for i, t in enumerate(updated_trailers): 

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

428 last_key_index = len(result) 

429 if t != new_trailer: 

430 result.append(t) 

431 

432 if last_key_index >= 0: 

433 result.insert(last_key_index + 1, new_trailer) 

434 else: 

435 result.append(new_trailer) 

436 updated_trailers = result 

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

438 

439 # Reconstruct the message 

440 result_message = message_body 

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

442 result_message += b"\n" 

443 

444 if updated_trailers: 

445 result_message += b"\n" 

446 result_message += format_trailers(updated_trailers) 

447 

448 return result_message