| 1 | |
| 2 | |
| 3 | use std::time::SystemTime; |
| 4 | |
| 5 | use crate::{ |
| 6 | application::port::GitQueryError, |
| 7 | domain::{CommitDetail, CommitEntry, CommitSummary, ObjectId}, |
| 8 | }; |
| 9 | |
| 10 | use super::unix_time; |
| 11 | |
| 12 | |
| 13 | pub(super) fn parse_log_entries(stdout: &[u8]) -> Result<Vec<CommitEntry>, GitQueryError> { |
| 14 | |
| 15 | |
| 16 | |
| 17 | |
| 18 | let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect(); |
| 19 | |
| 20 | if fields.last().is_some_and(|last| last.is_empty()) { |
| 21 | fields.pop(); |
| 22 | } |
| 23 | |
| 24 | let mut entries = Vec::with_capacity(fields.len() / 5); |
| 25 | |
| 26 | for record in fields.chunks(5) { |
| 27 | let [id, committed_at, author_name, summary, body] = record[..] else { |
| 28 | return Err(GitQueryError::new( |
| 29 | "git logged a commit with missing fields", |
| 30 | )); |
| 31 | }; |
| 32 | |
| 33 | let id = String::from_utf8_lossy(id); |
| 34 | let committed_at = String::from_utf8_lossy(committed_at); |
| 35 | let committed_at: i64 = committed_at.trim().parse().map_err(|_| { |
| 36 | GitQueryError::new(format!( |
| 37 | "git logged an unreadable commit time {committed_at:?}" |
| 38 | )) |
| 39 | })?; |
| 40 | |
| 41 | entries.push(CommitEntry { |
| 42 | commit: CommitSummary { |
| 43 | id: ObjectId::new(id.trim()).map_err(|error| { |
| 44 | GitQueryError::new(format!("git named a bad object id: {error}")) |
| 45 | })?, |
| 46 | summary: String::from_utf8_lossy(summary) |
| 47 | .lines() |
| 48 | .next() |
| 49 | .unwrap_or_default() |
| 50 | .to_owned(), |
| 51 | author_name: String::from_utf8_lossy(author_name).into_owned(), |
| 52 | committed_at: unix_time(committed_at), |
| 53 | }, |
| 54 | |
| 55 | |
| 56 | |
| 57 | body: String::from_utf8_lossy(body).trim_end().to_owned(), |
| 58 | }); |
| 59 | } |
| 60 | |
| 61 | Ok(entries) |
| 62 | } |
| 63 | |
| 64 | pub(super) fn parse_log(stdout: &[u8]) -> Result<Vec<CommitSummary>, GitQueryError> { |
| 65 | |
| 66 | |
| 67 | |
| 68 | |
| 69 | |
| 70 | let mut fields: Vec<&[u8]> = stdout.split(|byte| *byte == 0).collect(); |
| 71 | |
| 72 | if fields.last().is_some_and(|last| last.is_empty()) { |
| 73 | fields.pop(); |
| 74 | } |
| 75 | |
| 76 | let mut commits = Vec::with_capacity(fields.len() / 4); |
| 77 | |
| 78 | for record in fields.chunks(4) { |
| 79 | let [id, committed_at, author_name, summary] = record[..] else { |
| 80 | return Err(GitQueryError::new( |
| 81 | "git logged a commit with missing fields", |
| 82 | )); |
| 83 | }; |
| 84 | |
| 85 | let id = String::from_utf8_lossy(id); |
| 86 | let committed_at = String::from_utf8_lossy(committed_at); |
| 87 | let committed_at: i64 = committed_at.trim().parse().map_err(|_| { |
| 88 | GitQueryError::new(format!( |
| 89 | "git logged an unreadable commit time {committed_at:?}" |
| 90 | )) |
| 91 | })?; |
| 92 | |
| 93 | commits.push(CommitSummary { |
| 94 | id: ObjectId::new(id.trim()).map_err(|error| { |
| 95 | GitQueryError::new(format!("git named a bad object id: {error}")) |
| 96 | })?, |
| 97 | |
| 98 | |
| 99 | |
| 100 | summary: String::from_utf8_lossy(summary) |
| 101 | .lines() |
| 102 | .next() |
| 103 | .unwrap_or_default() |
| 104 | .to_owned(), |
| 105 | author_name: String::from_utf8_lossy(author_name).into_owned(), |
| 106 | committed_at: unix_time(committed_at), |
| 107 | }); |
| 108 | } |
| 109 | |
| 110 | Ok(commits) |
| 111 | } |
| 112 | |
| 113 | |
| 114 | |
| 115 | |
| 116 | |
| 117 | |
| 118 | |
| 119 | pub(super) const LOG_FORMAT: &str = "--format=%H%x00%ct%x00%an%x00%s"; |
| 120 | |
| 121 | |
| 122 | |
| 123 | |
| 124 | |
| 125 | |
| 126 | pub(super) const ENTRY_FORMAT: &str = "--format=%H%x00%ct%x00%an%x00%s%x00%b"; |
| 127 | |
| 128 | |
| 129 | |
| 130 | |
| 131 | |
| 132 | |
| 133 | |
| 134 | |
| 135 | |
| 136 | |
| 137 | |
| 138 | |
| 139 | |
| 140 | |
| 141 | |
| 142 | |
| 143 | |
| 144 | |
| 145 | |
| 146 | |
| 147 | |
| 148 | |
| 149 | |
| 150 | |
| 151 | |
| 152 | |
| 153 | |
| 154 | |
| 155 | pub(super) fn parse_commit_object( |
| 156 | id: &ObjectId, |
| 157 | bytes: &[u8], |
| 158 | ) -> Result<CommitDetail, GitQueryError> { |
| 159 | let (headers, message) = split_headers(bytes); |
| 160 | |
| 161 | let mut tree = None; |
| 162 | let mut parents = Vec::new(); |
| 163 | let mut author = None; |
| 164 | let mut committer = None; |
| 165 | |
| 166 | for line in headers.split(|byte| *byte == b'\n') { |
| 167 | |
| 168 | |
| 169 | if line.first() == Some(&b' ') { |
| 170 | continue; |
| 171 | } |
| 172 | |
| 173 | let line = String::from_utf8_lossy(line); |
| 174 | let Some((key, value)) = line.split_once(' ') else { |
| 175 | continue; |
| 176 | }; |
| 177 | |
| 178 | match key { |
| 179 | "tree" => tree = Some(object_id(value)?), |
| 180 | "parent" => parents.push(object_id(value)?), |
| 181 | "author" => author = Some(identity(value)?), |
| 182 | "committer" => committer = Some(identity(value)?), |
| 183 | _ => {} |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | let (Some(tree), Some(author), Some(committer)) = (tree, author, committer) else { |
| 188 | return Err(GitQueryError::new(format!( |
| 189 | "git stored {} as a commit with no tree, author or committer", |
| 190 | id.as_str() |
| 191 | ))); |
| 192 | }; |
| 193 | |
| 194 | let (summary, body) = split_message(&String::from_utf8_lossy(message)); |
| 195 | |
| 196 | Ok(CommitDetail { |
| 197 | id: id.clone(), |
| 198 | tree, |
| 199 | parents, |
| 200 | summary, |
| 201 | body, |
| 202 | author_name: author.name, |
| 203 | author_email: author.email, |
| 204 | authored_at: author.at, |
| 205 | committer_name: committer.name, |
| 206 | committer_email: committer.email, |
| 207 | committed_at: committer.at, |
| 208 | }) |
| 209 | } |
| 210 | |
| 211 | |
| 212 | |
| 213 | |
| 214 | |
| 215 | |
| 216 | |
| 217 | |
| 218 | |
| 219 | fn split_headers(bytes: &[u8]) -> (&[u8], &[u8]) { |
| 220 | if let Some(index) = bytes |
| 221 | .windows(2) |
| 222 | .position(|window| window == b"\n\n") |
| 223 | .map(|index| index + 1) |
| 224 | { |
| 225 | (&bytes[..index], &bytes[index + 1..]) |
| 226 | } else { |
| 227 | (bytes, &[]) |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | |
| 232 | fn split_message(message: &str) -> (String, String) { |
| 233 | let (subject, body) = match message.split_once("\n\n") { |
| 234 | Some((subject, body)) => (subject, body), |
| 235 | None => (message, ""), |
| 236 | }; |
| 237 | |
| 238 | let subject = subject |
| 239 | .lines() |
| 240 | .map(str::trim_end) |
| 241 | .collect::<Vec<&str>>() |
| 242 | .join(" "); |
| 243 | |
| 244 | (subject.trim().to_owned(), body.trim_end().to_owned()) |
| 245 | } |
| 246 | |
| 247 | |
| 248 | struct Identity { |
| 249 | name: String, |
| 250 | email: String, |
| 251 | at: SystemTime, |
| 252 | } |
| 253 | |
| 254 | |
| 255 | |
| 256 | |
| 257 | |
| 258 | |
| 259 | |
| 260 | fn identity(value: &str) -> Result<Identity, GitQueryError> { |
| 261 | let bad = || GitQueryError::new(format!("git wrote an identity we cannot read: {value:?}")); |
| 262 | |
| 263 | let (rest, _zone) = value.rsplit_once(' ').ok_or_else(bad)?; |
| 264 | let (rest, seconds) = rest.rsplit_once(' ').ok_or_else(bad)?; |
| 265 | let (name, email) = rest.rsplit_once(" <").ok_or_else(bad)?; |
| 266 | |
| 267 | Ok(Identity { |
| 268 | name: name.trim().to_owned(), |
| 269 | email: email.trim_end().trim_end_matches('>').to_owned(), |
| 270 | at: seconds |
| 271 | .trim() |
| 272 | .parse::<i64>() |
| 273 | .map(unix_time) |
| 274 | .map_err(|_| GitQueryError::new(format!("git dated a commit as {seconds:?}")))?, |
| 275 | }) |
| 276 | } |
| 277 | |
| 278 | |
| 279 | |
| 280 | |
| 281 | |
| 282 | |
| 283 | fn object_id(value: &str) -> Result<ObjectId, GitQueryError> { |
| 284 | ObjectId::new(value.trim()) |
| 285 | .map_err(|error| GitQueryError::new(format!("git named a bad object id: {error}"))) |
| 286 | } |
| 287 | |
| 288 | #[cfg(test)] |
| 289 | mod tests { |
| 290 | use super::*; |
| 291 | |
| 292 | |
| 293 | |
| 294 | |
| 295 | |
| 296 | |
| 297 | #[test] |
| 298 | fn an_empty_author_name_or_subject_does_not_shift_the_log() { |
| 299 | |
| 300 | |
| 301 | |
| 302 | |
| 303 | |
| 304 | |
| 305 | |
| 306 | let stdout = b"\ |
| 307 | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x001700000200\x00\x00a commit by nobody\x00\ |
| 308 | bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x001700000100\x00Ada Lovelace\x00\x00"; |
| 309 | |
| 310 | let commits = parse_log(stdout).expect("should parse"); |
| 311 | |
| 312 | assert_eq!(commits.len(), 2); |
| 313 | assert_eq!(commits[0].author_name, ""); |
| 314 | assert_eq!(commits[0].summary, "a commit by nobody"); |
| 315 | assert_eq!(commits[1].author_name, "Ada Lovelace"); |
| 316 | assert_eq!(commits[1].summary, "", "an empty subject stays empty"); |
| 317 | assert_eq!( |
| 318 | commits[1].id.as_str(), |
| 319 | "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", |
| 320 | "the second record must not have been shifted into the first" |
| 321 | ); |
| 322 | } |
| 323 | |
| 324 | fn commit_object(headers: &str, message: &str) -> Vec<u8> { |
| 325 | format!("{headers}\n{message}").into_bytes() |
| 326 | } |
| 327 | |
| 328 | fn an_id(byte: u8) -> ObjectId { |
| 329 | ObjectId::from_trusted(format!("{byte:02x}").repeat(20)) |
| 330 | } |
| 331 | |
| 332 | #[test] |
| 333 | fn a_commit_object_is_parsed_into_a_detail() { |
| 334 | let bytes = commit_object( |
| 335 | &format!( |
| 336 | "tree {}\nparent {}\nauthor Ada <ada@example.com> 1700000000 +0100\ncommitter Bo <bo@example.com> 1700000060 -0500\n", |
| 337 | an_id(0xaa).as_str(), |
| 338 | an_id(0xbb).as_str() |
| 339 | ), |
| 340 | "feat: a thing\n\nWhy it is a thing.\n", |
| 341 | ); |
| 342 | |
| 343 | let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse"); |
| 344 | |
| 345 | assert_eq!(commit.id, an_id(0x11)); |
| 346 | assert_eq!(commit.tree, an_id(0xaa)); |
| 347 | assert_eq!(commit.parents, vec![an_id(0xbb)]); |
| 348 | assert_eq!(commit.author_name, "Ada"); |
| 349 | assert_eq!(commit.author_email, "ada@example.com"); |
| 350 | assert_eq!(commit.committer_name, "Bo"); |
| 351 | assert_eq!(commit.summary, "feat: a thing"); |
| 352 | assert_eq!(commit.body, "Why it is a thing."); |
| 353 | assert_eq!(commit.authored_at, unix_time(1_700_000_000)); |
| 354 | assert_eq!(commit.committed_at, unix_time(1_700_000_060)); |
| 355 | } |
| 356 | |
| 357 | #[test] |
| 358 | fn a_root_commit_has_no_parents_and_a_merge_has_two() { |
| 359 | let root = commit_object( |
| 360 | &format!( |
| 361 | "tree {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n", |
| 362 | an_id(0xaa).as_str() |
| 363 | ), |
| 364 | "first\n", |
| 365 | ); |
| 366 | let merge = commit_object( |
| 367 | &format!( |
| 368 | "tree {}\nparent {}\nparent {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n", |
| 369 | an_id(0xaa).as_str(), |
| 370 | an_id(0xbb).as_str(), |
| 371 | an_id(0xcc).as_str() |
| 372 | ), |
| 373 | "merge\n", |
| 374 | ); |
| 375 | |
| 376 | assert!( |
| 377 | parse_commit_object(&an_id(0x11), &root) |
| 378 | .expect("should parse") |
| 379 | .parents |
| 380 | .is_empty() |
| 381 | ); |
| 382 | assert_eq!( |
| 383 | parse_commit_object(&an_id(0x11), &merge) |
| 384 | .expect("should parse") |
| 385 | .parents, |
| 386 | vec![an_id(0xbb), an_id(0xcc)] |
| 387 | ); |
| 388 | } |
| 389 | |
| 390 | #[test] |
| 391 | fn a_signature_does_not_end_the_headers() { |
| 392 | |
| 393 | |
| 394 | |
| 395 | let bytes = commit_object( |
| 396 | &format!( |
| 397 | "tree {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\ngpgsig -----BEGIN PGP SIGNATURE-----\n \n iQIzBAABCgAd\n -----END PGP SIGNATURE-----\n", |
| 398 | an_id(0xaa).as_str() |
| 399 | ), |
| 400 | "signed\n", |
| 401 | ); |
| 402 | |
| 403 | let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse"); |
| 404 | |
| 405 | assert_eq!(commit.summary, "signed"); |
| 406 | assert_eq!(commit.body, ""); |
| 407 | } |
| 408 | |
| 409 | #[test] |
| 410 | fn a_multi_line_subject_is_folded_the_way_git_folds_it() { |
| 411 | let bytes = commit_object( |
| 412 | &format!( |
| 413 | "tree {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n", |
| 414 | an_id(0xaa).as_str() |
| 415 | ), |
| 416 | "a subject that\nran onto a second line\n\nand then a body\n", |
| 417 | ); |
| 418 | |
| 419 | let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse"); |
| 420 | |
| 421 | assert_eq!(commit.summary, "a subject that ran onto a second line"); |
| 422 | assert_eq!(commit.body, "and then a body"); |
| 423 | } |
| 424 | |
| 425 | #[test] |
| 426 | fn a_commit_with_no_message_parses() { |
| 427 | let bytes = format!( |
| 428 | "tree {}\nauthor A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n", |
| 429 | an_id(0xaa).as_str() |
| 430 | ) |
| 431 | .into_bytes(); |
| 432 | |
| 433 | let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse"); |
| 434 | |
| 435 | assert_eq!(commit.summary, ""); |
| 436 | assert_eq!(commit.body, ""); |
| 437 | } |
| 438 | |
| 439 | #[test] |
| 440 | fn an_identity_is_read_from_the_right_so_a_name_may_contain_anything() { |
| 441 | let bytes = commit_object( |
| 442 | &format!( |
| 443 | "tree {}\nauthor A <weird> Name <real@example.com> 1700000000 +0000\ncommitter A <a@b> 1 +0000\n", |
| 444 | an_id(0xaa).as_str() |
| 445 | ), |
| 446 | "x\n", |
| 447 | ); |
| 448 | |
| 449 | let commit = parse_commit_object(&an_id(0x11), &bytes).expect("should parse"); |
| 450 | |
| 451 | assert_eq!(commit.author_name, "A <weird> Name"); |
| 452 | assert_eq!(commit.author_email, "real@example.com"); |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn a_commit_with_no_tree_is_a_fault() { |
| 457 | let bytes = commit_object("author A <a@b> 1 +0000\ncommitter A <a@b> 1 +0000\n", "x\n"); |
| 458 | |
| 459 | assert!(parse_commit_object(&an_id(0x11), &bytes).is_err()); |
| 460 | } |
| 461 | } |