Ask for last good commit in bisect level

This commit is contained in:
Joe Tretter
2026-05-18 20:19:34 -05:00
parent 008a322134
commit 1ce4df1209
7 changed files with 62 additions and 24 deletions

View File

@@ -119,7 +119,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
| `squash` | Commit count is two. | Commit count at most two and "Adding README" remains. | Slightly stricter on preserving the base README commit. |
| `merge_squash` | Commit count is three and all long-feature changes are included. | Squash action recorded and a commit exists. | Known gap: Android does not yet verify all squash file/content effects. |
| `reorder` | `git log` subject order matches `Third.*Second.*First.*Initial`. | Modeled commit order becomes First, Second, Third. | Equivalent relative to Android's oldest-first commit list. |
| `bisect` | Answer is hash prefix `18ed2ac` after using Ruby/make fixture. | Learner can run `git bisect start HEAD known-good` and `git bisect run ./test-balance.sh`; Android accepts the discovered bad hash or the final bisect state. | Same lesson, different fixture to keep it playable without Ruby/make. |
| `bisect` | Answer is hash prefix `18ed2ac` after using Ruby/make fixture. | Learner can run `git bisect start`, mark `HEAD` bad and `known-good` good, run `git bisect run ./test-balance.sh`, then answer the last good commit hash. | Same bisect lesson, different fixture and final answer target to keep the Android flow clear without Ruby/make. |
| `stage_lines` | Staged diff contains first feature line; unstaged diff contains second feature line. | `feature.rb` is staged. | Known gap: Android does not yet model partial hunk staging. |
| `find_old_branch` | Current branch is `solve_world_hunger`. | Same. | Equivalent. |
| `revert` | More than three commits and a revert of "Bad commit" exists. | A commit message starts with `Revert`. | Slightly looser; sufficient for current fixture. |

View File

@@ -134,7 +134,7 @@ The Android port keeps the upstream GitHug level order, but some upstream fixtur
| Level | Upstream behavior | Android behavior | Why it differs |
| --- | --- | --- | --- |
| `bisect` | Copies the upstream Ruby fixture. The learner tests each checked-out commit with `ruby prog.rb 5` or `make test`, then answers the abbreviated hash `18ed2ac`. | Creates a native Git history with `balance.txt` and `test-balance.sh`. The learner can run `./test-balance.sh` or `sh test-balance.sh`, then use `git bisect start HEAD known-good` and `git bisect run ./test-balance.sh`. The level accepts the discovered bad commit hash or the final bisect state. | Android does not bundle Ruby or `make`. The replacement still demonstrates the real `git bisect` workflow: identify known good/bad endpoints, run a test at each checked-out commit, and find the first bad commit. |
| `bisect` | Copies the upstream Ruby fixture. The learner tests each checked-out commit with `ruby prog.rb 5` or `make test`, then answers the abbreviated hash `18ed2ac`. | Creates a native Git history with `balance.txt` and `test-balance.sh`. The learner can run `./test-balance.sh` or `sh test-balance.sh`, mark `HEAD` bad and `known-good` good, then use `git bisect run ./test-balance.sh`. The level accepts only the abbreviated hash of the last good commit before the break. | Android does not bundle Ruby or `make`. The replacement still demonstrates the real `git bisect` workflow: identify known good/bad endpoints, run a test at each checked-out commit, and use the discovered first bad commit to identify the last good commit. |
| `clone` / `clone_to_folder` | Clones `https://github.com/Gazler/cloneme` and checks the cloned repository content. | Accepts the intended clone command and models the resulting folder. | The app must remain playable offline and avoid relying on GitHub network access from a phone. |
| `pull`, `fetch`, `push`, `push_branch`, `push_tags` | Use remote-style workflows from upstream fixtures. | Use local synthetic remotes created inside the sandbox and validate fetched/pushed refs through `RepoState`. | This preserves Git behavior without external network dependencies. |
| `contribute` | Expects cloning upstream and finding a commit authored by the configured user. | Treated as a mobile/offline final prompt with a nonblank response. | The original workflow leaves the sandbox and depends on external contribution infrastructure. |

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid"
minSdk = 26
targetSdk = 35
versionCode = 152
versionName = "0.1.151"
versionCode = 153
versionName = "0.1.152"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@@ -584,8 +584,8 @@ class GitRepositoryRuntime private constructor(
private fun materializeNativeGitState(nativeGit: File, sandbox: File, desired: RepoState, level: Level) {
level.nativeSetup?.let { setup ->
val nativeSetup = NativeLevelSetup(sandbox) { directory, arguments ->
runGit(nativeGit, directory, arguments).exitCode
val nativeSetup = NativeLevelSetup(sandbox) { directory, arguments, environment ->
runGit(nativeGit, directory, arguments, environment).exitCode
}
if (nativeSetup.setup()) {
return

View File

@@ -4,8 +4,10 @@ import java.io.File
class NativeLevelSetup internal constructor(
internal val sandbox: File,
private val runGit: (File, List<String>) -> Int,
private val runGit: (File, List<String>, Map<String, String>) -> Int,
) {
private var commitSequence = 0
fun resetFiles() {
sandbox.listFiles()
?.filterNot { it.name == ".git" }
@@ -13,9 +15,9 @@ class NativeLevelSetup internal constructor(
git("checkout", "-B", "master")
}
fun git(vararg arguments: String): Int = runGit(sandbox, arguments.toList())
fun git(vararg arguments: String): Int = runGit(sandbox, arguments.toList(), emptyMap())
fun git(directory: File, vararg arguments: String): Int = runGit(directory, arguments.toList())
fun git(directory: File, vararg arguments: String): Int = runGit(directory, arguments.toList(), emptyMap())
fun initRepo(directory: File) {
directory.mkdirs()
@@ -43,10 +45,15 @@ class NativeLevelSetup internal constructor(
}
fun commit(message: String, author: String? = null) {
val commitDate = nextDeterministicCommitDate()
val environment = mapOf(
"GIT_AUTHOR_DATE" to commitDate,
"GIT_COMMITTER_DATE" to commitDate,
)
if (author == null) {
git("commit", "-m", message)
gitWithEnvironment(environment, "commit", "-m", message)
} else {
git("commit", "--author", author, "-m", message)
gitWithEnvironment(environment, "commit", "--author", author, "-m", message)
}
}
@@ -85,4 +92,15 @@ class NativeLevelSetup internal constructor(
fun tag(name: String) {
git("tag", "-f", name)
}
private fun gitWithEnvironment(environment: Map<String, String>, vararg arguments: String): Int {
return runGit(sandbox, arguments.toList(), environment)
}
private fun nextDeterministicCommitDate(): String {
commitSequence += 1
val minute = (commitSequence / 60).toString().padStart(2, '0')
val second = (commitSequence % 60).toString().padStart(2, '0')
return "2000-01-01T00:$minute:$second+0000"
}
}

View File

@@ -10,16 +10,19 @@ package solutions.tretter.githugandroid
internal fun bisectLevel(): Level = level(
id = "bisect",
title = "Bisect",
description = "A balance check started failing somewhere in the history. Run `./test-balance.sh` to test the current commit. Use `git bisect` to identify the first bad commit.",
description = "A balance check started failing somewhere in the history. The current HEAD is bad: `./test-balance.sh` prints `balance broken`. Use `git bisect` to locate the break, then enter the abbreviated hash of the last good commit.",
hints = listOf(
"Start with a known bad commit and a known good commit.",
"Mark the current HEAD as bad, because the test fails there.",
"`known-good` marks a commit where the balance check passes.",
"You can automate the search with `git bisect run ./test-balance.sh`.",
"`git bisect run ./test-balance.sh` identifies the first bad commit; the last good commit is its parent in this linear history.",
),
commandSuggestions = listOf(
"./test-balance.sh",
"git bisect start HEAD known-good",
"git bisect start",
"git bisect bad HEAD",
"git bisect good known-good",
"git bisect run ./test-balance.sh",
"git rev-parse --short HEAD^",
),
setup = {
RepoState(
@@ -68,22 +71,26 @@ internal fun bisectLevel(): Level = level(
addCommit("Update help text", "README")
append("notes.txt", "Forecast still depends on the balance check\n")
addCommit("Add forecast note", "notes.txt")
append("README", "Use git bisect to find the first broken commit.\n")
append("README", "Use git bisect to find the last good commit before the report broke.\n")
addCommit("Polish report labels", "README")
true
},
validator = { repo, command ->
val badCommit = repo.commits.firstOrNull { it.message == "Break closing balance calculation" }
val lastGoodCommit = repo.commits.firstOrNull { it.message == "Add audit note" }
val normalized = command.trim()
val answerMatches = badCommit != null &&
normalized.isNotBlank() &&
lastGoodCommit != null &&
normalized.length >= 4 &&
!normalized.startsWith("git ") &&
(normalized.startsWith(badCommit.id, ignoreCase = true) || badCommit.id.startsWith(normalized, ignoreCase = true))
val bisectFoundBadCommit = normalized.startsWith("git bisect run", ignoreCase = true) &&
"bisect-found" in repo.maintenanceActions
answerMatches || bisectFoundBadCommit
(normalized.startsWith(lastGoodCommit.id, ignoreCase = true) || lastGoodCommit.id.startsWith(normalized, ignoreCase = true))
},
testCases = listOf(
levelTestCase("automated bisect", "git bisect start HEAD known-good", "git bisect run ./test-balance.sh"),
levelTestCase(
"answer last good commit",
"git bisect start",
"git bisect bad HEAD",
"git bisect good known-good",
"git bisect run ./test-balance.sh",
"c8c7c00",
),
),
)

View File

@@ -139,6 +139,19 @@ class LevelSolutionsTest {
"A normal commit using the current system date must not solve the commit_in_future level.",
futureCommitExercise.validator(currentDateCommitRepo, "git commit -m \"Current date commit\""),
)
val bisectExercise = bisectLevel()
val bisectRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary)
var bisectRepo = bisectRuntime.prepareLevel(bisectExercise)
listOf("git bisect start", "git bisect bad HEAD", "git bisect good known-good").forEach { command ->
val (nextRepo, _) = bisectRuntime.execute(bisectExercise, bisectRepo, command)
bisectRepo = nextRepo
}
val (bisectRunRepo, _) = bisectRuntime.execute(bisectExercise, bisectRepo, "git bisect run ./test-balance.sh")
assertFalse(
"Running bisect should not solve the bisect level until the learner enters the last good commit hash.",
bisectExercise.validator(bisectRunRepo, "git bisect run ./test-balance.sh"),
)
}
private companion object {