From 1dc346a47da1ed7b8fb8591216470a48f1e1af9b Mon Sep 17 00:00:00 2001
From: Yun Peng <pcloudy@google.com>
Date: Thu, 27 Apr 2023 19:08:41 +0200
Subject: [PATCH 1/5] Support bisecting Bazel to find which Bazel change breaks
 your build

- Use --bisect=<BASE>..<HEAD> to specify the bisecting range, Bazelisk
  uses the GitHub API to get the list of commits to bisect. You may need
  to set `BAZELISK_GITHUB_TOKEN` to get around GitHub rate limit.
- BAZELISK_SHUTDOWN, BAZELISK_CLEAN can be used to run `bazel shutdown`
  or `bazel clean --expunge` between builds.
---
 core/core.go | 224 ++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 203 insertions(+), 21 deletions(-)

diff --git a/core/core.go b/core/core.go
index 139c17b9..e40d529c 100644
--- a/core/core.go
+++ b/core/core.go
@@ -6,10 +6,12 @@ package core
 import (
 	"bufio"
 	"crypto/sha256"
+	"encoding/json"
 	"fmt"
 	"io"
 	"io/ioutil"
 	"log"
+	"net/http"
 	"os"
 	"os/exec"
 	"os/signal"
@@ -89,24 +91,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 	// If we aren't using a local Bazel binary, we'll have to parse the version string and
 	// download the version that the user wants.
 	if !filepath.IsAbs(bazelPath) {
-		bazelFork, bazelVersion, err := parseBazelForkAndVersion(bazelVersionString)
-		if err != nil {
-			return -1, fmt.Errorf("could not parse Bazel fork and version: %v", err)
-		}
-
-		var downloader DownloadFunc
-		resolvedBazelVersion, downloader, err = repos.ResolveVersion(bazeliskHome, bazelFork, bazelVersion)
-		if err != nil {
-			return -1, fmt.Errorf("could not resolve the version '%s' to an actual version number: %v", bazelVersion, err)
-		}
-
-		bazelForkOrURL := dirForURL(GetEnvOrConfig(BaseURLEnv))
-		if len(bazelForkOrURL) == 0 {
-			bazelForkOrURL = bazelFork
-		}
-
-		baseDirectory := filepath.Join(bazeliskHome, "downloads", bazelForkOrURL)
-		bazelPath, err = downloadBazelIfNecessary(resolvedBazelVersion, baseDirectory, repos, downloader)
+		bazelPath, err = downloadBazel(bazelVersionString, bazeliskHome, repos)
 		if err != nil {
 			return -1, fmt.Errorf("could not download Bazel: %v", err)
 		}
@@ -130,7 +115,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 		return 0, nil
 	}
 
-	// --strict and --migrate must be the first argument.
+	// --strict and --migrate and --bisect must be the first argument.
 	if len(args) > 0 && (args[0] == "--strict" || args[0] == "--migrate") {
 		cmd, err := getBazelCommand(args)
 		if err != nil {
@@ -140,14 +125,25 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 		if err != nil {
 			return -1, fmt.Errorf("could not get the list of incompatible flags: %v", err)
 		}
-
 		if args[0] == "--migrate" {
 			migrate(bazelPath, args[1:], newFlags)
-		} else {
+		} else if args[0] == "--migrate" {
 			// When --strict is present, it expands to the list of --incompatible_ flags
 			// that should be enabled for the given Bazel version.
 			args = insertArgs(args[1:], newFlags)
 		}
+	} else if len(args) > 0 && strings.HasPrefix(args[0], "--bisect") {
+		// When --bisect is present, we run the bisect logic.
+		if !strings.HasPrefix(args[0], "--bisect=") {
+			return -1, fmt.Errorf("Error: --bisect must have a value. Expected format: '--bisect=<good bazel commit>..<bad bazel commit>'")
+		}
+		value := args[0][len("--bisect="):]
+		commits := strings.Split(value, "..")
+		if len(commits) == 2 {
+			bisect(commits[0], commits[1], args[1:], bazeliskHome, repos)
+		} else {
+			return -1, fmt.Errorf("Error: Invalid format for --bisect. Expected format: '--bisect=<good bazel commit>..<bad bazel commit>'")
+		}
 	}
 
 	// print bazelisk version information if "version" is the first argument
@@ -404,6 +400,27 @@ func parseBazelForkAndVersion(bazelForkAndVersion string) (string, string, error
 	return bazelFork, bazelVersion, nil
 }
 
+func downloadBazel(bazelVersionString string, bazeliskHome string, repos *Repositories) (string, error) {
+	bazelFork, bazelVersion, err := parseBazelForkAndVersion(bazelVersionString)
+	if err != nil {
+		return "", fmt.Errorf("could not parse Bazel fork and version: %v", err)
+	}
+
+	resolvedBazelVersion, downloader, err := repos.ResolveVersion(bazeliskHome, bazelFork, bazelVersion)
+	if err != nil {
+		return "", fmt.Errorf("could not resolve the version '%s' to an actual version number: %v", bazelVersion, err)
+	}
+
+	bazelForkOrURL := dirForURL(GetEnvOrConfig(BaseURLEnv))
+	if len(bazelForkOrURL) == 0 {
+		bazelForkOrURL = bazelFork
+	}
+
+	baseDirectory := filepath.Join(bazeliskHome, "downloads", bazelForkOrURL)
+	bazelPath, err := downloadBazelIfNecessary(resolvedBazelVersion, baseDirectory, repos, downloader)
+	return bazelPath, err
+}
+
 func downloadBazelIfNecessary(version string, baseDirectory string, repos *Repositories, downloader DownloadFunc) (string, error) {
 	pathSegment, err := platforms.DetermineBazelFilename(version, false)
 	if err != nil {
@@ -717,6 +734,171 @@ func cleanIfNeeded(bazelPath string, startupOptions []string) {
 	}
 }
 
+type Commit struct {
+	SHA string `json:"sha"`
+}
+
+type CompareResponse struct {
+	Commits []Commit `json:"commits"`
+	MergeBaseCommit Commit `json:"merge_base_commit"`
+}
+
+type Message struct {
+	Content string `json:"message"`
+}
+
+func sendRequest(url string) (*http.Response, error) {
+	client := &http.Client{}
+
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	githubToken := GetEnvOrConfig("BAZELISK_GITHUB_TOKEN")
+	if len(githubToken) != 0 {
+		req.Header.Set("Authorization", fmt.Sprintf("token %s", githubToken))
+	}
+
+	return client.Do(req)
+}
+
+func getBazelCommitsBetween(goodCommit string, badCommit string) ([]string, error) {
+	commitList := make([]string, 0)
+	page := 1
+	perPage := 250 // 250 is the maximum number of commits per page
+
+	for {
+		url := fmt.Sprintf("https://api.github.com/repos/bazelbuild/bazel/compare/%s...%s?page=%d&per_page=%d", goodCommit, badCommit, page, perPage)
+
+		response, err := sendRequest(url)
+		if err != nil {
+			return nil, fmt.Errorf("Error fetching commit data: %v", err)
+		}
+		defer response.Body.Close()
+
+		body, err := ioutil.ReadAll(response.Body)
+		if err != nil {
+			return nil, fmt.Errorf("Error reading response body: %v", err)
+		}
+
+		if response.StatusCode == http.StatusNotFound {
+			return nil, fmt.Errorf("repository or commit not found: %s", string(body))
+		} else if response.StatusCode == 403 {
+			return nil, fmt.Errorf("github API rate limit hit, consider setting BAZELISK_GITHUB_TOKEN: %s", string(body))
+		} else if response.StatusCode != http.StatusOK {
+			return nil, fmt.Errorf("unexpected response status code %d: %s", response.StatusCode, string(body))
+		}
+
+		var compareResponse CompareResponse
+		err = json.Unmarshal(body, &compareResponse)
+		if err != nil {
+			return nil, fmt.Errorf("Error unmarshaling JSON: %v", err)
+		}
+
+		if len(compareResponse.Commits) == 0 {
+			break
+		}
+
+		if page == 1 {
+			mergeBaseCommit := compareResponse.MergeBaseCommit.SHA
+			if compareResponse.MergeBaseCommit.SHA != goodCommit {
+				fmt.Printf("The good Bazel commit is not an ancestor of the bad Bazel commit, overriding the good Bazel commit to the merge base commit %s\n", mergeBaseCommit)
+				goodCommit = mergeBaseCommit
+			}
+		}
+
+		for _, commit := range compareResponse.Commits {
+			commitList = append(commitList, commit.SHA)
+		}
+
+		// Check if there are more commits to fetch
+		if len(compareResponse.Commits) < perPage {
+			break
+		}
+
+		page++
+	}
+
+	if len(commitList) == 0 {
+		return nil, fmt.Errorf("no commits found between (%s, %s], the good commit should be first, maybe try with --bisect=%s..%s ?", goodCommit, badCommit, badCommit, goodCommit)
+	}
+	fmt.Printf("Found %d commits between (%s, %s]\n", len(commitList), goodCommit, badCommit)
+	return commitList, nil
+}
+
+func bisect(goodCommit string, badCommit string, args []string, bazeliskHome string, repos *Repositories) {
+
+	// 1. Get the list of commits between goodCommit and badCommit
+	fmt.Printf("\n\n--- Getting the list of commits between %s and %s\n\n", goodCommit, badCommit)
+	commitList, err := getBazelCommitsBetween(goodCommit, badCommit)
+	if err != nil {
+		log.Fatalf("Failed to get commits: %v", err)
+		os.Exit(1)
+	}
+
+	// 2. Check if goodCommit is actually good
+	fmt.Printf("\n\n--- Verifying if the given good Bazel commit (%s) is actually good\n\n", goodCommit)
+	bazelExitCode, err := testWithBazelAtCommit(goodCommit, args, bazeliskHome, repos)
+	if err != nil {
+		log.Fatalf("could not run Bazel: %v", err)
+		os.Exit(1)
+	}
+	if bazelExitCode != 0 {
+		fmt.Printf("Failure: Given good bazel commit is already broken.\n")
+		os.Exit(1)
+	}
+
+	// 3. Bisect commits
+	fmt.Printf("\n\n--- Start bisecting\n\n")
+	left := 0
+	right := len(commitList)
+	for left < right {
+		mid := (left + right) / 2
+		midCommit := commitList[mid]
+		fmt.Printf("\n\n--- Testing with Bazel built at %s, %d commits remaining...\n\n", midCommit, right -left)
+		bazelExitCode, err := testWithBazelAtCommit(midCommit, args, bazeliskHome, repos)
+		if err != nil {
+			log.Fatalf("could not run Bazel: %v", err)
+			os.Exit(1)
+		}
+		if bazelExitCode == 0 {
+			fmt.Printf("\n\n--- Succeeded at %s\n\n", midCommit)
+			left = mid + 1
+		} else {
+			fmt.Printf("\n\n--- Failed at %s\n\n", midCommit)
+			right = mid
+		}
+	}
+
+	// 4. Print the result
+	fmt.Printf("\n\n--- Bisect Result\n\n")
+	if right == len(commitList) {
+		fmt.Printf("first bad commit not found, every commit succeeded.\n")
+	} else {
+		firstBadCommit := commitList[right]
+		fmt.Printf("first bad commit is https://github.com/bazelbuild/bazel/commit/%s\n", firstBadCommit)
+	}
+
+	os.Exit(0)
+}
+
+func testWithBazelAtCommit(bazelCommit string, args []string, bazeliskHome string, repos *Repositories) (int, error) {
+	bazelPath, err := downloadBazel(bazelCommit, bazeliskHome, repos)
+	if err != nil {
+		return 1, fmt.Errorf("could not download Bazel: %v", err)
+	}
+	startupOptions := parseStartupOptions(args)
+	shutdownIfNeeded(bazelPath, startupOptions)
+	cleanIfNeeded(bazelPath, startupOptions)
+	fmt.Printf("bazel %s\n", strings.Join(args, " "))
+	bazelExitCode, err := runBazel(bazelPath, args, nil)
+	if err != nil {
+		return -1, fmt.Errorf("could not run Bazel: %v", err)
+	}
+	return bazelExitCode, nil
+}
+
 // migrate will run Bazel with each flag separately and report which ones are failing.
 func migrate(bazelPath string, baseArgs []string, flags []string) {
 	var startupOptions = parseStartupOptions(baseArgs)

From d1b28c147b6e8d4d3608fb9596e4caa7c5883fd0 Mon Sep 17 00:00:00 2001
From: Yun Peng <pcloudy@google.com>
Date: Fri, 28 Apr 2023 10:01:17 +0200
Subject: [PATCH 2/5] Add doc

---
 README.md | 23 ++++++++++++++++++++---
 1 file changed, 20 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index 1d059b51..76a3010e 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,9 @@ require users update their bazel.
 [shell wrapper script]: https://github.com/bazelbuild/bazel/blob/master/scripts/packages/bazel.sh
 ## Other features
 
-The Go version of Bazelisk offers two new flags.
+The Go version of Bazelisk offers three new flags.
+
+### --strict
 
 `--strict` expands to the set of incompatible flags which may be enabled for the given version of Bazel.
 
@@ -108,17 +110,32 @@ The Go version of Bazelisk offers two new flags.
 bazelisk --strict build //...
 ```
 
+### --migrate
+
 `--migrate` will run Bazel multiple times to help you identify compatibility issues.
 If the code fails with `--strict`, the flag `--migrate` will run Bazel with each one of the flag separately, and print a report at the end.
 This will show you which flags can safely enabled, and which flags require a migration.
 
+
+### --bisect
+
+`--bisect` flag allows you to bisect Bazel versions to find which version introduced a build failure. You can specify the range of versions to bisect with `--bisect=<GOOD>..<BAD>`, where GOOD is the last known working Bazel version and BAD is the first known non-working Bazel version. Bazelisk uses [GitHub's compare API](https://docs.github.com/en/rest/commits/commits#compare-two-commits) to get the list of commits to bisect. When GOOD is not an ancestor of BAD, GOOD is reset to their merge base commit.
+
+```shell
+bazelisk --bisect=6.0.0..HEAD test //foo:bar_test
+```
+
+### Useful environment variables
+
 You can set `BAZELISK_INCOMPATIBLE_FLAGS` to set a list of incompatible flags (separated by `,`) to be tested, otherwise Bazelisk tests all flags starting with `--incompatible_`.
 
 You can set `BAZELISK_GITHUB_TOKEN` to set a GitHub access token to use for API requests to avoid rate limiting when on shared networks.
 
-You can set `BAZELISK_SHUTDOWN` to run `shutdown` between builds when migrating if you suspect this affects your results.
+You can set `BAZELISK_SHUTDOWN` to run `shutdown` between builds when migrating or bisecting if you suspect this affects your results.
+
+You can set `BAZELISK_CLEAN` to run `clean --expunge` between builds when migrating or bisecting if you suspect this affects your results.
 
-You can set `BAZELISK_CLEAN` to run `clean --expunge` between builds when migrating if you suspect this affects your results.
+## tools/bazel
 
 If `tools/bazel` exists in your workspace root and is executable, Bazelisk will run this file, instead of the Bazel version it downloaded.
 It will set the environment variable `BAZEL_REAL` to the path of the downloaded Bazel binary.

From c3811face6d83bd191b5916fc49c111e2d47e658 Mon Sep 17 00:00:00 2001
From: Yun Peng <pcloudy@google.com>
Date: Fri, 28 Apr 2023 10:12:23 +0200
Subject: [PATCH 3/5] Remove unused struct

---
 core/core.go | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/core/core.go b/core/core.go
index e40d529c..63856c5c 100644
--- a/core/core.go
+++ b/core/core.go
@@ -743,10 +743,6 @@ type CompareResponse struct {
 	MergeBaseCommit Commit `json:"merge_base_commit"`
 }
 
-type Message struct {
-	Content string `json:"message"`
-}
-
 func sendRequest(url string) (*http.Response, error) {
 	client := &http.Client{}
 

From fc077ff618a03bf76216dc1c899f1b734843a305 Mon Sep 17 00:00:00 2001
From: Yun Peng <pcloudy@google.com>
Date: Fri, 28 Apr 2023 10:17:27 +0200
Subject: [PATCH 4/5] small fixes

---
 core/core.go | 10 ++++------
 1 file changed, 4 insertions(+), 6 deletions(-)

diff --git a/core/core.go b/core/core.go
index 63856c5c..70de0fe9 100644
--- a/core/core.go
+++ b/core/core.go
@@ -796,12 +796,10 @@ func getBazelCommitsBetween(goodCommit string, badCommit string) ([]string, erro
 			break
 		}
 
-		if page == 1 {
-			mergeBaseCommit := compareResponse.MergeBaseCommit.SHA
-			if compareResponse.MergeBaseCommit.SHA != goodCommit {
-				fmt.Printf("The good Bazel commit is not an ancestor of the bad Bazel commit, overriding the good Bazel commit to the merge base commit %s\n", mergeBaseCommit)
-				goodCommit = mergeBaseCommit
-			}
+		mergeBaseCommit := compareResponse.MergeBaseCommit.SHA
+		if compareResponse.MergeBaseCommit.SHA != goodCommit {
+			fmt.Printf("The good Bazel commit is not an ancestor of the bad Bazel commit, overriding the good Bazel commit to the merge base commit %s\n", mergeBaseCommit)
+			goodCommit = mergeBaseCommit
 		}
 
 		for _, commit := range compareResponse.Commits {

From 39b37756b05b42615b7048696d583fcc4c62b865 Mon Sep 17 00:00:00 2001
From: Yun Peng <pcloudy@google.com>
Date: Fri, 28 Apr 2023 10:45:54 +0200
Subject: [PATCH 5/5] Fix --strict

---
 core/core.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/core.go b/core/core.go
index 70de0fe9..753f88f5 100644
--- a/core/core.go
+++ b/core/core.go
@@ -127,7 +127,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 		}
 		if args[0] == "--migrate" {
 			migrate(bazelPath, args[1:], newFlags)
-		} else if args[0] == "--migrate" {
+		} else {
 			// When --strict is present, it expands to the list of --incompatible_ flags
 			// that should be enabled for the given Bazel version.
 			args = insertArgs(args[1:], newFlags)