notes.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878
  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, Sequence
  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: Sequence[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(
  345. tree: Tree, components: Sequence[bytes], blob_sha: bytes
  346. ) -> Tree:
  347. """Update tree with new note entry.
  348. Args:
  349. tree: Tree to update
  350. components: Path components
  351. blob_sha: SHA of the note blob
  352. Returns:
  353. Updated tree
  354. """
  355. if len(components) == 1:
  356. # Leaf level - add the note blob
  357. new_tree = Tree()
  358. for name, mode, sha in tree.items():
  359. if name != components[0]:
  360. assert name is not None and mode is not None and sha is not None
  361. new_tree.add(name, mode, sha)
  362. new_tree.add(components[0], stat.S_IFREG | 0o644, blob_sha)
  363. return new_tree
  364. else:
  365. # Directory level
  366. new_tree = Tree()
  367. found = False
  368. for name, mode, sha in tree.items():
  369. if name == components[0]:
  370. # Update this subtree
  371. assert mode is not None and sha is not None
  372. if stat.S_ISDIR(mode):
  373. subtree = self._object_store[sha]
  374. assert isinstance(subtree, Tree)
  375. else:
  376. # If not a directory, we need to replace it
  377. subtree = Tree()
  378. new_subtree = update_tree(subtree, components[1:], blob_sha)
  379. self._object_store.add_object(new_subtree)
  380. new_tree.add(name, stat.S_IFDIR, new_subtree.id)
  381. found = True
  382. else:
  383. assert name is not None and mode is not None and sha is not None
  384. new_tree.add(name, mode, sha)
  385. if not found:
  386. # Create new subtree path
  387. subtree = Tree()
  388. new_subtree = update_tree(subtree, components[1:], blob_sha)
  389. self._object_store.add_object(new_subtree)
  390. new_tree.add(components[0], stat.S_IFDIR, new_subtree.id)
  391. return new_tree
  392. new_tree = update_tree(self._tree, components, note_blob.id)
  393. self._object_store.add_object(new_tree)
  394. self._tree = new_tree
  395. self._fanout_level = self._detect_fanout_level()
  396. return new_tree
  397. def remove_note(self, object_sha: bytes) -> Optional[Tree]:
  398. """Remove a note for an object.
  399. Args:
  400. object_sha: SHA of the object to remove notes from
  401. Returns:
  402. New tree object with the note removed, or None if no note existed
  403. """
  404. if self._get_note_sha(object_sha) is None:
  405. return None
  406. # Get path components
  407. path = get_note_path(object_sha, self._fanout_level)
  408. components = path.split(b"/")
  409. # Build new tree structure without the note
  410. def remove_from_tree(tree: Tree, components: Sequence[bytes]) -> Optional[Tree]:
  411. """Remove note entry from tree.
  412. Args:
  413. tree: Tree to remove from
  414. components: Path components
  415. Returns:
  416. Updated tree or None if empty
  417. """
  418. if len(components) == 1:
  419. # Leaf level - remove the note
  420. new_tree = Tree()
  421. found = False
  422. for name, mode, sha in tree.items():
  423. if name != components[0]:
  424. assert name is not None and mode is not None and sha is not None
  425. new_tree.add(name, mode, sha)
  426. else:
  427. found = True
  428. if not found:
  429. return None
  430. # Return None if tree is now empty
  431. return new_tree if len(new_tree) > 0 else None
  432. else:
  433. # Directory level
  434. new_tree = Tree()
  435. modified = False
  436. for name, mode, sha in tree.items():
  437. assert name is not None and mode is not None and sha is not None
  438. if name == components[0] and stat.S_ISDIR(mode):
  439. # Update this subtree
  440. subtree = self._object_store[sha]
  441. assert isinstance(subtree, Tree)
  442. new_subtree = remove_from_tree(subtree, components[1:])
  443. if new_subtree is not None:
  444. self._object_store.add_object(new_subtree)
  445. new_tree.add(name, stat.S_IFDIR, new_subtree.id)
  446. modified = True
  447. else:
  448. new_tree.add(name, mode, sha)
  449. if not modified:
  450. return None
  451. # Return None if tree is now empty
  452. return new_tree if len(new_tree) > 0 else None
  453. new_tree = remove_from_tree(self._tree, components)
  454. if new_tree is None:
  455. new_tree = Tree() # Empty tree
  456. self._object_store.add_object(new_tree)
  457. self._tree = new_tree
  458. self._fanout_level = self._detect_fanout_level()
  459. return new_tree
  460. def list_notes(self) -> Iterator[tuple[bytes, bytes]]:
  461. """List all notes in this tree.
  462. Yields:
  463. Tuples of (object_sha, note_sha)
  464. """
  465. def walk_tree(tree: Tree, prefix: bytes = b"") -> Iterator[tuple[bytes, bytes]]:
  466. """Walk the notes tree recursively.
  467. Args:
  468. tree: Tree to walk
  469. prefix: Path prefix for current level
  470. Yields:
  471. Tuples of (object_sha, note_sha)
  472. """
  473. for name, mode, sha in tree.items():
  474. assert name is not None and mode is not None and sha is not None
  475. if stat.S_ISDIR(mode): # Directory
  476. subtree = self._object_store[sha]
  477. assert isinstance(subtree, Tree)
  478. yield from walk_tree(subtree, prefix + name)
  479. elif stat.S_ISREG(mode): # File
  480. # Reconstruct the full hex SHA from the path
  481. full_hex = prefix + name
  482. yield (full_hex, sha)
  483. yield from walk_tree(self._tree)
  484. def create_notes_tree(object_store: "BaseObjectStore") -> Tree:
  485. """Create an empty notes tree.
  486. Args:
  487. object_store: Object store to add the tree to
  488. Returns:
  489. Empty tree object
  490. """
  491. tree = Tree()
  492. object_store.add_object(tree)
  493. return tree
  494. class Notes:
  495. """High-level interface for Git notes operations."""
  496. def __init__(
  497. self, object_store: "BaseObjectStore", refs_container: "RefsContainer"
  498. ) -> None:
  499. """Initialize Notes.
  500. Args:
  501. object_store: Object store to read/write objects
  502. refs_container: Refs container to read/write refs
  503. """
  504. self._object_store = object_store
  505. self._refs = refs_container
  506. def get_notes_ref(
  507. self,
  508. notes_ref: Optional[bytes] = None,
  509. config: Optional["StackedConfig"] = None,
  510. ) -> bytes:
  511. """Get the notes reference to use.
  512. Args:
  513. notes_ref: The notes ref to use, or None to use the default
  514. config: Config to read notes.displayRef from
  515. Returns:
  516. The notes reference name
  517. """
  518. if notes_ref is None:
  519. if config is not None:
  520. notes_ref = config.get((b"notes",), b"displayRef")
  521. if notes_ref is None:
  522. notes_ref = DEFAULT_NOTES_REF
  523. return notes_ref
  524. def get_note(
  525. self,
  526. object_sha: bytes,
  527. notes_ref: Optional[bytes] = None,
  528. config: Optional["StackedConfig"] = None,
  529. ) -> Optional[bytes]:
  530. """Get the note for an object.
  531. Args:
  532. object_sha: SHA of the object to get notes for
  533. notes_ref: The notes ref to use, or None to use the default
  534. config: Config to read notes.displayRef from
  535. Returns:
  536. The note content as bytes, or None if no note exists
  537. """
  538. notes_ref = self.get_notes_ref(notes_ref, config)
  539. try:
  540. notes_commit_sha = self._refs[notes_ref]
  541. except KeyError:
  542. return None
  543. # Get the commit object
  544. notes_obj = self._object_store[notes_commit_sha]
  545. # If it's a commit, get the tree from it
  546. from .objects import Commit
  547. if isinstance(notes_obj, Commit):
  548. notes_tree = self._object_store[notes_obj.tree]
  549. else:
  550. # If it's directly a tree (shouldn't happen in normal usage)
  551. notes_tree = notes_obj
  552. if not isinstance(notes_tree, Tree):
  553. return None
  554. notes_tree_obj = NotesTree(notes_tree, self._object_store)
  555. return notes_tree_obj.get_note(object_sha)
  556. def set_note(
  557. self,
  558. object_sha: bytes,
  559. note_content: bytes,
  560. notes_ref: Optional[bytes] = None,
  561. author: Optional[bytes] = None,
  562. committer: Optional[bytes] = None,
  563. message: Optional[bytes] = None,
  564. config: Optional["StackedConfig"] = None,
  565. ) -> bytes:
  566. """Set or update a note for an object.
  567. Args:
  568. object_sha: SHA of the object to annotate
  569. note_content: Content of the note
  570. notes_ref: The notes ref to use, or None to use the default
  571. author: Author identity (defaults to committer)
  572. committer: Committer identity (defaults to config)
  573. message: Commit message for the notes update
  574. config: Config to read user identity and notes.displayRef from
  575. Returns:
  576. SHA of the new notes commit
  577. """
  578. import time
  579. from .objects import Commit
  580. from .repo import get_user_identity
  581. notes_ref = self.get_notes_ref(notes_ref, config)
  582. # Get current notes tree
  583. try:
  584. notes_commit_sha = self._refs[notes_ref]
  585. notes_obj = self._object_store[notes_commit_sha]
  586. # If it's a commit, get the tree from it
  587. if isinstance(notes_obj, Commit):
  588. notes_tree = self._object_store[notes_obj.tree]
  589. else:
  590. # If it's directly a tree (shouldn't happen in normal usage)
  591. notes_tree = notes_obj
  592. if not isinstance(notes_tree, Tree):
  593. notes_tree = create_notes_tree(self._object_store)
  594. except KeyError:
  595. notes_tree = create_notes_tree(self._object_store)
  596. # Update notes tree
  597. notes_tree_obj = NotesTree(notes_tree, self._object_store)
  598. new_tree = notes_tree_obj.set_note(object_sha, note_content)
  599. # Create commit
  600. if committer is None and config is not None:
  601. committer = get_user_identity(config, kind="COMMITTER")
  602. if committer is None:
  603. committer = b"Git User <user@example.com>"
  604. if author is None:
  605. author = committer
  606. if message is None:
  607. message = b"Notes added by 'git notes add'"
  608. commit = Commit()
  609. commit.tree = new_tree.id
  610. commit.author = author
  611. commit.committer = committer
  612. commit.commit_time = commit.author_time = int(time.time())
  613. commit.commit_timezone = commit.author_timezone = 0
  614. commit.encoding = b"UTF-8"
  615. commit.message = message
  616. # Set parent to previous notes commit if exists
  617. try:
  618. parent_sha = self._refs[notes_ref]
  619. parent = self._object_store[parent_sha]
  620. if isinstance(parent, Commit):
  621. commit.parents = [parent_sha]
  622. except KeyError:
  623. commit.parents = []
  624. self._object_store.add_object(commit)
  625. self._refs[notes_ref] = commit.id
  626. return commit.id
  627. def remove_note(
  628. self,
  629. object_sha: bytes,
  630. notes_ref: Optional[bytes] = None,
  631. author: Optional[bytes] = None,
  632. committer: Optional[bytes] = None,
  633. message: Optional[bytes] = None,
  634. config: Optional["StackedConfig"] = None,
  635. ) -> Optional[bytes]:
  636. """Remove a note for an object.
  637. Args:
  638. object_sha: SHA of the object to remove notes from
  639. notes_ref: The notes ref to use, or None to use the default
  640. author: Author identity (defaults to committer)
  641. committer: Committer identity (defaults to config)
  642. message: Commit message for the notes removal
  643. config: Config to read user identity and notes.displayRef from
  644. Returns:
  645. SHA of the new notes commit, or None if no note existed
  646. """
  647. import time
  648. from .objects import Commit
  649. from .repo import get_user_identity
  650. notes_ref = self.get_notes_ref(notes_ref, config)
  651. # Get current notes tree
  652. try:
  653. notes_commit_sha = self._refs[notes_ref]
  654. notes_obj = self._object_store[notes_commit_sha]
  655. # If it's a commit, get the tree from it
  656. if isinstance(notes_obj, Commit):
  657. notes_tree = self._object_store[notes_obj.tree]
  658. else:
  659. # If it's directly a tree (shouldn't happen in normal usage)
  660. notes_tree = notes_obj
  661. if not isinstance(notes_tree, Tree):
  662. return None
  663. except KeyError:
  664. return None
  665. # Remove from notes tree
  666. notes_tree_obj = NotesTree(notes_tree, self._object_store)
  667. new_tree = notes_tree_obj.remove_note(object_sha)
  668. if new_tree is None:
  669. return None
  670. # Create commit
  671. if committer is None and config is not None:
  672. committer = get_user_identity(config, kind="COMMITTER")
  673. if committer is None:
  674. committer = b"Git User <user@example.com>"
  675. if author is None:
  676. author = committer
  677. if message is None:
  678. message = b"Notes removed by 'git notes remove'"
  679. commit = Commit()
  680. commit.tree = new_tree.id
  681. commit.author = author
  682. commit.committer = committer
  683. commit.commit_time = commit.author_time = int(time.time())
  684. commit.commit_timezone = commit.author_timezone = 0
  685. commit.encoding = b"UTF-8"
  686. commit.message = message
  687. # Set parent to previous notes commit
  688. parent_sha = self._refs[notes_ref]
  689. parent = self._object_store[parent_sha]
  690. if isinstance(parent, Commit):
  691. commit.parents = [parent_sha]
  692. self._object_store.add_object(commit)
  693. self._refs[notes_ref] = commit.id
  694. return commit.id
  695. def list_notes(
  696. self,
  697. notes_ref: Optional[bytes] = None,
  698. config: Optional["StackedConfig"] = None,
  699. ) -> list[tuple[bytes, bytes]]:
  700. """List all notes in a notes ref.
  701. Args:
  702. notes_ref: The notes ref to use, or None to use the default
  703. config: Config to read notes.displayRef from
  704. Returns:
  705. List of tuples of (object_sha, note_content)
  706. """
  707. notes_ref = self.get_notes_ref(notes_ref, config)
  708. try:
  709. notes_commit_sha = self._refs[notes_ref]
  710. except KeyError:
  711. return []
  712. # Get the commit object
  713. from .objects import Commit
  714. notes_obj = self._object_store[notes_commit_sha]
  715. # If it's a commit, get the tree from it
  716. if isinstance(notes_obj, Commit):
  717. notes_tree = self._object_store[notes_obj.tree]
  718. else:
  719. # If it's directly a tree (shouldn't happen in normal usage)
  720. notes_tree = notes_obj
  721. if not isinstance(notes_tree, Tree):
  722. return []
  723. notes_tree_obj = NotesTree(notes_tree, self._object_store)
  724. result = []
  725. for object_sha, note_sha in notes_tree_obj.list_notes():
  726. note_obj = self._object_store[note_sha]
  727. if isinstance(note_obj, Blob):
  728. result.append((object_sha, note_obj.data))
  729. return result