notes.py 30 KB

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