notes.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892
  1. # notes.py -- Git notes handling
  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 published 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. """Git notes handling."""
  21. __all__ = [
  22. "DEFAULT_NOTES_REF",
  23. "NOTES_REF_PREFIX",
  24. "Notes",
  25. "NotesTree",
  26. "create_notes_tree",
  27. "get_note_fanout_level",
  28. "get_note_path",
  29. "split_path_for_fanout",
  30. ]
  31. import stat
  32. from collections.abc import Iterator, Sequence
  33. from typing import TYPE_CHECKING
  34. from .objects import Blob, ObjectID, Tree
  35. from .refs import Ref
  36. if TYPE_CHECKING:
  37. from .config import StackedConfig
  38. from .object_store import BaseObjectStore
  39. from .refs import RefsContainer
  40. NOTES_REF_PREFIX = b"refs/notes/"
  41. DEFAULT_NOTES_REF = NOTES_REF_PREFIX + b"commits"
  42. def get_note_fanout_level(tree: Tree, object_store: "BaseObjectStore") -> int:
  43. """Determine the fanout level for a note tree.
  44. Git uses a fanout directory structure for performance with large numbers
  45. of notes. The fanout level determines how many levels of subdirectories
  46. are used.
  47. Args:
  48. tree: The notes tree to analyze
  49. object_store: Object store to retrieve subtrees
  50. Returns:
  51. Fanout level (0 for no fanout, 1 or 2 for fanout)
  52. """
  53. # Count the total number of notes in the tree recursively
  54. def count_notes(tree: Tree, level: int = 0) -> int:
  55. """Count notes in a tree recursively.
  56. Args:
  57. tree: Tree to count notes in
  58. level: Current recursion level
  59. Returns:
  60. Total number of notes
  61. """
  62. count = 0
  63. for name, mode, sha in tree.items():
  64. assert mode is not None
  65. if stat.S_ISREG(mode):
  66. count += 1
  67. elif stat.S_ISDIR(mode) and level < 2: # Only recurse 2 levels deep
  68. assert sha is not None
  69. try:
  70. subtree = object_store[sha]
  71. assert isinstance(subtree, Tree)
  72. count += count_notes(subtree, level + 1)
  73. except KeyError:
  74. pass
  75. return count
  76. note_count = count_notes(tree)
  77. # Use fanout based on number of notes
  78. # Git typically starts using fanout around 256 notes
  79. if note_count < 256:
  80. return 0
  81. elif note_count < 65536: # 256^2
  82. return 1
  83. else:
  84. return 2
  85. def split_path_for_fanout(hexsha: bytes, fanout_level: int) -> tuple[bytes, ...]:
  86. """Split a hex SHA into path components based on fanout level.
  87. Args:
  88. hexsha: Hex SHA of the object
  89. fanout_level: Number of directory levels for fanout
  90. Returns:
  91. Tuple of path components
  92. """
  93. if fanout_level == 0:
  94. return (hexsha,)
  95. components = []
  96. for i in range(fanout_level):
  97. components.append(hexsha[i * 2 : (i + 1) * 2])
  98. components.append(hexsha[fanout_level * 2 :])
  99. return tuple(components)
  100. def get_note_path(object_sha: bytes, fanout_level: int = 0) -> bytes:
  101. """Get the path within the notes tree for a given object.
  102. Args:
  103. object_sha: Hex SHA of the object to get notes for
  104. fanout_level: Fanout level to use
  105. Returns:
  106. Path within the notes tree
  107. """
  108. components = split_path_for_fanout(object_sha, fanout_level)
  109. return b"/".join(components)
  110. class NotesTree:
  111. """Represents a Git notes tree."""
  112. def __init__(self, tree: Tree, object_store: "BaseObjectStore") -> None:
  113. """Initialize a notes tree.
  114. Args:
  115. tree: The tree object containing notes
  116. object_store: Object store to retrieve note contents from
  117. """
  118. self._tree = tree
  119. self._object_store = object_store
  120. self._fanout_level = self._detect_fanout_level()
  121. def _detect_fanout_level(self) -> int:
  122. """Detect the fanout level used in this notes tree.
  123. Returns:
  124. Detected fanout level
  125. """
  126. if not self._tree.items():
  127. return 0
  128. # Check for presence of both files and directories
  129. has_files = False
  130. has_dirs = False
  131. dir_names = []
  132. for name, mode, sha in self._tree.items():
  133. assert name is not None
  134. assert mode is not None
  135. if stat.S_ISDIR(mode):
  136. has_dirs = True
  137. dir_names.append(name)
  138. elif stat.S_ISREG(mode):
  139. has_files = True
  140. # If we have files at the root level, check if they're full SHA names
  141. if has_files and not has_dirs:
  142. # Check if any file names are full 40-char hex strings
  143. for name, mode, sha in self._tree.items():
  144. assert name is not None
  145. assert mode is not None
  146. if stat.S_ISREG(mode) and len(name) == 40:
  147. try:
  148. int(name, 16) # Verify it's a valid hex string
  149. return 0 # No fanout
  150. except ValueError:
  151. pass
  152. # Check if all directories are 2-character hex names
  153. if has_dirs and dir_names:
  154. all_two_char_hex = all(
  155. len(name) == 2 and all(c in b"0123456789abcdef" for c in name)
  156. for name in dir_names
  157. )
  158. if all_two_char_hex:
  159. # Check a sample directory to determine if it's level 1 or 2
  160. sample_dir_name = dir_names[0]
  161. try:
  162. _sample_mode, sample_sha = self._tree[sample_dir_name]
  163. sample_tree = self._object_store[sample_sha]
  164. assert isinstance(sample_tree, Tree)
  165. # Check if this subtree also has 2-char hex directories
  166. sub_has_dirs = False
  167. for sub_name, sub_mode, sub_sha in sample_tree.items():
  168. assert sub_name is not None
  169. assert sub_mode is not None
  170. if stat.S_ISDIR(sub_mode) and len(sub_name) == 2:
  171. try:
  172. int(sub_name, 16)
  173. sub_has_dirs = True
  174. break
  175. except ValueError:
  176. pass
  177. return 2 if sub_has_dirs else 1
  178. except KeyError:
  179. return 1 # Assume level 1 if we can't inspect
  180. return 0
  181. def _reorganize_tree(self, new_fanout_level: int) -> None:
  182. """Reorganize the notes tree to use a different fanout level.
  183. Args:
  184. new_fanout_level: The desired fanout level
  185. """
  186. if new_fanout_level == self._fanout_level:
  187. return
  188. # Collect all existing notes
  189. notes = []
  190. for object_sha, note_sha in self.list_notes():
  191. note_obj = self._object_store[note_sha]
  192. if isinstance(note_obj, Blob):
  193. notes.append((object_sha, note_obj.data))
  194. # Create new empty tree
  195. new_tree = Tree()
  196. self._object_store.add_object(new_tree)
  197. self._tree = new_tree
  198. self._fanout_level = new_fanout_level
  199. # Re-add all notes with new fanout structure using set_note
  200. # Temporarily set fanout back to avoid recursion
  201. for object_sha, note_content in notes:
  202. # Use the internal tree update logic without checking fanout again
  203. note_blob = Blob.from_string(note_content)
  204. self._object_store.add_object(note_blob)
  205. path = get_note_path(object_sha, new_fanout_level)
  206. components = path.split(b"/")
  207. # Build new tree structure
  208. def update_tree(
  209. tree: Tree, components: Sequence[bytes], blob_sha: ObjectID
  210. ) -> Tree:
  211. """Update tree with new note entry.
  212. Args:
  213. tree: Tree to update
  214. components: Path components
  215. blob_sha: SHA of the note blob
  216. Returns:
  217. Updated tree
  218. """
  219. if len(components) == 1:
  220. # Leaf level - add the note blob
  221. new_tree = Tree()
  222. for name, mode, sha in tree.items():
  223. if name != components[0]:
  224. assert name is not None
  225. assert mode is not None
  226. assert sha is not None
  227. new_tree.add(name, mode, sha)
  228. new_tree.add(components[0], stat.S_IFREG | 0o644, blob_sha)
  229. return new_tree
  230. else:
  231. # Directory level
  232. new_tree = Tree()
  233. found = False
  234. for name, mode, sha in tree.items():
  235. if name == components[0]:
  236. # Update this subtree
  237. assert mode is not None and sha is not None
  238. if stat.S_ISDIR(mode):
  239. subtree = self._object_store[sha]
  240. assert isinstance(subtree, Tree)
  241. else:
  242. # If not a directory, we need to replace it
  243. subtree = Tree()
  244. new_subtree = update_tree(subtree, components[1:], blob_sha)
  245. self._object_store.add_object(new_subtree)
  246. new_tree.add(name, stat.S_IFDIR, new_subtree.id)
  247. found = True
  248. else:
  249. assert (
  250. name is not None
  251. and mode is not None
  252. and sha is not None
  253. )
  254. new_tree.add(name, mode, sha)
  255. if not found:
  256. # Create new subtree path
  257. subtree = Tree()
  258. new_subtree = update_tree(subtree, components[1:], blob_sha)
  259. self._object_store.add_object(new_subtree)
  260. new_tree.add(components[0], stat.S_IFDIR, new_subtree.id)
  261. return new_tree
  262. self._tree = update_tree(self._tree, components, note_blob.id)
  263. self._object_store.add_object(self._tree)
  264. def _update_tree_entry(
  265. self, tree: Tree, name: bytes, mode: int, sha: ObjectID
  266. ) -> Tree:
  267. """Update a tree entry and return the updated tree.
  268. Args:
  269. tree: The tree to update
  270. name: Name of the entry
  271. mode: File mode
  272. sha: SHA of the object
  273. Returns:
  274. The updated tree
  275. """
  276. new_tree = Tree()
  277. for existing_name, existing_mode, existing_sha in tree.items():
  278. if existing_name != name:
  279. assert (
  280. existing_name is not None
  281. and existing_mode is not None
  282. and existing_sha is not None
  283. )
  284. new_tree.add(existing_name, existing_mode, existing_sha)
  285. new_tree.add(name, mode, sha)
  286. self._object_store.add_object(new_tree)
  287. # Update the tree reference
  288. if tree is self._tree:
  289. self._tree = new_tree
  290. return new_tree
  291. def _get_note_sha(self, object_sha: bytes) -> ObjectID | None:
  292. """Get the SHA of the note blob for an object.
  293. Args:
  294. object_sha: SHA of the object to get notes for
  295. Returns:
  296. SHA of the note blob, or None if no note exists
  297. """
  298. path = get_note_path(object_sha, self._fanout_level)
  299. components = path.split(b"/")
  300. current_tree = self._tree
  301. for component in components[:-1]:
  302. try:
  303. mode, sha = current_tree[component]
  304. if not stat.S_ISDIR(mode): # Not a directory
  305. return None
  306. obj = self._object_store[sha]
  307. assert isinstance(obj, Tree)
  308. current_tree = obj
  309. except KeyError:
  310. return None
  311. try:
  312. mode, sha = current_tree[components[-1]]
  313. if not stat.S_ISREG(mode): # Not a regular file
  314. return None
  315. return sha
  316. except KeyError:
  317. return None
  318. def get_note(self, object_sha: bytes) -> bytes | None:
  319. """Get the note content for an object.
  320. Args:
  321. object_sha: SHA of the object to get notes for
  322. Returns:
  323. Note content as bytes, or None if no note exists
  324. """
  325. note_sha = self._get_note_sha(object_sha)
  326. if note_sha is None:
  327. return None
  328. try:
  329. note_obj = self._object_store[note_sha]
  330. if not isinstance(note_obj, Blob):
  331. return None
  332. data: bytes = note_obj.data
  333. return data
  334. except KeyError:
  335. return None
  336. def set_note(self, object_sha: bytes, note_content: bytes) -> Tree:
  337. """Set or update a note for an object.
  338. Args:
  339. object_sha: SHA of the object to annotate
  340. note_content: Content of the note
  341. Returns:
  342. New tree object with the note added/updated
  343. """
  344. # Create note blob
  345. note_blob = Blob.from_string(note_content)
  346. self._object_store.add_object(note_blob)
  347. # Check if we need to reorganize the tree for better fanout
  348. desired_fanout = get_note_fanout_level(self._tree, self._object_store)
  349. if desired_fanout != self._fanout_level:
  350. self._reorganize_tree(desired_fanout)
  351. # Get path components
  352. path = get_note_path(object_sha, self._fanout_level)
  353. components = path.split(b"/")
  354. # Build new tree structure
  355. def update_tree(
  356. tree: Tree, components: Sequence[bytes], blob_sha: ObjectID
  357. ) -> Tree:
  358. """Update tree with new note entry.
  359. Args:
  360. tree: Tree to update
  361. components: Path components
  362. blob_sha: SHA of the note blob
  363. Returns:
  364. Updated tree
  365. """
  366. if len(components) == 1:
  367. # Leaf level - add the note blob
  368. new_tree = Tree()
  369. for name, mode, sha in tree.items():
  370. if name != components[0]:
  371. assert name is not None and mode is not None and sha is not None
  372. new_tree.add(name, mode, sha)
  373. new_tree.add(components[0], stat.S_IFREG | 0o644, blob_sha)
  374. return new_tree
  375. else:
  376. # Directory level
  377. new_tree = Tree()
  378. found = False
  379. for name, mode, sha in tree.items():
  380. if name == components[0]:
  381. # Update this subtree
  382. assert mode is not None and sha is not None
  383. if stat.S_ISDIR(mode):
  384. subtree = self._object_store[sha]
  385. assert isinstance(subtree, Tree)
  386. else:
  387. # If not a directory, we need to replace it
  388. subtree = Tree()
  389. new_subtree = update_tree(subtree, components[1:], blob_sha)
  390. self._object_store.add_object(new_subtree)
  391. new_tree.add(name, stat.S_IFDIR, new_subtree.id)
  392. found = True
  393. else:
  394. assert name is not None and mode is not None and sha is not None
  395. new_tree.add(name, mode, sha)
  396. if not found:
  397. # Create new subtree path
  398. subtree = Tree()
  399. new_subtree = update_tree(subtree, components[1:], blob_sha)
  400. self._object_store.add_object(new_subtree)
  401. new_tree.add(components[0], stat.S_IFDIR, new_subtree.id)
  402. return new_tree
  403. new_tree = update_tree(self._tree, components, note_blob.id)
  404. self._object_store.add_object(new_tree)
  405. self._tree = new_tree
  406. self._fanout_level = self._detect_fanout_level()
  407. return new_tree
  408. def remove_note(self, object_sha: bytes) -> Tree | None:
  409. """Remove a note for an object.
  410. Args:
  411. object_sha: SHA of the object to remove notes from
  412. Returns:
  413. New tree object with the note removed, or None if no note existed
  414. """
  415. if self._get_note_sha(object_sha) is None:
  416. return None
  417. # Get path components
  418. path = get_note_path(object_sha, self._fanout_level)
  419. components = path.split(b"/")
  420. # Build new tree structure without the note
  421. def remove_from_tree(tree: Tree, components: Sequence[bytes]) -> Tree | None:
  422. """Remove note entry from tree.
  423. Args:
  424. tree: Tree to remove from
  425. components: Path components
  426. Returns:
  427. Updated tree or None if empty
  428. """
  429. if len(components) == 1:
  430. # Leaf level - remove the note
  431. new_tree = Tree()
  432. found = False
  433. for name, mode, sha in tree.items():
  434. if name != components[0]:
  435. assert name is not None and mode is not None and sha is not None
  436. new_tree.add(name, mode, sha)
  437. else:
  438. found = True
  439. if not found:
  440. return None
  441. # Return None if tree is now empty
  442. return new_tree if len(new_tree) > 0 else None
  443. else:
  444. # Directory level
  445. new_tree = Tree()
  446. modified = False
  447. for name, mode, sha in tree.items():
  448. assert name is not None and mode is not None and sha is not None
  449. if name == components[0] and stat.S_ISDIR(mode):
  450. # Update this subtree
  451. subtree = self._object_store[sha]
  452. assert isinstance(subtree, Tree)
  453. new_subtree = remove_from_tree(subtree, components[1:])
  454. if new_subtree is not None:
  455. self._object_store.add_object(new_subtree)
  456. new_tree.add(name, stat.S_IFDIR, new_subtree.id)
  457. modified = True
  458. else:
  459. new_tree.add(name, mode, sha)
  460. if not modified:
  461. return None
  462. # Return None if tree is now empty
  463. return new_tree if len(new_tree) > 0 else None
  464. new_tree = remove_from_tree(self._tree, components)
  465. if new_tree is None:
  466. new_tree = Tree() # Empty tree
  467. self._object_store.add_object(new_tree)
  468. self._tree = new_tree
  469. self._fanout_level = self._detect_fanout_level()
  470. return new_tree
  471. def list_notes(self) -> Iterator[tuple[ObjectID, ObjectID]]:
  472. """List all notes in this tree.
  473. Yields:
  474. Tuples of (object_sha, note_sha)
  475. """
  476. def walk_tree(
  477. tree: Tree, prefix: bytes = b""
  478. ) -> Iterator[tuple[ObjectID, ObjectID]]:
  479. """Walk the notes tree recursively.
  480. Args:
  481. tree: Tree to walk
  482. prefix: Path prefix for current level
  483. Yields:
  484. Tuples of (object_sha, note_sha)
  485. """
  486. for name, mode, sha in tree.items():
  487. assert name is not None and mode is not None and sha is not None
  488. if stat.S_ISDIR(mode): # Directory
  489. subtree = self._object_store[sha]
  490. assert isinstance(subtree, Tree)
  491. yield from walk_tree(subtree, prefix + name)
  492. elif stat.S_ISREG(mode): # File
  493. # Reconstruct the full hex SHA from the path
  494. full_hex = prefix + name
  495. yield (ObjectID(full_hex), sha)
  496. yield from walk_tree(self._tree)
  497. def create_notes_tree(object_store: "BaseObjectStore") -> Tree:
  498. """Create an empty notes tree.
  499. Args:
  500. object_store: Object store to add the tree to
  501. Returns:
  502. Empty tree object
  503. """
  504. tree = Tree()
  505. object_store.add_object(tree)
  506. return tree
  507. class Notes:
  508. """High-level interface for Git notes operations."""
  509. def __init__(
  510. self, object_store: "BaseObjectStore", refs_container: "RefsContainer"
  511. ) -> None:
  512. """Initialize Notes.
  513. Args:
  514. object_store: Object store to read/write objects
  515. refs_container: Refs container to read/write refs
  516. """
  517. self._object_store = object_store
  518. self._refs = refs_container
  519. def get_notes_ref(
  520. self,
  521. notes_ref: bytes | None = None,
  522. config: "StackedConfig | None" = None,
  523. ) -> Ref:
  524. """Get the notes reference to use.
  525. Args:
  526. notes_ref: The notes ref to use, or None to use the default
  527. config: Config to read notes.displayRef from
  528. Returns:
  529. The notes reference name
  530. """
  531. if notes_ref is None:
  532. if config is not None:
  533. notes_ref = config.get((b"notes",), b"displayRef")
  534. if notes_ref is None:
  535. notes_ref = DEFAULT_NOTES_REF
  536. return Ref(notes_ref)
  537. def get_note(
  538. self,
  539. object_sha: bytes,
  540. notes_ref: bytes | None = None,
  541. config: "StackedConfig | None" = None,
  542. ) -> bytes | None:
  543. """Get the note for an object.
  544. Args:
  545. object_sha: SHA of the object to get notes for
  546. notes_ref: The notes ref to use, or None to use the default
  547. config: Config to read notes.displayRef from
  548. Returns:
  549. The note content as bytes, or None if no note exists
  550. """
  551. notes_ref = self.get_notes_ref(notes_ref, config)
  552. try:
  553. notes_commit_sha = self._refs[notes_ref]
  554. except KeyError:
  555. return None
  556. # Get the commit object
  557. notes_obj = self._object_store[notes_commit_sha]
  558. # If it's a commit, get the tree from it
  559. from .objects import Commit
  560. if isinstance(notes_obj, Commit):
  561. notes_tree = self._object_store[notes_obj.tree]
  562. else:
  563. # If it's directly a tree (shouldn't happen in normal usage)
  564. notes_tree = notes_obj
  565. if not isinstance(notes_tree, Tree):
  566. return None
  567. notes_tree_obj = NotesTree(notes_tree, self._object_store)
  568. return notes_tree_obj.get_note(object_sha)
  569. def set_note(
  570. self,
  571. object_sha: bytes,
  572. note_content: bytes,
  573. notes_ref: bytes | None = None,
  574. author: bytes | None = None,
  575. committer: bytes | None = None,
  576. message: bytes | None = None,
  577. config: "StackedConfig | None" = None,
  578. ) -> bytes:
  579. """Set or update a note for an object.
  580. Args:
  581. object_sha: SHA of the object to annotate
  582. note_content: Content of the note
  583. notes_ref: The notes ref to use, or None to use the default
  584. author: Author identity (defaults to committer)
  585. committer: Committer identity (defaults to config)
  586. message: Commit message for the notes update
  587. config: Config to read user identity and notes.displayRef from
  588. Returns:
  589. SHA of the new notes commit
  590. """
  591. import time
  592. from .objects import Commit
  593. from .repo import get_user_identity
  594. notes_ref = self.get_notes_ref(notes_ref, config)
  595. # Get current notes tree
  596. try:
  597. notes_commit_sha = self._refs[notes_ref]
  598. notes_obj = self._object_store[notes_commit_sha]
  599. # If it's a commit, get the tree from it
  600. if isinstance(notes_obj, Commit):
  601. notes_tree = self._object_store[notes_obj.tree]
  602. else:
  603. # If it's directly a tree (shouldn't happen in normal usage)
  604. notes_tree = notes_obj
  605. if not isinstance(notes_tree, Tree):
  606. notes_tree = create_notes_tree(self._object_store)
  607. except KeyError:
  608. notes_tree = create_notes_tree(self._object_store)
  609. # Update notes tree
  610. notes_tree_obj = NotesTree(notes_tree, self._object_store)
  611. new_tree = notes_tree_obj.set_note(object_sha, note_content)
  612. # Create commit
  613. if committer is None and config is not None:
  614. committer = get_user_identity(config, kind="COMMITTER")
  615. if committer is None:
  616. committer = b"Git User <user@example.com>"
  617. if author is None:
  618. author = committer
  619. if message is None:
  620. message = b"Notes added by 'git notes add'"
  621. commit = Commit()
  622. commit.tree = new_tree.id
  623. commit.author = author
  624. commit.committer = committer
  625. commit.commit_time = commit.author_time = int(time.time())
  626. commit.commit_timezone = commit.author_timezone = 0
  627. commit.encoding = b"UTF-8"
  628. commit.message = message
  629. # Set parent to previous notes commit if exists
  630. try:
  631. parent_sha = self._refs[notes_ref]
  632. parent = self._object_store[parent_sha]
  633. if isinstance(parent, Commit):
  634. commit.parents = [parent_sha]
  635. except KeyError:
  636. commit.parents = []
  637. self._object_store.add_object(commit)
  638. self._refs[notes_ref] = commit.id
  639. return commit.id
  640. def remove_note(
  641. self,
  642. object_sha: bytes,
  643. notes_ref: bytes | None = None,
  644. author: bytes | None = None,
  645. committer: bytes | None = None,
  646. message: bytes | None = None,
  647. config: "StackedConfig | None" = None,
  648. ) -> bytes | None:
  649. """Remove a note for an object.
  650. Args:
  651. object_sha: SHA of the object to remove notes from
  652. notes_ref: The notes ref to use, or None to use the default
  653. author: Author identity (defaults to committer)
  654. committer: Committer identity (defaults to config)
  655. message: Commit message for the notes removal
  656. config: Config to read user identity and notes.displayRef from
  657. Returns:
  658. SHA of the new notes commit, or None if no note existed
  659. """
  660. import time
  661. from .objects import Commit
  662. from .repo import get_user_identity
  663. notes_ref = self.get_notes_ref(notes_ref, config)
  664. # Get current notes tree
  665. try:
  666. notes_commit_sha = self._refs[notes_ref]
  667. notes_obj = self._object_store[notes_commit_sha]
  668. # If it's a commit, get the tree from it
  669. if isinstance(notes_obj, Commit):
  670. notes_tree = self._object_store[notes_obj.tree]
  671. else:
  672. # If it's directly a tree (shouldn't happen in normal usage)
  673. notes_tree = notes_obj
  674. if not isinstance(notes_tree, Tree):
  675. return None
  676. except KeyError:
  677. return None
  678. # Remove from notes tree
  679. notes_tree_obj = NotesTree(notes_tree, self._object_store)
  680. new_tree = notes_tree_obj.remove_note(object_sha)
  681. if new_tree is None:
  682. return None
  683. # Create commit
  684. if committer is None and config is not None:
  685. committer = get_user_identity(config, kind="COMMITTER")
  686. if committer is None:
  687. committer = b"Git User <user@example.com>"
  688. if author is None:
  689. author = committer
  690. if message is None:
  691. message = b"Notes removed by 'git notes remove'"
  692. commit = Commit()
  693. commit.tree = new_tree.id
  694. commit.author = author
  695. commit.committer = committer
  696. commit.commit_time = commit.author_time = int(time.time())
  697. commit.commit_timezone = commit.author_timezone = 0
  698. commit.encoding = b"UTF-8"
  699. commit.message = message
  700. # Set parent to previous notes commit
  701. parent_sha = self._refs[notes_ref]
  702. parent = self._object_store[parent_sha]
  703. if isinstance(parent, Commit):
  704. commit.parents = [parent_sha]
  705. self._object_store.add_object(commit)
  706. self._refs[notes_ref] = commit.id
  707. return commit.id
  708. def list_notes(
  709. self,
  710. notes_ref: bytes | None = None,
  711. config: "StackedConfig | None" = None,
  712. ) -> list[tuple[ObjectID, bytes]]:
  713. """List all notes in a notes ref.
  714. Args:
  715. notes_ref: The notes ref to use, or None to use the default
  716. config: Config to read notes.displayRef from
  717. Returns:
  718. List of tuples of (object_sha, note_content)
  719. """
  720. notes_ref = self.get_notes_ref(notes_ref, config)
  721. try:
  722. notes_commit_sha = self._refs[notes_ref]
  723. except KeyError:
  724. return []
  725. # Get the commit object
  726. from .objects import Commit
  727. notes_obj = self._object_store[notes_commit_sha]
  728. # If it's a commit, get the tree from it
  729. if isinstance(notes_obj, Commit):
  730. notes_tree = self._object_store[notes_obj.tree]
  731. else:
  732. # If it's directly a tree (shouldn't happen in normal usage)
  733. notes_tree = notes_obj
  734. if not isinstance(notes_tree, Tree):
  735. return []
  736. notes_tree_obj = NotesTree(notes_tree, self._object_store)
  737. result: list[tuple[ObjectID, bytes]] = []
  738. for object_sha, note_sha in notes_tree_obj.list_notes():
  739. note_obj = self._object_store[note_sha]
  740. if isinstance(note_obj, Blob):
  741. result.append((object_sha, note_obj.data))
  742. return result