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        let mut tag = Self {
94            id: ObjectHash::default(),
95            object_hash,
96            object_type,
97            tag_name,
98            tagger,
99            message,
100        };
101        // The id is the canonical git object hash of the *serialized* tag
102        // (`to_data()`), computed exactly like `Commit::new`. Previously this
103        // hashed a hand-written `format!` string that interpolated the tagger via
104        // `Signature`'s `Display` (a human-readable `"<name> <<email>>\nDate: …"`
105        // form) rather than `Signature::to_data()` (`"tagger <name> <<email>>
106        // <timestamp> <tz>"`). That made `id != hash(to_data())`, so the bytes
107        // libra stores/packs hash to a *different* OID than the ref points at —
108        // breaking every packfile round-trip (fetch/push/repack) of annotated
109        // tags. Hashing `to_data()` keeps the id canonical.
110        tag.id = ObjectHash::from_type_and_data(ObjectType::Tag, &tag.to_data().unwrap());
111        tag
112    }
113}
114
115impl ObjectTrait for Tag {
116    /// The tag object is used to Annotated tag, it's binary format is:
117    ///
118    /// ```bash
119    /// object <object_hash> 0x0a # The SHA-1 hash of the object that the annotated tag is attached to (usually a commit)
120    /// type <object_type> 0x0a #The type of Git object that the annotated tag is attached to (usually 'commit')
121    /// tag <tag_name> 0x0a # The name of the annotated tag(in UTF-8 encoding)
122    /// tagger <tagger> 0x0a # The name, email address, and date of the person who created the annotated tag
123    /// <message>
124    /// ```
125    fn from_bytes(row_data: &[u8], hash: ObjectHash) -> Result<Self, GitError>
126    where
127        Self: Sized,
128    {
129        let mut headers = row_data;
130        let mut message_start = 0;
131
132        if let Some(pos) = headers.find(b"\n\n") {
133            message_start = pos + 2;
134            headers = &headers[..pos];
135        }
136
137        let mut object_hash: Option<ObjectHash> = None;
138        let mut object_type: Option<ObjectType> = None;
139        let mut tag_name: Option<String> = None;
140        let mut tagger: Option<Signature> = None;
141
142        for line in headers.lines() {
143            if let Some(s) = line.strip_prefix(b"object ") {
144                let hash_str = s.to_str().map_err(|_| {
145                    GitError::InvalidTagObject("Invalid UTF-8 in object hash".to_string())
146                })?;
147                object_hash = Some(ObjectHash::from_str(hash_str).map_err(|_| {
148                    GitError::InvalidTagObject("Invalid object hash format".to_string())
149                })?);
150            } else if let Some(s) = line.strip_prefix(b"type ") {
151                let type_str = s.to_str().map_err(|_| {
152                    GitError::InvalidTagObject("Invalid UTF-8 in object type".to_string())
153                })?;
154                object_type = Some(ObjectType::from_string(type_str)?);
155            } else if let Some(s) = line.strip_prefix(b"tag ") {
156                let tag_str = s.to_str().map_err(|_| {
157                    GitError::InvalidTagObject("Invalid UTF-8 in tag name".to_string())
158                })?;
159                tag_name = Some(tag_str.to_string());
160            } else if line.starts_with(b"tagger ") {
161                tagger = Some(Signature::from_data(line.to_vec())?);
162            }
163        }
164
165        let message = if message_start > 0 {
166            String::from_utf8_lossy(&row_data[message_start..]).to_string()
167        } else {
168            String::new()
169        };
170
171        Ok(Tag {
172            id: hash,
173            object_hash: object_hash
174                .ok_or_else(|| GitError::InvalidTagObject("Missing object hash".to_string()))?,
175            object_type: object_type
176                .ok_or_else(|| GitError::InvalidTagObject("Missing object type".to_string()))?,
177            tag_name: tag_name
178                .ok_or_else(|| GitError::InvalidTagObject("Missing tag name".to_string()))?,
179            tagger: tagger
180                .ok_or_else(|| GitError::InvalidTagObject("Missing tagger".to_string()))?,
181            message,
182        })
183    }
184
185    fn get_type(&self) -> ObjectType {
186        ObjectType::Tag
187    }
188
189    fn get_size(&self) -> usize {
190        self.to_data().map(|data| data.len()).unwrap_or(0)
191    }
192
193    ///
194    /// ```bash
195    /// object <object_hash> 0x0a # The SHA-1/ SHA-256 hash of the object that the annotated tag is attached to (usually a commit)
196    /// type <object_type> 0x0a #The type of Git object that the annotated tag is attached to (usually 'commit')
197    /// tag <tag_name> 0x0a # The name of the annotated tag(in UTF-8 encoding)
198    /// tagger <tagger> 0x0a # The name, email address, and date of the person who created the annotated tag
199    /// <message>
200    /// ```
201    /// When using SHA-1, `<object_hash>` is 40 hex chars; when using SHA-256, it is 64 hex chars.
202    fn to_data(&self) -> Result<Vec<u8>, GitError> {
203        let mut data = Vec::new();
204
205        data.extend_from_slice(b"object ");
206        data.extend_from_slice(self.object_hash.to_string().as_bytes());
207        data.extend_from_slice(b"\n");
208
209        data.extend_from_slice(b"type ");
210        data.extend_from_slice(self.object_type.to_string().as_bytes());
211        data.extend_from_slice(b"\n");
212
213        data.extend_from_slice(b"tag ");
214        data.extend_from_slice(self.tag_name.as_bytes());
215        data.extend_from_slice(b"\n");
216
217        data.extend_from_slice(&self.tagger.to_data()?);
218        data.extend_from_slice(b"\n\n");
219
220        data.extend_from_slice(self.message.as_bytes());
221
222        Ok(data)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::{
230        hash::{HashKind, ObjectHash, set_hash_kind_for_test},
231        internal::object::signature::{Signature, SignatureType},
232    };
233
234    /// Helper to build a deterministic signature for tests.
235    fn make_sig() -> Signature {
236        Signature::new(
237            SignatureType::Tagger,
238            "tagger".to_string(),
239            "tagger@example.com".to_string(),
240        )
241    }
242
243    /// Tag creation should serialize/deserialize correctly under the given hash kind.
244    fn round_trip(kind: HashKind) {
245        let _guard = set_hash_kind_for_test(kind);
246        let target = match kind {
247            HashKind::Sha1 => {
248                ObjectHash::from_str("1234567890abcdef1234567890abcdef12345678").unwrap()
249            }
250            HashKind::Sha256 => ObjectHash::from_str(
251                "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
252            )
253            .unwrap(),
254        };
255        let sig = make_sig();
256        let tag = Tag::new(
257            target,
258            ObjectType::Commit,
259            "v1.0.0".to_string(),
260            sig.clone(),
261            "release".to_string(),
262        );
263
264        let data = tag.to_data().unwrap();
265        let parsed = Tag::from_bytes(&data, tag.id).unwrap();
266
267        assert_eq!(parsed.id, tag.id);
268        assert_eq!(parsed.object_hash, target);
269        assert_eq!(parsed.object_type, ObjectType::Commit);
270        assert_eq!(parsed.tag_name, "v1.0.0");
271        assert_eq!(parsed.message, "release");
272        assert_eq!(parsed.tagger.to_string(), sig.to_string());
273    }
274
275    /// Tag round trip tests for both SHA-1 and SHA-256 hash kinds.
276    #[tokio::test]
277    async fn tag_round_trip() {
278        round_trip(HashKind::Sha1);
279        round_trip(HashKind::Sha256);
280    }
281
282    /// Regression: a tag's `id` MUST equal the canonical git object hash of its
283    /// serialized (`to_data()`) form. Before the fix, `Tag::new` hashed a
284    /// `Display`-based string (which renders the tagger as a human-readable
285    /// `"<name> <<email>>\nDate: <chrono-date> <tz>"`) instead of `to_data()`
286    /// (`"tagger <name> <<email>> <timestamp> <tz>"`), so `id != hash(to_data())`.
287    /// The object was then stored/packed under different bytes than its id —
288    /// breaking every packfile round-trip (fetch/push/repack) of annotated tags.
289    #[test]
290    fn tag_id_is_canonical_hash_of_to_data() {
291        for kind in [HashKind::Sha1, HashKind::Sha256] {
292            let _guard = set_hash_kind_for_test(kind);
293            let target = match kind {
294                HashKind::Sha1 => {
295                    ObjectHash::from_str("1234567890abcdef1234567890abcdef12345678").unwrap()
296                }
297                HashKind::Sha256 => ObjectHash::from_str(
298                    "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
299                )
300                .unwrap(),
301            };
302            let tag = Tag::new(
303                target,
304                ObjectType::Commit,
305                "v1.0.0".to_string(),
306                make_sig(),
307                "release".to_string(),
308            );
309            let canonical =
310                ObjectHash::from_type_and_data(ObjectType::Tag, &tag.to_data().unwrap());
311            assert_eq!(
312                tag.id, canonical,
313                "tag id must be the canonical hash of its to_data() bytes ({kind:?})"
314            );
315        }
316    }
317
318    /// Invalid tag missing required fields should error.
319    #[test]
320    fn tag_invalid_missing_fields_errors() {
321        let _guard = set_hash_kind_for_test(HashKind::Sha1);
322        let bad = b"type commit\ntag v1.0.0\n\nno object line".to_vec();
323        let hash = ObjectHash::from_str("ffffffffffffffffffffffffffffffffffffffff").unwrap();
324        assert!(Tag::from_bytes(&bad, hash).is_err());
325    }
326}