Skip to main content

git_internal/internal/object/
tag.rs

1//! In Git objects there are two types of tags: Lightweight tags and annotated tags.
2//!
3//! A lightweight tag is simply a pointer to a specific commit in Git's version history,
4//! without any additional metadata or information associated with it. It is created by
5//! running the `git tag` command with a name for the tag and the commit hash that it points to.
6//!
7//! An annotated tag, on the other hand, is a Git object in its own right, and includes
8//! metadata such as the tagger's name and email address, the date and time the tag was created,
9//! and a message describing the tag. It is created by running the `git tag -a` command with
10//! a name for the tag, the commit hash that it points to, and the additional metadata that
11//! should be associated with the tag.
12//!
13//! When you create a tag in Git, whether it's a lightweight or annotated tag, Git creates a
14//! new object in its object database to represent the tag. This object includes the name of the
15//! tag, the hash of the commit it points to, and any additional metadata associated with the
16//! tag (in the case of an annotated tag).
17//!
18//! There is no difference in binary format between lightweight tags and annotated tags in Git,
19//! as both are represented using the same lightweight object format in Git's object database.
20//!
21//! The lightweight tag is a reference to a specific commit in Git's version history, not be stored
22//! as a separate object in Git's object database. This means that if you create a lightweight tag
23//! and then move the tag to a different commit, the tag will still point to the original commit.
24//!
25//! The lightweight just a text file with the commit hash in it, and the file name is the tag name.
26//! If one of -a, -s, or -u \<key-id\> is passed, the command creates a tag object, and requires a tag
27//! message. Unless -m \<msg\> or -F \<file\> is given, an editor is started for the user to type in the
28//! tag message.
29//!
30//! ```bash
31//! 4b00093bee9b3ef5afc5f8e3645dc39cfa2f49aa
32//! ```
33//!
34//! The annotated tag is a Git object in its own right, and includes metadata such as the tagger's
35//! name and email address, the date and time the tag was created, and a message describing the tag.
36//!
37//! So, we can use the `git cat-file -p <tag>` command to get the tag object, and the command not
38//! for the lightweight tag.
39use std::{fmt::Display, str::FromStr};
40
41use bstr::ByteSlice;
42
43use crate::{
44    errors::GitError,
45    hash::ObjectHash,
46    internal::object::{ObjectTrait, ObjectType, signature::Signature},
47};
48
49/// The tag object is used to Annotated tag
50#[derive(Eq, Debug, Clone)]
51pub struct Tag {
52    pub id: ObjectHash,
53    pub object_hash: ObjectHash,
54    pub object_type: ObjectType,
55    pub tag_name: String,
56    pub tagger: Signature,
57    pub message: String,
58}
59
60impl PartialEq for Tag {
61    fn eq(&self, other: &Self) -> bool {
62        self.id == other.id
63    }
64}
65
66impl Display for Tag {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        write!(
69            f,
70            "object {}\ntype {}\ntag {}\ntagger {}\n\n{}",
71            self.object_hash, self.object_type, self.tag_name, self.tagger, self.message
72        )
73    }
74}
75
76impl Tag {
77    // pub fn new_from_meta(meta: Meta) -> Result<Tag, GitError> {
78    //     Ok(Tag::new_from_data(meta.data))
79    // }
80
81    // pub fn new_from_file(path: &str) -> Result<Tag, GitError> {
82    //     let meta = Meta::new_from_file(path)?;
83    //     Tag::new_from_meta(meta)
84    // }
85
86    pub fn new(
87        object_hash: ObjectHash,
88        object_type: ObjectType,
89        tag_name: String,
90        tagger: Signature,
91        message: String,
92    ) -> Self {
93        // Serialize the tag data to calculate its hash
94        let data = format!(
95            "object {object_hash}\ntype {object_type}\ntag {tag_name}\ntagger {tagger}\n\n{message}"
96        );
97        let id = ObjectHash::from_type_and_data(ObjectType::Tag, data.as_bytes());
98
99        Self {
100            id,
101            object_hash,
102            object_type,
103            tag_name,
104            tagger,
105            message,
106        }
107    }
108}
109
110impl ObjectTrait for Tag {
111    /// The tag object is used to Annotated tag, it's binary format is:
112    ///
113    /// ```bash
114    /// object <object_hash> 0x0a # The SHA-1 hash of the object that the annotated tag is attached to (usually a commit)
115    /// type <object_type> 0x0a #The type of Git object that the annotated tag is attached to (usually 'commit')
116    /// tag <tag_name> 0x0a # The name of the annotated tag(in UTF-8 encoding)
117    /// tagger <tagger> 0x0a # The name, email address, and date of the person who created the annotated tag
118    /// <message>
119    /// ```
120    fn from_bytes(row_data: &[u8], hash: ObjectHash) -> Result<Self, GitError>
121    where
122        Self: Sized,
123    {
124        let mut headers = row_data;
125        let mut message_start = 0;
126
127        if let Some(pos) = headers.find(b"\n\n") {
128            message_start = pos + 2;
129            headers = &headers[..pos];
130        }
131
132        let mut object_hash: Option<ObjectHash> = None;
133        let mut object_type: Option<ObjectType> = None;
134        let mut tag_name: Option<String> = None;
135        let mut tagger: Option<Signature> = None;
136
137        for line in headers.lines() {
138            if let Some(s) = line.strip_prefix(b"object ") {
139                let hash_str = s.to_str().map_err(|_| {
140                    GitError::InvalidTagObject("Invalid UTF-8 in object hash".to_string())
141                })?;
142                object_hash = Some(ObjectHash::from_str(hash_str).map_err(|_| {
143                    GitError::InvalidTagObject("Invalid object hash format".to_string())
144                })?);
145            } else if let Some(s) = line.strip_prefix(b"type ") {
146                let type_str = s.to_str().map_err(|_| {
147                    GitError::InvalidTagObject("Invalid UTF-8 in object type".to_string())
148                })?;
149                object_type = Some(ObjectType::from_string(type_str)?);
150            } else if let Some(s) = line.strip_prefix(b"tag ") {
151                let tag_str = s.to_str().map_err(|_| {
152                    GitError::InvalidTagObject("Invalid UTF-8 in tag name".to_string())
153                })?;
154                tag_name = Some(tag_str.to_string());
155            } else if line.starts_with(b"tagger ") {
156                tagger = Some(Signature::from_data(line.to_vec())?);
157            }
158        }
159
160        let message = if message_start > 0 {
161            String::from_utf8_lossy(&row_data[message_start..]).to_string()
162        } else {
163            String::new()
164        };
165
166        Ok(Tag {
167            id: hash,
168            object_hash: object_hash
169                .ok_or_else(|| GitError::InvalidTagObject("Missing object hash".to_string()))?,
170            object_type: object_type
171                .ok_or_else(|| GitError::InvalidTagObject("Missing object type".to_string()))?,
172            tag_name: tag_name
173                .ok_or_else(|| GitError::InvalidTagObject("Missing tag name".to_string()))?,
174            tagger: tagger
175                .ok_or_else(|| GitError::InvalidTagObject("Missing tagger".to_string()))?,
176            message,
177        })
178    }
179
180    fn get_type(&self) -> ObjectType {
181        ObjectType::Tag
182    }
183
184    fn get_size(&self) -> usize {
185        self.to_data().map(|data| data.len()).unwrap_or(0)
186    }
187
188    ///
189    /// ```bash
190    /// object <object_hash> 0x0a # The SHA-1/ SHA-256 hash of the object that the annotated tag is attached to (usually a commit)
191    /// type <object_type> 0x0a #The type of Git object that the annotated tag is attached to (usually 'commit')
192    /// tag <tag_name> 0x0a # The name of the annotated tag(in UTF-8 encoding)
193    /// tagger <tagger> 0x0a # The name, email address, and date of the person who created the annotated tag
194    /// <message>
195    /// ```
196    /// When using SHA-1, `<object_hash>` is 40 hex chars; when using SHA-256, it is 64 hex chars.
197    fn to_data(&self) -> Result<Vec<u8>, GitError> {
198        let mut data = Vec::new();
199
200        data.extend_from_slice(b"object ");
201        data.extend_from_slice(self.object_hash.to_string().as_bytes());
202        data.extend_from_slice(b"\n");
203
204        data.extend_from_slice(b"type ");
205        data.extend_from_slice(self.object_type.to_string().as_bytes());
206        data.extend_from_slice(b"\n");
207
208        data.extend_from_slice(b"tag ");
209        data.extend_from_slice(self.tag_name.as_bytes());
210        data.extend_from_slice(b"\n");
211
212        data.extend_from_slice(&self.tagger.to_data()?);
213        data.extend_from_slice(b"\n\n");
214
215        data.extend_from_slice(self.message.as_bytes());
216
217        Ok(data)
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::{
225        hash::{HashKind, ObjectHash, set_hash_kind_for_test},
226        internal::object::signature::{Signature, SignatureType},
227    };
228
229    /// Helper to build a deterministic signature for tests.
230    fn make_sig() -> Signature {
231        Signature::new(
232            SignatureType::Tagger,
233            "tagger".to_string(),
234            "tagger@example.com".to_string(),
235        )
236    }
237
238    /// Tag creation should serialize/deserialize correctly under the given hash kind.
239    fn round_trip(kind: HashKind) {
240        let _guard = set_hash_kind_for_test(kind);
241        let target = match kind {
242            HashKind::Sha1 => {
243                ObjectHash::from_str("1234567890abcdef1234567890abcdef12345678").unwrap()
244            }
245            HashKind::Sha256 => ObjectHash::from_str(
246                "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
247            )
248            .unwrap(),
249        };
250        let sig = make_sig();
251        let tag = Tag::new(
252            target,
253            ObjectType::Commit,
254            "v1.0.0".to_string(),
255            sig.clone(),
256            "release".to_string(),
257        );
258
259        let data = tag.to_data().unwrap();
260        let parsed = Tag::from_bytes(&data, tag.id).unwrap();
261
262        assert_eq!(parsed.id, tag.id);
263        assert_eq!(parsed.object_hash, target);
264        assert_eq!(parsed.object_type, ObjectType::Commit);
265        assert_eq!(parsed.tag_name, "v1.0.0");
266        assert_eq!(parsed.message, "release");
267        assert_eq!(parsed.tagger.to_string(), sig.to_string());
268    }
269
270    /// Tag round trip tests for both SHA-1 and SHA-256 hash kinds.
271    #[tokio::test]
272    async fn tag_round_trip() {
273        round_trip(HashKind::Sha1);
274        round_trip(HashKind::Sha256);
275    }
276
277    /// Invalid tag missing required fields should error.
278    #[test]
279    fn tag_invalid_missing_fields_errors() {
280        let _guard = set_hash_kind_for_test(HashKind::Sha1);
281        let bad = b"type commit\ntag v1.0.0\n\nno object line".to_vec();
282        let hash = ObjectHash::from_str("ffffffffffffffffffffffffffffffffffffffff").unwrap();
283        assert!(Tag::from_bytes(&bad, hash).is_err());
284    }
285}