Make bisect playable and validate commit_in_future using Git author timestamps

This commit is contained in:
Joe Tretter
2026-05-18 19:44:23 -05:00
parent f5bd5f5a60
commit afb548d46f
10 changed files with 256 additions and 36 deletions

View File

@@ -58,7 +58,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
| `squash` | Creates initial hidden commit plus README and three squash-target README updates. | Same commit sequence. | Equivalent. | | `squash` | Creates initial hidden commit plus README and three squash-target README updates. | Same commit sequence. | Equivalent. |
| `merge_squash` | Creates master and `long-feature-branch` with multiple feature changes. | Native setup creates the same branch and `file3` feature effect. | Mostly equivalent; Android currently validates less detail. | | `merge_squash` | Creates master and `long-feature-branch` with multiple feature changes. | Native setup creates the same branch and `file3` feature effect. | Mostly equivalent; Android currently validates less detail. |
| `reorder` | Creates commits `Initial Setup`, `First commit`, `Third commit`, `Second commit`. | Same order. | Equivalent. | | `reorder` | Creates commits `Initial Setup`, `First commit`, `Third commit`, `Second commit`. | Same order. | Equivalent. |
| `bisect` | Copies upstream bisect fixture. | Initializes modeled bad hash `18ed2ac`. | Simplified setup; Android validates the intended answer directly. | | `bisect` | Copies upstream Ruby fixture where `ruby prog.rb 5` or `make test` identifies bad hash `18ed2ac`. | Creates a native Git history with `balance.txt`, `test-balance.sh`, and `known-good` tag. | Intentional Android adaptation: no Ruby or make dependency, but real `git bisect` remains playable. |
| `stage_lines` | Commits initial `feature.rb`, then leaves two unstaged feature lines. | Initializes tracked `feature.rb` containing both feature lines. | Setup is close, but Android does not yet model partial staged vs unstaged hunks. | | `stage_lines` | Commits initial `feature.rb`, then leaves two unstaged feature lines. | Initializes tracked `feature.rb` containing both feature lines. | Setup is close, but Android does not yet model partial staged vs unstaged hunks. |
| `find_old_branch` | Copies fixture with recoverable `solve_world_hunger` branch. | Initializes branch map with `solve_world_hunger`. | Equivalent for visible exercise state. | | `find_old_branch` | Copies fixture with recoverable `solve_world_hunger` branch. | Initializes branch map with `solve_world_hunger`. | Equivalent for visible exercise state. |
| `revert` | Creates commits `First commit`, `Bad commit`, `Second commit`. | Same commit messages. | Equivalent. | | `revert` | Creates commits `First commit`, `Bad commit`, `Second commit`. | Same commit messages. | Equivalent. |
@@ -90,7 +90,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
| `tag` | First tag is `new_tag`. | `new_tag` exists. | Equivalent. | | `tag` | First tag is `new_tag`. | `new_tag` exists. | Equivalent. |
| `push_tags` | Remote tag list contains `tag_to_be_pushed`. | `tag_to_be_pushed` is in `pushedTags`. | Equivalent state projection. | | `push_tags` | Remote tag list contains `tag_to_be_pushed`. | `tag_to_be_pushed` is in `pushedTags`. | Equivalent state projection. |
| `commit_amend` | One commit and amended commit contains two files. | One commit and `forgotten_file.rb` is tracked. | Equivalent. | | `commit_amend` | One commit and amended commit contains two files. | One commit and `forgotten_file.rb` is tracked. | Equivalent. |
| `commit_in_future` | Commit authored date is in the future. | Any commit exists. | Known gap: Android does not currently expose commit authored timestamps in `RepoState`. | | `commit_in_future` | Commit authored date is in the future. | At least one commit has an author timestamp later than the current system clock. | Equivalent. |
| `reset` | `to_commit_second.rb` exists but is unstaged; `to_commit_first.rb` remains staged. | Same staged/unstaged split with one commit. | Equivalent. | | `reset` | `to_commit_second.rb` exists but is unstaged; `to_commit_first.rb` remains staged. | Same staged/unstaged split with one commit. | Equivalent. |
| `reset_soft` | `newfile.rb` exists, is staged, and commit count is one. | Same. | Equivalent. | | `reset_soft` | `newfile.rb` exists, is staged, and commit count is one. | Same. | Equivalent. |
| `checkout_file` | `config.rb` no longer modified and commit count remains one. | `config.rb` content equals initial content. | Equivalent. | | `checkout_file` | `config.rb` no longer modified and commit count remains one. | `config.rb` content equals initial content. | Equivalent. |
@@ -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. | | `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. | | `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. | | `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`. | Answer is `18ed2ac`. | Equivalent. | | `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. |
| `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. | | `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. | | `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. | | `revert` | More than three commits and a revert of "Bad commit" exists. | A commit message starts with `Revert`. | Slightly looser; sufficient for current fixture. |
@@ -157,7 +157,6 @@ This file records the upstream Ruby setup and validation intent beside the Andro
These are the remaining known non-parity items that need additional model support if exact upstream validation is required: These are the remaining known non-parity items that need additional model support if exact upstream validation is required:
- `commit_in_future`: add authored timestamp inspection to `CommitNode`/`RepoState`.
- `stage_lines`: model partial staged vs unstaged hunks. - `stage_lines`: model partial staged vs unstaged hunks.
- `merge_squash`: verify the exact squashed file/content effects. - `merge_squash`: verify the exact squashed file/content effects.
- `conflict`: model merge parent count or inspect merge commit parents. - `conflict`: model merge parent count or inspect merge commit parents.

View File

@@ -126,7 +126,20 @@ The command engine has one app-facing runtime:
The runtime exposes a `RepoState` surface to validators. In addition to files, commits, branches, tags, remotes, and config, the model tracks learning-relevant effects such as stashes, fetched remote refs, pushed branches/tags, submodules, and repository maintenance actions. The runtime exposes a `RepoState` surface to validators. In addition to files, commits, branches, tags, remotes, and config, the model tracks learning-relevant effects such as stashes, fetched remote refs, pushed branches/tags, submodules, and repository maintenance actions.
Helper shell-like commands (`ls`, `pwd`, `cat`, `touch`, `mkdir`, `rm`, `echo`, `cd`) remain implemented in Kotlin so the mobile terminal behaves consistently across devices. Helper shell-like commands (`ls`, `pwd`, `cat`, `sh <script>`, `./<script>`, `touch`, `mkdir`, `rm`, `echo`, `cd`) remain implemented in Kotlin so the mobile terminal behaves consistently across devices.
## Known Level Differences From Upstream
The Android port keeps the upstream GitHug level order, but some upstream fixtures assume desktop tools, network access, Ruby, Perl/Python helpers, or direct filesystem behavior that should not be required in a mobile learning sandbox. Differences must be documented here when they are intentional.
| 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. |
| `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. |
| `stage_lines` | Requires partial hunk staging: one feature line staged and another left unstaged. | Currently validates that `feature.rb` is staged. | Android does not yet expose enough index-vs-working-tree hunk detail in `RepoState` to validate partial staging precisely. |
| `rebase_onto`, `merge_squash`, `conflict`, `repack` | Upstream validates detailed object graph, file content, merge-parent, or object database details. | Android validates the relevant user-facing action or resulting state, but with less object-level detail in some cases. | The current `RepoState` projection does not expose every low-level Git object fact. These should be tightened when the state surface grows. |
## Level Authoring ## Level Authoring
@@ -138,6 +151,7 @@ When adding or changing a level:
- Update `LevelsCompare.md` whenever level setup, validation, hints, or accepted solution behavior changes. - Update `LevelsCompare.md` whenever level setup, validation, hints, or accepted solution behavior changes.
- Prefer validators that inspect `RepoState` over validators that match command strings. - Prefer validators that inspect `RepoState` over validators that match command strings.
- Add multiple `LevelTestCase` scenarios when more than one solution path should be accepted. - Add multiple `LevelTestCase` scenarios when more than one solution path should be accepted.
- Update this README's known-differences table if the Android level intentionally differs from upstream Ruby GitHug.
- Run `bash ./AndroidProjectTooling.sh --test` before building. - Run `bash ./AndroidProjectTooling.sh --test` before building.
## Production Focus ## Production Focus

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 149 versionCode = 150
versionName = "0.1.148" versionName = "0.1.149"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -17,6 +17,7 @@ data class GitFile(
data class CommitNode( data class CommitNode(
val id: String, val id: String,
val message: String, val message: String,
val authorTimestampSeconds: Long? = null,
) )
data class InteractiveAddSession( data class InteractiveAddSession(

View File

@@ -148,18 +148,32 @@ class GitRepositoryRuntime private constructor(
tokens tokens
} else { } else {
expandShellPathspecs(currentRepo, invocation.command) expandShellPathspecs(currentRepo, invocation.command)
}.normalizeGitStageAlias() }
.normalizeGitStageAlias()
.normalizeGitBisectRunScriptShortcut()
executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it } executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it }
val result = when (expandedTokens.first()) { val result = when (expandedTokens.first()) {
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines "git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
"help", "?" -> currentRepo to commandReferenceLines() "help", "?" -> currentRepo to commandReferenceLines()
else -> executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens) else -> executeExecutableShortcut(sandboxRoot, workingDir, currentRepo, expandedTokens)
?: executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens)
} }
val inspectedRepo = inspectSandbox(level).copy(currentDir = result.first.currentDir) val inspectedRepo = inspectSandbox(level).copy(currentDir = result.first.currentDir)
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens) to result.second return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) to result.second
}
private fun executeExecutableShortcut(
sandboxRoot: File,
workingDir: File,
currentRepo: RepoState,
tokens: List<String>,
): Pair<RepoState, List<String>>? {
val executable = tokens.firstOrNull() ?: return null
if (!executable.startsWith("./") || executable.length <= 2) return null
return executeShellScript(sandboxRoot, workingDir, currentRepo, listOf("sh", executable) + tokens.drop(1))
} }
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> { fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
@@ -190,7 +204,7 @@ class GitRepositoryRuntime private constructor(
add("Native Git runtime:") add("Native Git runtime:")
add(" binary path: nativeLibraryDir/libgit.so") add(" binary path: nativeLibraryDir/libgit.so")
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}") add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
add(" helper commands: ls/dir, pwd, cat, touch, mkdir/md, cd.., rm/del, echo") add(" helper commands: ls/dir, pwd, cat, sh <script>, ./<script>, touch, mkdir/md, cd.., rm/del, echo")
add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad") add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad")
add(" git help <command>") add(" git help <command>")
} }
@@ -366,6 +380,7 @@ class GitRepositoryRuntime private constructor(
if (!file.exists() || file.isDirectory) currentRepo to listOf("cat: $target: No such file") if (!file.exists() || file.isDirectory) currentRepo to listOf("cat: $target: No such file")
else currentRepo to file.readLines().ifEmpty { listOf("") } else currentRepo to file.readLines().ifEmpty { listOf("") }
} }
"sh" -> executeShellScript(sandboxRoot, workingDir, currentRepo, tokens)
"touch" -> { "touch" -> {
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: touch <file>") val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: touch <file>")
val file = File(workingDir, target) val file = File(workingDir, target)
@@ -418,6 +433,28 @@ class GitRepositoryRuntime private constructor(
} }
} }
private fun executeShellScript(
sandboxRoot: File,
workingDir: File,
currentRepo: RepoState,
tokens: List<String>,
): Pair<RepoState, List<String>> {
val script = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: sh <script>")
val scriptFile = File(workingDir, script).canonicalFile
if (!scriptFile.path.startsWith(sandboxRoot.path)) {
return currentRepo to listOf("sh: $script: Permission denied")
}
if (!scriptFile.isFile) {
return currentRepo to listOf("sh: $script: No such file")
}
val result = runShellProcess(
File(shellExecutable()),
workingDir,
tokens.drop(1),
)
return currentRepo to result.outputLines
}
private fun placeholderManPage(topic: String): String { private fun placeholderManPage(topic: String): String {
bundledManPage(topic)?.let { return it } bundledManPage(topic)?.let { return it }
val body = when (topic) { val body = when (topic) {
@@ -645,7 +682,7 @@ class GitRepositoryRuntime private constructor(
"revert", "stash" -> true "revert", "stash" -> true
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb") "checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
"submodule" -> tokens.getOrNull(2) == "add" "submodule" -> tokens.getOrNull(2) == "add"
"commit" -> "merge-squash" in currentRepo.maintenanceActions || tokens.any { it == "--date" || it.startsWith("--date=") } "commit" -> "merge-squash" in currentRepo.maintenanceActions
else -> false else -> false
} }
if (!shouldUseSandboxSemantics) return null if (!shouldUseSandboxSemantics) return null
@@ -662,6 +699,20 @@ class GitRepositoryRuntime private constructor(
} }
} }
private fun List<String>.normalizeGitBisectRunScriptShortcut(): List<String> {
return if (
size >= 4 &&
this[0] == "git" &&
this[1] == "bisect" &&
this[2] == "run" &&
this[3].startsWith("./")
) {
take(3) + listOf("sh") + drop(3)
} else {
this
}
}
private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> { private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" } val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) { if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) {
@@ -714,7 +765,7 @@ class GitRepositoryRuntime private constructor(
} }
} }
val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%s")) val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%at\t%s"))
val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list")) val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list"))
val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list")) val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list"))
val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list")) val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list"))
@@ -742,8 +793,16 @@ class GitRepositoryRuntime private constructor(
) )
val commits = if (logResult.exitCode == 0) { val commits = if (logResult.exitCode == 0) {
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line -> logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
val parts = line.split('\t', limit = 2) val parts = line.split('\t', limit = 3)
if (parts.isEmpty()) null else CommitNode(parts[0], parts.getOrElse(1) { "" }) if (parts.isEmpty()) {
null
} else {
CommitNode(
id = parts[0],
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
message = parts.getOrElse(2) { "" },
)
}
} }
} else { } else {
emptyList() emptyList()
@@ -828,10 +887,22 @@ class GitRepositoryRuntime private constructor(
return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1)) return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1))
} }
private fun augmentObservedRepoFacts(previousRepo: RepoState, inspectedRepo: RepoState, tokens: List<String>): RepoState { private fun augmentObservedRepoFacts(
previousRepo: RepoState,
inspectedRepo: RepoState,
tokens: List<String>,
outputLines: List<String> = emptyList(),
): RepoState {
if (tokens.firstOrNull() != "git") return inspectedRepo if (tokens.firstOrNull() != "git") return inspectedRepo
return when (tokens.getOrNull(1)) { return when (tokens.getOrNull(1)) {
"bisect" -> {
if (tokens.getOrNull(2) == "run" && outputLines.any { it.contains("is the first bad commit") }) {
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions + "bisect-found")
} else {
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions)
}
}
"stash" -> inspectedRepo.copy( "stash" -> inspectedRepo.copy(
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes + "stash@{${previousRepo.stashes.size}}"), stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes + "stash@{${previousRepo.stashes.size}}"),
) )
@@ -996,6 +1067,25 @@ class GitRepositoryRuntime private constructor(
} }
} }
private fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
return try {
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
.directory(workingDir)
.redirectErrorStream(true)
.apply {
environment()["HOME"] = workingDir.absolutePath
environment()["LC_ALL"] = "C"
}
.start()
val output = process.inputStream.bufferedReader().readLines()
val exit = process.waitFor()
ProcessExecutionResult(exitCode = exit, outputLines = output)
} catch (error: Exception) {
ProcessExecutionResult(exitCode = -1, outputLines = listOf("Shell execution failed: ${error.message ?: error::class.java.simpleName}"))
}
}
private fun gitExecDirectory(binary: File): File { private fun gitExecDirectory(binary: File): File {
val directory = if (context != null) { val directory = if (context != null) {
File(context.filesDir, "git-exec") File(context.filesDir, "git-exec")

View File

@@ -8,7 +8,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
state.initialized, state.initialized,
state.headBranch, state.headBranch,
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) }, state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
state.commits.flatMap { listOf(it.id, it.message) }, state.commits.flatMap { listOf(it.id, it.message, it.authorTimestampSeconds?.toString().orEmpty()) },
state.branches.flatMap { listOf(it.key, it.value.toString()) }, state.branches.flatMap { listOf(it.key, it.value.toString()) },
state.currentDir, state.currentDir,
state.tags, state.tags,
@@ -53,9 +53,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false, deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
) )
}, },
commits = commitParts.chunked(2).map { commits = commitParts.restoreCommitNodes(),
CommitNode(it[0] as String, it[1] as String)
},
branches = branchParts.chunked(2).associate { (it[0] as String) to (it[1] as String).toInt() }, branches = branchParts.chunked(2).associate { (it[0] as String) to (it[1] as String).toInt() },
currentDir = saved[5] as String, currentDir = saved[5] as String,
tags = tags.filterIsInstance<String>(), tags = tags.filterIsInstance<String>(),
@@ -77,3 +75,21 @@ val RepoStateSaver = listSaver<RepoState, Any>(
) )
} }
) )
private fun List<*>.restoreCommitNodes(): List<CommitNode> {
val hasTimestampColumn = size % 3 == 0 && chunked(3).all { chunk ->
val timestamp = chunk.getOrNull(2) as? String
timestamp.isNullOrEmpty() || timestamp.toLongOrNull()?.let { it >= 100_000_000L } == true
}
val width = if (hasTimestampColumn) 3 else 2
return chunked(width).mapNotNull { chunk ->
val id = chunk.getOrNull(0) as? String ?: return@mapNotNull null
val message = chunk.getOrNull(1) as? String ?: ""
CommitNode(
id = id,
message = message,
authorTimestampSeconds = if (hasTimestampColumn) (chunk.getOrNull(2) as? String)?.toLongOrNull() else null,
)
}
}

View File

@@ -1,21 +1,89 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
/** /**
* Port of the upstream ruby-githug `bisect` level. * Android-native adaptation of the upstream ruby-githug `bisect` level.
* *
* Setup documents the repository shape the learner explores. Evaluation is kept * Upstream ships a Ruby fixture where `ruby prog.rb 5` should output 15.
* state-based whenever the exercise changes repository objects; answer-only * Android does not bundle Ruby, so this level keeps the same lesson and answer
* levels intentionally validate the answer entered at the prompt. * style but uses a POSIX shell test script committed into the exercise repo.
*/ */
internal fun bisectLevel(): Level = level( internal fun bisectLevel(): Level = level(
id = "bisect", id = "bisect",
title = "Bisect", title = "Bisect",
description = "A bug was introduced somewhere along the way. You know that running `ruby prog.rb 5` should output 15. You can also run `make test`. What are the first 7 chars of the hash of the commit (the abbreviated hash) that introduced the bug?", 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.",
hints = emptyList(), hints = listOf(
commandSuggestions = listOf("git bisect start", "make test"), "Start with a known bad commit and a known good commit.",
setup = { RepoState(initialized = true, branches = mapOf("master" to 7), commits = listOf(CommitNode("18ed2ac", "Introduce bug"))) }, "`known-good` marks a commit where the balance check passes.",
validator = commitHashAnswer("18ed2ac"), "You can automate the search with `git bisect run ./test-balance.sh`.",
),
commandSuggestions = listOf(
"./test-balance.sh",
"git bisect start HEAD known-good",
"git bisect run ./test-balance.sh",
),
setup = {
RepoState(
initialized = true,
branches = mapOf("master" to 7),
tags = listOf("known-good"),
commits = listOf(
CommitNode("7", "Polish report labels"),
CommitNode("6", "Add forecast note"),
CommitNode("5", "Update help text"),
CommitNode("4", "Break closing balance calculation"),
CommitNode("3", "Add audit note"),
CommitNode("2", "Add January note"),
CommitNode("1", "Add balance check"),
),
)
},
nativeSetup = {
resetFiles()
write("balance.txt", "BALANCE=100\n")
write(
"test-balance.sh",
"""
|#!/bin/sh
|while IFS= read -r line
|do
| if test "${'$'}line" = "BALANCE=100"
| then
| echo "balance ok"
| exit 0
| fi
|done < balance.txt
|echo "balance broken"
|exit 1
|""".trimMargin(),
)
addCommit("Add balance check", "balance.txt", "test-balance.sh")
tag("known-good")
write("notes.txt", "January review complete\n")
addCommit("Add January note", "notes.txt")
append("notes.txt", "Audit trail reviewed\n")
addCommit("Add audit note", "notes.txt")
write("balance.txt", "BALANCE=101\n")
addCommit("Break closing balance calculation", "balance.txt")
write("README", "Run ./test-balance.sh to check the report.\n")
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")
addCommit("Polish report labels", "README")
true
},
validator = { repo, command ->
val badCommit = repo.commits.firstOrNull { it.message == "Break closing balance calculation" }
val normalized = command.trim()
val answerMatches = badCommit != null &&
normalized.isNotBlank() &&
!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
},
testCases = listOf( testCases = listOf(
levelTestCase("answer bad commit", "18ed2ac"), levelTestCase("automated bisect", "git bisect start HEAD known-good", "git bisect run ./test-balance.sh"),
), ),
) )

View File

@@ -10,12 +10,17 @@ package solutions.tretter.githugandroid
internal fun commitInFutureLevel(): Level = level( internal fun commitInFutureLevel(): Level = level(
id = "commit_in_future", id = "commit_in_future",
title = "Commit In Future", title = "Commit In Future",
description = "Commit your changes with the future date (e.g. tomorrow).", description = "Commit your changes with a date later than the current system date.",
hints = listOf("Build a time format, and commit your code using --date parameter."), hints = listOf("Build a future timestamp, and commit your code using the --date parameter."),
commandSuggestions = listOf("git commit --date tomorrow -m \"Future commit\""), commandSuggestions = listOf("git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""),
setup = { RepoState(initialized = true, files = listOf(GitFile("README", staged = true)), branches = mapOf("master" to 0)) }, setup = { RepoState(initialized = true, files = listOf(GitFile("README", staged = true)), branches = mapOf("master" to 0)) },
validator = repoPredicate { repo -> repo.commits.isNotEmpty() }, validator = repoPredicate { repo ->
val nowSeconds = System.currentTimeMillis() / 1000
repo.commits.any { commit ->
commit.authorTimestampSeconds?.let { authorTime -> authorTime > nowSeconds } == true
}
},
testCases = listOf( testCases = listOf(
levelTestCase("commit with date option", "git commit --date tomorrow -m \"Future commit\""), levelTestCase("commit with date option", "git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""),
), ),
) )

View File

@@ -411,6 +411,24 @@ class GitSandboxEngineTest {
} }
} }
@Test
fun nativeExecutableShortcutRunsScriptThroughShell() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-script-shortcut").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = bisectLevel()
val repo = runtime.prepareLevel(level)
val (_, output) = runtime.execute(level, repo, "./test-balance.sh")
assertTrue(output.joinToString("\n"), output.any { it.contains("balance broken") })
} finally {
root.deleteRecursively()
}
}
private fun testGitBinary(): File { private fun testGitBinary(): File {
System.getenv("GITHUG_TEST_GIT_BINARY") System.getenv("GITHUG_TEST_GIT_BINARY")
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }

View File

@@ -130,6 +130,15 @@ class LevelSolutionsTest {
"Starting an interactive rebase without an upstream/range must not solve the reorder level.", "Starting an interactive rebase without an upstream/range must not solve the reorder level.",
reorderExercise.validator(bareInteractiveRebaseRepo, "git rebase -i"), reorderExercise.validator(bareInteractiveRebaseRepo, "git rebase -i"),
) )
val futureCommitExercise = commitInFutureLevel()
val futureCommitRuntime = GitRepositoryRuntime(runtimeRoot, gitBinary)
val futureCommitRepo = futureCommitRuntime.prepareLevel(futureCommitExercise)
val (currentDateCommitRepo, _) = futureCommitRuntime.execute(futureCommitExercise, futureCommitRepo, "git commit -m \"Current date commit\"")
assertFalse(
"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\""),
)
} }
private companion object { private companion object {
@@ -213,7 +222,7 @@ class LevelSolutionsTest {
appendLine(" <none>") appendLine(" <none>")
} else { } else {
commits.forEach { commit -> commits.forEach { commit ->
appendLine(" ${commit.id} ${commit.message}") appendLine(" ${commit.id} ${commit.authorTimestampSeconds ?: "unknown-time"} ${commit.message}")
} }
} }
} }