Kaynağa Gözat

Add basic notes support

Jelmer Vernooij 1 ay önce
ebeveyn
işleme
e23394168a
8 değiştirilmiş dosya ile 1507 ekleme ve 0 silme
  1. 2 0
      NEWS
  2. 70 0
      dulwich/cli.py
  3. 786 0
      dulwich/notes.py
  4. 131 0
      dulwich/porcelain.py
  5. 1 0
      dulwich/refs.py
  6. 12 0
      dulwich/repo.py
  7. 331 0
      tests/test_notes.py
  8. 174 0
      tests/test_porcelain_notes.py

+ 2 - 0
NEWS

@@ -1,5 +1,7 @@
 0.23.1	UNRELEASED
 
+ * Add basic support for managing Notes. (Jelmer Vernooij)
+
 0.23.0	2025-06-21
 
  * Add basic ``rebase`` subcommand. (Jelmer Vernooij)

+ 70 - 0
dulwich/cli.py

@@ -982,6 +982,75 @@ class cmd_merge(Command):
             return 1
 
 
+class cmd_notes_add(Command):
+    def run(self, args) -> None:
+        parser = argparse.ArgumentParser()
+        parser.add_argument("object", help="Object to annotate")
+        parser.add_argument("-m", "--message", help="Note message", required=True)
+        parser.add_argument(
+            "--ref", default="commits", help="Notes ref (default: commits)"
+        )
+        args = parser.parse_args(args)
+
+        porcelain.notes_add(".", args.object, args.message, ref=args.ref)
+
+
+class cmd_notes_show(Command):
+    def run(self, args) -> None:
+        parser = argparse.ArgumentParser()
+        parser.add_argument("object", help="Object to show notes for")
+        parser.add_argument(
+            "--ref", default="commits", help="Notes ref (default: commits)"
+        )
+        args = parser.parse_args(args)
+
+        note = porcelain.notes_show(".", args.object, ref=args.ref)
+        if note:
+            sys.stdout.buffer.write(note)
+        else:
+            print(f"No notes found for object {args.object}")
+
+
+class cmd_notes_remove(Command):
+    def run(self, args) -> None:
+        parser = argparse.ArgumentParser()
+        parser.add_argument("object", help="Object to remove notes from")
+        parser.add_argument(
+            "--ref", default="commits", help="Notes ref (default: commits)"
+        )
+        args = parser.parse_args(args)
+
+        result = porcelain.notes_remove(".", args.object, ref=args.ref)
+        if result:
+            print(f"Removed notes for object {args.object}")
+        else:
+            print(f"No notes found for object {args.object}")
+
+
+class cmd_notes_list(Command):
+    def run(self, args) -> None:
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "--ref", default="commits", help="Notes ref (default: commits)"
+        )
+        args = parser.parse_args(args)
+
+        notes = porcelain.notes_list(".", ref=args.ref)
+        for object_sha, note_content in notes:
+            print(f"{object_sha.hex()}")
+
+
+class cmd_notes(SuperCommand):
+    subcommands: ClassVar[dict[str, type[Command]]] = {
+        "add": cmd_notes_add,
+        "show": cmd_notes_show,
+        "remove": cmd_notes_remove,
+        "list": cmd_notes_list,
+    }
+
+    default_command = cmd_notes_list
+
+
 class cmd_merge_tree(Command):
     def run(self, args) -> Optional[int]:
         parser = argparse.ArgumentParser(
@@ -1293,6 +1362,7 @@ commands = {
     "ls-tree": cmd_ls_tree,
     "merge": cmd_merge,
     "merge-tree": cmd_merge_tree,
+    "notes": cmd_notes,
     "pack-objects": cmd_pack_objects,
     "pack-refs": cmd_pack_refs,
     "pull": cmd_pull,

+ 786 - 0
dulwich/notes.py

@@ -0,0 +1,786 @@
+# notes.py -- Git notes handling
+# Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Git notes handling."""
+
+import stat
+from collections.abc import Iterator
+from typing import TYPE_CHECKING, Optional
+
+from .objects import Blob, Tree
+
+if TYPE_CHECKING:
+    from .config import StackedConfig
+
+NOTES_REF_PREFIX = b"refs/notes/"
+DEFAULT_NOTES_REF = NOTES_REF_PREFIX + b"commits"
+
+
+def get_note_fanout_level(tree: Tree, object_store) -> int:
+    """Determine the fanout level for a note tree.
+
+    Git uses a fanout directory structure for performance with large numbers
+    of notes. The fanout level determines how many levels of subdirectories
+    are used.
+
+    Args:
+        tree: The notes tree to analyze
+        object_store: Object store to retrieve subtrees
+
+    Returns:
+        Fanout level (0 for no fanout, 1 or 2 for fanout)
+    """
+
+    # Count the total number of notes in the tree recursively
+    def count_notes(tree: Tree, level: int = 0) -> int:
+        count = 0
+        for name, mode, sha in tree.items():
+            if stat.S_ISREG(mode):
+                count += 1
+            elif stat.S_ISDIR(mode) and level < 2:  # Only recurse 2 levels deep
+                try:
+                    subtree = object_store[sha]
+                    count += count_notes(subtree, level + 1)
+                except KeyError:
+                    pass
+        return count
+
+    note_count = count_notes(tree)
+
+    # Use fanout based on number of notes
+    # Git typically starts using fanout around 256 notes
+    if note_count < 256:
+        return 0
+    elif note_count < 65536:  # 256^2
+        return 1
+    else:
+        return 2
+
+
+def split_path_for_fanout(hexsha: bytes, fanout_level: int) -> tuple[bytes, ...]:
+    """Split a hex SHA into path components based on fanout level.
+
+    Args:
+        hexsha: Hex SHA of the object
+        fanout_level: Number of directory levels for fanout
+
+    Returns:
+        Tuple of path components
+    """
+    if fanout_level == 0:
+        return (hexsha,)
+
+    components = []
+    for i in range(fanout_level):
+        components.append(hexsha[i * 2 : (i + 1) * 2])
+    components.append(hexsha[fanout_level * 2 :])
+    return tuple(components)
+
+
+def get_note_path(object_sha: bytes, fanout_level: int = 0) -> bytes:
+    """Get the path within the notes tree for a given object.
+
+    Args:
+        object_sha: Hex SHA of the object to get notes for
+        fanout_level: Fanout level to use
+
+    Returns:
+        Path within the notes tree
+    """
+    components = split_path_for_fanout(object_sha, fanout_level)
+    return b"/".join(components)
+
+
+class NotesTree:
+    """Represents a Git notes tree."""
+
+    def __init__(self, tree: Tree, object_store):
+        """Initialize a notes tree.
+
+        Args:
+            tree: The tree object containing notes
+            object_store: Object store to retrieve note contents from
+        """
+        self._tree = tree
+        self._object_store = object_store
+        self._fanout_level = self._detect_fanout_level()
+
+    def _detect_fanout_level(self) -> int:
+        """Detect the fanout level used in this notes tree.
+
+        Returns:
+            Detected fanout level
+        """
+        if not self._tree.items():
+            return 0
+
+        # Check for presence of both files and directories
+        has_files = False
+        has_dirs = False
+        dir_names = []
+
+        for name, mode, sha in self._tree.items():
+            if stat.S_ISDIR(mode):
+                has_dirs = True
+                dir_names.append(name)
+            elif stat.S_ISREG(mode):
+                has_files = True
+
+        # If we have files at the root level, check if they're full SHA names
+        if has_files and not has_dirs:
+            # Check if any file names are full 40-char hex strings
+            for name, mode, sha in self._tree.items():
+                if stat.S_ISREG(mode) and len(name) == 40:
+                    try:
+                        int(name, 16)  # Verify it's a valid hex string
+                        return 0  # No fanout
+                    except ValueError:
+                        pass
+
+        # Check if all directories are 2-character hex names
+        if has_dirs and dir_names:
+            all_two_char_hex = all(
+                len(name) == 2 and all(c in b"0123456789abcdef" for c in name)
+                for name in dir_names
+            )
+
+            if all_two_char_hex:
+                # Check a sample directory to determine if it's level 1 or 2
+                sample_dir_name = dir_names[0]
+                try:
+                    sample_mode, sample_sha = self._tree[sample_dir_name]
+                    sample_tree = self._object_store[sample_sha]
+
+                    # Check if this subtree also has 2-char hex directories
+                    sub_has_dirs = False
+                    for sub_name, sub_mode, sub_sha in sample_tree.items():
+                        if stat.S_ISDIR(sub_mode) and len(sub_name) == 2:
+                            try:
+                                int(sub_name, 16)
+                                sub_has_dirs = True
+                                break
+                            except ValueError:
+                                pass
+
+                    return 2 if sub_has_dirs else 1
+                except KeyError:
+                    return 1  # Assume level 1 if we can't inspect
+
+        return 0
+
+    def _reorganize_tree(self, new_fanout_level: int) -> None:
+        """Reorganize the notes tree to use a different fanout level.
+
+        Args:
+            new_fanout_level: The desired fanout level
+        """
+        if new_fanout_level == self._fanout_level:
+            return
+
+        # Collect all existing notes
+        notes = []
+        for object_sha, note_sha in self.list_notes():
+            note_obj = self._object_store[note_sha]
+            if isinstance(note_obj, Blob):
+                notes.append((object_sha, note_obj.data))
+
+        # Create new empty tree
+        new_tree = Tree()
+        self._object_store.add_object(new_tree)
+        self._tree = new_tree
+        self._fanout_level = new_fanout_level
+
+        # Re-add all notes with new fanout structure using set_note
+        # Temporarily set fanout back to avoid recursion
+        for object_sha, note_content in notes:
+            # Use the internal tree update logic without checking fanout again
+            note_blob = Blob.from_string(note_content)
+            self._object_store.add_object(note_blob)
+
+            path = get_note_path(object_sha, new_fanout_level)
+            components = path.split(b"/")
+
+            # Build new tree structure
+            def update_tree(tree: Tree, components: list, blob_sha: bytes) -> Tree:
+                if len(components) == 1:
+                    # Leaf level - add the note blob
+                    new_tree = Tree()
+                    for name, mode, sha in tree.items():
+                        if name != components[0]:
+                            new_tree.add(name, mode, sha)
+                    new_tree.add(components[0], stat.S_IFREG | 0o644, blob_sha)
+                    return new_tree
+                else:
+                    # Directory level
+                    new_tree = Tree()
+                    found = False
+                    for name, mode, sha in tree.items():
+                        if name == components[0]:
+                            # Update this subtree
+                            if stat.S_ISDIR(mode):
+                                subtree = self._object_store[sha]
+                            else:
+                                # If not a directory, we need to replace it
+                                subtree = Tree()
+                            new_subtree = update_tree(subtree, components[1:], blob_sha)
+                            self._object_store.add_object(new_subtree)
+                            new_tree.add(name, stat.S_IFDIR, new_subtree.id)
+                            found = True
+                        else:
+                            new_tree.add(name, mode, sha)
+
+                    if not found:
+                        # Create new subtree path
+                        subtree = Tree()
+                        new_subtree = update_tree(subtree, components[1:], blob_sha)
+                        self._object_store.add_object(new_subtree)
+                        new_tree.add(components[0], stat.S_IFDIR, new_subtree.id)
+
+                    return new_tree
+
+            self._tree = update_tree(self._tree, components, note_blob.id)
+            self._object_store.add_object(self._tree)
+
+    def _update_tree_entry(
+        self, tree: Tree, name: bytes, mode: int, sha: bytes
+    ) -> Tree:
+        """Update a tree entry and return the updated tree.
+
+        Args:
+            tree: The tree to update
+            name: Name of the entry
+            mode: File mode
+            sha: SHA of the object
+
+        Returns:
+            The updated tree
+        """
+        new_tree = Tree()
+        for existing_name, existing_mode, existing_sha in tree.items():
+            if existing_name != name:
+                new_tree.add(existing_name, existing_mode, existing_sha)
+        new_tree.add(name, mode, sha)
+        self._object_store.add_object(new_tree)
+
+        # Update the tree reference
+        if tree is self._tree:
+            self._tree = new_tree
+
+        return new_tree
+
+    def _get_note_sha(self, object_sha: bytes) -> Optional[bytes]:
+        """Get the SHA of the note blob for an object.
+
+        Args:
+            object_sha: SHA of the object to get notes for
+
+        Returns:
+            SHA of the note blob, or None if no note exists
+        """
+        path = get_note_path(object_sha, self._fanout_level)
+        components = path.split(b"/")
+
+        current_tree = self._tree
+        for component in components[:-1]:
+            try:
+                mode, sha = current_tree[component]
+                if not stat.S_ISDIR(mode):  # Not a directory
+                    return None
+                current_tree = self._object_store[sha]
+            except KeyError:
+                return None
+
+        try:
+            mode, sha = current_tree[components[-1]]
+            if not stat.S_ISREG(mode):  # Not a regular file
+                return None
+            return sha
+        except KeyError:
+            return None
+
+    def get_note(self, object_sha: bytes) -> Optional[bytes]:
+        """Get the note content for an object.
+
+        Args:
+            object_sha: SHA of the object to get notes for
+
+        Returns:
+            Note content as bytes, or None if no note exists
+        """
+        note_sha = self._get_note_sha(object_sha)
+        if note_sha is None:
+            return None
+
+        try:
+            note_obj = self._object_store[note_sha]
+            if not isinstance(note_obj, Blob):
+                return None
+            return note_obj.data
+        except KeyError:
+            return None
+
+    def set_note(self, object_sha: bytes, note_content: bytes) -> Tree:
+        """Set or update a note for an object.
+
+        Args:
+            object_sha: SHA of the object to annotate
+            note_content: Content of the note
+
+        Returns:
+            New tree object with the note added/updated
+        """
+        # Create note blob
+        note_blob = Blob.from_string(note_content)
+        self._object_store.add_object(note_blob)
+
+        # Check if we need to reorganize the tree for better fanout
+        desired_fanout = get_note_fanout_level(self._tree, self._object_store)
+        if desired_fanout != self._fanout_level:
+            self._reorganize_tree(desired_fanout)
+
+        # Get path components
+        path = get_note_path(object_sha, self._fanout_level)
+        components = path.split(b"/")
+
+        # Build new tree structure
+        def update_tree(tree: Tree, components: list, blob_sha: bytes) -> Tree:
+            if len(components) == 1:
+                # Leaf level - add the note blob
+                new_tree = Tree()
+                for name, mode, sha in tree.items():
+                    if name != components[0]:
+                        new_tree.add(name, mode, sha)
+                new_tree.add(components[0], stat.S_IFREG | 0o644, blob_sha)
+                return new_tree
+            else:
+                # Directory level
+                new_tree = Tree()
+                found = False
+                for name, mode, sha in tree.items():
+                    if name == components[0]:
+                        # Update this subtree
+                        if stat.S_ISDIR(mode):
+                            subtree = self._object_store[sha]
+                        else:
+                            # If not a directory, we need to replace it
+                            subtree = Tree()
+                        new_subtree = update_tree(subtree, components[1:], blob_sha)
+                        self._object_store.add_object(new_subtree)
+                        new_tree.add(name, stat.S_IFDIR, new_subtree.id)
+                        found = True
+                    else:
+                        new_tree.add(name, mode, sha)
+
+                if not found:
+                    # Create new subtree path
+                    subtree = Tree()
+                    new_subtree = update_tree(subtree, components[1:], blob_sha)
+                    self._object_store.add_object(new_subtree)
+                    new_tree.add(components[0], stat.S_IFDIR, new_subtree.id)
+
+                return new_tree
+
+        new_tree = update_tree(self._tree, components, note_blob.id)
+        self._object_store.add_object(new_tree)
+        self._tree = new_tree
+        self._fanout_level = self._detect_fanout_level()
+        return new_tree
+
+    def remove_note(self, object_sha: bytes) -> Optional[Tree]:
+        """Remove a note for an object.
+
+        Args:
+            object_sha: SHA of the object to remove notes from
+
+        Returns:
+            New tree object with the note removed, or None if no note existed
+        """
+        if self._get_note_sha(object_sha) is None:
+            return None
+
+        # Get path components
+        path = get_note_path(object_sha, self._fanout_level)
+        components = path.split(b"/")
+
+        # Build new tree structure without the note
+        def remove_from_tree(tree: Tree, components: list) -> Optional[Tree]:
+            if len(components) == 1:
+                # Leaf level - remove the note
+                new_tree = Tree()
+                found = False
+                for name, mode, sha in tree.items():
+                    if name != components[0]:
+                        new_tree.add(name, mode, sha)
+                    else:
+                        found = True
+
+                if not found:
+                    return None
+
+                # Return None if tree is now empty
+                return new_tree if len(new_tree) > 0 else None
+            else:
+                # Directory level
+                new_tree = Tree()
+                modified = False
+                for name, mode, sha in tree.items():
+                    if name == components[0] and stat.S_ISDIR(mode):
+                        # Update this subtree
+                        subtree = self._object_store[sha]
+                        new_subtree = remove_from_tree(subtree, components[1:])
+                        if new_subtree is not None:
+                            self._object_store.add_object(new_subtree)
+                            new_tree.add(name, stat.S_IFDIR, new_subtree.id)
+                        modified = True
+                    else:
+                        new_tree.add(name, mode, sha)
+
+                if not modified:
+                    return None
+
+                # Return None if tree is now empty
+                return new_tree if len(new_tree) > 0 else None
+
+        new_tree = remove_from_tree(self._tree, components)
+        if new_tree is None:
+            new_tree = Tree()  # Empty tree
+
+        self._object_store.add_object(new_tree)
+        self._tree = new_tree
+        self._fanout_level = self._detect_fanout_level()
+        return new_tree
+
+    def list_notes(self) -> Iterator[tuple[bytes, bytes]]:
+        """List all notes in this tree.
+
+        Yields:
+            Tuples of (object_sha, note_sha)
+        """
+
+        def walk_tree(tree: Tree, prefix: bytes = b"") -> Iterator[tuple[bytes, bytes]]:
+            for name, mode, sha in tree.items():
+                if stat.S_ISDIR(mode):  # Directory
+                    subtree = self._object_store[sha]
+                    yield from walk_tree(subtree, prefix + name)
+                elif stat.S_ISREG(mode):  # File
+                    # Reconstruct the full hex SHA from the path
+                    full_hex = prefix + name
+                    yield (full_hex, sha)
+
+        yield from walk_tree(self._tree)
+
+
+def create_notes_tree(object_store) -> Tree:
+    """Create an empty notes tree.
+
+    Args:
+        object_store: Object store to add the tree to
+
+    Returns:
+        Empty tree object
+    """
+    tree = Tree()
+    object_store.add_object(tree)
+    return tree
+
+
+class Notes:
+    """High-level interface for Git notes operations."""
+
+    def __init__(self, object_store, refs_container):
+        """Initialize Notes.
+
+        Args:
+            object_store: Object store to read/write objects
+            refs_container: Refs container to read/write refs
+        """
+        self._object_store = object_store
+        self._refs = refs_container
+
+    def get_notes_ref(
+        self,
+        notes_ref: Optional[bytes] = None,
+        config: Optional["StackedConfig"] = None,
+    ) -> bytes:
+        """Get the notes reference to use.
+
+        Args:
+            notes_ref: The notes ref to use, or None to use the default
+            config: Config to read notes.displayRef from
+
+        Returns:
+            The notes reference name
+        """
+        if notes_ref is None:
+            if config is not None:
+                notes_ref = config.get((b"notes",), b"displayRef")
+            if notes_ref is None:
+                notes_ref = DEFAULT_NOTES_REF
+        return notes_ref
+
+    def get_note(
+        self,
+        object_sha: bytes,
+        notes_ref: Optional[bytes] = None,
+        config: Optional["StackedConfig"] = None,
+    ) -> Optional[bytes]:
+        """Get the note for an object.
+
+        Args:
+            object_sha: SHA of the object to get notes for
+            notes_ref: The notes ref to use, or None to use the default
+            config: Config to read notes.displayRef from
+
+        Returns:
+            The note content as bytes, or None if no note exists
+        """
+        notes_ref = self.get_notes_ref(notes_ref, config)
+        try:
+            notes_commit_sha = self._refs[notes_ref]
+        except KeyError:
+            return None
+
+        # Get the commit object
+        notes_obj = self._object_store[notes_commit_sha]
+
+        # If it's a commit, get the tree from it
+        from .objects import Commit
+
+        if isinstance(notes_obj, Commit):
+            notes_tree = self._object_store[notes_obj.tree]
+        else:
+            # If it's directly a tree (shouldn't happen in normal usage)
+            notes_tree = notes_obj
+
+        if not isinstance(notes_tree, Tree):
+            return None
+
+        notes_tree_obj = NotesTree(notes_tree, self._object_store)
+        return notes_tree_obj.get_note(object_sha)
+
+    def set_note(
+        self,
+        object_sha: bytes,
+        note_content: bytes,
+        notes_ref: Optional[bytes] = None,
+        author: Optional[bytes] = None,
+        committer: Optional[bytes] = None,
+        message: Optional[bytes] = None,
+        config: Optional["StackedConfig"] = None,
+    ) -> bytes:
+        """Set or update a note for an object.
+
+        Args:
+            object_sha: SHA of the object to annotate
+            note_content: Content of the note
+            notes_ref: The notes ref to use, or None to use the default
+            author: Author identity (defaults to committer)
+            committer: Committer identity (defaults to config)
+            message: Commit message for the notes update
+            config: Config to read user identity and notes.displayRef from
+
+        Returns:
+            SHA of the new notes commit
+        """
+        import time
+
+        from .objects import Commit
+        from .repo import get_user_identity
+
+        notes_ref = self.get_notes_ref(notes_ref, config)
+
+        # Get current notes tree
+        try:
+            notes_commit_sha = self._refs[notes_ref]
+            notes_obj = self._object_store[notes_commit_sha]
+
+            # If it's a commit, get the tree from it
+            if isinstance(notes_obj, Commit):
+                notes_tree = self._object_store[notes_obj.tree]
+            else:
+                # If it's directly a tree (shouldn't happen in normal usage)
+                notes_tree = notes_obj
+
+            if not isinstance(notes_tree, Tree):
+                notes_tree = create_notes_tree(self._object_store)
+        except KeyError:
+            notes_tree = create_notes_tree(self._object_store)
+
+        # Update notes tree
+        notes_tree_obj = NotesTree(notes_tree, self._object_store)
+        new_tree = notes_tree_obj.set_note(object_sha, note_content)
+
+        # Create commit
+        if committer is None and config is not None:
+            committer = get_user_identity(config, kind="COMMITTER")
+        if committer is None:
+            committer = b"Git User <user@example.com>"
+        if author is None:
+            author = committer
+        if message is None:
+            message = b"Notes added by 'git notes add'"
+
+        commit = Commit()
+        commit.tree = new_tree.id
+        commit.author = author
+        commit.committer = committer
+        commit.commit_time = commit.author_time = int(time.time())
+        commit.commit_timezone = commit.author_timezone = 0
+        commit.encoding = b"UTF-8"
+        commit.message = message
+
+        # Set parent to previous notes commit if exists
+        try:
+            parent_sha = self._refs[notes_ref]
+            parent = self._object_store[parent_sha]
+            if isinstance(parent, Commit):
+                commit.parents = [parent_sha]
+        except KeyError:
+            commit.parents = []
+
+        self._object_store.add_object(commit)
+        self._refs[notes_ref] = commit.id
+
+        return commit.id
+
+    def remove_note(
+        self,
+        object_sha: bytes,
+        notes_ref: Optional[bytes] = None,
+        author: Optional[bytes] = None,
+        committer: Optional[bytes] = None,
+        message: Optional[bytes] = None,
+        config: Optional["StackedConfig"] = None,
+    ) -> Optional[bytes]:
+        """Remove a note for an object.
+
+        Args:
+            object_sha: SHA of the object to remove notes from
+            notes_ref: The notes ref to use, or None to use the default
+            author: Author identity (defaults to committer)
+            committer: Committer identity (defaults to config)
+            message: Commit message for the notes removal
+            config: Config to read user identity and notes.displayRef from
+
+        Returns:
+            SHA of the new notes commit, or None if no note existed
+        """
+        import time
+
+        from .objects import Commit
+        from .repo import get_user_identity
+
+        notes_ref = self.get_notes_ref(notes_ref, config)
+
+        # Get current notes tree
+        try:
+            notes_commit_sha = self._refs[notes_ref]
+            notes_obj = self._object_store[notes_commit_sha]
+
+            # If it's a commit, get the tree from it
+            if isinstance(notes_obj, Commit):
+                notes_tree = self._object_store[notes_obj.tree]
+            else:
+                # If it's directly a tree (shouldn't happen in normal usage)
+                notes_tree = notes_obj
+
+            if not isinstance(notes_tree, Tree):
+                return None
+        except KeyError:
+            return None
+
+        # Remove from notes tree
+        notes_tree_obj = NotesTree(notes_tree, self._object_store)
+        new_tree = notes_tree_obj.remove_note(object_sha)
+        if new_tree is None:
+            return None
+
+        # Create commit
+        if committer is None and config is not None:
+            committer = get_user_identity(config, kind="COMMITTER")
+        if committer is None:
+            committer = b"Git User <user@example.com>"
+        if author is None:
+            author = committer
+        if message is None:
+            message = b"Notes removed by 'git notes remove'"
+
+        commit = Commit()
+        commit.tree = new_tree.id
+        commit.author = author
+        commit.committer = committer
+        commit.commit_time = commit.author_time = int(time.time())
+        commit.commit_timezone = commit.author_timezone = 0
+        commit.encoding = b"UTF-8"
+        commit.message = message
+
+        # Set parent to previous notes commit
+        parent_sha = self._refs[notes_ref]
+        parent = self._object_store[parent_sha]
+        if isinstance(parent, Commit):
+            commit.parents = [parent_sha]
+
+        self._object_store.add_object(commit)
+        self._refs[notes_ref] = commit.id
+
+        return commit.id
+
+    def list_notes(
+        self,
+        notes_ref: Optional[bytes] = None,
+        config: Optional["StackedConfig"] = None,
+    ) -> list[tuple[bytes, bytes]]:
+        """List all notes in a notes ref.
+
+        Args:
+            notes_ref: The notes ref to use, or None to use the default
+            config: Config to read notes.displayRef from
+
+        Returns:
+            List of tuples of (object_sha, note_content)
+        """
+        notes_ref = self.get_notes_ref(notes_ref, config)
+        try:
+            notes_commit_sha = self._refs[notes_ref]
+        except KeyError:
+            return []
+
+        # Get the commit object
+        from .objects import Commit
+
+        notes_obj = self._object_store[notes_commit_sha]
+
+        # If it's a commit, get the tree from it
+        if isinstance(notes_obj, Commit):
+            notes_tree = self._object_store[notes_obj.tree]
+        else:
+            # If it's directly a tree (shouldn't happen in normal usage)
+            notes_tree = notes_obj
+
+        if not isinstance(notes_tree, Tree):
+            return []
+
+        notes_tree_obj = NotesTree(notes_tree, self._object_store)
+        result = []
+        for object_sha, note_sha in notes_tree_obj.list_notes():
+            note_obj = self._object_store[note_sha]
+            if isinstance(note_obj, Blob):
+                result.append((object_sha, note_obj.data))
+        return result

+ 131 - 0
dulwich/porcelain.py

@@ -126,6 +126,7 @@ from .patch import write_tree_diff
 from .protocol import ZERO_SHA, Protocol
 from .refs import (
     LOCAL_BRANCH_PREFIX,
+    LOCAL_NOTES_PREFIX,
     LOCAL_TAG_PREFIX,
     Ref,
     _import_remote_refs,
@@ -1238,6 +1239,136 @@ def tag_delete(repo, name) -> None:
             del r.refs[_make_tag_ref(name)]
 
 
+def _make_notes_ref(name: bytes) -> bytes:
+    """Make a notes ref name."""
+    if name.startswith(b"refs/notes/"):
+        return name
+    return LOCAL_NOTES_PREFIX + name
+
+
+def notes_add(
+    repo, object_sha, note, ref=b"commits", author=None, committer=None, message=None
+):
+    """Add or update a note for an object.
+
+    Args:
+      repo: Path to repository
+      object_sha: SHA of the object to annotate
+      note: Note content
+      ref: Notes ref to use (defaults to "commits" for refs/notes/commits)
+      author: Author identity (defaults to committer)
+      committer: Committer identity (defaults to config)
+      message: Commit message for the notes update
+
+    Returns:
+      SHA of the new notes commit
+    """
+    with open_repo_closing(repo) as r:
+        # Parse the object to get its SHA
+        obj = parse_object(r, object_sha)
+        object_sha = obj.id
+
+        if isinstance(note, str):
+            note = note.encode(DEFAULT_ENCODING)
+        if isinstance(ref, str):
+            ref = ref.encode(DEFAULT_ENCODING)
+
+        notes_ref = _make_notes_ref(ref)
+        config = r.get_config_stack()
+
+        return r.notes.set_note(
+            object_sha,
+            note,
+            notes_ref,
+            author=author,
+            committer=committer,
+            message=message,
+            config=config,
+        )
+
+
+def notes_remove(
+    repo, object_sha, ref=b"commits", author=None, committer=None, message=None
+):
+    """Remove a note for an object.
+
+    Args:
+      repo: Path to repository
+      object_sha: SHA of the object to remove notes from
+      ref: Notes ref to use (defaults to "commits" for refs/notes/commits)
+      author: Author identity (defaults to committer)
+      committer: Committer identity (defaults to config)
+      message: Commit message for the notes removal
+
+    Returns:
+      SHA of the new notes commit, or None if no note existed
+    """
+    with open_repo_closing(repo) as r:
+        # Parse the object to get its SHA
+        obj = parse_object(r, object_sha)
+        object_sha = obj.id
+
+        if isinstance(ref, str):
+            ref = ref.encode(DEFAULT_ENCODING)
+
+        notes_ref = _make_notes_ref(ref)
+        config = r.get_config_stack()
+
+        return r.notes.remove_note(
+            object_sha,
+            notes_ref,
+            author=author,
+            committer=committer,
+            message=message,
+            config=config,
+        )
+
+
+def notes_show(repo, object_sha, ref=b"commits"):
+    """Show the note for an object.
+
+    Args:
+      repo: Path to repository
+      object_sha: SHA of the object
+      ref: Notes ref to use (defaults to "commits" for refs/notes/commits)
+
+    Returns:
+      Note content as bytes, or None if no note exists
+    """
+    with open_repo_closing(repo) as r:
+        # Parse the object to get its SHA
+        obj = parse_object(r, object_sha)
+        object_sha = obj.id
+
+        if isinstance(ref, str):
+            ref = ref.encode(DEFAULT_ENCODING)
+
+        notes_ref = _make_notes_ref(ref)
+        config = r.get_config_stack()
+
+        return r.notes.get_note(object_sha, notes_ref, config=config)
+
+
+def notes_list(repo, ref=b"commits"):
+    """List all notes in a notes ref.
+
+    Args:
+      repo: Path to repository
+      ref: Notes ref to use (defaults to "commits" for refs/notes/commits)
+
+    Returns:
+      List of tuples of (object_sha, note_content)
+    """
+    with open_repo_closing(repo) as r:
+        if isinstance(ref, str):
+            ref = ref.encode(DEFAULT_ENCODING)
+
+        notes_ref = _make_notes_ref(ref)
+        config = r.get_config_stack()
+
+        return r.notes.list_notes(notes_ref, config=config)
+
+
 def reset(repo, mode, treeish="HEAD") -> None:
     """Reset current HEAD to the specified state.
 

+ 1 - 0
dulwich/refs.py

@@ -40,6 +40,7 @@ SYMREF = b"ref: "
 LOCAL_BRANCH_PREFIX = b"refs/heads/"
 LOCAL_TAG_PREFIX = b"refs/tags/"
 LOCAL_REMOTE_PREFIX = b"refs/remotes/"
+LOCAL_NOTES_PREFIX = b"refs/notes/"
 BAD_REF_CHARS = set(b"\177 ~^:?*[")
 PEELED_TAG_SUFFIX = b"^{}"
 

+ 12 - 0
dulwich/repo.py

@@ -51,6 +51,7 @@ if TYPE_CHECKING:
     # these imports.
     from .config import ConfigFile, StackedConfig
     from .index import Index
+    from .notes import Notes
 
 from .errors import (
     CommitError,
@@ -804,6 +805,17 @@ class BaseRepo:
             return cached
         return peel_sha(self.object_store, self.refs[ref])[1].id
 
+    @property
+    def notes(self) -> "Notes":
+        """Access notes functionality for this repository.
+
+        Returns:
+            Notes object for accessing notes
+        """
+        from .notes import Notes
+
+        return Notes(self.object_store, self.refs)
+
     def get_walker(self, include: Optional[list[bytes]] = None, *args, **kwargs):
         """Obtain a walker for this repository.
 

+ 331 - 0
tests/test_notes.py

@@ -0,0 +1,331 @@
+# test_notes.py -- Tests for Git notes functionality
+# Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Tests for Git notes."""
+
+import stat
+from unittest import TestCase
+
+from dulwich.notes import (
+    DEFAULT_NOTES_REF,
+    Notes,
+    NotesTree,
+    create_notes_tree,
+    get_note_path,
+    split_path_for_fanout,
+)
+from dulwich.object_store import MemoryObjectStore
+from dulwich.objects import Blob, Commit, Tree
+from dulwich.refs import DictRefsContainer
+
+
+class TestNotesHelpers(TestCase):
+    """Test helper functions for notes."""
+
+    def test_split_path_for_fanout_no_fanout(self):
+        """Test splitting path with no fanout."""
+        hexsha = b"1234567890abcdef1234567890abcdef12345678"
+        result = split_path_for_fanout(hexsha, 0)
+        self.assertEqual((hexsha,), result)
+
+    def test_split_path_for_fanout_level_1(self):
+        """Test splitting path with fanout level 1."""
+        hexsha = b"1234567890abcdef1234567890abcdef12345678"
+        result = split_path_for_fanout(hexsha, 1)
+        self.assertEqual((b"12", b"34567890abcdef1234567890abcdef12345678"), result)
+
+    def test_split_path_for_fanout_level_2(self):
+        """Test splitting path with fanout level 2."""
+        hexsha = b"1234567890abcdef1234567890abcdef12345678"
+        result = split_path_for_fanout(hexsha, 2)
+        self.assertEqual(
+            (b"12", b"34", b"567890abcdef1234567890abcdef12345678"), result
+        )
+
+    def test_get_note_path_no_fanout(self):
+        """Test getting note path with no fanout."""
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+        path = get_note_path(sha, 0)
+        self.assertEqual(b"1234567890abcdef1234567890abcdef12345678", path)
+
+    def test_get_note_path_with_fanout(self):
+        """Test getting note path with fanout."""
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+        path = get_note_path(sha, 2)
+        self.assertEqual(b"12/34/567890abcdef1234567890abcdef12345678", path)
+
+
+class TestNotesTree(TestCase):
+    """Test NotesTree class."""
+
+    def setUp(self):
+        self.store = MemoryObjectStore()
+        self.tree = Tree()
+        self.store.add_object(self.tree)
+
+    def test_create_notes_tree(self):
+        """Test creating an empty notes tree."""
+        tree = create_notes_tree(self.store)
+        self.assertIsInstance(tree, Tree)
+        self.assertEqual(0, len(tree))
+        self.assertIn(tree.id, self.store)
+
+    def test_get_note_not_found(self):
+        """Test getting a note that doesn't exist."""
+        notes_tree = NotesTree(self.tree, self.store)
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+        self.assertIsNone(notes_tree.get_note(sha))
+
+    def test_set_and_get_note(self):
+        """Test setting and getting a note."""
+        notes_tree = NotesTree(self.tree, self.store)
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+        note_content = b"This is a test note"
+
+        new_tree = notes_tree.set_note(sha, note_content)
+        self.assertIsInstance(new_tree, Tree)
+        self.assertIn(new_tree.id, self.store)
+
+        # Create new NotesTree with updated tree
+        notes_tree = NotesTree(new_tree, self.store)
+        retrieved_note = notes_tree.get_note(sha)
+        self.assertEqual(note_content, retrieved_note)
+
+    def test_remove_note(self):
+        """Test removing a note."""
+        notes_tree = NotesTree(self.tree, self.store)
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+        note_content = b"This is a test note"
+
+        # First add a note
+        new_tree = notes_tree.set_note(sha, note_content)
+        notes_tree = NotesTree(new_tree, self.store)
+
+        # Then remove it
+        new_tree = notes_tree.remove_note(sha)
+        self.assertIsNotNone(new_tree)
+
+        # Verify it's gone
+        notes_tree = NotesTree(new_tree, self.store)
+        self.assertIsNone(notes_tree.get_note(sha))
+
+    def test_remove_nonexistent_note(self):
+        """Test removing a note that doesn't exist."""
+        notes_tree = NotesTree(self.tree, self.store)
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+
+        result = notes_tree.remove_note(sha)
+        self.assertIsNone(result)
+
+    def test_list_notes_empty(self):
+        """Test listing notes from empty tree."""
+        notes_tree = NotesTree(self.tree, self.store)
+        notes = list(notes_tree.list_notes())
+        self.assertEqual([], notes)
+
+    def test_list_notes(self):
+        """Test listing notes."""
+        notes_tree = NotesTree(self.tree, self.store)
+
+        # Add multiple notes
+        sha1 = b"1234567890abcdef1234567890abcdef12345678"
+        sha2 = b"abcdef1234567890abcdef1234567890abcdef12"
+
+        new_tree = notes_tree.set_note(sha1, b"Note 1")
+        notes_tree = NotesTree(new_tree, self.store)
+        new_tree = notes_tree.set_note(sha2, b"Note 2")
+        notes_tree = NotesTree(new_tree, self.store)
+
+        # List notes
+        notes = list(notes_tree.list_notes())
+        self.assertEqual(2, len(notes))
+
+        # Sort by SHA for consistent comparison
+        notes.sort(key=lambda x: x[0])
+        self.assertEqual(sha1, notes[0][0])
+        self.assertEqual(sha2, notes[1][0])
+
+    def test_detect_fanout_level(self):
+        """Test fanout level detection."""
+        # Test no fanout (files at root)
+        tree = Tree()
+        blob = Blob.from_string(b"test note")
+        self.store.add_object(blob)
+        tree.add(
+            b"1234567890abcdef1234567890abcdef12345678", stat.S_IFREG | 0o644, blob.id
+        )
+        self.store.add_object(tree)
+
+        notes_tree = NotesTree(tree, self.store)
+        self.assertEqual(0, notes_tree._fanout_level)
+
+        # Test level 1 fanout (2-char dirs with files)
+        tree = Tree()
+        subtree = Tree()
+        self.store.add_object(subtree)
+        subtree.add(
+            b"34567890abcdef1234567890abcdef12345678", stat.S_IFREG | 0o644, blob.id
+        )
+        tree.add(b"12", stat.S_IFDIR, subtree.id)
+        tree.add(b"ab", stat.S_IFDIR, subtree.id)
+        self.store.add_object(tree)
+
+        notes_tree = NotesTree(tree, self.store)
+        self.assertEqual(1, notes_tree._fanout_level)
+
+        # Test level 2 fanout (2-char dirs containing 2-char dirs)
+        tree = Tree()
+        subtree1 = Tree()
+        subtree2 = Tree()
+        self.store.add_object(subtree2)
+        subtree2.add(
+            b"567890abcdef1234567890abcdef12345678", stat.S_IFREG | 0o644, blob.id
+        )
+        subtree1.add(b"34", stat.S_IFDIR, subtree2.id)
+        self.store.add_object(subtree1)
+        tree.add(b"12", stat.S_IFDIR, subtree1.id)
+        self.store.add_object(tree)
+
+        notes_tree = NotesTree(tree, self.store)
+        self.assertEqual(2, notes_tree._fanout_level)
+
+    def test_automatic_fanout_reorganization(self):
+        """Test that tree automatically reorganizes when crossing fanout thresholds."""
+        notes_tree = NotesTree(self.tree, self.store)
+
+        # Add notes until we cross the fanout threshold
+        # We need to add enough notes to trigger fanout (256+)
+        for i in range(260):
+            # Generate unique SHA for each note
+            sha = f"{i:040x}".encode("ascii")
+            note_content = f"Note {i}".encode("ascii")
+            new_tree = notes_tree.set_note(sha, note_content)
+            notes_tree = NotesTree(new_tree, self.store)
+
+        # Should now have fanout level 1
+        self.assertEqual(1, notes_tree._fanout_level)
+
+        # Verify all notes are still accessible
+        for i in range(260):
+            sha = f"{i:040x}".encode("ascii")
+            note = notes_tree.get_note(sha)
+            self.assertEqual(f"Note {i}".encode("ascii"), note)
+
+
+class TestNotes(TestCase):
+    """Test Notes high-level interface."""
+
+    def setUp(self):
+        self.store = MemoryObjectStore()
+        self.refs = DictRefsContainer({})
+
+    def test_get_notes_ref_default(self):
+        """Test getting default notes ref."""
+        notes = Notes(self.store, self.refs)
+        ref = notes.get_notes_ref()
+        self.assertEqual(DEFAULT_NOTES_REF, ref)
+
+    def test_get_notes_ref_custom(self):
+        """Test getting custom notes ref."""
+        notes = Notes(self.store, self.refs)
+        ref = notes.get_notes_ref(b"refs/notes/custom")
+        self.assertEqual(b"refs/notes/custom", ref)
+
+    def test_get_note_no_ref(self):
+        """Test getting note when ref doesn't exist."""
+        notes = Notes(self.store, self.refs)
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+        self.assertIsNone(notes.get_note(sha))
+
+    def test_set_and_get_note(self):
+        """Test setting and getting a note through Notes interface."""
+        notes = Notes(self.store, self.refs)
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+        note_content = b"Test note content"
+
+        # Set note
+        commit_sha = notes.set_note(sha, note_content)
+        self.assertIsInstance(commit_sha, bytes)
+        self.assertIn(commit_sha, self.store)
+
+        # Verify commit
+        commit = self.store[commit_sha]
+        self.assertIsInstance(commit, Commit)
+        self.assertEqual(b"Notes added by 'git notes add'", commit.message)
+
+        # Get note
+        retrieved_note = notes.get_note(sha)
+        self.assertEqual(note_content, retrieved_note)
+
+    def test_remove_note(self):
+        """Test removing a note through Notes interface."""
+        notes = Notes(self.store, self.refs)
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+        note_content = b"Test note content"
+
+        # First set a note
+        notes.set_note(sha, note_content)
+
+        # Then remove it
+        commit_sha = notes.remove_note(sha)
+        self.assertIsNotNone(commit_sha)
+
+        # Verify it's gone
+        self.assertIsNone(notes.get_note(sha))
+
+    def test_list_notes(self):
+        """Test listing notes through Notes interface."""
+        notes = Notes(self.store, self.refs)
+
+        # Add multiple notes
+        sha1 = b"1234567890abcdef1234567890abcdef12345678"
+        sha2 = b"abcdef1234567890abcdef1234567890abcdef12"
+
+        notes.set_note(sha1, b"Note 1")
+        notes.set_note(sha2, b"Note 2")
+
+        # List notes
+        notes_list = notes.list_notes()
+        self.assertEqual(2, len(notes_list))
+
+        # Sort for consistent comparison
+        notes_list.sort(key=lambda x: x[0])
+        self.assertEqual(sha1, notes_list[0][0])
+        self.assertEqual(b"Note 1", notes_list[0][1])
+        self.assertEqual(sha2, notes_list[1][0])
+        self.assertEqual(b"Note 2", notes_list[1][1])
+
+    def test_custom_commit_info(self):
+        """Test setting note with custom commit info."""
+        notes = Notes(self.store, self.refs)
+        sha = b"1234567890abcdef1234567890abcdef12345678"
+
+        commit_sha = notes.set_note(
+            sha,
+            b"Test note",
+            author=b"Test Author <test@example.com>",
+            committer=b"Test Committer <committer@example.com>",
+            message=b"Custom commit message",
+        )
+
+        commit = self.store[commit_sha]
+        self.assertEqual(b"Test Author <test@example.com>", commit.author)
+        self.assertEqual(b"Test Committer <committer@example.com>", commit.committer)
+        self.assertEqual(b"Custom commit message", commit.message)

+ 174 - 0
tests/test_porcelain_notes.py

@@ -0,0 +1,174 @@
+# test_porcelain_notes.py -- Tests for porcelain notes functions
+# Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Tests for porcelain notes functions."""
+
+import os
+import tempfile
+from unittest import TestCase
+
+from dulwich import porcelain
+from dulwich.repo import Repo
+
+
+class TestPorcelainNotes(TestCase):
+    """Test porcelain notes functions."""
+
+    def setUp(self):
+        self.test_dir = tempfile.mkdtemp()
+        self.repo = Repo.init(self.test_dir)
+        self.addCleanup(self.cleanup)
+
+        # Create a test commit to annotate using porcelain
+        with open(os.path.join(self.test_dir, "test.txt"), "wb") as f:
+            f.write(b"Test content")
+
+        porcelain.add(self.test_dir, ["test.txt"])
+        self.test_commit_id = porcelain.commit(
+            self.test_dir,
+            message=b"Test commit",
+            author=b"Test User <test@example.com>",
+            committer=b"Test User <test@example.com>",
+        )
+
+    def cleanup(self):
+        import shutil
+
+        shutil.rmtree(self.test_dir)
+
+    def test_notes_add_and_show(self):
+        """Test adding and showing a note."""
+        # Add a note
+        note_commit = porcelain.notes_add(
+            self.test_dir, self.test_commit_id, "This is a test note"
+        )
+        self.assertIsNotNone(note_commit)
+
+        # Show the note
+        note = porcelain.notes_show(self.test_dir, self.test_commit_id)
+        self.assertEqual(b"This is a test note", note)
+
+    def test_notes_add_bytes(self):
+        """Test adding a note with bytes."""
+        # Add a note with bytes
+        note_commit = porcelain.notes_add(
+            self.test_dir, self.test_commit_id, b"This is a byte note"
+        )
+        self.assertIsNotNone(note_commit)
+
+        # Show the note
+        note = porcelain.notes_show(self.test_dir, self.test_commit_id)
+        self.assertEqual(b"This is a byte note", note)
+
+    def test_notes_show_nonexistent(self):
+        """Test showing a note that doesn't exist."""
+        note = porcelain.notes_show(self.test_dir, self.test_commit_id)
+        self.assertIsNone(note)
+
+    def test_notes_remove(self):
+        """Test removing a note."""
+        # First add a note
+        porcelain.notes_add(self.test_dir, self.test_commit_id, "Test note to remove")
+
+        # Then remove it
+        result = porcelain.notes_remove(self.test_dir, self.test_commit_id)
+        self.assertIsNotNone(result)
+
+        # Verify it's gone
+        note = porcelain.notes_show(self.test_dir, self.test_commit_id)
+        self.assertIsNone(note)
+
+    def test_notes_remove_nonexistent(self):
+        """Test removing a note that doesn't exist."""
+        result = porcelain.notes_remove(self.test_dir, self.test_commit_id)
+        self.assertIsNone(result)
+
+    def test_notes_list_empty(self):
+        """Test listing notes when there are none."""
+        notes = porcelain.notes_list(self.test_dir)
+        self.assertEqual([], notes)
+
+    def test_notes_list(self):
+        """Test listing notes."""
+        # Create another commit to test multiple notes
+        with open(os.path.join(self.test_dir, "test2.txt"), "wb") as f:
+            f.write(b"Test content 2")
+
+        porcelain.add(self.test_dir, ["test2.txt"])
+        commit2_id = porcelain.commit(
+            self.test_dir,
+            message=b"Test commit 2",
+            author=b"Test User <test@example.com>",
+            committer=b"Test User <test@example.com>",
+        )
+
+        porcelain.notes_add(self.test_dir, self.test_commit_id, "Note 1")
+        porcelain.notes_add(self.test_dir, commit2_id, "Note 2")
+
+        # List notes
+        notes = porcelain.notes_list(self.test_dir)
+        self.assertEqual(2, len(notes))
+
+        # Check content
+        notes_dict = dict(notes)
+        self.assertEqual(b"Note 1", notes_dict[self.test_commit_id])
+        self.assertEqual(b"Note 2", notes_dict[commit2_id])
+
+    def test_notes_custom_ref(self):
+        """Test using a custom notes ref."""
+        # Add note to custom ref
+        porcelain.notes_add(
+            self.test_dir, self.test_commit_id, "Custom ref note", ref="custom"
+        )
+
+        # Show from default ref (should not exist)
+        note = porcelain.notes_show(self.test_dir, self.test_commit_id)
+        self.assertIsNone(note)
+
+        # Show from custom ref
+        note = porcelain.notes_show(self.test_dir, self.test_commit_id, ref="custom")
+        self.assertEqual(b"Custom ref note", note)
+
+    def test_notes_update(self):
+        """Test updating an existing note."""
+        # Add initial note
+        porcelain.notes_add(self.test_dir, self.test_commit_id, "Initial note")
+
+        # Update the note
+        porcelain.notes_add(self.test_dir, self.test_commit_id, "Updated note")
+
+        # Verify update
+        note = porcelain.notes_show(self.test_dir, self.test_commit_id)
+        self.assertEqual(b"Updated note", note)
+
+    def test_notes_custom_author_committer(self):
+        """Test adding note with custom author and committer."""
+        note_commit_id = porcelain.notes_add(
+            self.test_dir,
+            self.test_commit_id,
+            "Test note",
+            author=b"Custom Author <author@example.com>",
+            committer=b"Custom Committer <committer@example.com>",
+        )
+
+        # Check the commit
+        commit = self.repo[note_commit_id]
+        self.assertEqual(b"Custom Author <author@example.com>", commit.author)
+        self.assertEqual(b"Custom Committer <committer@example.com>", commit.committer)