Add util/fpga/bitstream_bisect.py
This tool enables us to bisect over cached bitstreams, and optionally
narrow down ambiguous results in a second phase.
Proposal: https://github.com/lowRISC/opentitan/issues/16406
Signed-off-by: Dan McArdle <dmcardle@google.com>
diff --git a/rules/scripts/BUILD b/rules/scripts/BUILD
index 5941449..43824ed 100644
--- a/rules/scripts/BUILD
+++ b/rules/scripts/BUILD
@@ -20,10 +20,19 @@
],
)
+py_library(
+ name = "bitstreams_workspace",
+ srcs = [
+ "bitstreams_workspace.py",
+ ],
+)
+
py_test(
name = "bitstreams_workspace_test",
srcs = [
- "bitstreams_workspace.py",
"bitstreams_workspace_test.py",
],
+ deps = [
+ ":bitstreams_workspace",
+ ],
)
diff --git a/rules/scripts/bitstreams_workspace.py b/rules/scripts/bitstreams_workspace.py
index 21de475..1aa6c16 100755
--- a/rules/scripts/bitstreams_workspace.py
+++ b/rules/scripts/bitstreams_workspace.py
@@ -63,6 +63,7 @@
class BitstreamCache(object):
+
def __init__(self, bucket_url, cachedir, latest_update, offline=False):
"""Initialize the Bitstream Cache Manager."""
if bucket_url[-1] != '/':
@@ -76,6 +77,14 @@
self.offline = offline
self.available = {}
+ @staticmethod
+ def MakeWithDefaults() -> 'BitstreamCache':
+ """Create a BitstreamCache with default parameters."""
+ args = parser.parse_args([])
+ cache = BitstreamCache(args.bucket_url, args.cache, args.latest_update,
+ args.offline)
+ return cache
+
def InitRepository(self):
"""Create the cache directory and symlink it into the bazel repository dir."""
os.makedirs(self.cachedir, exist_ok=True)
diff --git a/rules/scripts/bitstreams_workspace_test.py b/rules/scripts/bitstreams_workspace_test.py
index 7e00782..09abb00 100644
--- a/rules/scripts/bitstreams_workspace_test.py
+++ b/rules/scripts/bitstreams_workspace_test.py
@@ -11,6 +11,11 @@
class TestBitstreamCache(unittest.TestCase):
+ def test_make_with_default(self):
+ # Changes to command-line argument defaults could break this method, so
+ # it's important to at least have code coverage.
+ BitstreamCache.MakeWithDefaults()
+
def test_get_from_cache(self):
BITSTREAM_ORIG = 'lowrisc_systems_chip_earlgrey_cw310_0.1.bit.orig'
BITSTREAM_SPLICE = 'lowrisc_systems_chip_earlgrey_cw310_0.1.bit.splice'
diff --git a/util/fpga/BUILD b/util/fpga/BUILD
new file mode 100644
index 0000000..9ce1891
--- /dev/null
+++ b/util/fpga/BUILD
@@ -0,0 +1,32 @@
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+
+load("@rules_python//python:defs.bzl", "py_binary")
+load("@ot_python_deps//:requirements.bzl", "requirement")
+
+package(default_visibility = ["//visibility:public"])
+
+py_library(
+ name = "bitstream_bisect_support",
+ deps = [
+ "//rules/scripts:bitstreams_workspace",
+ requirement("typer"),
+ requirement("rich"),
+ ],
+)
+
+py_binary(
+ name = "bitstream_bisect",
+ srcs = ["bitstream_bisect.py"],
+ deps = [":bitstream_bisect_support"],
+)
+
+py_test(
+ name = "bitstream_bisect_test",
+ srcs = [
+ "bitstream_bisect.py",
+ "bitstream_bisect_test.py",
+ ],
+ deps = [":bitstream_bisect_support"],
+)
diff --git a/util/fpga/bitstream_bisect.py b/util/fpga/bitstream_bisect.py
new file mode 100644
index 0000000..2ec6e22
--- /dev/null
+++ b/util/fpga/bitstream_bisect.py
@@ -0,0 +1,413 @@
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+r"""Efficiently bisect FPGA regressions with the bitstream cache.
+
+This tool bisects while minimizing the number of calls to the slow command.
+Under the hood, it actually runs up to two git bisects. The first bisect only
+tests commits that have cached bitstreams. The optional second bisect narrows
+down ambiguous results. (For more information on bisecting, see `man 1
+git-bisect` or <https://git-scm.com/docs/git-bisect>.)
+
+In the first phase, the tool classifies commits as good/bad/skip with the
+user-provided `--fast-command`. In the second phase, it uses `--slow-command`.
+These commands classify the current commit via their exit status. Exiting with 0
+indicates that the commit is good. Exiting with 125 indicates the commit cannot
+be tested. Exiting with any other status indicates that the commit is bad.
+
+Run bitstream_bisect.py with Bazel like so:
+
+./bazelisk.sh run //util/fpga:bitstream_bisect -- \
+ --good HEAD~30 \
+ --bad HEAD \
+ --fast-command "./bazelisk.sh test //sw/device/tests:uart_smoketest_fpga_cw310_rom" \
+ --slow-command "./bazelisk.sh test --define bitstream=vivado \
+ //sw/device/tests:uart_smoketest_fpga_cw310_rom"
+
+"""
+
+import abc
+import enum
+import os
+import re
+import shlex
+import string
+import subprocess
+from typing import Dict, List, Optional, Set, Tuple
+
+import rich
+import rich.console
+import typer
+
+from rules.scripts.bitstreams_workspace import BitstreamCache
+
+
+class CommitHash(str):
+ """A string representation of a Git commit hash.
+
+ The given string must contain only hex digits. It is canonicalized to
+ lowercase, but not canonicalized relative to the current git repository. As
+ such, two CommitHash objects may fail to compare equal even though git would
+ say they refer to the same commit.
+ """
+
+ def __new__(cls, s):
+ t = super().__new__(cls, s).lower()
+ if len(t) == 0 or len(set(t) - set(string.hexdigits)) > 0:
+ raise Exception("Invalid CommitHash input: '{}'".format(t))
+ return t
+
+
+class CommitJudgment(enum.Enum):
+ GOOD = enum.auto()
+ BAD = enum.auto()
+ SKIP = enum.auto()
+
+
+class BisectLog:
+ """A container and parser for the results of `git bisect log`.
+ """
+
+ class ParserBug(Exception):
+ pass
+
+ def __init__(self, log: str):
+ """Parse `log` and initialize the object."""
+ lines = [s.rstrip() for s in log.splitlines()]
+ self.judgments = BisectLog._extract_judgments(lines)
+ self.candidates = BisectLog._extract_candidates(lines)
+
+ @staticmethod
+ def _extract_judgments(
+ lines: List[str]) -> List[Tuple[CommitHash, CommitJudgment]]:
+ out = []
+ for line in lines:
+ if line == '' or line.startswith("#"):
+ continue
+ if line == 'git bisect start':
+ continue
+ match = re.match("^git bisect (good|bad|skip) ([0-9a-f]+)$", line)
+ if match is None:
+ raise BisectLog.ParserBug(
+ "Failed to parse line: '{}'".format(line))
+ assert len(match.groups()) == 2
+ judgment, commit = match.groups()
+ if judgment == 'good':
+ out.append((CommitHash(commit), CommitJudgment.GOOD))
+ elif judgment == 'bad':
+ out.append((CommitHash(commit), CommitJudgment.BAD))
+ elif judgment == 'skip':
+ out.append((CommitHash(commit), CommitJudgment.SKIP))
+ else:
+ raise BisectLog.ParserBug("Unreachable")
+ return out
+
+ @staticmethod
+ def _extract_candidates(lines: List[str]) -> List[CommitHash]:
+ # Scan through the log to find a line indicating that there is a single
+ # first bad commit.
+ re_first_bad = re.compile(r"^# first bad commit: \[([0-9a-f]+)\] .*$")
+ for i, line in enumerate(lines):
+ match = re_first_bad.match(line)
+ if match is None:
+ continue
+ assert len(match.groups()) == 1
+ first_bad = match.group(1)
+ return [CommitHash(first_bad)]
+
+ # Scan through the log to find a sequence of possible first bad commits.
+ LOG_LINE_ONLY_SKIPPED = "# only skipped commits left to test"
+ candidate_lines = []
+ for i, line in reversed(list(enumerate(lines))):
+ # When only skipped commits remain, expect a sequence of "possible
+ # first bad commit" lines.
+ if line == LOG_LINE_ONLY_SKIPPED:
+ candidate_lines = lines[i + 1:]
+ break
+
+ re_possible_first_bad = re.compile(
+ r"^# possible first bad commit: \[([0-9a-f]+)\] .*$")
+ candidates = []
+ for line in candidate_lines:
+ match = re_possible_first_bad.match(line)
+ if match is None:
+ continue
+ assert len(match.groups()) == 1
+ commit = match.group(1)
+ candidates.append(CommitHash(commit))
+ return candidates
+
+
+class GitControllerABC(abc.ABC):
+ """An abstract Git interface that will never run the system `git`."""
+
+ @abc.abstractmethod
+ def _git(self, *args: str) -> Tuple[int, str, str]:
+ """Runs git with `args`. Returns (returncode, stdout, stderr)."""
+ pass
+
+ @abc.abstractmethod
+ def _git_no_capture_output(self, *args: str) -> int:
+ """Like `_git()`, but doesn't capture stdout/stderr."""
+ pass
+
+ def _git_checked(self, *args: str) -> Tuple[str, str]:
+ """Like _git(), but raises an exception when the returncode != 0."""
+ returncode, stdout, stderr = self._git(*args)
+ if returncode != 0:
+ lines = [
+ "Git exited with {}".format(returncode),
+ "---stdout---",
+ stdout,
+ "---stderr---",
+ stderr,
+ ]
+ raise Exception("\n".join(lines))
+ return (stdout, stderr)
+
+ def rev_parse(self, commitish: str) -> CommitHash:
+ stdout, _ = self._git_checked("rev-parse", commitish)
+ return CommitHash(stdout.rstrip())
+
+ def bisect_start(self):
+ self._git_checked("bisect", "start")
+
+ def bisect_reset(self):
+ self._git_checked("bisect", "reset")
+
+ def bisect_good(self, rev: CommitHash):
+ self._git_checked("bisect", "good", rev)
+
+ def bisect_bad(self, rev: CommitHash):
+ self._git_checked("bisect", "bad", rev)
+
+ def bisect_skip(self, rev: CommitHash):
+ self._git_checked("bisect", "skip", rev)
+
+ def bisect_run(self, *args: str) -> BisectLog:
+ """Executes `git bisect run` without capturing stdout/stderr."""
+ # Git's exit status is non-zero when bisect results are ambiguous.
+ _ = self._git_no_capture_output("bisect", "run", *args)
+ return self.bisect_log()
+
+ def bisect_log(self) -> BisectLog:
+ stdout, _ = self._git_checked("bisect", "log")
+ return BisectLog(stdout)
+
+ def bisect_view(self) -> List[Tuple[CommitHash, str]]:
+ stdout, _ = self._git_checked("bisect", "view", "--oneline",
+ "--no-abbrev-commit")
+ out = []
+ for line in stdout.splitlines():
+ commit, tail = line.split(" ", 1)
+ out.append((CommitHash(commit), tail))
+ return out
+
+
+class GitController(GitControllerABC):
+ """A concrete git controller that actually executes git commands."""
+
+ def __init__(self, git_binary: str, verbose: bool):
+ self._git_binary = git_binary
+ self._verbose = verbose
+ self._console = rich.console.Console()
+ self._env: Dict[str, str] = dict(os.environ)
+ # Prevent `git bisect view` from launching the `gitk` GUI.
+ self._env.pop("DISPLAY", None)
+
+ def _git(self, *args: str) -> Tuple[int, str, str]:
+ command = [self._git_binary] + list(args)
+ proc = subprocess.run(command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=self._env,
+ encoding="utf-8")
+ if self._verbose or proc.returncode != 0:
+ print()
+ command_str = " ".join(command)
+ rich.print(
+ "Command [green]{}[/green] exited with status {}".format(
+ rich.markup.escape(command_str), proc.returncode))
+ rich.print(
+ rich.panel.Panel(rich.markup.escape(proc.stdout),
+ title="git stdout"))
+ rich.print(
+ rich.panel.Panel(rich.markup.escape(proc.stderr),
+ title="git stderr"))
+ return (proc.returncode, proc.stdout, proc.stderr)
+
+ def _git_no_capture_output(self, *args: str) -> int:
+ command = [self._git_binary] + list(args)
+ command_str = " ".join(command)
+ command_str = rich.markup.escape(command_str)
+ self._console.rule(
+ "Streaming output from [bold]{}".format(command_str))
+ proc = subprocess.run(["git"] + list(args), env=self._env)
+ self._console.rule("Git exited with status {}".format(proc.returncode))
+ self._console.print()
+ return proc.returncode
+
+
+class BisectSession:
+
+ def __init__(self,
+ git: GitControllerABC,
+ cache_keys: Set[CommitHash],
+ visualize: bool = False):
+ self._git = git
+ self._cache_keys: Set[CommitHash] = cache_keys
+ self._console = rich.console.Console()
+ self._visualize = visualize
+
+ def run(self,
+ good_commit: CommitHash,
+ bad_commit: CommitHash,
+ fast_command: List[str],
+ slow_command: Optional[List[str]] = None) -> BisectLog:
+ """Run an unattended bisect session in one or two phases.
+
+ This method is conceptually equivalent to `git bisect run`. Unlike plain
+ `git bisect`, it runs in two phases. The first phase only operates on
+ cached bitstreams. This will only get us so far, so it optionally can
+ fall back to a slower command, e.g. one that builds the bitstreams it
+ tests.
+
+ Args:
+ good_commit:
+ Commit where `fast_command` and `slow_command` are known to succeed.
+ bad_commit:
+ Commit where `fast_command` and `slow_command` are known to fail.
+ fast_command:
+ A list of strings that represent a git-bisect script and args. These
+ are passed to `git-bisect run` in the first phase.
+ slow_command:
+ Just like `fast_command`, but for the second phase. If this arg is
+ None, we skip the second phase.
+
+ Returns:
+ Parsed output from `git bisect log`, run after the final bisect phase.
+ """
+
+ self._git.bisect_start()
+ self._git.bisect_good(good_commit)
+ self._git.bisect_bad(bad_commit)
+
+ # Skip commits that aren't in the bitstream cache.
+ for commit, _ in self._git.bisect_view():
+ if commit not in self._cache_keys:
+ self._git.bisect_skip(commit)
+
+ self._maybe_visualize("Only testing commits with cached bitstreams")
+ bisect_log = self._git.bisect_run(*fast_command)
+
+ # Skip the second phase when results are unambiguous.
+ if len(bisect_log.candidates) == 1:
+ self._maybe_visualize("Final results")
+ self._git.bisect_reset()
+ return bisect_log
+
+ # Prepare a new git bisect session where we will run the slow script
+ # without the benefit of the bitstream cache. Replay all the "good" and
+ # "bad" commands, but none of the "skip" commands.
+ if slow_command is None:
+ self._git.bisect_reset()
+ return bisect_log
+
+ self._git.bisect_reset()
+ self._git.bisect_start()
+
+ # Replay only "good" and "bad" decisions from the previous phase. It's
+ # tempting to unconditionally skip cached commits, but it's better to
+ # simply reuse judgments from the previous phase. It's possible that the
+ # user-supplied `fast_command` chose to skip some cached commits for
+ # reasons outside the scope of this tool, and we now need to test them
+ # in the second phase.
+ for commit, judgment in bisect_log.judgments:
+ if judgment == CommitJudgment.GOOD:
+ self._git.bisect_good(commit)
+ elif judgment == CommitJudgment.BAD:
+ self._git.bisect_bad(commit)
+
+ self._maybe_visualize("Narrowing down ambiguous results")
+ bisect_log = self._git.bisect_run(*slow_command)
+
+ self._maybe_visualize("Final results")
+ self._git.bisect_reset()
+ return bisect_log
+
+ def _maybe_visualize(self, label: str):
+ """Prints a table summarizing the current range of commits."""
+ if not self._visualize:
+ return
+
+ table = rich.table.Table(
+ title="Bitstream Bisect Status: [bold]{}".format(label))
+ table.add_column("Commit")
+ table.add_column("Description")
+ table.add_column("Cached")
+ table.add_column("Judgment")
+
+ bisect_log = self._git.bisect_log()
+ judgment_dict: Dict[CommitHash,
+ CommitJudgment] = dict(bisect_log.judgments)
+
+ for commit, description in self._git.bisect_view():
+ judgment = judgment_dict.get(commit)
+ table.add_row(commit, rich.markup.escape(description),
+ "Cached" if commit in self._cache_keys else "",
+ judgment.name if judgment else "")
+
+ self._console.print(table)
+
+
+app = typer.Typer(pretty_exceptions_enable=False,
+ rich_markup_mode="rich",
+ add_completion=False)
+
+
+@app.command(help=__doc__)
+def main(
+ good: str = typer.Option(..., help="Commit-ish for known-good revision."),
+ bad: str = typer.Option(..., help="Commit-ish for known-bad revision."),
+ fast_command: str = typer.Option(
+ ..., help="Command for testing commits with cached bitstreams."),
+ slow_command: Optional[str] = typer.Option(
+ None, help="Command that tests remaining commits in second phase."),
+ git_binary: str = typer.Option("/usr/bin/git", help="Path to Git binary."),
+ verbose: bool = typer.Option(
+ False, help="Log stdout/stderr of every git command."),
+):
+ # Escape the Bazel sandbox. Without this step, git would not find the .git/
+ # directory. Note that this works nicely with git worktrees.
+ workspace_dir = os.environ['BUILD_WORKSPACE_DIRECTORY']
+ os.chdir(workspace_dir)
+
+ # Create a Git controller capable of invoking the system's `git` binary. Use
+ # it to canonicalize potentially-symbolic "commitish" strings so we don't
+ # misinterpret them when a different commit is checked out.
+ git = GitController(git_binary, verbose)
+ good_commit = git.rev_parse(good)
+ bad_commit = git.rev_parse(bad)
+ print("Canonicalized good revision:", good_commit)
+ print("Canonicalized bad revision: ", bad_commit)
+
+ # NOTE: This will generate network traffic!
+ # Fetch a listing of the current bitstream cache.
+ bitstream_cache = BitstreamCache.MakeWithDefaults()
+ bitstream_cache.GetBitstreamsAvailable(refresh=True)
+ bitstream_cache_keys = set(bitstream_cache.available.keys())
+
+ fast_command_pieces = shlex.split(fast_command)
+ slow_command_pieces = shlex.split(
+ slow_command) if slow_command is not None else None
+
+ session = BisectSession(git, bitstream_cache_keys, visualize=True)
+ session.run(good_commit,
+ bad_commit,
+ fast_command_pieces,
+ slow_command=slow_command_pieces)
+ return 0
+
+
+if __name__ == '__main__':
+ app()
diff --git a/util/fpga/bitstream_bisect_test.py b/util/fpga/bitstream_bisect_test.py
new file mode 100644
index 0000000..ca222d0
--- /dev/null
+++ b/util/fpga/bitstream_bisect_test.py
@@ -0,0 +1,342 @@
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+
+import unittest
+from typing import Tuple
+from unittest.mock import MagicMock, call
+
+from bitstream_bisect import (BisectLog, BisectSession, CommitHash,
+ CommitJudgment, GitControllerABC)
+
+
+class MockableGitController(GitControllerABC):
+ """Provides stub implementations of GitControllerABC's abstract methods."""
+
+ def _git(self, *args: str) -> Tuple[int, str, str]:
+ raise Exception("_git unexpectedly called")
+
+ def _git_no_capture_output(self, *args: str) -> int:
+ returncode, _, _ = self._git(*args)
+ return returncode
+
+
+class TestCommitHash(unittest.TestCase):
+ """Test that CommitHash validates its inputs."""
+
+ def test_simple(self):
+ c = CommitHash("c1")
+ self.assertEqual(str(c), "c1")
+
+ c = CommitHash("deadbeef")
+ self.assertEqual(str(c), "deadbeef")
+ self.assertEqual(str(c), CommitHash("deadbeef"))
+
+ def test_non_lowercase(self):
+ c = CommitHash("C1")
+ self.assertEqual(str(c), "c1")
+ self.assertEqual(str(c), CommitHash("C1"))
+ self.assertEqual(str(c), CommitHash("c1"))
+
+ c = CommitHash("DEADBEEF")
+ self.assertEqual(str(c), "deadbeef")
+ self.assertEqual(str(c), CommitHash("DEADBEEF"))
+ self.assertEqual(str(c), CommitHash("deadbeef"))
+
+ c = CommitHash("DeAdBeEf")
+ self.assertEqual(str(c), "deadbeef")
+ self.assertEqual(str(c), CommitHash("DeAdBeEf"))
+ self.assertEqual(str(c), CommitHash("deadbeef"))
+
+ def test_invalid(self):
+ with self.assertRaises(Exception):
+ CommitHash("")
+ with self.assertRaises(Exception):
+ CommitHash("hello")
+ with self.assertRaises(Exception):
+ CommitHash("HEAD")
+
+
+class TestParseBisectLog(unittest.TestCase):
+
+ def test_one_line(self):
+ bisect_log = BisectLog("git bisect good c123")
+ self.assertEqual(bisect_log.judgments, [('c123', CommitJudgment.GOOD)])
+ self.assertEqual(bisect_log.candidates, [])
+
+ bisect_log = BisectLog("git bisect bad c123")
+ self.assertEqual(bisect_log.judgments, [('c123', CommitJudgment.BAD)])
+ self.assertEqual(bisect_log.candidates, [])
+
+ bisect_log = BisectLog("git bisect skip c123")
+ self.assertEqual(bisect_log.judgments, [('c123', CommitJudgment.SKIP)])
+ self.assertEqual(bisect_log.candidates, [])
+
+ bisect_log = BisectLog("")
+ self.assertEqual(bisect_log.judgments, [])
+ self.assertEqual(bisect_log.candidates, [])
+
+ bisect_log = BisectLog("git bisect start")
+ self.assertEqual(bisect_log.judgments, [])
+ self.assertEqual(bisect_log.candidates, [])
+
+ def test_unrecognized_actions_rejected(self):
+ """The parser should reject non-comment lines it doesn't understand."""
+ # Unexpected whitespace.
+ with self.assertRaises(BisectLog.ParserBug):
+ _ = BisectLog(" git bisect skip c123")
+ with self.assertRaises(BisectLog.ParserBug):
+ _ = BisectLog("git bisect skip c123")
+ with self.assertRaises(BisectLog.ParserBug):
+ _ = BisectLog("git bisect skip c123")
+ with self.assertRaises(BisectLog.ParserBug):
+ _ = BisectLog(" # git bisect skip c123")
+ # Unrecognized action with commit.
+ with self.assertRaises(BisectLog.ParserBug):
+ _ = BisectLog("git bisect foo c123")
+ # Unrecognized action without commit.
+ with self.assertRaises(BisectLog.ParserBug):
+ _ = BisectLog("git bisect bar")
+
+ def test_unrecognized_comments_ignored(self):
+ bisect_log = BisectLog("# foo")
+ self.assertEqual(bisect_log.judgments, [])
+ self.assertEqual(bisect_log.candidates, [])
+
+ def test_candidates_simple(self):
+ bisect_log = BisectLog("")
+ self.assertEqual(bisect_log.judgments, [])
+ self.assertEqual(bisect_log.candidates, [])
+
+ bisect_log = BisectLog(
+ "# first bad commit: [c42] Commit description...")
+ self.assertEqual(bisect_log.judgments, [])
+ self.assertEqual(bisect_log.candidates, ["c42"])
+
+ bisect_log = BisectLog("""
+# only skipped commits left to test
+# possible first bad commit: [c123] Foo description
+# possible first bad commit: [c234] Bar description
+""")
+ self.assertEqual(bisect_log.judgments, [])
+ self.assertEqual(bisect_log.candidates, ["c123", "c234"])
+
+ def test_realistic_only_skipped_remain(self):
+ # This output was produced by `git` on a temporary repo.
+ BISECT_LOG = """# status: waiting for both good and bad commits
+# good: [0b47ff5f2ac78589cab7d9b93c4cabb9e4a1f736] First commit
+git bisect good 0b47ff5f2ac78589cab7d9b93c4cabb9e4a1f736
+# status: waiting for bad commit, 1 good commit known
+# bad: [4f166216806f44b1ddf67c4649d5e646cf9bbf22] Fourth commit
+git bisect bad 4f166216806f44b1ddf67c4649d5e646cf9bbf22
+# skip: [132c4e1f8773cd3e14e2f4de8a8b77422b74e28b] Third commit
+git bisect skip 132c4e1f8773cd3e14e2f4de8a8b77422b74e28b
+# skip: [8fe76ab59c4f7d43237292a57f1d84b774436224] Second commit
+git bisect skip 8fe76ab59c4f7d43237292a57f1d84b774436224
+# only skipped commits left to test
+# possible first bad commit: [4f166216806f44b1ddf67c4649d5e646cf9bbf22] Fourth commit
+# possible first bad commit: [132c4e1f8773cd3e14e2f4de8a8b77422b74e28b] Third commit
+# possible first bad commit: [8fe76ab59c4f7d43237292a57f1d84b774436224] Second commit
+# good: [8fe76ab59c4f7d43237292a57f1d84b774436224] Second commit
+git bisect good 8fe76ab59c4f7d43237292a57f1d84b774436224
+# only skipped commits left to test
+# possible first bad commit: [4f166216806f44b1ddf67c4649d5e646cf9bbf22] Fourth commit
+# possible first bad commit: [132c4e1f8773cd3e14e2f4de8a8b77422b74e28b] Third commit
+"""
+ bisect_log = BisectLog(BISECT_LOG)
+ self.assertEqual(bisect_log.candidates, [
+ "4f166216806f44b1ddf67c4649d5e646cf9bbf22",
+ "132c4e1f8773cd3e14e2f4de8a8b77422b74e28b"
+ ])
+ self.assertEqual(bisect_log.judgments, [
+ ("0b47ff5f2ac78589cab7d9b93c4cabb9e4a1f736", CommitJudgment.GOOD),
+ ("4f166216806f44b1ddf67c4649d5e646cf9bbf22", CommitJudgment.BAD),
+ ("132c4e1f8773cd3e14e2f4de8a8b77422b74e28b", CommitJudgment.SKIP),
+ ("8fe76ab59c4f7d43237292a57f1d84b774436224", CommitJudgment.SKIP),
+ ("8fe76ab59c4f7d43237292a57f1d84b774436224", CommitJudgment.GOOD),
+ ])
+
+ def test_realistic_has_first_bad_commit(self):
+ # This output was produced by `git` on a temporary repo.
+ BISECT_LOG = """git bisect start
+# status: waiting for both good and bad commits
+# good: [00722da469712cd917e59082601d189fe0c6507e] First commit
+git bisect good 00722da469712cd917e59082601d189fe0c6507e
+# status: waiting for bad commit, 1 good commit known
+# bad: [3e2031c1cb7b27e8bf36c85756c301279b8f9f30] Fourth commit
+git bisect bad 3e2031c1cb7b27e8bf36c85756c301279b8f9f30
+# bad: [dc5320f6d974d3fce7bcc03fd18d920c8b5e3cbf] Third commit
+git bisect bad dc5320f6d974d3fce7bcc03fd18d920c8b5e3cbf
+# bad: [d72b7491e6fdadb2ef742370410d5488a7bbfdba] Second commit
+git bisect bad d72b7491e6fdadb2ef742370410d5488a7bbfdba
+# first bad commit: [d72b7491e6fdadb2ef742370410d5488a7bbfdba] Second commit
+"""
+ bisect_log = BisectLog(BISECT_LOG)
+ self.assertEqual(bisect_log.candidates,
+ ["d72b7491e6fdadb2ef742370410d5488a7bbfdba"])
+ self.assertEqual(bisect_log.judgments, [
+ ("00722da469712cd917e59082601d189fe0c6507e", CommitJudgment.GOOD),
+ ("3e2031c1cb7b27e8bf36c85756c301279b8f9f30", CommitJudgment.BAD),
+ ("dc5320f6d974d3fce7bcc03fd18d920c8b5e3cbf", CommitJudgment.BAD),
+ ("d72b7491e6fdadb2ef742370410d5488a7bbfdba", CommitJudgment.BAD),
+ ])
+
+
+class TestBisectSession(unittest.TestCase):
+
+ def test_two_cached_only_fast(self):
+ """Simple case with two cached commits and only the fast command."""
+
+ BISECT_LOG = """git bisect start
+# status: waiting for both good and bad commits
+# good: [c1] Fake good commit
+git bisect good c1
+# status: waiting for bad commit, 1 good commit known
+# bad: [c2] Fake bad commit
+git bisect bad c2
+# first bad commit: [c2] Fake bad commit
+"""
+
+ git = MockableGitController()
+ git._git = MagicMock(return_value=(0, "", ""))
+ git.bisect_view = MagicMock(return_value=[
+ (CommitHash("c1"), "Commit description"),
+ (CommitHash("c2"), "Commit description"),
+ ])
+ git.bisect_log = MagicMock(return_value=BisectLog(BISECT_LOG))
+
+ session = BisectSession(git, cache_keys=set(["c1", "c2"]))
+ result: BisectLog = session.run("c1", "c2", ["fast_script.sh"])
+
+ self.assertEqual(result.candidates, ["c2"])
+ git._git.assert_has_calls([
+ call("bisect", "start"),
+ call("bisect", "good", "c1"),
+ call("bisect", "bad", "c2"),
+ call('bisect', 'run', 'fast_script.sh'),
+ # We would expect `call('bisect', 'log')` here, but mocking
+ # `git.bisect_log()` prevented it from calling `git._git()`.
+ call('bisect', 'reset'),
+ ])
+ git.bisect_log.assert_called_once_with()
+ git.bisect_view.assert_called_once_with()
+
+ def test_only_fast(self):
+ """Mix of cached and uncached with only the fast command."""
+
+ BISECT_LOG = """git bisect start
+# status: waiting for both good and bad commits
+# skip: [c2] Skipped commit
+git bisect skip c2
+# good: [c1] Fake good commit
+git bisect good c1
+# status: waiting for bad commit, 1 good commit known
+# bad: [c3] Fake bad commit
+git bisect bad c3
+# only skipped commits left to test
+# possible first bad commit: [c2] Skipped commit
+# possible first bad commit: [c3] Fake bad commit
+"""
+
+ git = MockableGitController()
+ git._git = MagicMock(return_value=(0, "", ""))
+ git.bisect_view = MagicMock(return_value=[
+ (CommitHash("c1"), "Fake good commit"),
+ (CommitHash("c2"), "Skipped commit"),
+ (CommitHash("c3"), "Fake bad commit"),
+ ])
+
+ git.bisect_log = MagicMock(return_value=BisectLog(BISECT_LOG))
+
+ session = BisectSession(git, cache_keys=set(["c1", "c3"]))
+ bisect_log = session.run("c1", "c3", ["fast_script.sh"])
+
+ self.assertEqual(bisect_log.candidates, ["c2", "c3"])
+ git._git.assert_has_calls([
+ call("bisect", "start"),
+ call("bisect", "good", "c1"),
+ call("bisect", "bad", "c3"),
+ call("bisect", "skip", "c2"),
+ call('bisect', 'run', 'fast_script.sh'),
+ # We would expect `call('bisect', 'log')` here, but mocking
+ # `git.bisect_log()` prevented it from calling `git._git()`.
+ ])
+ git.bisect_log.assert_called_once_with()
+ git.bisect_view.assert_called_once_with()
+
+ def test_fast_and_slow(self):
+ """Mix of cached and uncached with fast and slow command."""
+
+ BISECT_LOG_1 = """git bisect start
+# status: waiting for both good and bad commits
+# good: [c1] Commit 1
+git bisect good c1
+# status: waiting for bad commit, 1 good commit known
+# bad: [c5] Commit 5
+git bisect bad c5
+# skip: [c3] Commit 3
+git bisect skip c3
+# skip: [c4] Commit 4
+git bisect skip c4
+# good: [c2] Commit 2
+git bisect good c2
+# only skipped commits left to test
+# possible first bad commit: [c3] Commit 3
+# possible first bad commit: [c4] Commit 4
+"""
+ BISECT_LOG_2 = """git bisect start
+# status: waiting for both good and bad commits
+# good: [c1] Commit 1
+git bisect good c1
+# status: waiting for bad commit, 1 good commit known
+# bad: [c5] Commit 5
+git bisect bad c5
+# good: [c2] Commit 2
+git bisect good c2
+# good: [c3] Commit 3
+git bisect good c3
+# bad: [c4] Commit 4
+git bisect bad c4
+# first bad commit: [c4] Commit 4
+"""
+ parsed_bisect_logs = [BisectLog(BISECT_LOG_1), BisectLog(BISECT_LOG_2)]
+
+ git = MockableGitController()
+ git._git = MagicMock(return_value=(0, "", ""))
+ git.bisect_log = MagicMock(side_effect=parsed_bisect_logs)
+
+ git.bisect_view = MagicMock(return_value=[
+ (CommitHash("c1"), "Commit 1"),
+ (CommitHash("c2"), "Commit 2"),
+ (CommitHash("c3"), "Commit 3"),
+ (CommitHash("c4"), "Commit 4"),
+ (CommitHash("c5"), "Commit 5"),
+ ])
+
+ session = BisectSession(git, cache_keys=set(["c1", "c2"]))
+ bisect_log = session.run("c1", "c5", ["fast_script.sh"],
+ ["slow_script.sh"])
+
+ self.assertEqual(bisect_log.candidates, ["c4"])
+
+ git._git.assert_has_calls([
+ call("bisect", "start"),
+ call("bisect", "good", "c1"),
+ call("bisect", "bad", "c5"),
+ call("bisect", "skip", "c3"),
+ call("bisect", "skip", "c4"),
+ call("bisect", "skip", "c5"),
+ call('bisect', 'run', 'fast_script.sh'),
+ call('bisect', 'reset'),
+ call('bisect', 'start'),
+ call('bisect', 'good', 'c1'),
+ call('bisect', 'bad', 'c5'),
+ call('bisect', 'good', 'c2'),
+ call('bisect', 'run', 'slow_script.sh'),
+ ])
+ git.bisect_log.assert_has_calls([call(), call()])
+ git.bisect_view.assert_called_once_with()
+
+
+if __name__ == '__main__':
+ unittest.main()