Skip to main content

git_spawn/
tags.rs

1//! Typed listing and bulk operations on tags.
2//!
3//! Reached through [`Repository::tags`], which returns a [`TagOps`] handle:
4//!
5//! ```no_run
6//! # async fn ex() -> git_spawn::Result<()> {
7//! use git_spawn::Repository;
8//! use git_spawn::tags::TagKind;
9//!
10//! let repo = Repository::open("/path/to/repo")?;
11//!
12//! for tag in repo.tags().list().await? {
13//!     match tag.kind {
14//!         TagKind::Annotated => println!("{} (annotated): {}",
15//!             tag.name,
16//!             tag.message.as_deref().unwrap_or("")),
17//!         TagKind::Lightweight => println!("{} (lightweight) -> {}", tag.name, tag.target),
18//!     }
19//! }
20//!
21//! repo.tags().create("v0.1.0", "HEAD").await?;
22//! repo.tags().create_annotated("v0.2.0", "HEAD", "release 0.2.0").await?;
23//! # Ok(())
24//! # }
25//! ```
26//!
27//! Like [`crate::branches`], listing uses `for-each-ref` with a fixed
28//! NUL-delimited format string.
29
30use crate::command::GitCommand;
31use crate::command::for_each_ref::ForEachRefCommand;
32use crate::command::tag::TagCommand;
33use crate::error::Result;
34use crate::repo::Repository;
35
36/// One tag.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct Tag {
40    /// Short tag name (e.g. `"v1.2.3"`).
41    pub name: String,
42    /// Whether the tag is a plain ref (lightweight) or carries its own object
43    /// (annotated).
44    pub kind: TagKind,
45    /// Short SHA of the commit the tag ultimately points at.
46    pub target: String,
47    /// Subject line of the tag message — `None` for lightweight tags.
48    pub message: Option<String>,
49    /// Tagger metadata — `None` for lightweight tags.
50    pub tagger: Option<Tagger>,
51}
52
53/// Lightweight vs. annotated tag.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56pub enum TagKind {
57    /// Plain ref pointing at a commit.
58    Lightweight,
59    /// Tag object with its own metadata (message, tagger, date).
60    Annotated,
61}
62
63/// Tagger identity attached to an annotated tag.
64#[derive(Debug, Clone, PartialEq, Eq)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66pub struct Tagger {
67    /// Tagger display name.
68    pub name: String,
69    /// Tagger email (with `<>` stripped).
70    pub email: String,
71    /// Tag date, RFC 3339 / ISO 8601 strict.
72    pub date: String,
73}
74
75/// Operations on tags, scoped to a [`Repository`].
76#[derive(Debug)]
77pub struct TagOps<'a> {
78    repo: &'a Repository,
79}
80
81impl<'a> TagOps<'a> {
82    /// List every tag in the repository.
83    pub async fn list(&self) -> Result<Vec<Tag>> {
84        self.list_inner(None).await
85    }
86
87    /// List tags whose ref path matches `pattern`
88    /// (e.g. `"refs/tags/v1.*"`).
89    pub async fn list_matching(&self, pattern: impl Into<String>) -> Result<Vec<Tag>> {
90        self.list_inner(Some(pattern.into())).await
91    }
92
93    /// Create a lightweight tag `name` pointing at `target` (a commit-ish).
94    pub async fn create(&self, name: impl Into<String>, target: impl Into<String>) -> Result<()> {
95        let mut cmd = TagCommand::new();
96        cmd.name(name).commit(target);
97        cmd.current_dir(self.repo.path());
98        cmd.execute().await?;
99        Ok(())
100    }
101
102    /// Create an annotated tag `name` pointing at `target` with `message`.
103    pub async fn create_annotated(
104        &self,
105        name: impl Into<String>,
106        target: impl Into<String>,
107        message: impl Into<String>,
108    ) -> Result<()> {
109        let mut cmd = TagCommand::new();
110        cmd.name(name).commit(target).message(message);
111        cmd.current_dir(self.repo.path());
112        cmd.execute().await?;
113        Ok(())
114    }
115
116    /// Delete the tag `name`.
117    pub async fn delete(&self, name: impl Into<String>) -> Result<()> {
118        let mut cmd = TagCommand::new();
119        cmd.name(name).delete();
120        cmd.current_dir(self.repo.path());
121        cmd.execute().await?;
122        Ok(())
123    }
124
125    async fn list_inner(&self, pattern: Option<String>) -> Result<Vec<Tag>> {
126        let mut cmd = ForEachRefCommand::new();
127        cmd.format(FORMAT.to_string())
128            .pattern(pattern.unwrap_or_else(|| "refs/tags/".to_string()));
129        cmd.current_dir(self.repo.path());
130        let out = cmd.execute().await?;
131        parse_tags(&out.stdout)
132    }
133}
134
135impl Repository {
136    /// Operations on tags.
137    #[must_use]
138    pub fn tags(&self) -> TagOps<'_> {
139        TagOps { repo: self }
140    }
141}
142
143/// NUL-delimited per-record format. Field order matches [`parse_tags`].
144///
145/// Fields: name, objecttype, objectname:short, *objectname:short,
146/// contents:subject, taggername, taggeremail, taggerdate.
147const FORMAT: &str = concat!(
148    "%(refname:short)",
149    "%00",
150    "%(objecttype)",
151    "%00",
152    "%(objectname:short)",
153    "%00",
154    "%(*objectname:short)",
155    "%00",
156    "%(contents:subject)",
157    "%00",
158    "%(taggername)",
159    "%00",
160    "%(taggeremail)",
161    "%00",
162    "%(taggerdate:iso-strict)",
163);
164
165fn parse_tags(stdout: &str) -> Result<Vec<Tag>> {
166    let mut out = Vec::new();
167    for line in stdout.lines() {
168        if line.is_empty() {
169            continue;
170        }
171        let fields: Vec<&str> = line.split('\0').collect();
172        if fields.len() < 8 {
173            return Err(crate::error::Error::parse_error(format!(
174                "tag record has {} fields, expected 8: {line:?}",
175                fields.len()
176            )));
177        }
178        let kind = if fields[1] == "tag" {
179            TagKind::Annotated
180        } else {
181            TagKind::Lightweight
182        };
183        let target = match kind {
184            TagKind::Annotated => fields[3].to_string(),
185            TagKind::Lightweight => fields[2].to_string(),
186        };
187        let (message, tagger) = match kind {
188            TagKind::Annotated => {
189                let msg = if fields[4].is_empty() {
190                    None
191                } else {
192                    Some(fields[4].to_string())
193                };
194                let email = fields[6]
195                    .trim_start_matches('<')
196                    .trim_end_matches('>')
197                    .to_string();
198                let tagger = if fields[5].is_empty() && email.is_empty() {
199                    None
200                } else {
201                    Some(Tagger {
202                        name: fields[5].to_string(),
203                        email,
204                        date: fields[7].to_string(),
205                    })
206                };
207                (msg, tagger)
208            }
209            TagKind::Lightweight => (None, None),
210        };
211        out.push(Tag {
212            name: fields[0].to_string(),
213            kind,
214            target,
215            message,
216            tagger,
217        });
218    }
219    Ok(out)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn parses_lightweight_tag() {
228        let input = "v0.1\0commit\0abc1234\0\0\0\0\0\n";
229        let tags = parse_tags(input).unwrap();
230        assert_eq!(tags.len(), 1);
231        assert_eq!(tags[0].name, "v0.1");
232        assert_eq!(tags[0].kind, TagKind::Lightweight);
233        assert_eq!(tags[0].target, "abc1234");
234        assert!(tags[0].message.is_none());
235        assert!(tags[0].tagger.is_none());
236    }
237
238    #[test]
239    fn parses_annotated_tag() {
240        let input = "v1.0\0tag\0deadbeef\0abc1234\0release 1.0\0Alice\0<alice@example.com>\x002026-04-01T12:00:00+00:00\n";
241        let tags = parse_tags(input).unwrap();
242        assert_eq!(tags.len(), 1);
243        let t = &tags[0];
244        assert_eq!(t.name, "v1.0");
245        assert_eq!(t.kind, TagKind::Annotated);
246        assert_eq!(t.target, "abc1234");
247        assert_eq!(t.message.as_deref(), Some("release 1.0"));
248        let tagger = t.tagger.as_ref().unwrap();
249        assert_eq!(tagger.name, "Alice");
250        assert_eq!(tagger.email, "alice@example.com");
251        assert_eq!(tagger.date, "2026-04-01T12:00:00+00:00");
252    }
253
254    #[test]
255    fn parses_mixed_records() {
256        let input = concat!(
257            "lw\0commit\0aaaaaaa\0\0\0\0\0\n",
258            "ann\0tag\0bbbbbbb\0ccccccc\0msg\0Bob\0<b@example.com>\x002026-01-01T00:00:00+00:00\n",
259        );
260        let tags = parse_tags(input).unwrap();
261        assert_eq!(tags.len(), 2);
262        assert_eq!(tags[0].kind, TagKind::Lightweight);
263        assert_eq!(tags[1].kind, TagKind::Annotated);
264        assert_eq!(tags[1].target, "ccccccc");
265    }
266}