Make bisect playable and validate commit_in_future using Git author timestamps
This commit is contained in:
@@ -17,6 +17,7 @@ data class GitFile(
|
||||
data class CommitNode(
|
||||
val id: String,
|
||||
val message: String,
|
||||
val authorTimestampSeconds: Long? = null,
|
||||
)
|
||||
|
||||
data class InteractiveAddSession(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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\""),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
System.getenv("GITHUG_TEST_GIT_BINARY")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
@@ -130,6 +130,15 @@ class LevelSolutionsTest {
|
||||
"Starting an interactive rebase without an upstream/range must not solve the reorder level.",
|
||||
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 {
|
||||
@@ -213,7 +222,7 @@ class LevelSolutionsTest {
|
||||
appendLine(" <none>")
|
||||
} else {
|
||||
commits.forEach { commit ->
|
||||
appendLine(" ${commit.id} ${commit.message}")
|
||||
appendLine(" ${commit.id} ${commit.authorTimestampSeconds ?: "unknown-time"} ${commit.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user