test_notes.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. # test_notes.py -- Tests for Git notes functionality
  2. # Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
  3. #
  4. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  5. # General Public License as public by the Free Software Foundation; version 2.0
  6. # or (at your option) any later version. You can redistribute it and/or
  7. # modify it under the terms of either of these two licenses.
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. #
  15. # You should have received a copy of the licenses; if not, see
  16. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  17. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  18. # License, Version 2.0.
  19. #
  20. """Tests for Git notes."""
  21. import stat
  22. from unittest import TestCase
  23. from dulwich.notes import (
  24. DEFAULT_NOTES_REF,
  25. Notes,
  26. NotesTree,
  27. create_notes_tree,
  28. get_note_path,
  29. split_path_for_fanout,
  30. )
  31. from dulwich.object_store import MemoryObjectStore
  32. from dulwich.objects import Blob, Commit, Tree
  33. from dulwich.refs import DictRefsContainer
  34. class TestNotesHelpers(TestCase):
  35. """Test helper functions for notes."""
  36. def test_split_path_for_fanout_no_fanout(self):
  37. """Test splitting path with no fanout."""
  38. hexsha = b"1234567890abcdef1234567890abcdef12345678"
  39. result = split_path_for_fanout(hexsha, 0)
  40. self.assertEqual((hexsha,), result)
  41. def test_split_path_for_fanout_level_1(self):
  42. """Test splitting path with fanout level 1."""
  43. hexsha = b"1234567890abcdef1234567890abcdef12345678"
  44. result = split_path_for_fanout(hexsha, 1)
  45. self.assertEqual((b"12", b"34567890abcdef1234567890abcdef12345678"), result)
  46. def test_split_path_for_fanout_level_2(self):
  47. """Test splitting path with fanout level 2."""
  48. hexsha = b"1234567890abcdef1234567890abcdef12345678"
  49. result = split_path_for_fanout(hexsha, 2)
  50. self.assertEqual(
  51. (b"12", b"34", b"567890abcdef1234567890abcdef12345678"), result
  52. )
  53. def test_get_note_path_no_fanout(self):
  54. """Test getting note path with no fanout."""
  55. sha = b"1234567890abcdef1234567890abcdef12345678"
  56. path = get_note_path(sha, 0)
  57. self.assertEqual(b"1234567890abcdef1234567890abcdef12345678", path)
  58. def test_get_note_path_with_fanout(self):
  59. """Test getting note path with fanout."""
  60. sha = b"1234567890abcdef1234567890abcdef12345678"
  61. path = get_note_path(sha, 2)
  62. self.assertEqual(b"12/34/567890abcdef1234567890abcdef12345678", path)
  63. class TestNotesTree(TestCase):
  64. """Test NotesTree class."""
  65. def setUp(self):
  66. self.store = MemoryObjectStore()
  67. self.tree = Tree()
  68. self.store.add_object(self.tree)
  69. def test_create_notes_tree(self):
  70. """Test creating an empty notes tree."""
  71. tree = create_notes_tree(self.store)
  72. self.assertIsInstance(tree, Tree)
  73. self.assertEqual(0, len(tree))
  74. self.assertIn(tree.id, self.store)
  75. def test_get_note_not_found(self):
  76. """Test getting a note that doesn't exist."""
  77. notes_tree = NotesTree(self.tree, self.store)
  78. sha = b"1234567890abcdef1234567890abcdef12345678"
  79. self.assertIsNone(notes_tree.get_note(sha))
  80. def test_set_and_get_note(self):
  81. """Test setting and getting a note."""
  82. notes_tree = NotesTree(self.tree, self.store)
  83. sha = b"1234567890abcdef1234567890abcdef12345678"
  84. note_content = b"This is a test note"
  85. new_tree = notes_tree.set_note(sha, note_content)
  86. self.assertIsInstance(new_tree, Tree)
  87. self.assertIn(new_tree.id, self.store)
  88. # Create new NotesTree with updated tree
  89. notes_tree = NotesTree(new_tree, self.store)
  90. retrieved_note = notes_tree.get_note(sha)
  91. self.assertEqual(note_content, retrieved_note)
  92. def test_remove_note(self):
  93. """Test removing a note."""
  94. notes_tree = NotesTree(self.tree, self.store)
  95. sha = b"1234567890abcdef1234567890abcdef12345678"
  96. note_content = b"This is a test note"
  97. # First add a note
  98. new_tree = notes_tree.set_note(sha, note_content)
  99. notes_tree = NotesTree(new_tree, self.store)
  100. # Then remove it
  101. new_tree = notes_tree.remove_note(sha)
  102. self.assertIsNotNone(new_tree)
  103. # Verify it's gone
  104. notes_tree = NotesTree(new_tree, self.store)
  105. self.assertIsNone(notes_tree.get_note(sha))
  106. def test_remove_nonexistent_note(self):
  107. """Test removing a note that doesn't exist."""
  108. notes_tree = NotesTree(self.tree, self.store)
  109. sha = b"1234567890abcdef1234567890abcdef12345678"
  110. result = notes_tree.remove_note(sha)
  111. self.assertIsNone(result)
  112. def test_list_notes_empty(self):
  113. """Test listing notes from empty tree."""
  114. notes_tree = NotesTree(self.tree, self.store)
  115. notes = list(notes_tree.list_notes())
  116. self.assertEqual([], notes)
  117. def test_list_notes(self):
  118. """Test listing notes."""
  119. notes_tree = NotesTree(self.tree, self.store)
  120. # Add multiple notes
  121. sha1 = b"1234567890abcdef1234567890abcdef12345678"
  122. sha2 = b"abcdef1234567890abcdef1234567890abcdef12"
  123. new_tree = notes_tree.set_note(sha1, b"Note 1")
  124. notes_tree = NotesTree(new_tree, self.store)
  125. new_tree = notes_tree.set_note(sha2, b"Note 2")
  126. notes_tree = NotesTree(new_tree, self.store)
  127. # List notes
  128. notes = list(notes_tree.list_notes())
  129. self.assertEqual(2, len(notes))
  130. # Sort by SHA for consistent comparison
  131. notes.sort(key=lambda x: x[0])
  132. self.assertEqual(sha1, notes[0][0])
  133. self.assertEqual(sha2, notes[1][0])
  134. def test_detect_fanout_level(self):
  135. """Test fanout level detection."""
  136. # Test no fanout (files at root)
  137. tree = Tree()
  138. blob = Blob.from_string(b"test note")
  139. self.store.add_object(blob)
  140. tree.add(
  141. b"1234567890abcdef1234567890abcdef12345678", stat.S_IFREG | 0o644, blob.id
  142. )
  143. self.store.add_object(tree)
  144. notes_tree = NotesTree(tree, self.store)
  145. self.assertEqual(0, notes_tree._fanout_level)
  146. # Test level 1 fanout (2-char dirs with files)
  147. tree = Tree()
  148. subtree = Tree()
  149. self.store.add_object(subtree)
  150. subtree.add(
  151. b"34567890abcdef1234567890abcdef12345678", stat.S_IFREG | 0o644, blob.id
  152. )
  153. tree.add(b"12", stat.S_IFDIR, subtree.id)
  154. tree.add(b"ab", stat.S_IFDIR, subtree.id)
  155. self.store.add_object(tree)
  156. notes_tree = NotesTree(tree, self.store)
  157. self.assertEqual(1, notes_tree._fanout_level)
  158. # Test level 2 fanout (2-char dirs containing 2-char dirs)
  159. tree = Tree()
  160. subtree1 = Tree()
  161. subtree2 = Tree()
  162. self.store.add_object(subtree2)
  163. subtree2.add(
  164. b"567890abcdef1234567890abcdef12345678", stat.S_IFREG | 0o644, blob.id
  165. )
  166. subtree1.add(b"34", stat.S_IFDIR, subtree2.id)
  167. self.store.add_object(subtree1)
  168. tree.add(b"12", stat.S_IFDIR, subtree1.id)
  169. self.store.add_object(tree)
  170. notes_tree = NotesTree(tree, self.store)
  171. self.assertEqual(2, notes_tree._fanout_level)
  172. def test_automatic_fanout_reorganization(self):
  173. """Test that tree automatically reorganizes when crossing fanout thresholds."""
  174. notes_tree = NotesTree(self.tree, self.store)
  175. # Add notes until we cross the fanout threshold
  176. # We need to add enough notes to trigger fanout (256+)
  177. for i in range(260):
  178. # Generate unique SHA for each note
  179. sha = f"{i:040x}".encode("ascii")
  180. note_content = f"Note {i}".encode("ascii")
  181. new_tree = notes_tree.set_note(sha, note_content)
  182. notes_tree = NotesTree(new_tree, self.store)
  183. # Should now have fanout level 1
  184. self.assertEqual(1, notes_tree._fanout_level)
  185. # Verify all notes are still accessible
  186. for i in range(260):
  187. sha = f"{i:040x}".encode("ascii")
  188. note = notes_tree.get_note(sha)
  189. self.assertEqual(f"Note {i}".encode("ascii"), note)
  190. class TestNotes(TestCase):
  191. """Test Notes high-level interface."""
  192. def setUp(self):
  193. self.store = MemoryObjectStore()
  194. self.refs = DictRefsContainer({})
  195. def test_get_notes_ref_default(self):
  196. """Test getting default notes ref."""
  197. notes = Notes(self.store, self.refs)
  198. ref = notes.get_notes_ref()
  199. self.assertEqual(DEFAULT_NOTES_REF, ref)
  200. def test_get_notes_ref_custom(self):
  201. """Test getting custom notes ref."""
  202. notes = Notes(self.store, self.refs)
  203. ref = notes.get_notes_ref(b"refs/notes/custom")
  204. self.assertEqual(b"refs/notes/custom", ref)
  205. def test_get_note_no_ref(self):
  206. """Test getting note when ref doesn't exist."""
  207. notes = Notes(self.store, self.refs)
  208. sha = b"1234567890abcdef1234567890abcdef12345678"
  209. self.assertIsNone(notes.get_note(sha))
  210. def test_set_and_get_note(self):
  211. """Test setting and getting a note through Notes interface."""
  212. notes = Notes(self.store, self.refs)
  213. sha = b"1234567890abcdef1234567890abcdef12345678"
  214. note_content = b"Test note content"
  215. # Set note
  216. commit_sha = notes.set_note(sha, note_content)
  217. self.assertIsInstance(commit_sha, bytes)
  218. self.assertIn(commit_sha, self.store)
  219. # Verify commit
  220. commit = self.store[commit_sha]
  221. self.assertIsInstance(commit, Commit)
  222. self.assertEqual(b"Notes added by 'git notes add'", commit.message)
  223. # Get note
  224. retrieved_note = notes.get_note(sha)
  225. self.assertEqual(note_content, retrieved_note)
  226. def test_remove_note(self):
  227. """Test removing a note through Notes interface."""
  228. notes = Notes(self.store, self.refs)
  229. sha = b"1234567890abcdef1234567890abcdef12345678"
  230. note_content = b"Test note content"
  231. # First set a note
  232. notes.set_note(sha, note_content)
  233. # Then remove it
  234. commit_sha = notes.remove_note(sha)
  235. self.assertIsNotNone(commit_sha)
  236. # Verify it's gone
  237. self.assertIsNone(notes.get_note(sha))
  238. def test_list_notes(self):
  239. """Test listing notes through Notes interface."""
  240. notes = Notes(self.store, self.refs)
  241. # Add multiple notes
  242. sha1 = b"1234567890abcdef1234567890abcdef12345678"
  243. sha2 = b"abcdef1234567890abcdef1234567890abcdef12"
  244. notes.set_note(sha1, b"Note 1")
  245. notes.set_note(sha2, b"Note 2")
  246. # List notes
  247. notes_list = notes.list_notes()
  248. self.assertEqual(2, len(notes_list))
  249. # Sort for consistent comparison
  250. notes_list.sort(key=lambda x: x[0])
  251. self.assertEqual(sha1, notes_list[0][0])
  252. self.assertEqual(b"Note 1", notes_list[0][1])
  253. self.assertEqual(sha2, notes_list[1][0])
  254. self.assertEqual(b"Note 2", notes_list[1][1])
  255. def test_custom_commit_info(self):
  256. """Test setting note with custom commit info."""
  257. notes = Notes(self.store, self.refs)
  258. sha = b"1234567890abcdef1234567890abcdef12345678"
  259. commit_sha = notes.set_note(
  260. sha,
  261. b"Test note",
  262. author=b"Test Author <test@example.com>",
  263. committer=b"Test Committer <committer@example.com>",
  264. message=b"Custom commit message",
  265. )
  266. commit = self.store[commit_sha]
  267. self.assertEqual(b"Test Author <test@example.com>", commit.author)
  268. self.assertEqual(b"Test Committer <committer@example.com>", commit.committer)
  269. self.assertEqual(b"Custom commit message", commit.message)