Bläddra i källkod

Add bisect (#1668)

Fixes #1631
Jelmer Vernooij 1 månad sedan
förälder
incheckning
79715aeb10
7 ändrade filer med 1211 tillägg och 19 borttagningar
  1. 5 0
      NEWS
  2. 428 0
      dulwich/bisect.py
  3. 129 0
      dulwich/cli.py
  4. 192 0
      dulwich/porcelain.py
  5. 1 0
      tests/__init__.py
  6. 255 0
      tests/test_bisect.py
  7. 201 19
      tests/test_porcelain.py

+ 5 - 0
NEWS

@@ -24,6 +24,11 @@
    normalization, and filter specifications for files.
    (Jelmer Vernooij, #1211)
 
+ * Add git bisect functionality including core bisect logic, porcelain
+   commands (bisect_start, bisect_bad, bisect_good, bisect_skip,
+   bisect_reset, bisect_log, bisect_replay), and CLI support.
+   (Jelmer Vernooij, #1631)
+
 0.23.1	2025-06-30
 
  * Support ``untracked_files="normal"`` argument to ``porcelain.status``,

+ 428 - 0
dulwich/bisect.py

@@ -0,0 +1,428 @@
+# bisect.py -- Git bisect algorithm implementation
+# Copyright (C) 2025 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 bisect implementation."""
+
+import os
+from typing import Optional
+
+from dulwich.object_store import peel_sha
+from dulwich.objects import Commit
+from dulwich.repo import Repo
+
+
+class BisectState:
+    """Manages the state of a bisect session."""
+
+    def __init__(self, repo: Repo) -> None:
+        self.repo = repo
+        self._bisect_dir = os.path.join(repo.controldir(), "BISECT_START")
+
+    @property
+    def is_active(self) -> bool:
+        """Check if a bisect session is active."""
+        return os.path.exists(self._bisect_dir)
+
+    def start(
+        self,
+        bad: Optional[bytes] = None,
+        good: Optional[list[bytes]] = None,
+        paths: Optional[list[str]] = None,
+        no_checkout: bool = False,
+        term_bad: str = "bad",
+        term_good: str = "good",
+    ) -> None:
+        """Start a new bisect session.
+
+        Args:
+            bad: The bad commit SHA (defaults to HEAD)
+            good: List of good commit SHAs
+            paths: Optional paths to limit bisect to
+            no_checkout: If True, don't checkout commits during bisect
+            term_bad: Term to use for bad commits (default: "bad")
+            term_good: Term to use for good commits (default: "good")
+        """
+        if self.is_active:
+            raise ValueError("Bisect session already in progress")
+
+        # Create bisect state directory
+        bisect_refs_dir = os.path.join(self.repo.controldir(), "refs", "bisect")
+        os.makedirs(bisect_refs_dir, exist_ok=True)
+
+        # Store current branch/commit
+        try:
+            ref_chain, sha = self.repo.refs.follow(b"HEAD")
+            if sha is None:
+                # No HEAD exists
+                raise ValueError("Cannot start bisect: repository has no HEAD")
+            # Use the first non-HEAD ref in the chain, or the SHA itself
+            if len(ref_chain) > 1:
+                current_branch = ref_chain[1]  # The actual branch ref
+            else:
+                current_branch = sha  # Detached HEAD
+        except KeyError:
+            # Detached HEAD
+            try:
+                current_branch = self.repo.head()
+            except KeyError:
+                # No HEAD exists - can't start bisect
+                raise ValueError("Cannot start bisect: repository has no HEAD")
+
+        # Write BISECT_START
+        with open(self._bisect_dir, "wb") as f:
+            f.write(current_branch)
+
+        # Write BISECT_TERMS
+        terms_file = os.path.join(self.repo.controldir(), "BISECT_TERMS")
+        with open(terms_file, "w") as f:
+            f.write(f"{term_bad}\n{term_good}\n")
+
+        # Write BISECT_NAMES (paths)
+        names_file = os.path.join(self.repo.controldir(), "BISECT_NAMES")
+        with open(names_file, "w") as f:
+            if paths:
+                f.write("\n".join(paths) + "\n")
+            else:
+                f.write("\n")
+
+        # Initialize BISECT_LOG
+        log_file = os.path.join(self.repo.controldir(), "BISECT_LOG")
+        with open(log_file, "w") as f:
+            f.write("git bisect start\n")
+            f.write("# status: waiting for both good and bad commits\n")
+
+        # Mark bad commit if provided
+        if bad is not None:
+            self.mark_bad(bad)
+
+        # Mark good commits if provided
+        if good:
+            for g in good:
+                self.mark_good(g)
+
+    def mark_bad(self, rev: Optional[bytes] = None) -> Optional[bytes]:
+        """Mark a commit as bad.
+
+        Args:
+            rev: Commit SHA to mark as bad (defaults to HEAD)
+
+        Returns:
+            The SHA of the next commit to test, or None if bisect is complete
+        """
+        if not self.is_active:
+            raise ValueError("No bisect session in progress")
+
+        if rev is None:
+            rev = self.repo.head()
+        else:
+            rev = peel_sha(self.repo.object_store, rev)[1].id
+
+        # Write bad ref
+        bad_ref_path = os.path.join(self.repo.controldir(), "refs", "bisect", "bad")
+        with open(bad_ref_path, "wb") as f:
+            f.write(rev + b"\n")
+
+        # Update log
+        self._append_to_log(
+            f"# bad: [{rev.decode('ascii')}] {self._get_commit_subject(rev)}"
+        )
+        self._append_to_log(f"git bisect bad {rev.decode('ascii')}")
+
+        return self._find_next_commit()
+
+    def mark_good(self, rev: Optional[bytes] = None) -> Optional[bytes]:
+        """Mark a commit as good.
+
+        Args:
+            rev: Commit SHA to mark as good (defaults to HEAD)
+
+        Returns:
+            The SHA of the next commit to test, or None if bisect is complete
+        """
+        if not self.is_active:
+            raise ValueError("No bisect session in progress")
+
+        if rev is None:
+            rev = self.repo.head()
+        else:
+            rev = peel_sha(self.repo.object_store, rev)[1].id
+
+        # Write good ref
+        good_ref_path = os.path.join(
+            self.repo.controldir(), "refs", "bisect", f"good-{rev.decode('ascii')}"
+        )
+        with open(good_ref_path, "wb") as f:
+            f.write(rev + b"\n")
+
+        # Update log
+        self._append_to_log(
+            f"# good: [{rev.decode('ascii')}] {self._get_commit_subject(rev)}"
+        )
+        self._append_to_log(f"git bisect good {rev.decode('ascii')}")
+
+        return self._find_next_commit()
+
+    def skip(self, revs: Optional[list[bytes]] = None) -> Optional[bytes]:
+        """Skip one or more commits.
+
+        Args:
+            revs: List of commits to skip (defaults to [HEAD])
+
+        Returns:
+            The SHA of the next commit to test, or None if bisect is complete
+        """
+        if not self.is_active:
+            raise ValueError("No bisect session in progress")
+
+        if revs is None:
+            revs = [self.repo.head()]
+
+        for rev in revs:
+            rev = peel_sha(self.repo.object_store, rev)[1].id
+            skip_ref_path = os.path.join(
+                self.repo.controldir(), "refs", "bisect", f"skip-{rev.decode('ascii')}"
+            )
+            with open(skip_ref_path, "wb") as f:
+                f.write(rev + b"\n")
+
+            self._append_to_log(f"git bisect skip {rev.decode('ascii')}")
+
+        return self._find_next_commit()
+
+    def reset(self, commit: Optional[bytes] = None) -> None:
+        """Reset bisect state and return to original branch/commit.
+
+        Args:
+            commit: Optional commit to reset to (defaults to original branch/commit)
+        """
+        if not self.is_active:
+            raise ValueError("No bisect session in progress")
+
+        # Read original branch/commit
+        with open(self._bisect_dir, "rb") as f:
+            original = f.read().strip()
+
+        # Clean up bisect files
+        for filename in [
+            "BISECT_START",
+            "BISECT_TERMS",
+            "BISECT_NAMES",
+            "BISECT_LOG",
+            "BISECT_EXPECTED_REV",
+            "BISECT_ANCESTORS_OK",
+        ]:
+            filepath = os.path.join(self.repo.controldir(), filename)
+            if os.path.exists(filepath):
+                os.remove(filepath)
+
+        # Clean up refs/bisect directory
+        bisect_refs_dir = os.path.join(self.repo.controldir(), "refs", "bisect")
+        if os.path.exists(bisect_refs_dir):
+            for filename in os.listdir(bisect_refs_dir):
+                os.remove(os.path.join(bisect_refs_dir, filename))
+            os.rmdir(bisect_refs_dir)
+
+        # Reset to target commit/branch
+        if commit is None:
+            if original.startswith(b"refs/"):
+                # It's a branch reference - need to create a symbolic ref
+                self.repo.refs.set_symbolic_ref(b"HEAD", original)
+            else:
+                # It's a commit SHA
+                self.repo.refs[b"HEAD"] = original
+        else:
+            commit = peel_sha(self.repo.object_store, commit)[1].id
+            self.repo.refs[b"HEAD"] = commit
+
+    def get_log(self) -> str:
+        """Get the bisect log."""
+        if not self.is_active:
+            raise ValueError("No bisect session in progress")
+
+        log_file = os.path.join(self.repo.controldir(), "BISECT_LOG")
+        with open(log_file) as f:
+            return f.read()
+
+    def replay(self, log_content: str) -> None:
+        """Replay a bisect log.
+
+        Args:
+            log_content: The bisect log content to replay
+        """
+        # Parse and execute commands from log
+        for line in log_content.splitlines():
+            line = line.strip()
+            if line.startswith("#") or not line:
+                continue
+
+            parts = line.split()
+            if len(parts) < 3 or parts[0] != "git" or parts[1] != "bisect":
+                continue
+
+            cmd = parts[2]
+            args = parts[3:] if len(parts) > 3 else []
+
+            if cmd == "start":
+                self.start()
+            elif cmd == "bad":
+                rev = args[0].encode("ascii") if args else None
+                self.mark_bad(rev)
+            elif cmd == "good":
+                rev = args[0].encode("ascii") if args else None
+                self.mark_good(rev)
+            elif cmd == "skip":
+                revs = [arg.encode("ascii") for arg in args] if args else None
+                self.skip(revs)
+
+    def _find_next_commit(self) -> Optional[bytes]:
+        """Find the next commit to test using binary search.
+
+        Returns:
+            The SHA of the next commit to test, or None if bisect is complete
+        """
+        # Get bad commit
+        bad_ref_path = os.path.join(self.repo.controldir(), "refs", "bisect", "bad")
+        if not os.path.exists(bad_ref_path):
+            self._append_to_log("# status: waiting for both good and bad commits")
+            return None
+
+        with open(bad_ref_path, "rb") as f:
+            bad_sha = f.read().strip()
+
+        # Get all good commits
+        good_shas = []
+        bisect_refs_dir = os.path.join(self.repo.controldir(), "refs", "bisect")
+        for filename in os.listdir(bisect_refs_dir):
+            if filename.startswith("good-"):
+                with open(os.path.join(bisect_refs_dir, filename), "rb") as f:
+                    good_shas.append(f.read().strip())
+
+        if not good_shas:
+            self._append_to_log(
+                "# status: waiting for good commit(s), bad commit known"
+            )
+            return None
+
+        # Get skip commits
+        skip_shas = set()
+        for filename in os.listdir(bisect_refs_dir):
+            if filename.startswith("skip-"):
+                with open(os.path.join(bisect_refs_dir, filename), "rb") as f:
+                    skip_shas.add(f.read().strip())
+
+        # Find commits between good and bad
+        candidates = self._find_bisect_candidates(bad_sha, good_shas, skip_shas)
+
+        if not candidates:
+            # Bisect complete - the first bad commit is found
+            self._append_to_log(
+                f"# first bad commit: [{bad_sha.decode('ascii')}] "
+                f"{self._get_commit_subject(bad_sha)}"
+            )
+            return None
+
+        # Find midpoint
+        mid_idx = len(candidates) // 2
+        next_commit = candidates[mid_idx]
+
+        # Write BISECT_EXPECTED_REV
+        expected_file = os.path.join(self.repo.controldir(), "BISECT_EXPECTED_REV")
+        with open(expected_file, "wb") as f:
+            f.write(next_commit + b"\n")
+
+        # Update status in log
+        steps_remaining = self._estimate_steps(len(candidates))
+        self._append_to_log(
+            f"Bisecting: {len(candidates) - 1} revisions left to test after this "
+            f"(roughly {steps_remaining} steps)"
+        )
+        self._append_to_log(
+            f"[{next_commit.decode('ascii')}] {self._get_commit_subject(next_commit)}"
+        )
+
+        return next_commit
+
+    def _find_bisect_candidates(
+        self, bad_sha: bytes, good_shas: list[bytes], skip_shas: set
+    ) -> list[bytes]:
+        """Find all commits between good and bad commits.
+
+        Args:
+            bad_sha: The bad commit SHA
+            good_shas: List of good commit SHAs
+            skip_shas: Set of commits to skip
+
+        Returns:
+            List of candidate commit SHAs in topological order
+        """
+        # Use git's graph walking to find commits
+        # This is a simplified version - a full implementation would need
+        # to handle merge commits properly
+        candidates = []
+        visited = set(good_shas)
+        queue = [bad_sha]
+
+        while queue:
+            sha = queue.pop(0)
+            if sha in visited or sha in skip_shas:
+                continue
+
+            visited.add(sha)
+            commit = self.repo.object_store[sha]
+
+            # Don't include good commits
+            if sha not in good_shas:
+                candidates.append(sha)
+
+            # Add parents to queue
+            if isinstance(commit, Commit):
+                for parent in commit.parents:
+                    if parent not in visited:
+                        queue.append(parent)
+
+        # Remove the bad commit itself
+        if bad_sha in candidates:
+            candidates.remove(bad_sha)
+
+        return candidates
+
+    def _get_commit_subject(self, sha: bytes) -> str:
+        """Get the subject line of a commit message."""
+        obj = self.repo.object_store[sha]
+        if isinstance(obj, Commit):
+            message = obj.message.decode("utf-8", errors="replace")
+            return message.split("\n")[0]
+        return ""
+
+    def _append_to_log(self, line: str) -> None:
+        """Append a line to the bisect log."""
+        log_file = os.path.join(self.repo.controldir(), "BISECT_LOG")
+        with open(log_file, "a") as f:
+            f.write(line + "\n")
+
+    def _estimate_steps(self, num_candidates: int) -> int:
+        """Estimate the number of steps remaining in bisect."""
+        if num_candidates <= 1:
+            return 0
+        steps = 0
+        while num_candidates > 1:
+            num_candidates //= 2
+            steps += 1
+        return steps

+ 129 - 0
dulwich/cli.py

@@ -1078,6 +1078,134 @@ class cmd_stash_pop(Command):
         print("Restored working directory and index state")
 
 
+class cmd_bisect(SuperCommand):
+    """Git bisect command implementation."""
+
+    subcommands: ClassVar[dict[str, type[Command]]] = {}
+
+    def run(self, args):
+        parser = argparse.ArgumentParser(prog="dulwich bisect")
+        subparsers = parser.add_subparsers(dest="subcommand", help="bisect subcommands")
+
+        # bisect start
+        start_parser = subparsers.add_parser("start", help="Start a new bisect session")
+        start_parser.add_argument("bad", nargs="?", help="Bad commit")
+        start_parser.add_argument("good", nargs="*", help="Good commit(s)")
+        start_parser.add_argument(
+            "--no-checkout",
+            action="store_true",
+            help="Don't checkout commits during bisect",
+        )
+        start_parser.add_argument(
+            "--term-bad", default="bad", help="Term to use for bad commits"
+        )
+        start_parser.add_argument(
+            "--term-good", default="good", help="Term to use for good commits"
+        )
+        start_parser.add_argument(
+            "--", dest="paths", nargs="*", help="Paths to limit bisect to"
+        )
+
+        # bisect bad
+        bad_parser = subparsers.add_parser("bad", help="Mark a commit as bad")
+        bad_parser.add_argument("rev", nargs="?", help="Commit to mark as bad")
+
+        # bisect good
+        good_parser = subparsers.add_parser("good", help="Mark a commit as good")
+        good_parser.add_argument("rev", nargs="?", help="Commit to mark as good")
+
+        # bisect skip
+        skip_parser = subparsers.add_parser("skip", help="Skip commits")
+        skip_parser.add_argument("revs", nargs="*", help="Commits to skip")
+
+        # bisect reset
+        reset_parser = subparsers.add_parser("reset", help="Reset bisect state")
+        reset_parser.add_argument("commit", nargs="?", help="Commit to reset to")
+
+        # bisect log
+        subparsers.add_parser("log", help="Show bisect log")
+
+        # bisect replay
+        replay_parser = subparsers.add_parser("replay", help="Replay bisect log")
+        replay_parser.add_argument("logfile", help="Log file to replay")
+
+        # bisect help
+        subparsers.add_parser("help", help="Show help")
+
+        parsed_args = parser.parse_args(args)
+
+        if not parsed_args.subcommand:
+            parser.print_help()
+            return 1
+
+        try:
+            if parsed_args.subcommand == "start":
+                next_sha = porcelain.bisect_start(
+                    bad=parsed_args.bad,
+                    good=parsed_args.good if parsed_args.good else None,
+                    paths=parsed_args.paths,
+                    no_checkout=parsed_args.no_checkout,
+                    term_bad=parsed_args.term_bad,
+                    term_good=parsed_args.term_good,
+                )
+                if next_sha:
+                    print(f"Bisecting: checking out '{next_sha.decode('ascii')}'")
+
+            elif parsed_args.subcommand == "bad":
+                next_sha = porcelain.bisect_bad(rev=parsed_args.rev)
+                if next_sha:
+                    print(f"Bisecting: checking out '{next_sha.decode('ascii')}'")
+                else:
+                    # Bisect complete - find the first bad commit
+                    with porcelain.open_repo_closing(".") as r:
+                        bad_ref = os.path.join(r.controldir(), "refs", "bisect", "bad")
+                        with open(bad_ref, "rb") as f:
+                            bad_sha = f.read().strip()
+                        commit = r.object_store[bad_sha]
+                        message = commit.message.decode(
+                            "utf-8", errors="replace"
+                        ).split("\n")[0]
+                        print(f"{bad_sha.decode('ascii')} is the first bad commit")
+                        print(f"commit {bad_sha.decode('ascii')}")
+                        print(f"    {message}")
+
+            elif parsed_args.subcommand == "good":
+                next_sha = porcelain.bisect_good(rev=parsed_args.rev)
+                if next_sha:
+                    print(f"Bisecting: checking out '{next_sha.decode('ascii')}'")
+
+            elif parsed_args.subcommand == "skip":
+                next_sha = porcelain.bisect_skip(
+                    revs=parsed_args.revs if parsed_args.revs else None
+                )
+                if next_sha:
+                    print(f"Bisecting: checking out '{next_sha.decode('ascii')}'")
+
+            elif parsed_args.subcommand == "reset":
+                porcelain.bisect_reset(commit=parsed_args.commit)
+                print("Bisect reset")
+
+            elif parsed_args.subcommand == "log":
+                log = porcelain.bisect_log()
+                print(log, end="")
+
+            elif parsed_args.subcommand == "replay":
+                porcelain.bisect_replay(log_file=parsed_args.logfile)
+                print(f"Replayed bisect log from {parsed_args.logfile}")
+
+            elif parsed_args.subcommand == "help":
+                parser.print_help()
+
+        except porcelain.Error as e:
+            print(f"Error: {e}", file=sys.stderr)
+            return 1
+        except ValueError as e:
+            print(f"Error: {e}", file=sys.stderr)
+            return 1
+
+        return 0
+
+
 class cmd_stash(SuperCommand):
     subcommands: ClassVar[dict[str, type[Command]]] = {
         "list": cmd_stash_list,
@@ -1801,6 +1929,7 @@ commands = {
     "add": cmd_add,
     "annotate": cmd_annotate,
     "archive": cmd_archive,
+    "bisect": cmd_bisect,
     "blame": cmd_blame,
     "branch": cmd_branch,
     "check-ignore": cmd_check_ignore,

+ 192 - 0
dulwich/porcelain.py

@@ -24,6 +24,7 @@
 Currently implemented:
  * archive
  * add
+ * bisect{_start,_bad,_good,_skip,_reset,_log,_replay}
  * branch{_create,_delete,_list}
  * check_ignore
  * checkout
@@ -90,6 +91,7 @@ from typing import Optional, Union
 
 from . import replace_me
 from .archive import tar_stream
+from .bisect import BisectState
 from .client import get_transport_and_path
 from .config import Config, ConfigFile, StackedConfig, read_submodules
 from .diff_tree import (
@@ -4310,3 +4312,193 @@ def filter_branch(
             )
         except ValueError as e:
             raise Error(str(e)) from e
+
+
+def bisect_start(
+    repo=".",
+    bad=None,
+    good=None,
+    paths=None,
+    no_checkout=False,
+    term_bad="bad",
+    term_good="good",
+):
+    """Start a new bisect session.
+
+    Args:
+        repo: Path to repository or a Repo object
+        bad: The bad commit (defaults to HEAD)
+        good: List of good commits or a single good commit
+        paths: Optional paths to limit bisect to
+        no_checkout: If True, don't checkout commits during bisect
+        term_bad: Term to use for bad commits (default: "bad")
+        term_good: Term to use for good commits (default: "good")
+    """
+    with open_repo_closing(repo) as r:
+        state = BisectState(r)
+
+        # Convert single good commit to list
+        if good is not None and not isinstance(good, list):
+            good = [good]
+
+        # Parse commits
+        bad_sha = parse_commit(r, bad).id if bad else None
+        good_shas = [parse_commit(r, g).id for g in good] if good else None
+
+        state.start(bad_sha, good_shas, paths, no_checkout, term_bad, term_good)
+
+        # Return the next commit to test if we have both good and bad
+        if bad_sha and good_shas:
+            next_sha = state._find_next_commit()
+            if next_sha and not no_checkout:
+                # Checkout the next commit
+                old_tree = r[r.head()].tree if r.head() else None
+                r.refs[b"HEAD"] = next_sha
+                commit = r[next_sha]
+                update_working_tree(r, old_tree, commit.tree)
+            return next_sha
+
+
+def bisect_bad(repo=".", rev=None):
+    """Mark a commit as bad.
+
+    Args:
+        repo: Path to repository or a Repo object
+        rev: Commit to mark as bad (defaults to HEAD)
+
+    Returns:
+        The SHA of the next commit to test, or None if bisect is complete
+    """
+    with open_repo_closing(repo) as r:
+        state = BisectState(r)
+        rev_sha = parse_commit(r, rev).id if rev else None
+        next_sha = state.mark_bad(rev_sha)
+
+        if next_sha:
+            # Checkout the next commit
+            old_tree = r[r.head()].tree if r.head() else None
+            r.refs[b"HEAD"] = next_sha
+            commit = r[next_sha]
+            update_working_tree(r, old_tree, commit.tree)
+
+        return next_sha
+
+
+def bisect_good(repo=".", rev=None):
+    """Mark a commit as good.
+
+    Args:
+        repo: Path to repository or a Repo object
+        rev: Commit to mark as good (defaults to HEAD)
+
+    Returns:
+        The SHA of the next commit to test, or None if bisect is complete
+    """
+    with open_repo_closing(repo) as r:
+        state = BisectState(r)
+        rev_sha = parse_commit(r, rev).id if rev else None
+        next_sha = state.mark_good(rev_sha)
+
+        if next_sha:
+            # Checkout the next commit
+            old_tree = r[r.head()].tree if r.head() else None
+            r.refs[b"HEAD"] = next_sha
+            commit = r[next_sha]
+            update_working_tree(r, old_tree, commit.tree)
+
+        return next_sha
+
+
+def bisect_skip(repo=".", revs=None):
+    """Skip one or more commits.
+
+    Args:
+        repo: Path to repository or a Repo object
+        revs: List of commits to skip (defaults to [HEAD])
+
+    Returns:
+        The SHA of the next commit to test, or None if bisect is complete
+    """
+    with open_repo_closing(repo) as r:
+        state = BisectState(r)
+
+        if revs is None:
+            rev_shas = None
+        else:
+            # Convert single rev to list
+            if not isinstance(revs, list):
+                revs = [revs]
+            rev_shas = [parse_commit(r, rev).id for rev in revs]
+
+        next_sha = state.skip(rev_shas)
+
+        if next_sha:
+            # Checkout the next commit
+            old_tree = r[r.head()].tree if r.head() else None
+            r.refs[b"HEAD"] = next_sha
+            commit = r[next_sha]
+            update_working_tree(r, old_tree, commit.tree)
+
+        return next_sha
+
+
+def bisect_reset(repo=".", commit=None):
+    """Reset bisect state and return to original branch/commit.
+
+    Args:
+        repo: Path to repository or a Repo object
+        commit: Optional commit to reset to (defaults to original branch/commit)
+    """
+    with open_repo_closing(repo) as r:
+        state = BisectState(r)
+        # Get old tree before reset
+        try:
+            old_tree = r[r.head()].tree
+        except KeyError:
+            old_tree = None
+
+        commit_sha = parse_commit(r, commit).id if commit else None
+        state.reset(commit_sha)
+
+        # Update working tree to new HEAD
+        try:
+            new_head = r.head()
+            if new_head:
+                new_commit = r[new_head]
+                update_working_tree(r, old_tree, new_commit.tree)
+        except KeyError:
+            # No HEAD after reset
+            pass
+
+
+def bisect_log(repo="."):
+    """Get the bisect log.
+
+    Args:
+        repo: Path to repository or a Repo object
+
+    Returns:
+        The bisect log as a string
+    """
+    with open_repo_closing(repo) as r:
+        state = BisectState(r)
+        return state.get_log()
+
+
+def bisect_replay(repo, log_file):
+    """Replay a bisect log.
+
+    Args:
+        repo: Path to repository or a Repo object
+        log_file: Path to the log file or file-like object
+    """
+    with open_repo_closing(repo) as r:
+        state = BisectState(r)
+
+        if isinstance(log_file, str):
+            with open(log_file) as f:
+                log_content = f.read()
+        else:
+            log_content = log_file.read()
+
+        state.replay(log_content)

+ 1 - 0
tests/__init__.py

@@ -118,6 +118,7 @@ def self_test_suite():
         "annotate",
         "archive",
         "attrs",
+        "bisect",
         "blackbox",
         "bundle",
         "cli",

+ 255 - 0
tests/test_bisect.py

@@ -0,0 +1,255 @@
+# test_bisect.py -- Tests for bisect functionality
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# 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 bisect functionality."""
+
+import os
+import shutil
+import tempfile
+
+from dulwich import porcelain
+from dulwich.bisect import BisectState
+from dulwich.objects import Tree
+from dulwich.tests.utils import make_commit
+
+from . import TestCase
+
+
+class BisectStateTests(TestCase):
+    """Tests for BisectState class."""
+
+    def setUp(self):
+        self.test_dir = tempfile.mkdtemp()
+        self.repo = porcelain.init(self.test_dir)
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir)
+
+    def test_is_active_false(self):
+        """Test is_active when no bisect session is active."""
+        state = BisectState(self.repo)
+        self.assertFalse(state.is_active)
+
+    def test_start_bisect(self):
+        """Test starting a bisect session."""
+        # Create at least one commit so HEAD exists
+        c1 = make_commit(id=b"1" * 40, message=b"initial commit")
+        self.repo.object_store.add_object(c1)
+        self.repo.refs[b"HEAD"] = c1.id
+        self.repo.refs[b"refs/heads/main"] = c1.id
+
+        state = BisectState(self.repo)
+        state.start()
+
+        self.assertTrue(state.is_active)
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
+        )
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_TERMS"))
+        )
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_NAMES"))
+        )
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_LOG"))
+        )
+
+    def test_start_bisect_no_head(self):
+        """Test starting a bisect session when repository has no HEAD."""
+        state = BisectState(self.repo)
+
+        with self.assertRaises(ValueError) as cm:
+            state.start()
+        self.assertIn("Cannot start bisect: repository has no HEAD", str(cm.exception))
+
+    def test_start_bisect_already_active(self):
+        """Test starting a bisect session when one is already active."""
+        # Create at least one commit so HEAD exists
+        c1 = make_commit(id=b"1" * 40, message=b"initial commit")
+        self.repo.object_store.add_object(c1)
+        self.repo.refs[b"HEAD"] = c1.id
+
+        state = BisectState(self.repo)
+        state.start()
+
+        with self.assertRaises(ValueError):
+            state.start()
+
+    def test_mark_bad_no_session(self):
+        """Test marking bad commit when no session is active."""
+        state = BisectState(self.repo)
+
+        with self.assertRaises(ValueError):
+            state.mark_bad()
+
+    def test_mark_good_no_session(self):
+        """Test marking good commit when no session is active."""
+        state = BisectState(self.repo)
+
+        with self.assertRaises(ValueError):
+            state.mark_good()
+
+    def test_reset_no_session(self):
+        """Test resetting when no session is active."""
+        state = BisectState(self.repo)
+
+        with self.assertRaises(ValueError):
+            state.reset()
+
+    def test_bisect_workflow(self):
+        """Test a complete bisect workflow."""
+        # Create some commits
+        c1 = make_commit(id=b"1" * 40, message=b"good commit 1")
+        c2 = make_commit(id=b"2" * 40, message=b"good commit 2", parents=[b"1" * 40])
+        c3 = make_commit(id=b"3" * 40, message=b"bad commit", parents=[b"2" * 40])
+        c4 = make_commit(id=b"4" * 40, message=b"bad commit 2", parents=[b"3" * 40])
+
+        # Add commits to object store
+        for commit in [c1, c2, c3, c4]:
+            self.repo.object_store.add_object(commit)
+
+        # Set HEAD to latest commit
+        self.repo.refs[b"HEAD"] = c4.id
+
+        # Start bisect
+        state = BisectState(self.repo)
+        state.start()
+
+        # Mark bad and good
+        state.mark_bad(c4.id)
+        state.mark_good(c1.id)
+
+        # Check that refs were created
+        self.assertTrue(
+            os.path.exists(
+                os.path.join(self.repo.controldir(), "refs", "bisect", "bad")
+            )
+        )
+        self.assertTrue(
+            os.path.exists(
+                os.path.join(
+                    self.repo.controldir(),
+                    "refs",
+                    "bisect",
+                    f"good-{c1.id.decode('ascii')}",
+                )
+            )
+        )
+
+        # Reset
+        state.reset()
+        self.assertFalse(state.is_active)
+
+
+class BisectPorcelainTests(TestCase):
+    """Tests for porcelain bisect functions."""
+
+    def setUp(self):
+        self.test_dir = tempfile.mkdtemp()
+        self.repo = porcelain.init(self.test_dir)
+
+        # Create tree objects
+        tree = Tree()
+        self.repo.object_store.add_object(tree)
+
+        # Create some commits with proper trees
+        self.c1 = make_commit(id=b"1" * 40, message=b"initial commit", tree=tree.id)
+        self.c2 = make_commit(
+            id=b"2" * 40, message=b"second commit", parents=[b"1" * 40], tree=tree.id
+        )
+        self.c3 = make_commit(
+            id=b"3" * 40, message=b"third commit", parents=[b"2" * 40], tree=tree.id
+        )
+        self.c4 = make_commit(
+            id=b"4" * 40, message=b"fourth commit", parents=[b"3" * 40], tree=tree.id
+        )
+
+        # Add commits to object store
+        for commit in [self.c1, self.c2, self.c3, self.c4]:
+            self.repo.object_store.add_object(commit)
+
+        # Set HEAD to latest commit
+        self.repo.refs[b"HEAD"] = self.c4.id
+        self.repo.refs[b"refs/heads/master"] = self.c4.id
+
+    def tearDown(self):
+        shutil.rmtree(self.test_dir)
+
+    def test_bisect_start(self):
+        """Test bisect_start porcelain function."""
+        porcelain.bisect_start(self.test_dir)
+
+        # Check that bisect state files exist
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
+        )
+
+    def test_bisect_bad_good(self):
+        """Test marking commits as bad and good."""
+        porcelain.bisect_start(self.test_dir)
+        porcelain.bisect_bad(self.test_dir, self.c4.id.decode("ascii"))
+        porcelain.bisect_good(self.test_dir, self.c1.id.decode("ascii"))
+
+        # Check that refs were created
+        self.assertTrue(
+            os.path.exists(
+                os.path.join(self.repo.controldir(), "refs", "bisect", "bad")
+            )
+        )
+        self.assertTrue(
+            os.path.exists(
+                os.path.join(
+                    self.repo.controldir(),
+                    "refs",
+                    "bisect",
+                    f"good-{self.c1.id.decode('ascii')}",
+                )
+            )
+        )
+
+    def test_bisect_log(self):
+        """Test getting bisect log."""
+        porcelain.bisect_start(self.test_dir)
+        porcelain.bisect_bad(self.test_dir, self.c4.id.decode("ascii"))
+        porcelain.bisect_good(self.test_dir, self.c1.id.decode("ascii"))
+
+        log = porcelain.bisect_log(self.test_dir)
+
+        self.assertIn("git bisect start", log)
+        self.assertIn("git bisect bad", log)
+        self.assertIn("git bisect good", log)
+
+    def test_bisect_reset(self):
+        """Test resetting bisect state."""
+        porcelain.bisect_start(self.test_dir)
+        porcelain.bisect_bad(self.test_dir)
+        porcelain.bisect_good(self.test_dir, self.c1.id.decode("ascii"))
+
+        porcelain.bisect_reset(self.test_dir)
+
+        # Check that bisect state files are removed
+        self.assertFalse(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
+        )
+        self.assertFalse(
+            os.path.exists(os.path.join(self.repo.controldir(), "refs", "bisect"))
+        )

+ 201 - 19
tests/test_porcelain.py

@@ -41,7 +41,7 @@ from dulwich.client import SendPackResult
 from dulwich.diff_tree import tree_changes
 from dulwich.errors import CommitError
 from dulwich.object_store import DEFAULT_TEMPFILE_GRACE_PERIOD
-from dulwich.objects import ZERO_SHA, Blob, Tag, Tree
+from dulwich.objects import ZERO_SHA, Blob, Commit, Tag, Tree
 from dulwich.porcelain import (
     CheckoutError,  # Hypothetical or real error class
     CountObjectsResult,
@@ -422,6 +422,7 @@ class CommitTests(PorcelainTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         self.assertEqual(commit._author_timezone, 18000)
         self.assertEqual(commit._commit_timezone, 18000)
 
@@ -438,6 +439,7 @@ class CommitTests(PorcelainTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         self.assertEqual(commit._author_timezone, -18060)
         self.assertEqual(commit._commit_timezone, -18060)
 
@@ -456,6 +458,7 @@ class CommitTests(PorcelainTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         self.assertEqual(commit._author_timezone, local_timezone)
         self.assertEqual(commit._commit_timezone, local_timezone)
 
@@ -485,6 +488,7 @@ class CommitSignTests(PorcelainGpgTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         # GPG Signatures aren't deterministic, so we can't do a static assertion.
         commit.verify()
         commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
@@ -496,6 +500,7 @@ class CommitSignTests(PorcelainGpgTestCase):
             keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
         )
 
+        assert isinstance(commit, Commit)
         commit.committer = b"Alice <alice@example.com>"
         self.assertRaises(
             gpg.errors.BadSignatures,
@@ -522,6 +527,7 @@ class CommitSignTests(PorcelainGpgTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         # GPG Signatures aren't deterministic, so we can't do a static assertion.
         commit.verify()
 
@@ -552,6 +558,7 @@ class CommitSignTests(PorcelainGpgTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         # Verify the commit is signed with the configured key
         commit.verify()
         commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
@@ -584,6 +591,7 @@ class CommitSignTests(PorcelainGpgTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         # Verify the commit is signed due to config
         commit.verify()
         commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
@@ -616,6 +624,7 @@ class CommitSignTests(PorcelainGpgTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         # Verify the commit is not signed
         self.assertIsNone(commit._gpgsig)
 
@@ -646,6 +655,7 @@ class CommitSignTests(PorcelainGpgTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         # Verify the commit is signed with default key
         commit.verify()
 
@@ -677,6 +687,7 @@ class CommitSignTests(PorcelainGpgTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         # Verify the commit is signed despite config=false
         commit.verify()
         commit.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
@@ -709,6 +720,7 @@ class CommitSignTests(PorcelainGpgTestCase):
         self.assertEqual(len(sha), 40)
 
         commit = self.repo.get_object(sha)
+        assert isinstance(commit, Commit)
         # Verify the commit is NOT signed despite config=true
         self.assertIsNone(commit._gpgsig)
 
@@ -913,8 +925,10 @@ class CloneTests(PorcelainTestCase):
         c = r.get_config()
         encoded_path = self.repo.path
         if not isinstance(encoded_path, bytes):
-            encoded_path = encoded_path.encode("utf-8")
-        self.assertEqual(encoded_path, c.get((b"remote", b"origin"), b"url"))
+            encoded_path_bytes = encoded_path.encode("utf-8")
+        else:
+            encoded_path_bytes = encoded_path
+        self.assertEqual(encoded_path_bytes, c.get((b"remote", b"origin"), b"url"))
         self.assertEqual(
             b"+refs/heads/*:refs/remotes/origin/*",
             c.get((b"remote", b"origin"), b"fetch"),
@@ -1323,6 +1337,8 @@ class AddTests(PorcelainTestCase):
         self.assertEqual([], list(self.repo.open_index()))
 
     def test_add_file_clrf_conversion(self) -> None:
+        from dulwich.index import IndexEntry
+
         # Set the right configuration to the repo
         c = self.repo.get_config()
         c.set("core", "autocrlf", "input")
@@ -1339,6 +1355,7 @@ class AddTests(PorcelainTestCase):
         self.assertIn(b"foo", index)
 
         entry = index[b"foo"]
+        assert isinstance(entry, IndexEntry)
         blob = self.repo[entry.sha]
         self.assertEqual(blob.data, b"line1\nline2")
 
@@ -2456,6 +2473,7 @@ class TagCreateSignTests(PorcelainGpgTestCase):
         self.assertEqual(b"bar\n", tag.message)
         self.assertRecentTimestamp(tag.tag_time)
         tag = self.repo[b"refs/tags/tryme"]
+        assert isinstance(tag, Tag)
         # GPG Signatures aren't deterministic, so we can't do a static assertion.
         tag.verify()
         tag.verify(keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID])
@@ -2467,7 +2485,8 @@ class TagCreateSignTests(PorcelainGpgTestCase):
             keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
         )
 
-        tag._chunked_text = [b"bad data", tag._signature]
+        assert tag.signature is not None
+        tag._chunked_text = [b"bad data", tag.signature]
         self.assertRaises(
             gpg.errors.BadSignatures,
             tag.verify,
@@ -2488,7 +2507,7 @@ class TagCreateSignTests(PorcelainGpgTestCase):
             b"foo <foo@bar.com>",
             b"bar",
             annotated=True,
-            sign=PorcelainGpgTestCase.NON_DEFAULT_KEY_ID,
+            sign=True,
         )
 
         tags = self.repo.refs.as_dict(b"refs/tags")
@@ -2499,6 +2518,7 @@ class TagCreateSignTests(PorcelainGpgTestCase):
         self.assertEqual(b"bar\n", tag.message)
         self.assertRecentTimestamp(tag.tag_time)
         tag = self.repo[b"refs/tags/tryme"]
+        assert isinstance(tag, Tag)
         # GPG Signatures aren't deterministic, so we can't do a static assertion.
         tag.verify()
 
@@ -2796,7 +2816,7 @@ class ResetTests(PorcelainTestCase):
         index = self.repo.open_index()
         changes = list(
             tree_changes(
-                self.repo,
+                self.repo.object_store,
                 index.commit(self.repo.object_store),
                 self.repo[b"HEAD"].tree,
             )
@@ -2831,7 +2851,7 @@ class ResetTests(PorcelainTestCase):
         index = self.repo.open_index()
         changes = list(
             tree_changes(
-                self.repo,
+                self.repo.object_store,
                 index.commit(self.repo.object_store),
                 self.repo[sha].tree,
             )
@@ -2868,7 +2888,7 @@ class ResetTests(PorcelainTestCase):
         index = self.repo.open_index()
         changes = list(
             tree_changes(
-                self.repo,
+                self.repo.object_store,
                 index.commit(self.repo.object_store),
                 self.repo[sha].tree,
             )
@@ -3032,7 +3052,7 @@ class ResetTests(PorcelainTestCase):
         index = self.repo.open_index()
         changes = list(
             tree_changes(
-                self.repo,
+                self.repo.object_store,
                 index.commit(self.repo.object_store),
                 self.repo[first_sha].tree,
             )
@@ -4165,7 +4185,7 @@ class PushTests(PorcelainTestCase):
             change = next(
                 iter(
                     tree_changes(
-                        self.repo,
+                        self.repo.object_store,
                         self.repo[b"HEAD"].tree,
                         self.repo[b"refs/heads/foo"].tree,
                     )
@@ -5677,10 +5697,10 @@ class LsTreeTests(PorcelainTestCase):
             committer=b"committer <email>",
         )
 
-        f = StringIO()
-        porcelain.ls_tree(self.repo, b"HEAD", outstream=f)
+        output = StringIO()
+        porcelain.ls_tree(self.repo, b"HEAD", outstream=output)
         self.assertEqual(
-            f.getvalue(),
+            output.getvalue(),
             "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tfoo\n",
         )
 
@@ -5698,16 +5718,16 @@ class LsTreeTests(PorcelainTestCase):
             author=b"author <email>",
             committer=b"committer <email>",
         )
-        f = StringIO()
-        porcelain.ls_tree(self.repo, b"HEAD", outstream=f)
+        output = StringIO()
+        porcelain.ls_tree(self.repo, b"HEAD", outstream=output)
         self.assertEqual(
-            f.getvalue(),
+            output.getvalue(),
             "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n",
         )
-        f = StringIO()
-        porcelain.ls_tree(self.repo, b"HEAD", outstream=f, recursive=True)
+        output2 = StringIO()
+        porcelain.ls_tree(self.repo, b"HEAD", outstream=output2, recursive=True)
         self.assertEqual(
-            f.getvalue(),
+            output2.getvalue(),
             "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n"
             "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tadir"
             "/afile\n",
@@ -7191,3 +7211,165 @@ class StashTests(PorcelainTestCase):
 
         # Tracked file should be restored
         self.assertTrue(os.path.exists(tracked_file))
+
+
+class BisectTests(PorcelainTestCase):
+    """Tests for bisect porcelain functions."""
+
+    def test_bisect_start(self):
+        """Test starting a bisect session."""
+        # Create some commits
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store,
+            [[1], [2, 1], [3, 2]],
+            attrs={
+                1: {"message": b"initial"},
+                2: {"message": b"second"},
+                3: {"message": b"third"},
+            },
+        )
+        self.repo.refs[b"refs/heads/master"] = c3.id
+        self.repo.refs[b"HEAD"] = c3.id
+
+        # Start bisect
+        porcelain.bisect_start(self.repo_path)
+
+        # Check that bisect state files exist
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
+        )
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_TERMS"))
+        )
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_NAMES"))
+        )
+        self.assertTrue(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_LOG"))
+        )
+
+    def test_bisect_workflow(self):
+        """Test a complete bisect workflow."""
+        # Create some commits
+        c1, c2, c3, c4 = build_commit_graph(
+            self.repo.object_store,
+            [[1], [2, 1], [3, 2], [4, 3]],
+            attrs={
+                1: {"message": b"good commit 1"},
+                2: {"message": b"good commit 2"},
+                3: {"message": b"bad commit"},
+                4: {"message": b"bad commit 2"},
+            },
+        )
+        self.repo.refs[b"refs/heads/master"] = c4.id
+        self.repo.refs[b"HEAD"] = c4.id
+
+        # Start bisect with bad and good
+        next_sha = porcelain.bisect_start(self.repo_path, bad=c4.id, good=c1.id)
+
+        # Should return the middle commit
+        self.assertIsNotNone(next_sha)
+        self.assertIn(next_sha, [c2.id, c3.id])
+
+        # Mark the middle commit as good or bad
+        if next_sha == c2.id:
+            # c2 is good, next should be c3
+            next_sha = porcelain.bisect_good(self.repo_path)
+            self.assertEqual(next_sha, c3.id)
+            # Mark c3 as bad - bisect complete
+            next_sha = porcelain.bisect_bad(self.repo_path)
+            self.assertIsNone(next_sha)
+        else:
+            # c3 is bad, next should be c2
+            next_sha = porcelain.bisect_bad(self.repo_path)
+            self.assertEqual(next_sha, c2.id)
+            # Mark c2 as good - bisect complete
+            next_sha = porcelain.bisect_good(self.repo_path)
+            self.assertIsNone(next_sha)
+
+    def test_bisect_log(self):
+        """Test getting bisect log."""
+        # Create some commits
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store,
+            [[1], [2, 1], [3, 2]],
+            attrs={
+                1: {"message": b"initial"},
+                2: {"message": b"second"},
+                3: {"message": b"third"},
+            },
+        )
+        self.repo.refs[b"refs/heads/master"] = c3.id
+        self.repo.refs[b"HEAD"] = c3.id
+
+        # Start bisect and mark commits
+        porcelain.bisect_start(self.repo_path)
+        porcelain.bisect_bad(self.repo_path, c3.id)
+        porcelain.bisect_good(self.repo_path, c1.id)
+
+        # Get log
+        log = porcelain.bisect_log(self.repo_path)
+
+        self.assertIn("git bisect start", log)
+        self.assertIn("git bisect bad", log)
+        self.assertIn("git bisect good", log)
+
+    def test_bisect_reset(self):
+        """Test resetting bisect state."""
+        # Create some commits
+        c1, c2, c3 = build_commit_graph(
+            self.repo.object_store,
+            [[1], [2, 1], [3, 2]],
+            attrs={
+                1: {"message": b"initial"},
+                2: {"message": b"second"},
+                3: {"message": b"third"},
+            },
+        )
+        self.repo.refs[b"refs/heads/master"] = c3.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
+
+        # Start bisect
+        porcelain.bisect_start(self.repo_path)
+        porcelain.bisect_bad(self.repo_path)
+        porcelain.bisect_good(self.repo_path, c1.id)
+
+        # Reset
+        porcelain.bisect_reset(self.repo_path)
+
+        # Check that bisect state files are removed
+        self.assertFalse(
+            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
+        )
+        self.assertFalse(
+            os.path.exists(os.path.join(self.repo.controldir(), "refs", "bisect"))
+        )
+
+        # HEAD should be back to being a symbolic ref to master
+        head_target, _ = self.repo.refs.follow(b"HEAD")
+        self.assertEqual(head_target[-1], b"refs/heads/master")
+
+    def test_bisect_skip(self):
+        """Test skipping commits during bisect."""
+        # Create some commits
+        c1, c2, c3, c4, c5 = build_commit_graph(
+            self.repo.object_store,
+            [[1], [2, 1], [3, 2], [4, 3], [5, 4]],
+            attrs={
+                1: {"message": b"good"},
+                2: {"message": b"skip this"},
+                3: {"message": b"bad"},
+                4: {"message": b"bad"},
+                5: {"message": b"bad"},
+            },
+        )
+        self.repo.refs[b"refs/heads/master"] = c5.id
+        self.repo.refs[b"HEAD"] = c5.id
+
+        # Start bisect
+        porcelain.bisect_start(self.repo_path, bad=c5.id, good=c1.id)
+
+        # Skip c2 if it's selected
+        next_sha = porcelain.bisect_skip(self.repo_path, [c2.id])
+        self.assertIsNotNone(next_sha)
+        self.assertNotEqual(next_sha, c2.id)