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. |
| `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. |
| `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. |
| `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. |
@@ -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. |
| `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_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_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. |
@@ -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`. | 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. |
| `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. |
@@ -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:
- `commit_in_future`: add authored timestamp inspection to `CommitNode`/`RepoState`.
- `stage_lines`: model partial staged vs unstaged hunks.
- `merge_squash`: verify the exact squashed file/content effects.
- `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.
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
@@ -138,6 +151,7 @@ When adding or changing a level:
- Update `LevelsCompare.md` whenever level setup, validation, hints, or accepted solution behavior changes.
- Prefer validators that inspect `RepoState` over validators that match command strings.
- 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.
## Production Focus

View File

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

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\""),
),
)

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 {
System.getenv("GITHUG_TEST_GIT_BINARY")
?.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.",
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}")
}
}
}