Make bisect playable and validate commit_in_future using Git author timestamps
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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\""),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user