rustic_git/commands/
tag.rs

1//! Git tag operations
2//!
3//! This module provides functionality for creating, listing, deleting, and managing Git tags.
4//! It supports both lightweight and annotated tags with comprehensive type safety.
5//!
6//! # Examples
7//!
8//! ```rust,no_run
9//! use rustic_git::{Repository, TagType, TagOptions};
10//!
11//! let repo = Repository::open(".")?;
12//!
13//! // List all tags
14//! let tags = repo.tags()?;
15//! for tag in tags.iter() {
16//!     println!("{} -> {}", tag.name, tag.hash.short());
17//! }
18//!
19//! // Create a lightweight tag
20//! let tag = repo.create_tag("v1.0.0", None)?;
21//!
22//! // Create an annotated tag
23//! let options = TagOptions::new()
24//!     .with_message("Release version 1.0.0".to_string())
25//!     .with_annotated();
26//! let tag = repo.create_tag_with_options("v1.0.0-rc1", None, options)?;
27//!
28//! # Ok::<(), rustic_git::GitError>(())
29//! ```
30
31use crate::commands::log::Author;
32use crate::error::{GitError, Result};
33use crate::repository::Repository;
34use crate::types::Hash;
35use crate::utils::{git, parse_unix_timestamp};
36use chrono::{DateTime, Utc};
37use std::fmt;
38
39/// Represents a Git tag
40#[derive(Debug, Clone, PartialEq)]
41pub struct Tag {
42    /// The name of the tag
43    pub name: String,
44    /// The commit hash this tag points to
45    pub hash: Hash,
46    /// The type of tag (lightweight or annotated)
47    pub tag_type: TagType,
48    /// The tag message (only for annotated tags)
49    pub message: Option<String>,
50    /// The tagger information (only for annotated tags)
51    pub tagger: Option<Author>,
52    /// The tag creation timestamp (only for annotated tags)
53    pub timestamp: Option<DateTime<Utc>>,
54}
55
56/// Type of Git tag
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum TagType {
59    /// Lightweight tag - just a reference to a commit
60    Lightweight,
61    /// Annotated tag - full object with message, author, and date
62    Annotated,
63}
64
65impl fmt::Display for TagType {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            TagType::Lightweight => write!(f, "lightweight"),
69            TagType::Annotated => write!(f, "annotated"),
70        }
71    }
72}
73
74/// A collection of tags with efficient iteration and filtering methods
75#[derive(Debug, Clone)]
76pub struct TagList {
77    tags: Box<[Tag]>,
78}
79
80impl TagList {
81    /// Create a new TagList from a vector of tags
82    pub fn new(mut tags: Vec<Tag>) -> Self {
83        // Sort tags by name for consistent ordering
84        tags.sort_by(|a, b| a.name.cmp(&b.name));
85        Self {
86            tags: tags.into_boxed_slice(),
87        }
88    }
89
90    /// Get an iterator over all tags
91    pub fn iter(&self) -> impl Iterator<Item = &Tag> + '_ {
92        self.tags.iter()
93    }
94
95    /// Get an iterator over lightweight tags only
96    pub fn lightweight(&self) -> impl Iterator<Item = &Tag> + '_ {
97        self.tags
98            .iter()
99            .filter(|tag| tag.tag_type == TagType::Lightweight)
100    }
101
102    /// Get an iterator over annotated tags only
103    pub fn annotated(&self) -> impl Iterator<Item = &Tag> + '_ {
104        self.tags
105            .iter()
106            .filter(|tag| tag.tag_type == TagType::Annotated)
107    }
108
109    /// Find a tag by exact name
110    pub fn find(&self, name: &str) -> Option<&Tag> {
111        self.tags.iter().find(|tag| tag.name == name)
112    }
113
114    /// Find tags whose names contain the given substring
115    pub fn find_containing<'a>(&'a self, substring: &'a str) -> impl Iterator<Item = &'a Tag> + 'a {
116        self.tags
117            .iter()
118            .filter(move |tag| tag.name.contains(substring))
119    }
120
121    /// Get the total number of tags
122    pub fn len(&self) -> usize {
123        self.tags.len()
124    }
125
126    /// Check if the tag list is empty
127    pub fn is_empty(&self) -> bool {
128        self.tags.is_empty()
129    }
130
131    /// Get the number of lightweight tags
132    pub fn lightweight_count(&self) -> usize {
133        self.lightweight().count()
134    }
135
136    /// Get the number of annotated tags
137    pub fn annotated_count(&self) -> usize {
138        self.annotated().count()
139    }
140
141    /// Get tags that point to a specific commit
142    pub fn for_commit<'a>(&'a self, hash: &'a Hash) -> impl Iterator<Item = &'a Tag> + 'a {
143        self.tags.iter().filter(move |tag| &tag.hash == hash)
144    }
145}
146
147/// Options for creating tags
148#[derive(Debug, Clone, Default)]
149pub struct TagOptions {
150    /// Create an annotated tag (default: false - lightweight)
151    pub annotated: bool,
152    /// Force tag creation (overwrite existing tag)
153    pub force: bool,
154    /// Tag message (for annotated tags)
155    pub message: Option<String>,
156    /// Sign the tag with GPG (requires annotated)
157    pub sign: bool,
158}
159
160impl TagOptions {
161    /// Create new default tag options
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Create an annotated tag instead of lightweight
167    pub fn with_annotated(mut self) -> Self {
168        self.annotated = true;
169        self
170    }
171
172    /// Force tag creation (overwrite existing)
173    pub fn with_force(mut self) -> Self {
174        self.force = true;
175        self
176    }
177
178    /// Set the tag message (implies annotated)
179    pub fn with_message(mut self, message: String) -> Self {
180        self.message = Some(message);
181        self.annotated = true; // Message implies annotated tag
182        self
183    }
184
185    /// Sign the tag with GPG (implies annotated)
186    pub fn with_sign(mut self) -> Self {
187        self.sign = true;
188        self.annotated = true; // Signing implies annotated tag
189        self
190    }
191}
192
193impl Repository {
194    /// List all tags in the repository
195    ///
196    /// Returns a `TagList` containing all tags sorted by name.
197    ///
198    /// # Example
199    ///
200    /// ```rust,no_run
201    /// use rustic_git::Repository;
202    ///
203    /// let repo = Repository::open(".")?;
204    /// let tags = repo.tags()?;
205    ///
206    /// println!("Found {} tags:", tags.len());
207    /// for tag in tags.iter() {
208    ///     println!("  {} ({}) -> {}", tag.name, tag.tag_type, tag.hash.short());
209    /// }
210    /// # Ok::<(), rustic_git::GitError>(())
211    /// ```
212    pub fn tags(&self) -> Result<TagList> {
213        Self::ensure_git()?;
214
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        )?;
225
226        if output.trim().is_empty() {
227            return Ok(TagList::new(vec![]));
228        }
229
230        let mut tags = Vec::new();
231
232        for line in output.lines() {
233            let line = line.trim();
234            if line.is_empty() {
235                continue;
236            }
237
238            // Parse tag information from for-each-ref output
239            if let Ok(tag) = parse_for_each_ref_line(line) {
240                tags.push(tag);
241            }
242        }
243
244        Ok(TagList::new(tags))
245    }
246
247    /// Create a lightweight tag pointing to the current HEAD or specified commit
248    ///
249    /// # Arguments
250    ///
251    /// * `name` - The name of the tag to create
252    /// * `target` - Optional commit hash to tag (defaults to HEAD)
253    ///
254    /// # Example
255    ///
256    /// ```rust,no_run
257    /// use rustic_git::Repository;
258    ///
259    /// let repo = Repository::open(".")?;
260    ///
261    /// // Tag current HEAD
262    /// let tag = repo.create_tag("v1.0.0", None)?;
263    ///
264    /// // Tag specific commit
265    /// let commits = repo.recent_commits(1)?;
266    /// if let Some(commit) = commits.iter().next() {
267    ///     let tag = repo.create_tag("v0.9.0", Some(&commit.hash))?;
268    /// }
269    /// # Ok::<(), rustic_git::GitError>(())
270    /// ```
271    pub fn create_tag(&self, name: &str, target: Option<&Hash>) -> Result<Tag> {
272        self.create_tag_with_options(name, target, TagOptions::new())
273    }
274
275    /// Create a tag with custom options
276    ///
277    /// # Arguments
278    ///
279    /// * `name` - The name of the tag to create
280    /// * `target` - Optional commit hash to tag (defaults to HEAD)
281    /// * `options` - Tag creation options
282    ///
283    /// # Example
284    ///
285    /// ```rust,no_run
286    /// use rustic_git::{Repository, TagOptions};
287    ///
288    /// let repo = Repository::open(".")?;
289    ///
290    /// // Create annotated tag with message
291    /// let options = TagOptions::new()
292    ///     .with_message("Release version 1.0.0".to_string());
293    /// let tag = repo.create_tag_with_options("v1.0.0", None, options)?;
294    ///
295    /// // Create and force overwrite existing tag
296    /// let options = TagOptions::new().with_force();
297    /// let tag = repo.create_tag_with_options("latest", None, options)?;
298    /// # Ok::<(), rustic_git::GitError>(())
299    /// ```
300    pub fn create_tag_with_options(
301        &self,
302        name: &str,
303        target: Option<&Hash>,
304        options: TagOptions,
305    ) -> Result<Tag> {
306        Self::ensure_git()?;
307
308        let mut args = vec!["tag"];
309
310        if options.annotated || options.message.is_some() {
311            args.push("-a");
312        }
313
314        if options.force {
315            args.push("-f");
316        }
317
318        if options.sign {
319            args.push("-s");
320        }
321
322        if let Some(ref message) = options.message {
323            args.push("-m");
324            args.push(message);
325        }
326
327        args.push(name);
328
329        if let Some(target_hash) = target {
330            args.push(target_hash.as_str());
331        }
332
333        git(&args, Some(self.repo_path()))?;
334
335        // Get the created tag information
336        let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
337        parse_tag_info(name, &show_output)
338    }
339
340    /// Delete a tag
341    ///
342    /// # Arguments
343    ///
344    /// * `name` - The name of the tag to delete
345    ///
346    /// # Example
347    ///
348    /// ```rust,no_run
349    /// use rustic_git::Repository;
350    ///
351    /// let repo = Repository::open(".")?;
352    /// repo.delete_tag("v0.1.0")?;
353    /// # Ok::<(), rustic_git::GitError>(())
354    /// ```
355    pub fn delete_tag(&self, name: &str) -> Result<()> {
356        Self::ensure_git()?;
357
358        git(&["tag", "-d", name], Some(self.repo_path()))?;
359        Ok(())
360    }
361
362    /// Show detailed information about a specific tag
363    ///
364    /// # Arguments
365    ///
366    /// * `name` - The name of the tag to show
367    ///
368    /// # Example
369    ///
370    /// ```rust,no_run
371    /// use rustic_git::Repository;
372    ///
373    /// let repo = Repository::open(".")?;
374    /// let tag = repo.show_tag("v1.0.0")?;
375    ///
376    /// println!("Tag: {} ({})", tag.name, tag.tag_type);
377    /// println!("Commit: {}", tag.hash.short());
378    /// if let Some(message) = &tag.message {
379    ///     println!("Message: {}", message);
380    /// }
381    /// # Ok::<(), rustic_git::GitError>(())
382    /// ```
383    pub fn show_tag(&self, name: &str) -> Result<Tag> {
384        Self::ensure_git()?;
385
386        let show_output = git(&["show", "--format=fuller", name], Some(self.repo_path()))?;
387        parse_tag_info(name, &show_output)
388    }
389}
390
391/// Parse tag information from git for-each-ref output
392/// Format: refname:short|objecttype|objectname|*objectname|taggername|taggeremail|taggerdate:unix|subject|body
393fn 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)
471fn parse_tag_info(tag_name: &str, show_output: &str) -> Result<Tag> {
472    let lines: Vec<&str> = show_output.lines().collect();
473
474    // Determine if this is an annotated tag or lightweight tag
475    let is_annotated = show_output.contains("tag ") && show_output.contains("Tagger:");
476
477    if is_annotated {
478        parse_annotated_tag(tag_name, &lines)
479    } else {
480        parse_lightweight_tag(tag_name, &lines)
481    }
482}
483
484/// Parse annotated tag information
485fn parse_annotated_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
486    let mut hash = None;
487    let mut tagger = None;
488    let mut collecting_message = false;
489    let mut message_lines = Vec::new();
490
491    for line in lines {
492        if line.starts_with("commit ") {
493            if let Some(hash_str) = line.split_whitespace().nth(1) {
494                hash = Some(Hash::from(hash_str));
495            }
496        } else if let Some(stripped) = line.strip_prefix("Tagger: ") {
497            tagger = parse_author_line(stripped);
498        } else if line.trim().is_empty() && !collecting_message {
499            collecting_message = true;
500        } else if collecting_message && !line.starts_with("commit ") && !line.starts_with("Author:")
501        {
502            message_lines.push(line.trim());
503        }
504    }
505
506    let message_text = if message_lines.is_empty() {
507        None
508    } else {
509        Some(message_lines.join("\n").trim().to_string())
510    };
511
512    let timestamp = tagger.as_ref().map(|t| t.timestamp);
513
514    Ok(Tag {
515        name: tag_name.to_string(),
516        hash: hash.ok_or_else(|| {
517            GitError::CommandFailed("Could not parse tag commit hash".to_string())
518        })?,
519        tag_type: TagType::Annotated,
520        message: message_text,
521        tagger,
522        timestamp,
523    })
524}
525
526/// Parse lightweight tag information
527fn parse_lightweight_tag(tag_name: &str, lines: &[&str]) -> Result<Tag> {
528    let mut hash = None;
529
530    for line in lines {
531        if line.starts_with("commit ")
532            && let Some(hash_str) = line.split_whitespace().nth(1)
533        {
534            hash = Some(Hash::from(hash_str));
535            break;
536        }
537    }
538
539    Ok(Tag {
540        name: tag_name.to_string(),
541        hash: hash.ok_or_else(|| {
542            GitError::CommandFailed("Could not parse tag commit hash".to_string())
543        })?,
544        tag_type: TagType::Lightweight,
545        message: None,
546        tagger: None,
547        timestamp: None,
548    })
549}
550
551/// Parse author information from a git tagger line
552/// Format: "Tagger: Name <email>" (timestamp not available in this format)
553fn parse_author_line(line: &str) -> Option<Author> {
554    // Parse format: "Name <email>" (no timestamp in git show --format=fuller tagger line)
555    if let Some(email_start) = line.find('<')
556        && let Some(email_end) = line.find('>')
557    {
558        let name = line[..email_start].trim().to_string();
559        let email = line[email_start + 1..email_end].to_string();
560
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
564        let timestamp = Utc::now();
565
566        return Some(Author {
567            name,
568            email,
569            timestamp,
570        });
571    }
572    None
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use std::env;
579    use std::fs;
580
581    fn create_test_repo() -> (Repository, std::path::PathBuf) {
582        use std::thread;
583        use std::time::{SystemTime, UNIX_EPOCH};
584
585        let timestamp = SystemTime::now()
586            .duration_since(UNIX_EPOCH)
587            .unwrap()
588            .as_nanos();
589        let thread_id = format!("{:?}", thread::current().id());
590        let test_path = env::temp_dir().join(format!(
591            "rustic_git_tag_test_{}_{}_{}",
592            std::process::id(),
593            timestamp,
594            thread_id.replace("ThreadId(", "").replace(")", "")
595        ));
596
597        // Ensure clean state
598        if test_path.exists() {
599            fs::remove_dir_all(&test_path).unwrap();
600        }
601
602        let repo = Repository::init(&test_path, false).unwrap();
603
604        // Configure git user for commits
605        repo.config()
606            .set_user("Test User", "test@example.com")
607            .unwrap();
608
609        // Disable tag signing for tests to ensure lightweight tags work
610        repo.config().set("tag.gpgsign", "false").unwrap();
611
612        (repo, test_path)
613    }
614
615    fn create_test_commit(repo: &Repository, test_path: &std::path::Path) {
616        fs::write(test_path.join("test.txt"), "test content").unwrap();
617        repo.add(&["test.txt"]).unwrap();
618        repo.commit("Test commit").unwrap();
619    }
620
621    #[test]
622    fn test_tag_list_empty_repository() {
623        let (repo, test_path) = create_test_repo();
624
625        let tags = repo.tags().unwrap();
626        assert!(tags.is_empty());
627        assert_eq!(tags.len(), 0);
628
629        // Clean up
630        fs::remove_dir_all(&test_path).unwrap();
631    }
632
633    #[test]
634    fn test_create_lightweight_tag() {
635        let (repo, test_path) = create_test_repo();
636        create_test_commit(&repo, &test_path);
637
638        let tag = repo.create_tag("v1.0.0", None).unwrap();
639        assert_eq!(tag.name, "v1.0.0");
640        assert_eq!(tag.tag_type, TagType::Lightweight);
641        assert!(tag.message.is_none());
642        assert!(tag.tagger.is_none());
643
644        // Verify tag exists in list
645        let tags = repo.tags().unwrap();
646        assert_eq!(tags.len(), 1);
647        assert!(tags.find("v1.0.0").is_some());
648
649        // Clean up
650        fs::remove_dir_all(&test_path).unwrap();
651    }
652
653    #[test]
654    fn test_create_annotated_tag() {
655        let (repo, test_path) = create_test_repo();
656        create_test_commit(&repo, &test_path);
657
658        let options = TagOptions::new().with_message("Release version 1.0.0".to_string());
659        let tag = repo
660            .create_tag_with_options("v1.0.0", None, options)
661            .unwrap();
662
663        assert_eq!(tag.name, "v1.0.0");
664        assert_eq!(tag.tag_type, TagType::Annotated);
665        assert!(tag.message.is_some());
666
667        // Clean up
668        fs::remove_dir_all(&test_path).unwrap();
669    }
670
671    #[test]
672    fn test_delete_tag() {
673        let (repo, test_path) = create_test_repo();
674        create_test_commit(&repo, &test_path);
675
676        // Create a tag
677        repo.create_tag("to-delete", None).unwrap();
678
679        // Verify it exists
680        let tags = repo.tags().unwrap();
681        assert_eq!(tags.len(), 1);
682
683        // Delete it
684        repo.delete_tag("to-delete").unwrap();
685
686        // Verify it's gone
687        let tags = repo.tags().unwrap();
688        assert_eq!(tags.len(), 0);
689
690        // Clean up
691        fs::remove_dir_all(&test_path).unwrap();
692    }
693
694    #[test]
695    fn test_tag_list_filtering() {
696        let (repo, test_path) = create_test_repo();
697        create_test_commit(&repo, &test_path);
698
699        // Create multiple tags
700        repo.create_tag("v1.0.0", None).unwrap();
701        repo.create_tag("v1.1.0", None).unwrap();
702        let options = TagOptions::new().with_message("Annotated".to_string());
703        repo.create_tag_with_options("v2.0.0", None, options)
704            .unwrap();
705
706        let tags = repo.tags().unwrap();
707        assert_eq!(tags.len(), 3);
708        assert_eq!(tags.lightweight_count(), 2);
709        assert_eq!(tags.annotated_count(), 1);
710
711        // Test filtering
712        let v1_tags: Vec<_> = tags.find_containing("v1").collect();
713        assert_eq!(v1_tags.len(), 2);
714
715        // Clean up
716        fs::remove_dir_all(&test_path).unwrap();
717    }
718
719    #[test]
720    fn test_tag_options_builder() {
721        let options = TagOptions::new()
722            .with_annotated()
723            .with_force()
724            .with_message("Test message".to_string());
725
726        assert!(options.annotated);
727        assert!(options.force);
728        assert_eq!(options.message, Some("Test message".to_string()));
729    }
730
731    #[test]
732    fn test_show_tag() {
733        let (repo, test_path) = create_test_repo();
734        create_test_commit(&repo, &test_path);
735
736        repo.create_tag("show-test", None).unwrap();
737        let tag = repo.show_tag("show-test").unwrap();
738
739        assert_eq!(tag.name, "show-test");
740        assert_eq!(tag.tag_type, TagType::Lightweight);
741
742        // Clean up
743        fs::remove_dir_all(&test_path).unwrap();
744    }
745
746    #[test]
747    fn test_tag_force_overwrite() {
748        let (repo, test_path) = create_test_repo();
749        create_test_commit(&repo, &test_path);
750
751        // Create initial tag
752        repo.create_tag("overwrite-test", None).unwrap();
753
754        // Try to create again without force (should fail)
755        let result = repo.create_tag("overwrite-test", None);
756        assert!(result.is_err());
757
758        // Create with force (should succeed)
759        let options = TagOptions::new().with_force();
760        let result = repo.create_tag_with_options("overwrite-test", None, options);
761        assert!(result.is_ok());
762
763        // Clean up
764        fs::remove_dir_all(&test_path).unwrap();
765    }
766
767    #[test]
768    fn test_parse_for_each_ref_line_invalid_format() {
769        // Test with insufficient parts (should have 9 parts minimum)
770        let invalid_line = "tag1|commit|abc123"; // Only 3 parts instead of 9
771        let result = parse_for_each_ref_line(invalid_line);
772
773        assert!(result.is_err());
774
775        if let Err(GitError::CommandFailed(msg)) = result {
776            assert!(msg.contains("Invalid for-each-ref format"));
777            assert!(msg.contains("expected 9 parts"));
778            assert!(msg.contains("got 3"));
779        } else {
780            panic!("Expected CommandFailed error with specific message");
781        }
782    }
783
784    #[test]
785    fn test_parse_for_each_ref_line_with_invalid_timestamp() {
786        // Test annotated tag with invalid timestamp - should still parse but use fallback timestamp
787        let line_with_invalid_timestamp =
788            "v1.0.0|tag|abc123|def456|John Doe|john@example.com|invalid-timestamp|Subject|Body";
789        let result = parse_for_each_ref_line(line_with_invalid_timestamp);
790
791        assert!(result.is_ok());
792        let tag = result.unwrap();
793        assert_eq!(tag.name, "v1.0.0");
794        assert_eq!(tag.tag_type, TagType::Annotated);
795        assert!(tag.tagger.is_some());
796
797        // The timestamp should use Unix epoch (1970-01-01) as fallback for invalid data
798        let tagger = tag.tagger.unwrap();
799        assert_eq!(tagger.name, "John Doe");
800        assert_eq!(tagger.email, "john@example.com");
801
802        // Verify fallback timestamp is Unix epoch (indicates data corruption)
803        assert_eq!(tagger.timestamp.timestamp(), 0); // Unix epoch
804        assert_eq!(
805            tagger.timestamp.format("%Y-%m-%d").to_string(),
806            "1970-01-01"
807        );
808    }
809}