use crate::command::GitCommand;
use crate::command::for_each_ref::ForEachRefCommand;
use crate::command::tag::TagCommand;
use crate::error::Result;
use crate::repo::Repository;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Tag {
pub name: String,
pub kind: TagKind,
pub target: String,
pub message: Option<String>,
pub tagger: Option<Tagger>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TagKind {
Lightweight,
Annotated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Tagger {
pub name: String,
pub email: String,
pub date: String,
}
#[derive(Debug)]
pub struct TagOps<'a> {
repo: &'a Repository,
}
impl<'a> TagOps<'a> {
pub async fn list(&self) -> Result<Vec<Tag>> {
self.list_inner(None).await
}
pub async fn list_matching(&self, pattern: impl Into<String>) -> Result<Vec<Tag>> {
self.list_inner(Some(pattern.into())).await
}
pub async fn create(&self, name: impl Into<String>, target: impl Into<String>) -> Result<()> {
let mut cmd = TagCommand::new();
cmd.name(name).commit(target);
cmd.current_dir(self.repo.path());
cmd.execute().await?;
Ok(())
}
pub async fn create_annotated(
&self,
name: impl Into<String>,
target: impl Into<String>,
message: impl Into<String>,
) -> Result<()> {
let mut cmd = TagCommand::new();
cmd.name(name).commit(target).message(message);
cmd.current_dir(self.repo.path());
cmd.execute().await?;
Ok(())
}
pub async fn delete(&self, name: impl Into<String>) -> Result<()> {
let mut cmd = TagCommand::new();
cmd.name(name).delete();
cmd.current_dir(self.repo.path());
cmd.execute().await?;
Ok(())
}
async fn list_inner(&self, pattern: Option<String>) -> Result<Vec<Tag>> {
let mut cmd = ForEachRefCommand::new();
cmd.format(FORMAT.to_string())
.pattern(pattern.unwrap_or_else(|| "refs/tags/".to_string()));
cmd.current_dir(self.repo.path());
let out = cmd.execute().await?;
parse_tags(&out.stdout)
}
}
impl Repository {
#[must_use]
pub fn tags(&self) -> TagOps<'_> {
TagOps { repo: self }
}
}
const FORMAT: &str = concat!(
"%(refname:short)",
"%00",
"%(objecttype)",
"%00",
"%(objectname:short)",
"%00",
"%(*objectname:short)",
"%00",
"%(contents:subject)",
"%00",
"%(taggername)",
"%00",
"%(taggeremail)",
"%00",
"%(taggerdate:iso-strict)",
);
fn parse_tags(stdout: &str) -> Result<Vec<Tag>> {
let mut out = Vec::new();
for line in stdout.lines() {
if line.is_empty() {
continue;
}
let fields: Vec<&str> = line.split('\0').collect();
if fields.len() < 8 {
return Err(crate::error::Error::parse_error(format!(
"tag record has {} fields, expected 8: {line:?}",
fields.len()
)));
}
let kind = if fields[1] == "tag" {
TagKind::Annotated
} else {
TagKind::Lightweight
};
let target = match kind {
TagKind::Annotated => fields[3].to_string(),
TagKind::Lightweight => fields[2].to_string(),
};
let (message, tagger) = match kind {
TagKind::Annotated => {
let msg = if fields[4].is_empty() {
None
} else {
Some(fields[4].to_string())
};
let email = fields[6]
.trim_start_matches('<')
.trim_end_matches('>')
.to_string();
let tagger = if fields[5].is_empty() && email.is_empty() {
None
} else {
Some(Tagger {
name: fields[5].to_string(),
email,
date: fields[7].to_string(),
})
};
(msg, tagger)
}
TagKind::Lightweight => (None, None),
};
out.push(Tag {
name: fields[0].to_string(),
kind,
target,
message,
tagger,
});
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_lightweight_tag() {
let input = "v0.1\0commit\0abc1234\0\0\0\0\0\n";
let tags = parse_tags(input).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "v0.1");
assert_eq!(tags[0].kind, TagKind::Lightweight);
assert_eq!(tags[0].target, "abc1234");
assert!(tags[0].message.is_none());
assert!(tags[0].tagger.is_none());
}
#[test]
fn parses_annotated_tag() {
let input = "v1.0\0tag\0deadbeef\0abc1234\0release 1.0\0Alice\0<alice@example.com>\x002026-04-01T12:00:00+00:00\n";
let tags = parse_tags(input).unwrap();
assert_eq!(tags.len(), 1);
let t = &tags[0];
assert_eq!(t.name, "v1.0");
assert_eq!(t.kind, TagKind::Annotated);
assert_eq!(t.target, "abc1234");
assert_eq!(t.message.as_deref(), Some("release 1.0"));
let tagger = t.tagger.as_ref().unwrap();
assert_eq!(tagger.name, "Alice");
assert_eq!(tagger.email, "alice@example.com");
assert_eq!(tagger.date, "2026-04-01T12:00:00+00:00");
}
#[test]
fn parses_mixed_records() {
let input = concat!(
"lw\0commit\0aaaaaaa\0\0\0\0\0\n",
"ann\0tag\0bbbbbbb\0ccccccc\0msg\0Bob\0<b@example.com>\x002026-01-01T00:00:00+00:00\n",
);
let tags = parse_tags(input).unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].kind, TagKind::Lightweight);
assert_eq!(tags[1].kind, TagKind::Annotated);
assert_eq!(tags[1].target, "ccccccc");
}
}