diff --git a/LevelsCompare.md b/LevelsCompare.md index 6a8d3f8..224d25e 100644 --- a/LevelsCompare.md +++ b/LevelsCompare.md @@ -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. | diff --git a/README.md b/README.md index d77f4d3..6e9bccb 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c94708c..d2822d3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index b5e684a..466ce3b 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -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 diff --git a/app/src/main/java/solutions/tretter/githugandroid/NativeLevelSetup.kt b/app/src/main/java/solutions/tretter/githugandroid/NativeLevelSetup.kt index b323cb6..2898d10 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/NativeLevelSetup.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/NativeLevelSetup.kt @@ -4,8 +4,10 @@ import java.io.File class NativeLevelSetup internal constructor( internal val sandbox: File, - private val runGit: (File, List) -> Int, + private val runGit: (File, List, Map) -> 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, 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" + } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/BisectLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/BisectLevel.kt index a59df30..8f77cd8 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/BisectLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/BisectLevel.kt @@ -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", + ), ), ) diff --git a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt index de20330..100ef61 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt @@ -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 {