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