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

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

View File

@@ -148,18 +148,32 @@ class GitRepositoryRuntime private constructor(
tokens
} else {
expandShellPathspecs(currentRepo, invocation.command)
}.normalizeGitStageAlias()
}
.normalizeGitStageAlias()
.normalizeGitBisectRunScriptShortcut()
executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it }
val result = when (expandedTokens.first()) {
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
"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)
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> {
@@ -190,7 +204,7 @@ class GitRepositoryRuntime private constructor(
add("Native Git runtime:")
add(" binary path: nativeLibraryDir/libgit.so")
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(" git help <command>")
}
@@ -366,6 +380,7 @@ class GitRepositoryRuntime private constructor(
if (!file.exists() || file.isDirectory) currentRepo to listOf("cat: $target: No such file")
else currentRepo to file.readLines().ifEmpty { listOf("") }
}
"sh" -> executeShellScript(sandboxRoot, workingDir, currentRepo, tokens)
"touch" -> {
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: touch <file>")
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 {
bundledManPage(topic)?.let { return it }
val body = when (topic) {
@@ -645,7 +682,7 @@ class GitRepositoryRuntime private constructor(
"revert", "stash" -> true
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
"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
}
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>> {
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
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 remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list"))
val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list"))
@@ -742,8 +793,16 @@ class GitRepositoryRuntime private constructor(
)
val commits = if (logResult.exitCode == 0) {
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
val parts = line.split('\t', limit = 2)
if (parts.isEmpty()) null else CommitNode(parts[0], parts.getOrElse(1) { "" })
val parts = line.split('\t', limit = 3)
if (parts.isEmpty()) {
null
} else {
CommitNode(
id = parts[0],
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
message = parts.getOrElse(2) { "" },
)
}
}
} else {
emptyList()
@@ -828,10 +887,22 @@ class GitRepositoryRuntime private constructor(
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
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(
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 {
val directory = if (context != null) {
File(context.filesDir, "git-exec")

View File

@@ -8,7 +8,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
state.initialized,
state.headBranch,
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.currentDir,
state.tags,
@@ -53,9 +53,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
)
},
commits = commitParts.chunked(2).map {
CommitNode(it[0] as String, it[1] as String)
},
commits = commitParts.restoreCommitNodes(),
branches = branchParts.chunked(2).associate { (it[0] as String) to (it[1] as String).toInt() },
currentDir = saved[5] as 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
/**
* 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
* state-based whenever the exercise changes repository objects; answer-only
* levels intentionally validate the answer entered at the prompt.
* Upstream ships a Ruby fixture where `ruby prog.rb 5` should output 15.
* Android does not bundle Ruby, so this level keeps the same lesson and answer
* style but uses a POSIX shell test script committed into the exercise repo.
*/
internal fun bisectLevel(): Level = level(
id = "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?",
hints = emptyList(),
commandSuggestions = listOf("git bisect start", "make test"),
setup = { RepoState(initialized = true, branches = mapOf("master" to 7), commits = listOf(CommitNode("18ed2ac", "Introduce bug"))) },
validator = commitHashAnswer("18ed2ac"),
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 = listOf(
"Start with a known bad commit and a known good commit.",
"`known-good` marks a commit where the balance check passes.",
"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(
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(
id = "commit_in_future",
title = "Commit In Future",
description = "Commit your changes with the future date (e.g. tomorrow).",
hints = listOf("Build a time format, and commit your code using --date parameter."),
commandSuggestions = listOf("git commit --date tomorrow -m \"Future commit\""),
description = "Commit your changes with a date later than the current system date.",
hints = listOf("Build a future timestamp, and commit your code using the --date parameter."),
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)) },
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(
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\""),
),
)