|
28 | 28 | //! # Ok::<(), rustic_git::GitError>(()) |
29 | 29 | //! ``` |
30 | 30 |
|
| 31 | +use crate::commands::log::Author; |
31 | 32 | use crate::error::{GitError, Result}; |
32 | 33 | use crate::repository::Repository; |
33 | 34 | use crate::types::Hash; |
34 | | -use crate::utils::git; |
| 35 | +use crate::utils::{git, parse_unix_timestamp}; |
35 | 36 | use chrono::{DateTime, Utc}; |
36 | 37 | use std::fmt; |
37 | 38 |
|
@@ -70,23 +71,6 @@ impl fmt::Display for TagType { |
70 | 71 | } |
71 | 72 | } |
72 | 73 |
|
73 | | -/// Author information for annotated tags |
74 | | -#[derive(Debug, Clone, PartialEq)] |
75 | | -pub struct Author { |
76 | | - /// Author name |
77 | | - pub name: String, |
78 | | - /// Author email |
79 | | - pub email: String, |
80 | | - /// Author timestamp |
81 | | - pub timestamp: DateTime<Utc>, |
82 | | -} |
83 | | - |
84 | | -impl fmt::Display for Author { |
85 | | - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
86 | | - write!(f, "{} <{}>", self.name, self.email) |
87 | | - } |
88 | | -} |
89 | | - |
90 | 74 | /// A collection of tags with efficient iteration and filtering methods |
91 | 75 | #[derive(Debug, Clone)] |
92 | 76 | pub struct TagList { |
@@ -228,29 +212,31 @@ impl Repository { |
228 | 212 | pub fn tags(&self) -> Result<TagList> { |
229 | 213 | Self::ensure_git()?; |
230 | 214 |
|
231 | | - // Get list of tag names |
232 | | - let output = git(&["tag", "-l"], Some(self.repo_path()))?; |
| 215 | + // Use git for-each-ref to get all tag information in a single call |
| 216 | + // Format: refname:short objecttype objectname *objectname taggername taggeremail taggerdate:unix subject body |
| 217 | + let output = git( |
| 218 | + &[ |
| 219 | + "for-each-ref", |
| 220 | + "--format=%(refname:short)|%(objecttype)|%(objectname)|%(*objectname)|%(taggername)|%(taggeremail)|%(taggerdate:unix)|%(subject)|%(body)", |
| 221 | + "refs/tags/", |
| 222 | + ], |
| 223 | + Some(self.repo_path()), |
| 224 | + )?; |
233 | 225 |
|
234 | 226 | if output.trim().is_empty() { |
235 | 227 | return Ok(TagList::new(vec![])); |
236 | 228 | } |
237 | 229 |
|
238 | 230 | let mut tags = Vec::new(); |
239 | 231 |
|
240 | | - for tag_name in output.lines() { |
241 | | - let tag_name = tag_name.trim(); |
242 | | - if tag_name.is_empty() { |
| 232 | + for line in output.lines() { |
| 233 | + let line = line.trim(); |
| 234 | + if line.is_empty() { |
243 | 235 | continue; |
244 | 236 | } |
245 | 237 |
|
246 | | - // Get tag information |
247 | | - let show_output = git( |
248 | | - &["show", "--format=fuller", tag_name], |
249 | | - Some(self.repo_path()), |
250 | | - )?; |
251 | | - |
252 | | - // Parse tag information |
253 | | - if let Ok(tag) = parse_tag_info(tag_name, &show_output) { |
| 238 | + // Parse tag information from for-each-ref output |
| 239 | + if let Ok(tag) = parse_for_each_ref_line(line) { |
254 | 240 | tags.push(tag); |
255 | 241 | } |
256 | 242 | } |
@@ -402,7 +388,86 @@ impl Repository { |
402 | 388 | } |
403 | 389 | } |
404 | 390 |
|
405 | | -/// Parse tag information from git show output |
| 391 | +/// Parse tag information from git for-each-ref output |
| 392 | +/// Format: refname:short|objecttype|objectname|*objectname|taggername|taggeremail|taggerdate:unix|subject|body |
| 393 | +fn parse_for_each_ref_line(line: &str) -> Result<Tag> { |
| 394 | + let parts: Vec<&str> = line.split('|').collect(); |
| 395 | + |
| 396 | + if parts.len() < 9 { |
| 397 | + return Err(GitError::CommandFailed(format!( |
| 398 | + "Invalid for-each-ref format: expected 9 parts, got {}", |
| 399 | + parts.len() |
| 400 | + ))); |
| 401 | + } |
| 402 | + |
| 403 | + let name = parts[0].to_string(); |
| 404 | + let object_type = parts[1]; |
| 405 | + let object_name = parts[2]; |
| 406 | + let dereferenced_object = parts[3]; // For annotated tags, this is the commit hash |
| 407 | + let tagger_name = parts[4]; |
| 408 | + let tagger_email = parts[5]; |
| 409 | + let tagger_date = parts[6]; |
| 410 | + let subject = parts[7]; |
| 411 | + let body = parts[8]; |
| 412 | + |
| 413 | + // Determine tag type and commit hash |
| 414 | + let (tag_type, hash) = if object_type == "tag" { |
| 415 | + // Annotated tag - use dereferenced object (the commit it points to) |
| 416 | + (TagType::Annotated, Hash::from(dereferenced_object)) |
| 417 | + } else { |
| 418 | + // Lightweight tag - use object name (direct commit reference) |
| 419 | + (TagType::Lightweight, Hash::from(object_name)) |
| 420 | + }; |
| 421 | + |
| 422 | + // Build tagger information for annotated tags |
| 423 | + let tagger = |
| 424 | + if tag_type == TagType::Annotated && !tagger_name.is_empty() && !tagger_email.is_empty() { |
| 425 | + // Parse the timestamp - if it fails, the tag metadata may be corrupted |
| 426 | + // Use Unix epoch as fallback to clearly indicate corrupted/invalid timestamp data |
| 427 | + let timestamp = parse_unix_timestamp(tagger_date).unwrap_or_else(|_| { |
| 428 | + // Timestamp parsing failed - this indicates malformed git metadata |
| 429 | + // Use Unix epoch (1970-01-01) as fallback to make data corruption obvious |
| 430 | + DateTime::from_timestamp(0, 0).unwrap() |
| 431 | + }); |
| 432 | + Some(Author { |
| 433 | + name: tagger_name.to_string(), |
| 434 | + email: tagger_email.to_string(), |
| 435 | + timestamp, |
| 436 | + }) |
| 437 | + } else { |
| 438 | + None |
| 439 | + }; |
| 440 | + |
| 441 | + // Build message for annotated tags |
| 442 | + let message = if tag_type == TagType::Annotated && (!subject.is_empty() || !body.is_empty()) { |
| 443 | + let full_message = if !body.is_empty() { |
| 444 | + format!("{}\n\n{}", subject, body) |
| 445 | + } else { |
| 446 | + subject.to_string() |
| 447 | + }; |
| 448 | + Some(full_message.trim().to_string()) |
| 449 | + } else { |
| 450 | + None |
| 451 | + }; |
| 452 | + |
| 453 | + // Timestamp for the tag |
| 454 | + let timestamp = if tag_type == TagType::Annotated { |
| 455 | + tagger.as_ref().map(|t| t.timestamp) |
| 456 | + } else { |
| 457 | + None |
| 458 | + }; |
| 459 | + |
| 460 | + Ok(Tag { |
| 461 | + name, |
| 462 | + hash, |
| 463 | + tag_type, |
| 464 | + message, |
| 465 | + tagger, |
| 466 | + timestamp, |
| 467 | + }) |
| 468 | +} |
| 469 | + |
| 470 | +/// Parse tag information from git show output (fallback method) |
406 | 471 | fn parse_tag_info(tag_name: &str, show_output: &str) -> Result<Tag> { |
407 | 472 | let lines: Vec<&str> = show_output.lines().collect(); |
408 | 473 |
|
@@ -483,16 +548,19 @@ fn parse_lightweight_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> { |
483 | 548 | }) |
484 | 549 | } |
485 | 550 |
|
486 | | -/// Parse author information from a git log line |
| 551 | +/// Parse author information from a git tagger line |
| 552 | +/// Format: "Tagger: Name <email>" (timestamp not available in this format) |
487 | 553 | fn parse_author_line(line: &str) -> Option<Author> { |
488 | | - // Parse format: "Name <email> timestamp timezone" |
| 554 | + // Parse format: "Name <email>" (no timestamp in git show --format=fuller tagger line) |
489 | 555 | if let Some(email_start) = line.find('<') |
490 | 556 | && let Some(email_end) = line.find('>') |
491 | 557 | { |
492 | 558 | let name = line[..email_start].trim().to_string(); |
493 | 559 | let email = line[email_start + 1..email_end].to_string(); |
494 | 560 |
|
495 | | - // Parse timestamp (simplified - just use current time for now) |
| 561 | + // Timestamp is not available in the tagger line from git show --format=fuller |
| 562 | + // We use the current time as a fallback, which matches the review feedback |
| 563 | + // that tagger timestamp may default |
496 | 564 | let timestamp = Utc::now(); |
497 | 565 |
|
498 | 566 | return Some(Author { |
@@ -692,4 +760,47 @@ mod tests { |
692 | 760 | // Clean up |
693 | 761 | fs::remove_dir_all(&test_path).unwrap(); |
694 | 762 | } |
| 763 | + |
| 764 | + #[test] |
| 765 | + fn test_parse_for_each_ref_line_invalid_format() { |
| 766 | + // Test with insufficient parts (should have 9 parts minimum) |
| 767 | + let invalid_line = "tag1|commit|abc123"; // Only 3 parts instead of 9 |
| 768 | + let result = parse_for_each_ref_line(invalid_line); |
| 769 | + |
| 770 | + assert!(result.is_err()); |
| 771 | + |
| 772 | + if let Err(GitError::CommandFailed(msg)) = result { |
| 773 | + assert!(msg.contains("Invalid for-each-ref format")); |
| 774 | + assert!(msg.contains("expected 9 parts")); |
| 775 | + assert!(msg.contains("got 3")); |
| 776 | + } else { |
| 777 | + panic!("Expected CommandFailed error with specific message"); |
| 778 | + } |
| 779 | + } |
| 780 | + |
| 781 | + #[test] |
| 782 | + fn test_parse_for_each_ref_line_with_invalid_timestamp() { |
| 783 | + // Test annotated tag with invalid timestamp - should still parse but use fallback timestamp |
| 784 | + let line_with_invalid_timestamp = |
| 785 | + "v1.0.0|tag|abc123|def456|John Doe|[email protected]|invalid-timestamp|Subject|Body"; |
| 786 | + let result = parse_for_each_ref_line(line_with_invalid_timestamp); |
| 787 | + |
| 788 | + assert!(result.is_ok()); |
| 789 | + let tag = result.unwrap(); |
| 790 | + assert_eq!(tag.name, "v1.0.0"); |
| 791 | + assert_eq!(tag.tag_type, TagType::Annotated); |
| 792 | + assert!(tag.tagger.is_some()); |
| 793 | + |
| 794 | + // The timestamp should use Unix epoch (1970-01-01) as fallback for invalid data |
| 795 | + let tagger = tag.tagger.unwrap(); |
| 796 | + assert_eq!(tagger.name, "John Doe"); |
| 797 | + assert_eq!(tagger .email , "[email protected]"); |
| 798 | + |
| 799 | + // Verify fallback timestamp is Unix epoch (indicates data corruption) |
| 800 | + assert_eq!(tagger.timestamp.timestamp(), 0); // Unix epoch |
| 801 | + assert_eq!( |
| 802 | + tagger.timestamp.format("%Y-%m-%d").to_string(), |
| 803 | + "1970-01-01" |
| 804 | + ); |
| 805 | + } |
695 | 806 | } |
0 commit comments