Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/trailers.py: 7%

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

163 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 # Find the trailer block by scanning backward from the last non-blank line. 

145 # A trailer block is a run of trailer/continuation/blank lines that contains 

146 # at least one trailer and is preceded by a blank line. Because the block 

147 # only grows as the scan moves up, a non-blank line that is neither a 

148 # continuation nor a trailer invalidates every larger block, so the scan can 

149 # stop at it. Doing this in one pass keeps parsing linear in the message 

150 # size; the previous version re-sliced and re-checked the tail for every 

151 # blank line, which is cubic on a message made mostly of blank lines. 

152 content_end = search_end 

153 while content_end > 0 and not lines[content_end - 1].strip(): 

154 content_end -= 1 

155 

156 has_trailer = False 

157 for k in range(content_end - 1, 0, -1): 

158 line = lines[k].rstrip() 

159 if line and not line[0].isspace(): 

160 # A non-blank, non-continuation line must be a trailer, otherwise no 

161 # block reaching this line (or starting above it) can be valid. 

162 if not _is_trailer_line(line, separators): 

163 break 

164 has_trailer = True 

165 # Blank and continuation lines are allowed inside the block. Stop at the 

166 # first preceding blank line once a trailer has been seen. 

167 if has_trailer and not lines[k - 1].strip(): 

168 trailer_start = k 

169 break 

170 

171 if trailer_start is None: 

172 # No trailer block found 

173 return (message, []) 

174 

175 # Parse the trailers 

176 trailer_lines = lines[trailer_start:search_end] 

177 trailers = _parse_trailer_lines(trailer_lines, separators) 

178 

179 # Reconstruct the message without trailers 

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

181 message_lines = lines[:trailer_start] 

182 

183 # Remove trailing blank lines from the message 

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

185 message_lines.pop() 

186 

187 message_without_trailers = "".join(message_lines) 

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

189 message_without_trailers += "\n" 

190 

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

192 

193 

194def _is_trailer_line(line: str, separators: str) -> bool: 

195 """Check if a single line is a trailer line. 

196 

197 A trailer line contains one of the separator characters and the token 

198 before the first separator is non-empty and free of whitespace. 

199 

200 Args: 

201 line: The line to check (trailing whitespace already removed) 

202 separators: Valid separator characters 

203 

204 Returns: 

205 True if the line is a trailer line 

206 """ 

207 for sep in separators: 

208 if sep in line: 

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

210 # Key must not contain whitespace 

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

212 return True 

213 return False 

214 

215 

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

217 """Parse individual trailer lines. 

218 

219 Args: 

220 lines: The trailer lines to parse 

221 separators: Valid separator characters 

222 

223 Returns: 

224 List of parsed Trailer objects 

225 """ 

226 trailers: list[Trailer] = [] 

227 current_trailer: Trailer | None = None 

228 

229 for line in lines: 

230 stripped = line.rstrip() 

231 

232 if not stripped: 

233 # Empty line - finalize current trailer if any 

234 if current_trailer: 

235 trailers.append(current_trailer) 

236 current_trailer = None 

237 continue 

238 

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

240 if stripped[0].isspace(): 

241 if current_trailer: 

242 # Append to the current trailer value 

243 continuation = stripped.lstrip() 

244 current_trailer.value += " " + continuation 

245 continue 

246 

247 # Finalize the previous trailer if any 

248 if current_trailer: 

249 trailers.append(current_trailer) 

250 current_trailer = None 

251 

252 # Try to parse as a new trailer 

253 for sep in separators: 

254 if sep in stripped: 

255 parts = stripped.split(sep, 1) 

256 key = parts[0] 

257 

258 # Key must not contain whitespace 

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

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

261 current_trailer = Trailer(key, value, sep) 

262 break 

263 

264 # Don't forget the last trailer 

265 if current_trailer: 

266 trailers.append(current_trailer) 

267 

268 return trailers 

269 

270 

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

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

273 

274 Args: 

275 trailers: List of Trailer objects 

276 

277 Returns: 

278 Formatted trailers as bytes 

279 """ 

280 if not trailers: 

281 return b"" 

282 

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

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

285 

286 

287def add_trailer_to_message( 

288 message: bytes, 

289 key: str, 

290 value: str, 

291 separator: str = ":", 

292 where: str = "end", 

293 if_exists: str = "addIfDifferentNeighbor", 

294 if_missing: str = "add", 

295) -> bytes: 

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

297 

298 Args: 

299 message: The original commit message 

300 key: The trailer key 

301 value: The trailer value 

302 separator: The separator to use 

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

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

305 - 'add': Always add 

306 - 'replace': Replace all existing 

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

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

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

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

311 - 'add': Add the trailer 

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

313 

314 Returns: 

315 The message with the trailer added 

316 """ 

317 message_body, existing_trailers = parse_trailers(message, separator) 

318 

319 new_trailer = Trailer(key, value, separator) 

320 

321 # Check if the key exists 

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

323 

324 if not key_exists: 

325 if if_missing == "doNothing": 

326 return message 

327 # Add the new trailer 

328 updated_trailers = [*existing_trailers, new_trailer] 

329 else: 

330 # Key exists - apply if_exists logic 

331 if if_exists == "doNothing": 

332 return message 

333 elif if_exists == "replace": 

334 # Replace all trailers with this key 

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

336 updated_trailers.append(new_trailer) 

337 elif if_exists == "addIfDifferent": 

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

339 has_same_value = any( 

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

341 ) 

342 if has_same_value: 

343 return message 

344 updated_trailers = [*existing_trailers, new_trailer] 

345 elif if_exists == "addIfDifferentNeighbor": 

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

347 should_add = True 

348 

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

350 for i, t in enumerate(existing_trailers): 

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

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

353 is_neighbor = True 

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

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

356 is_neighbor = False 

357 break 

358 if is_neighbor: 

359 should_add = False 

360 break 

361 

362 if not should_add: 

363 return message 

364 updated_trailers = [*existing_trailers, new_trailer] 

365 else: # 'add' 

366 updated_trailers = [*existing_trailers, new_trailer] 

367 

368 # Apply where logic 

369 if where == "start": 

370 updated_trailers = [new_trailer] + [ 

371 t for t in updated_trailers if t != new_trailer 

372 ] 

373 elif where == "before": 

374 # Insert before the first trailer with the same key 

375 result = [] 

376 inserted = False 

377 for t in updated_trailers: 

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

379 result.append(new_trailer) 

380 inserted = True 

381 if t != new_trailer: 

382 result.append(t) 

383 if not inserted: 

384 result.append(new_trailer) 

385 updated_trailers = result 

386 elif where == "after": 

387 # Insert after the last trailer with the same key 

388 result = [] 

389 last_key_index = -1 

390 for i, t in enumerate(updated_trailers): 

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

392 last_key_index = len(result) 

393 if t != new_trailer: 

394 result.append(t) 

395 

396 if last_key_index >= 0: 

397 result.insert(last_key_index + 1, new_trailer) 

398 else: 

399 result.append(new_trailer) 

400 updated_trailers = result 

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

402 

403 # Reconstruct the message 

404 result_message = message_body 

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

406 result_message += b"\n" 

407 

408 if updated_trailers: 

409 result_message += b"\n" 

410 result_message += format_trailers(updated_trailers) 

411 

412 return result_message