Skip to main content

git_internal/internal/object/
note.rs

1//! Git Note object implementation
2//!
3//! Git Notes are a mechanism for adding metadata to existing Git objects (usually commits)
4//! without modifying the original objects. Notes are commonly used for:
5//!
6//! - Adding review comments or approval metadata
7//! - Storing CI/CD build status and code scan results  
8//! - Attaching author signatures, annotations, or other metadata
9//!
10//! In Git's object model, Notes are stored as Blob objects, with the association between
11//! notes and target objects managed through the refs/notes/* namespace.
12
13use std::fmt::Display;
14
15use crate::{
16    errors::GitError,
17    hash::ObjectHash,
18    internal::object::{ObjectTrait, ObjectType},
19};
20
21/// Git Note object structure
22///
23/// A Note represents additional metadata attached to a Git object (typically a commit).
24/// The Note itself is stored as a Blob object in Git's object database, with the
25/// association managed through Git's reference system.
26#[derive(
27    Eq,
28    Debug,
29    Clone,
30    serde::Serialize,
31    serde::Deserialize,
32    rkyv::Archive,
33    rkyv::Serialize,
34    rkyv::Deserialize,
35)]
36pub struct Note {
37    /// The ObjectHash of this Note object (same as the underlying Blob)
38    pub id: ObjectHash,
39    /// The ObjectHash of the object this Note annotates (usually a commit)
40    pub target_object_id: ObjectHash,
41    /// The textual content of the Note
42    pub content: String,
43}
44
45impl PartialEq for Note {
46    /// Two Notes are equal if they have the same ID
47    fn eq(&self, other: &Self) -> bool {
48        self.id == other.id
49    }
50}
51
52impl Display for Note {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        writeln!(f, "Note for object: {}", self.target_object_id)?;
55        writeln!(f, "Content: {}", self.content)
56    }
57}
58
59impl Note {
60    /// Create a new Note for the specified target object with the given content
61    ///
62    /// # Arguments
63    /// * `target_object_id` - The ObjectHash of the object to annotate
64    /// * `content` - The textual content of the note
65    ///
66    /// # Returns
67    /// A new Note instance with calculated ID based on the content
68    pub fn new(target_object_id: ObjectHash, content: String) -> Self {
69        // Calculate the SHA-1/ SHA-256 hash for this Note's content
70        // Notes are stored as Blob objects in Git
71        let id = ObjectHash::from_type_and_data(ObjectType::Blob, content.as_bytes());
72
73        Self {
74            id,
75            target_object_id,
76            content,
77        }
78    }
79
80    /// Create a Note from content string, with default target object
81    ///
82    /// This is a convenience method for creating Notes when the target
83    /// will be set later by the notes management system.
84    ///
85    /// # Arguments
86    /// * `content` - The textual content of the note
87    ///
88    /// # Returns
89    /// A new Note instance with default target object ID
90    pub fn from_content(content: &str) -> Self {
91        Self::new(ObjectHash::default(), content.to_string())
92    }
93
94    /// Get the size of the Note content in bytes
95    pub fn content_size(&self) -> usize {
96        self.content.len()
97    }
98
99    /// Check if the Note is empty (has no content)
100    pub fn is_empty(&self) -> bool {
101        self.content.is_empty()
102    }
103
104    /// Update the target object ID for this Note
105    ///
106    /// This method allows changing which object this Note annotates
107    /// without changing the Note's content or ID.
108    ///
109    /// # Arguments
110    /// * `new_target` - The new target object SHA-1/ SHA-256 hash
111    pub fn set_target(&mut self, new_target: ObjectHash) {
112        self.target_object_id = new_target;
113    }
114
115    /// Create a Note object from raw bytes with explicit target object ID
116    ///
117    /// This is the preferred method when you know both the content and the target object,
118    /// as it preserves the complete Note association information.
119    ///
120    /// # Arguments
121    /// * `data` - The raw byte data (UTF-8 encoded text content)
122    /// * `hash` - The SHA-1/ SHA-256 hash of this Note object
123    /// * `target_object_id` - The SHA-1/ SHA-256 hash of the object this Note annotates
124    ///
125    /// # Returns
126    /// A Result containing the Note object with complete association info
127    pub fn from_bytes_with_target(
128        data: &[u8],
129        hash: ObjectHash,
130        target_object_id: ObjectHash,
131    ) -> Result<Self, GitError> {
132        let content = String::from_utf8(data.to_vec())
133            .map_err(|e| GitError::InvalidNoteObject(format!("Invalid UTF-8 content: {e}")))?;
134
135        Ok(Note {
136            id: hash,
137            target_object_id,
138            content,
139        })
140    }
141
142    /// Serialize a Note with its target association for external storage
143    ///
144    /// This method returns both the Git object data and the target object ID,
145    /// which can be used by higher-level systems to manage the refs/notes/* references.
146    ///
147    /// # Returns
148    /// A tuple of (object_data, target_object_id)
149    pub fn to_data_with_target(&self) -> Result<(Vec<u8>, ObjectHash), GitError> {
150        let data = self.to_data()?;
151        Ok((data, self.target_object_id))
152    }
153}
154
155impl ObjectTrait for Note {
156    /// Create a Note object from raw bytes and hash
157    ///
158    /// # Arguments
159    /// * `data` - The raw byte data (UTF-8 encoded text content)
160    /// * `hash` - The SHA-1/ SHA-256 hash of this Note object
161    ///
162    /// # Returns
163    /// A Result containing the Note object or an error
164    fn from_bytes(data: &[u8], hash: ObjectHash) -> Result<Self, GitError>
165    where
166        Self: Sized,
167    {
168        // Convert bytes to UTF-8 string
169        let content = String::from_utf8(data.to_vec())
170            .map_err(|e| GitError::InvalidNoteObject(format!("Invalid UTF-8 content: {e}")))?;
171
172        Ok(Note {
173            id: hash,
174            target_object_id: ObjectHash::default(), // Target association managed externally
175            content,
176        })
177    }
178
179    /// Get the Git object type for Notes
180    ///
181    /// Notes are stored as Blob objects in Git's object database
182    fn get_type(&self) -> ObjectType {
183        ObjectType::Blob
184    }
185
186    /// Get the size of the Note content
187    fn get_size(&self) -> usize {
188        self.content.len()
189    }
190
191    /// Convert the Note to raw byte data for storage
192    ///
193    /// # Returns
194    /// A Result containing the byte representation or an error
195    fn to_data(&self) -> Result<Vec<u8>, GitError> {
196        Ok(self.content.as_bytes().to_vec())
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use std::str::FromStr;
203
204    use super::*;
205    use crate::hash::{HashKind, ObjectHash, set_hash_kind_for_test};
206
207    /// Helper to build a Note, serialize/deserialize with/without target under given hash kind.
208    fn round_trip(kind: HashKind) {
209        let _guard = set_hash_kind_for_test(kind);
210        let (target_id, hash_len) = match kind {
211            HashKind::Sha1 => (
212                ObjectHash::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(),
213                40,
214            ),
215            HashKind::Sha256 => (
216                ObjectHash::from_str(
217                    "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
218                )
219                .unwrap(),
220                64,
221            ),
222        };
223        let content = "This commit needs review".to_string();
224        let note = Note::new(target_id, content.clone());
225
226        assert_eq!(note.target_object_id, target_id);
227        assert_eq!(note.content, content);
228        assert_eq!(note.get_type(), ObjectType::Blob);
229        assert_eq!(note.id.to_string().len(), hash_len);
230
231        // serialization without target
232        let data = note.to_data().unwrap();
233        assert_eq!(data, content.as_bytes());
234        assert_eq!(note.get_size(), content.len());
235
236        // basic deserialization (target remains default)
237        let basic = Note::from_bytes(&data, note.id).unwrap();
238        assert_eq!(basic.content, content);
239        assert_eq!(basic.id, note.id);
240        assert_eq!(basic.target_object_id, ObjectHash::default());
241
242        // with target
243        let (data_with_target, returned_target) = note.to_data_with_target().unwrap();
244        assert_eq!(returned_target, target_id);
245        let restored = Note::from_bytes_with_target(&data_with_target, note.id, target_id).unwrap();
246        assert_eq!(restored, note);
247        assert_eq!(restored.target_object_id, target_id);
248        assert_eq!(restored.content, content);
249    }
250
251    /// Test round-trip Note serialization/deserialization with SHA-1 and SHA-256
252    #[tokio::test]
253    async fn note_async_round_trip() {
254        round_trip(HashKind::Sha1);
255        round_trip(HashKind::Sha256);
256    }
257
258    /// Invalid UTF-8 content should return an error in both constructors.
259    #[test]
260    fn note_invalid_utf8_errors() {
261        let _guard = set_hash_kind_for_test(HashKind::Sha1);
262        let invalid_utf8 = vec![0xFF, 0xFE, 0xFD];
263        let hash = ObjectHash::from_str("3333333333333333333333333333333333333333").unwrap();
264        let target = ObjectHash::from_str("4444444444444444444444444444444444444444").unwrap();
265        assert!(Note::from_bytes(&invalid_utf8, hash).is_err());
266        assert!(Note::from_bytes_with_target(&invalid_utf8, hash, target).is_err());
267    }
268
269    /// Test Note demo functionality showcasing best practices
270    #[test]
271    fn test_note_demo_functionality() {
272        let _guard = set_hash_kind_for_test(HashKind::Sha1);
273        // This is a demonstration test that shows the complete functionality
274        // It's kept separate from unit tests for clarity
275        println!("\n🚀 Git Note Object Demo - Best Practices");
276        println!("==========================================");
277
278        let commit_id = ObjectHash::from_str("a1b2c3d4e5f6789012345678901234567890abcd").unwrap();
279
280        println!("\n1️⃣ Creating a new Note object:");
281        let note = Note::new(
282            commit_id,
283            "Code review: LGTM! Great implementation.".to_string(),
284        );
285        println!("   Target Commit: {}", note.target_object_id);
286        println!("   Note ID: {}", note.id);
287        println!("   Content: {}", note.content);
288        println!("   Size: {} bytes", note.get_size());
289
290        println!("\n2️⃣ Serializing Note with target association:");
291        let (serialized_data, target_id) = note.to_data_with_target().unwrap();
292        println!("   Serialized size: {} bytes", serialized_data.len());
293        println!("   Target object ID: {}", target_id);
294        println!(
295            "   Git object format: blob {}\\0<content>",
296            note.content.len()
297        );
298        println!(
299            "   Raw data preview: {:?}...",
300            &serialized_data[..std::cmp::min(30, serialized_data.len())]
301        );
302
303        println!("\n3️⃣ Basic deserialization (ObjectTrait):");
304        let basic_note = Note::from_bytes(&serialized_data, note.id).unwrap();
305        println!("   Successfully deserialized!");
306        println!(
307            "   Target Commit: {} (default - target managed externally)",
308            basic_note.target_object_id
309        );
310        println!("   Content: {}", basic_note.content);
311        println!("   Content matches: {}", note.content == basic_note.content);
312
313        println!("\n4️⃣ Best practice deserialization (with target):");
314        let complete_note =
315            Note::from_bytes_with_target(&serialized_data, note.id, target_id).unwrap();
316        println!("   Successfully deserialized with target!");
317        println!("   Target Commit: {}", complete_note.target_object_id);
318        println!("   Content: {}", complete_note.content);
319        println!("   Complete objects are equal: {}", note == complete_note);
320
321        // Basic assertions to ensure demo works
322        assert_eq!(note, complete_note);
323        assert_eq!(target_id, commit_id);
324    }
325
326    /// Test Note demo functionality showcasing best practices with SHA-256
327    #[test]
328    fn test_note_demo_functionality_sha256() {
329        let _guard = set_hash_kind_for_test(HashKind::Sha256);
330        // This is a demonstration test that shows the complete functionality
331        // It's kept separate from unit tests for clarity
332        println!("\n🚀 Git Note Object Demo - Best Practices");
333        println!("==========================================");
334
335        let commit_id = ObjectHash::from_str(
336            "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
337        )
338        .unwrap();
339
340        println!("\n1️⃣ Creating a new Note object:");
341        let note = Note::new(
342            commit_id,
343            "Code review: LGTM! Great implementation.".to_string(),
344        );
345        println!("   Target Commit: {}", note.target_object_id);
346        println!("   Note ID: {}", note.id);
347        println!("   Content: {}", note.content);
348        println!("   Size: {} bytes", note.get_size());
349
350        println!("\n2️⃣ Serializing Note with target association:");
351        let (serialized_data, target_id) = note.to_data_with_target().unwrap();
352        println!("   Serialized size: {} bytes", serialized_data.len());
353        println!("   Target object ID: {}", target_id);
354        println!(
355            "   Git object format: blob {}\\0<content>",
356            note.content.len()
357        );
358        println!(
359            "   Raw data preview: {:?}...",
360            &serialized_data[..std::cmp::min(30, serialized_data.len())]
361        );
362
363        println!("\n3️⃣ Basic deserialization (ObjectTrait):");
364        let basic_note = Note::from_bytes(&serialized_data, note.id).unwrap();
365        println!("   Successfully deserialized!");
366        println!(
367            "   Target Commit: {} (default - target managed externally)",
368            basic_note.target_object_id
369        );
370        println!("   Content: {}", basic_note.content);
371        println!("   Content matches: {}", note.content == basic_note.content);
372
373        println!("\n4️⃣ Best practice deserialization (with target):");
374        let complete_note =
375            Note::from_bytes_with_target(&serialized_data, note.id, target_id).unwrap();
376        println!("   Successfully deserialized with target!");
377        println!("   Target Commit: {}", complete_note.target_object_id);
378        println!("   Content: {}", complete_note.content);
379        println!("   Complete objects are equal: {}", note == complete_note);
380
381        // Basic assertions to ensure demo works
382        assert_eq!(note, complete_note);
383        assert_eq!(target_id, commit_id);
384    }
385}