Fix level setup, reword flow, and catalog scope
- create and enter the level's declared starting directory - inspect Git state from the repository containing the active directory - start the init level in /sandbox/git_hug and cover it with a runtime test - apply edited reword subjects as commit messages in the in-app rebase editor - cover rename_commit with a native interactive rebase regression test - remove the upstream contribute call to action from the playable level catalog - update level parity documentation
This commit is contained in:
@@ -10,7 +10,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
|
|
||||||
| Level | Upstream setup intent | Android setup intent | Parity / reason for difference |
|
| Level | Upstream setup intent | Android setup intent | Parity / reason for difference |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `init` | Starts outside a Git repository. | Starts with empty `RepoState`. | Equivalent. |
|
| `init` | Creates `git_hug`, changes into it, and starts outside a Git repository. | Creates `/sandbox/git_hug`, starts the learner in it, and leaves it uninitialized. | Equivalent. |
|
||||||
| `config` | Initializes a repository. | Initializes a repository. | Equivalent. |
|
| `config` | Initializes a repository. | Initializes a repository. | Equivalent. |
|
||||||
| `add` | Initializes repo, creates untracked `README`, renames branch to `master`. | Initializes repo with untracked `README` on `master`. | Equivalent. |
|
| `add` | Initializes repo, creates untracked `README`, renames branch to `master`. | Initializes repo with untracked `README` on `master`. | Equivalent. |
|
||||||
| `commit` | Initializes repo, creates and stages `README`. | Initializes repo with staged `README`. | Equivalent. |
|
| `commit` | Initializes repo, creates and stages `README`. | Initializes repo with staged `README`. | Equivalent. |
|
||||||
@@ -65,7 +65,6 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| `restore` | Creates `file1`, `file2`, then creates and removes `file3` so it is recoverable from reflog/history. | Native setup creates matching history and removes `file3`; model starts without `file3`. | Equivalent. |
|
| `restore` | Creates `file1`, `file2`, then creates and removes `file3` so it is recoverable from reflog/history. | Native setup creates matching history and removes `file3`; model starts without `file3`. | Equivalent. |
|
||||||
| `conflict` | Copies fixture with `master` and `mybranch` conflict in non-empty `poem.txt`. | Native setup creates the conflicting poem history, leaving `master` with `Categorized shoes by color` and `mybranch` with the correct `Sat on a wall` line. | Equivalent setup. |
|
| `conflict` | Copies fixture with `master` and `mybranch` conflict in non-empty `poem.txt`. | Native setup creates the conflicting poem history, leaving `master` with `Categorized shoes by color` and `mybranch` with the correct `Sat on a wall` line. | Equivalent setup. |
|
||||||
| `submodule` | Initializes empty repo. | Same. | Equivalent; network submodule operation is modeled. |
|
| `submodule` | Initializes empty repo. | Same. | Equivalent; network submodule operation is modeled. |
|
||||||
| `contribute` | No local setup; solution clones upstream externally. | Empty state. | Deliberate simplification for mobile/offline final prompt. |
|
|
||||||
|
|
||||||
## Validation Summary
|
## Validation Summary
|
||||||
|
|
||||||
@@ -115,7 +114,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| `repack` | `git count-objects -v` includes packed/pruned object evidence. | `repack` action recorded. | Equivalent action-level validation; Android does not model object database packing stats. |
|
| `repack` | `git count-objects -v` includes packed/pruned object evidence. | `repack` action recorded. | Equivalent action-level validation; Android does not model object database packing stats. |
|
||||||
| `cherry-pick` | Top commits are "Filled in README..." then "Added fancy branded output". | Same commit message order plus `README.md` tracked. | Equivalent. |
|
| `cherry-pick` | Top commits are "Filled in README..." then "Added fancy branded output". | Same commit message order plus `README.md` tracked. | Equivalent. |
|
||||||
| `grep` | Answer is TODO count `4`. | Answer is `4`. | Equivalent. |
|
| `grep` | Answer is TODO count `4`. | Answer is `4`. | Equivalent. |
|
||||||
| `rename_commit` | Parent commit message is corrected to `First commit`. | No `coommit` remains and `First commit` exists. | Equivalent for intended reword. |
|
| `rename_commit` | Parent commit message is corrected to `First commit`. | No `coommit` remains and `First commit` exists; the in-app rebase editor uses the subject text on a `reword` line as the replacement message. | Equivalent outcome with a single mobile editor step. |
|
||||||
| `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. |
|
||||||
@@ -126,7 +125,6 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| `restore` | `file3` exists. | `file3` is tracked. | Equivalent. |
|
| `restore` | `file3` exists. | `file3` is tracked. | Equivalent. |
|
||||||
| `conflict` | On `master`, merge commit has two parents, conflict markers removed, both poem lines preserved. | Requires the latest commit on `master` to be a two-parent merge commit, conflict markers removed, and the correct `Sat on a wall` poem line preserved. | Equivalent. |
|
| `conflict` | On `master`, merge commit has two parents, conflict markers removed, both poem lines preserved. | Requires the latest commit on `master` to be a two-parent merge commit, conflict markers removed, and the correct `Sat on a wall` poem line preserved. | Equivalent. |
|
||||||
| `submodule` | `githug-include-me` directory exists, has README, and is a gitlink/submodule. | `submodules` contains `githug-include-me` URL. | Equivalent state projection. |
|
| `submodule` | `githug-include-me` directory exists, has README, and is a gitlink/submodule. | `submodules` contains `githug-include-me` URL. | Equivalent state projection. |
|
||||||
| `contribute` | Clones upstream and checks for a commit authored by configured user. | Any nonblank command. | Deliberate mobile/offline simplification for the final contribution prompt. |
|
|
||||||
|
|
||||||
## Focused Source-To-Android Checks
|
## Focused Source-To-Android Checks
|
||||||
|
|
||||||
@@ -160,4 +158,6 @@ These are the remaining known non-parity items that need additional model suppor
|
|||||||
- `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.
|
||||||
- `rebase_onto`: verify final commit count/content and removal of "Wrong changes".
|
- `rebase_onto`: verify final commit count/content and removal of "Wrong changes".
|
||||||
- `contribute`, `clone`, `clone_to_folder`: current Android behavior intentionally avoids real network-dependent validation.
|
- `clone`, `clone_to_folder`: current Android behavior intentionally avoids real network-dependent validation.
|
||||||
|
|
||||||
|
The upstream `contribute` call to action is intentionally not implemented as a level because it asks learners to contribute to the original GitHug repository rather than teaching or validating a Git operation.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Native Android adaptation of the Ruby [Githug](https://github.com/Gazler/githug)
|
|||||||
|
|
||||||
The app is organized around GitHug parity rather than simplified command quizzes:
|
The app is organized around GitHug parity rather than simplified command quizzes:
|
||||||
|
|
||||||
- The level catalog follows the upstream Ruby Githug order from `Githug::Level::LEVELS`.
|
- The level catalog follows the upstream Ruby Githug exercise order from `Githug::Level::LEVELS`, excluding the final `contribute` call to action.
|
||||||
- Each exercise lives in its own Kotlin source file under `app/src/main/java/solutions/tretter/githugandroid/levels/`.
|
- Each exercise lives in its own Kotlin source file under `app/src/main/java/solutions/tretter/githugandroid/levels/`.
|
||||||
- Each level file documents the intended repository setup, evaluation strategy, hints, command suggestions, and embedded solution scenarios.
|
- Each level file documents the intended repository setup, evaluation strategy, hints, command suggestions, and embedded solution scenarios.
|
||||||
- Validators prefer repository state and Git objects over raw command text. Direct command-answer validation is reserved for upstream answer-style levels such as identifying a hash, filename, remote URL, author, or count.
|
- Validators prefer repository state and Git objects over raw command text. Direct command-answer validation is reserved for upstream answer-style levels such as identifying a hash, filename, remote URL, author, or count.
|
||||||
@@ -133,14 +133,13 @@ Source files should stay comfortably reviewable. Treat files approaching roughly
|
|||||||
|
|
||||||
## Known Level Differences From Upstream
|
## 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.
|
The Android port keeps the upstream GitHug exercise order through `submodule`. The final upstream `contribute` entry is intentionally excluded because it is a call to action for the original repository rather than a Git exercise. Some included 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 |
|
| 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`, mark `HEAD` bad and `known-good` good, then use `git bisect run ./test-balance.sh`. The level accepts only the abbreviated hash of the last good commit before the break. | 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 use the discovered first bad commit to identify the last good commit. |
|
| `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`, mark `HEAD` bad and `known-good` good, then use `git bisect run ./test-balance.sh`. The level accepts only the abbreviated hash of the last good commit before the break. | 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 use the discovered first bad commit to identify the last good 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. |
|
| `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. |
|
| `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. |
|
| `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`, `repack` | Upstream validates detailed object graph, 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. |
|
| `rebase_onto`, `merge_squash`, `repack` | Upstream validates detailed object graph, 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. |
|
||||||
| `conflict` | Copies the upstream conflicting poem fixture and validates that the merge commit has two parents, conflict markers are removed, and the correct poem line remains. | Recreates the conflicting poem history natively and validates the latest commit has two parents, no conflict markers remain, and the correct `Sat on a wall` line is present. | Equivalent. |
|
| `conflict` | Copies the upstream conflicting poem fixture and validates that the merge commit has two parents, conflict markers are removed, and the correct poem line remains. | Recreates the conflicting poem history natively and validates the latest commit has two parents, no conflict markers remain, and the correct `Sat on a wall` line is present. | Equivalent. |
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId = "solutions.tretter.githugandroid"
|
applicationId = "solutions.tretter.githugandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 167
|
versionCode = 168
|
||||||
versionName = "0.1.166"
|
versionName = "0.1.167"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -24,6 +24,6 @@ fun parseGitHelpInvocation(command: String): GitHelpInvocation? {
|
|||||||
tokens[1] == "--help" -> tokens[2]
|
tokens[1] == "--help" -> tokens[2]
|
||||||
tokens.drop(2).any { it == "--help" } -> tokens[1]
|
tokens.drop(2).any { it == "--help" } -> tokens[1]
|
||||||
else -> return null
|
else -> return null
|
||||||
}.takeIf { it.matches(Regex("[A-Za-z0-9_-]+")) } ?: return null
|
}.takeIf { !it.startsWith("-") && it.matches(Regex("[A-Za-z0-9_-]+")) } ?: return null
|
||||||
return GitHelpInvocation(topic = topic, command = command)
|
return GitHelpInvocation(topic = topic, command = command)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
writeText(file.content)
|
writeText(file.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
File(sandbox, desired.currentDir).mkdirs()
|
||||||
|
|
||||||
val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty()
|
val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty()
|
||||||
if (needsGit) {
|
if (needsGit) {
|
||||||
@@ -80,7 +81,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
levelMaterializer.materialize(nativeGit, sandbox, desired, level)
|
levelMaterializer.materialize(nativeGit, sandbox, desired, level)
|
||||||
}
|
}
|
||||||
|
|
||||||
return inspectSandbox(level)
|
return inspectSandbox(level, desired.currentDir).copy(currentDir = desired.currentDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun execute(level: Level, currentRepo: RepoState, command: String): Pair<RepoState, List<String>> {
|
fun execute(level: Level, currentRepo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||||
@@ -99,7 +100,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command)
|
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command)
|
||||||
val invocation = parseEnvironmentPrefixedCommand(shellTokens)
|
val invocation = parseEnvironmentPrefixedCommand(shellTokens)
|
||||||
val tokens = invocation.command.map { it.value }
|
val tokens = invocation.command.map { it.value }
|
||||||
if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
|
if (tokens.isEmpty()) return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to emptyList()
|
||||||
if (currentRepo.interactiveAddSession != null) {
|
if (currentRepo.interactiveAddSession != null) {
|
||||||
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
|
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
|
||||||
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
|
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
|
||||||
@@ -108,7 +109,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
if (newlyStagedPaths.isNotEmpty()) {
|
if (newlyStagedPaths.isNotEmpty()) {
|
||||||
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
|
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
|
||||||
}
|
}
|
||||||
val inspectedRepo = inspectSandbox(level).copy(
|
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
|
||||||
currentDir = updatedRepo.currentDir,
|
currentDir = updatedRepo.currentDir,
|
||||||
interactiveAddSession = updatedRepo.interactiveAddSession,
|
interactiveAddSession = updatedRepo.interactiveAddSession,
|
||||||
)
|
)
|
||||||
@@ -131,7 +132,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
val inspectedRepo = inspectSandbox(level).copy(currentDir = result.first.currentDir)
|
val inspectedRepo = inspectSandbox(level, result.first.currentDir).copy(currentDir = result.first.currentDir)
|
||||||
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) to result.second
|
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) to result.second
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +220,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
|
|
||||||
file.parentFile?.mkdirs()
|
file.parentFile?.mkdirs()
|
||||||
file.writeText(content)
|
file.writeText(content)
|
||||||
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path")
|
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
fun gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
||||||
@@ -245,7 +246,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
appendLine("#")
|
appendLine("#")
|
||||||
appendLine("# Commands:")
|
appendLine("# Commands:")
|
||||||
appendLine("# p, pick <commit> = use commit")
|
appendLine("# p, pick <commit> = use commit")
|
||||||
appendLine("# r, reword <commit> = use commit, but edit the commit message")
|
appendLine("# r, reword <commit> = use commit and replace its message with the text on this line")
|
||||||
appendLine("# e, edit <commit> = use commit, but stop for amending")
|
appendLine("# e, edit <commit> = use commit, but stop for amending")
|
||||||
appendLine("# s, squash <commit> = use commit, but meld into previous commit")
|
appendLine("# s, squash <commit> = use commit, but meld into previous commit")
|
||||||
appendLine("# f, fixup [-C | -c] <commit> = like squash but keep only the previous commit's log message")
|
appendLine("# f, fixup [-C | -c] <commit> = like squash but keep only the previous commit's log message")
|
||||||
@@ -282,7 +283,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
if (newlyStagedPaths.isNotEmpty()) {
|
if (newlyStagedPaths.isNotEmpty()) {
|
||||||
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
|
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
|
||||||
}
|
}
|
||||||
val inspectedRepo = inspectSandbox(level).copy(
|
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
|
||||||
currentDir = updatedRepo.currentDir,
|
currentDir = updatedRepo.currentDir,
|
||||||
interactiveAddSession = updatedRepo.interactiveAddSession,
|
interactiveAddSession = updatedRepo.interactiveAddSession,
|
||||||
)
|
)
|
||||||
@@ -300,7 +301,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
val result = runGit(nativeGit, workingDir, arguments)
|
val result = runGit(nativeGit, workingDir, arguments)
|
||||||
messageFile.delete()
|
messageFile.delete()
|
||||||
|
|
||||||
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun executeInteractiveRebaseEditorCommand(
|
private fun executeInteractiveRebaseEditorCommand(
|
||||||
@@ -316,6 +317,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
|
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
|
||||||
writeText(todo)
|
writeText(todo)
|
||||||
}
|
}
|
||||||
|
val rewordMessages = todo.lineSequence().mapNotNull(::rewordMessageFromTodoLine).toList()
|
||||||
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
|
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
|
||||||
writeText(
|
writeText(
|
||||||
"""
|
"""
|
||||||
@@ -325,6 +327,31 @@ class GitRepositoryRuntime private constructor(
|
|||||||
)
|
)
|
||||||
setReadable(true, true)
|
setReadable(true, true)
|
||||||
}
|
}
|
||||||
|
val rewordMessageFiles = rewordMessages.mapIndexed { index, message ->
|
||||||
|
File(gitDir, "GITHUG_ANDROID_REWORD_${index + 1}").apply {
|
||||||
|
writeText(message.trimEnd() + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rewordCounterFile = File(gitDir, "GITHUG_ANDROID_REWORD_COUNTER")
|
||||||
|
val messageEditorScript = File(gitDir, "githug-android-message-editor.sh").apply {
|
||||||
|
writeText(
|
||||||
|
"""
|
||||||
|
|#!/bin/sh
|
||||||
|
|counter_file=${rewordCounterFile.absolutePath.toShellSingleQuoted()}
|
||||||
|
|index=0
|
||||||
|
|if [ -f "${'$'}counter_file" ]; then
|
||||||
|
| index=$(cat "${'$'}counter_file")
|
||||||
|
|fi
|
||||||
|
|index=$((index + 1))
|
||||||
|
|printf '%s\n' "${'$'}index" > "${'$'}counter_file"
|
||||||
|
|message_file=${File(gitDir, "GITHUG_ANDROID_REWORD_").absolutePath.toShellSingleQuoted()}"${'$'}index"
|
||||||
|
|if [ -f "${'$'}message_file" ]; then
|
||||||
|
| cat "${'$'}message_file" > "$1"
|
||||||
|
|fi
|
||||||
|
|""".trimMargin(),
|
||||||
|
)
|
||||||
|
setReadable(true, true)
|
||||||
|
}
|
||||||
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command).drop(1)
|
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command).drop(1)
|
||||||
val result = runGit(
|
val result = runGit(
|
||||||
nativeGit,
|
nativeGit,
|
||||||
@@ -332,13 +359,28 @@ class GitRepositoryRuntime private constructor(
|
|||||||
arguments,
|
arguments,
|
||||||
mapOf(
|
mapOf(
|
||||||
"GIT_SEQUENCE_EDITOR" to "${shellExecutable().toShellSingleQuoted()} ${editorScript.absolutePath.toShellSingleQuoted()}",
|
"GIT_SEQUENCE_EDITOR" to "${shellExecutable().toShellSingleQuoted()} ${editorScript.absolutePath.toShellSingleQuoted()}",
|
||||||
"GIT_EDITOR" to "true",
|
"GIT_EDITOR" to if (rewordMessages.isEmpty()) {
|
||||||
|
"true"
|
||||||
|
} else {
|
||||||
|
"${shellExecutable().toShellSingleQuoted()} ${messageEditorScript.absolutePath.toShellSingleQuoted()}"
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
todoFile.delete()
|
todoFile.delete()
|
||||||
editorScript.delete()
|
editorScript.delete()
|
||||||
|
rewordMessageFiles.forEach { it.delete() }
|
||||||
|
rewordCounterFile.delete()
|
||||||
|
messageEditorScript.delete()
|
||||||
|
|
||||||
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rewordMessageFromTodoLine(line: String): String? {
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed.startsWith("#")) return null
|
||||||
|
val parts = trimmed.split(Regex("\\s+"), limit = 3)
|
||||||
|
if (parts.size < 3 || parts[0] !in setOf("r", "reword")) return null
|
||||||
|
return parts[2].takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun executeSyntheticGitCommand(
|
private fun executeSyntheticGitCommand(
|
||||||
@@ -394,22 +436,33 @@ class GitRepositoryRuntime private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun inspectSandbox(level: Level): RepoState {
|
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState {
|
||||||
val sandbox = sandboxDir(level)
|
val sandbox = sandboxDir(level)
|
||||||
val nativeGit = requireNativeGit()
|
val nativeGit = requireNativeGit()
|
||||||
val filesOnDisk = sandbox.walkTopDown()
|
val workingDir = File(sandbox, currentDir).canonicalFile
|
||||||
.filter { it.isFile && !it.relativeTo(sandbox).path.startsWith(".git/") }
|
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
|
||||||
|
?: sandbox
|
||||||
|
val repositoryRoot = generateSequence(workingDir) { directory ->
|
||||||
|
directory.parentFile?.takeIf {
|
||||||
|
it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator)
|
||||||
|
}
|
||||||
|
}.firstOrNull { File(it, ".git").exists() }
|
||||||
|
val inspectionRoot = repositoryRoot ?: sandbox
|
||||||
|
val filesOnDisk = inspectionRoot.walkTopDown()
|
||||||
|
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
.toList()
|
.toList()
|
||||||
|
|
||||||
if (!File(sandbox, ".git").exists()) {
|
if (repositoryRoot == null) {
|
||||||
return RepoState(
|
return RepoState(
|
||||||
initialized = File(sandbox, ".git").exists(),
|
initialized = false,
|
||||||
files = filesOnDisk.map { GitFile(name = it.name, content = it.readText()) },
|
files = filesOnDisk.map {
|
||||||
|
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val statusResult = runGit(nativeGit, sandbox, listOf("status", "--porcelain"))
|
val statusResult = runGit(nativeGit, inspectionRoot, listOf("status", "--porcelain"))
|
||||||
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
|
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
|
||||||
val deletedStatusPaths = mutableSetOf<String>()
|
val deletedStatusPaths = mutableSetOf<String>()
|
||||||
statusResult.outputLines.forEach { line ->
|
statusResult.outputLines.forEach { line ->
|
||||||
@@ -425,16 +478,16 @@ class GitRepositoryRuntime private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
|
val logResult = runGit(nativeGit, inspectionRoot, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
|
||||||
val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list"))
|
val branchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--list"))
|
||||||
val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list"))
|
val remoteBranchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "-r", "--list"))
|
||||||
val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list"))
|
val tagResult = runGit(nativeGit, inspectionRoot, listOf("tag", "--list"))
|
||||||
val remoteResult = runGit(nativeGit, sandbox, listOf("remote", "-v"))
|
val remoteResult = runGit(nativeGit, inspectionRoot, listOf("remote", "-v"))
|
||||||
val headResult = runGit(nativeGit, sandbox, listOf("branch", "--show-current"))
|
val headResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--show-current"))
|
||||||
val exactTagResult = runGit(nativeGit, sandbox, listOf("describe", "--tags", "--exact-match"))
|
val exactTagResult = runGit(nativeGit, inspectionRoot, listOf("describe", "--tags", "--exact-match"))
|
||||||
val userNameResult = runGit(nativeGit, sandbox, listOf("config", "--get", "user.name"))
|
val userNameResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.name"))
|
||||||
val userEmailResult = runGit(nativeGit, sandbox, listOf("config", "--get", "user.email"))
|
val userEmailResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.email"))
|
||||||
val fetchHeadCount = File(sandbox, ".git/FETCH_HEAD")
|
val fetchHeadCount = File(inspectionRoot, ".git/FETCH_HEAD")
|
||||||
.takeIf { it.isFile }
|
.takeIf { it.isFile }
|
||||||
?.readLines()
|
?.readLines()
|
||||||
?.count { it.isNotBlank() }
|
?.count { it.isNotBlank() }
|
||||||
@@ -474,7 +527,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
.map { it.removePrefix("*").trim() }
|
.map { it.removePrefix("*").trim() }
|
||||||
.filter { it.isNotBlank() && !it.startsWith("(") }
|
.filter { it.isNotBlank() && !it.startsWith("(") }
|
||||||
.associateWith { branch ->
|
.associateWith { branch ->
|
||||||
runGit(nativeGit, sandbox, listOf("rev-list", "--count", branch))
|
runGit(nativeGit, inspectionRoot, listOf("rev-list", "--count", branch))
|
||||||
.outputLines
|
.outputLines
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?.toIntOrNull()
|
?.toIntOrNull()
|
||||||
@@ -490,7 +543,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
return RepoState(
|
return RepoState(
|
||||||
initialized = true,
|
initialized = true,
|
||||||
files = filesOnDisk.map { file ->
|
files = filesOnDisk.map { file ->
|
||||||
val relativePath = file.relativeTo(sandbox).path
|
val relativePath = file.relativeTo(inspectionRoot).path
|
||||||
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
||||||
GitFile(
|
GitFile(
|
||||||
name = relativePath,
|
name = relativePath,
|
||||||
@@ -499,7 +552,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
tracked = tracked,
|
tracked = tracked,
|
||||||
)
|
)
|
||||||
} + deletedStatusPaths
|
} + deletedStatusPaths
|
||||||
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(sandbox).path == deletedPath } }
|
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
|
||||||
.map { deletedPath ->
|
.map { deletedPath ->
|
||||||
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
|
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
|
||||||
GitFile(
|
GitFile(
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package solutions.tretter.githugandroid
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Port of the upstream ruby-githug `contribute` 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.
|
|
||||||
*/
|
|
||||||
internal fun contributeLevel(): Level = level(
|
|
||||||
id = "contribute",
|
|
||||||
title = "Contribute",
|
|
||||||
description = "This is the final level, the goal is to contribute to this repository by making a pull request on GitHub. Please note that this level is designed to encourage you to add a valid contribution to Githug, not testing your ability to create a pull request. Contributions that are likely to be accepted are levels, bug fixes and improved documentation.",
|
|
||||||
hints = listOf("Forking the repository would be a good start!"),
|
|
||||||
commandSuggestions = listOf("Open a pull request"),
|
|
||||||
setup = { RepoState() },
|
|
||||||
validator = { _, command -> command.isNotBlank() },
|
|
||||||
testCases = listOf(
|
|
||||||
levelTestCase("acknowledge contribution goal", "Open a pull request"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -13,9 +13,15 @@ internal fun initLevel(): Level = level(
|
|||||||
description = "A new directory, `git_hug`, has been created; initialize an empty repository in it.",
|
description = "A new directory, `git_hug`, has been created; initialize an empty repository in it.",
|
||||||
hints = listOf("You can type `git --help` or `git` in your shell to get a list of available git commands."),
|
hints = listOf("You can type `git --help` or `git` in your shell to get a list of available git commands."),
|
||||||
commandSuggestions = listOf("git init", "git status"),
|
commandSuggestions = listOf("git init", "git status"),
|
||||||
setup = { RepoState() },
|
setup = { RepoState(currentDir = "git_hug") },
|
||||||
validator = repoPredicate { it.initialized },
|
validator = repoPredicate { it.initialized },
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("plain init", "git init"),
|
levelTestCase("plain init", "git init"),
|
||||||
),
|
),
|
||||||
|
setupChecks = listOf(
|
||||||
|
levelSetupCheck(
|
||||||
|
name = "starts in git_hug",
|
||||||
|
failureMessage = "Expected the learner to start in the created git_hug directory.",
|
||||||
|
) { repo -> repo.currentDir == "git_hug" && !repo.initialized },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ fun allGithugLevels(): List<Level> = listOf(
|
|||||||
restoreLevel(),
|
restoreLevel(),
|
||||||
conflictLevel(),
|
conflictLevel(),
|
||||||
submoduleLevel(),
|
submoduleLevel(),
|
||||||
contributeLevel(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun level(
|
internal fun level(
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ class GitHelpCommandsTest {
|
|||||||
assertEquals("git --help tag", invocation?.command)
|
assertEquals("git --help tag", invocation?.command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesGitHelpListingOptionsForNativeGit() {
|
||||||
|
assertNull(parseGitHelpInvocation("git help -a"))
|
||||||
|
assertNull(parseGitHelpInvocation("git help --all"))
|
||||||
|
assertNull(parseGitHelpInvocation("git help -g"))
|
||||||
|
assertNull(parseGitHelpInvocation("git help --guides"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parsesManGitCommand() {
|
fun parsesManGitCommand() {
|
||||||
val invocation = parseGitHelpInvocation("man git-cherry-pick")
|
val invocation = parseGitHelpInvocation("man git-cherry-pick")
|
||||||
|
|||||||
@@ -9,6 +9,31 @@ import java.io.File
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
||||||
class GitSandboxEngineTest {
|
class GitSandboxEngineTest {
|
||||||
|
@Test
|
||||||
|
fun initLevelStartsInsideCreatedGitHugDirectory() {
|
||||||
|
val git = testGitBinary()
|
||||||
|
assumeTrue(git.exists() && git.canExecute())
|
||||||
|
val root = Files.createTempDirectory("githug-init-level").toFile()
|
||||||
|
try {
|
||||||
|
val runtime = GitRepositoryRuntime(root, git)
|
||||||
|
val level = initLevel()
|
||||||
|
val repo = runtime.prepareLevel(level)
|
||||||
|
|
||||||
|
val (_, pwdOutput) = runtime.execute(level, repo, "pwd")
|
||||||
|
val (initializedRepo, _) = runtime.execute(level, repo, "git init")
|
||||||
|
|
||||||
|
assertEquals("git_hug", repo.currentDir)
|
||||||
|
assertFalse(repo.initialized)
|
||||||
|
assertTrue(File(root, "init/git_hug").isDirectory)
|
||||||
|
assertEquals(listOf("/sandbox/git_hug"), pwdOutput)
|
||||||
|
assertTrue(initializedRepo.initialized)
|
||||||
|
assertTrue(File(root, "init/git_hug/.git").isDirectory)
|
||||||
|
assertTrue(level.validator(initializedRepo, "git init"))
|
||||||
|
} finally {
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun statusShowsDeletedTrackedFiles() {
|
fun statusShowsDeletedTrackedFiles() {
|
||||||
val repo = RepoState(
|
val repo = RepoState(
|
||||||
@@ -203,6 +228,36 @@ class GitSandboxEngineTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nativeGitHelpListsCommandsAndGuides() {
|
||||||
|
val git = testGitBinary()
|
||||||
|
assumeTrue(git.exists() && git.canExecute())
|
||||||
|
val root = Files.createTempDirectory("githug-help-listing").toFile()
|
||||||
|
try {
|
||||||
|
val runtime = GitRepositoryRuntime(root, git)
|
||||||
|
val level = level(
|
||||||
|
id = "help-listing-test",
|
||||||
|
title = "Help Listing Test",
|
||||||
|
description = "",
|
||||||
|
hints = emptyList(),
|
||||||
|
commandSuggestions = emptyList(),
|
||||||
|
setup = { RepoState(initialized = true) },
|
||||||
|
validator = { _, _ -> false },
|
||||||
|
)
|
||||||
|
val repo = runtime.prepareLevel(level)
|
||||||
|
|
||||||
|
val (_, commandOutput) = runtime.execute(level, repo, "git help -a")
|
||||||
|
val (_, guideOutput) = runtime.execute(level, repo, "git help -g")
|
||||||
|
|
||||||
|
assertTrue(commandOutput.any { it.contains("Main Porcelain Commands") })
|
||||||
|
assertTrue(commandOutput.any { it.contains("commit") && it.contains("Record changes") })
|
||||||
|
assertTrue(guideOutput.any { it.contains("Git concept guides") })
|
||||||
|
assertTrue(guideOutput.any { it.contains("tutorial") })
|
||||||
|
} finally {
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nativeGitExecAliasesRefreshWhenBinaryChanges() {
|
fun nativeGitExecAliasesRefreshWhenBinaryChanges() {
|
||||||
val git = testGitBinary()
|
val git = testGitBinary()
|
||||||
@@ -354,6 +409,34 @@ class GitSandboxEngineTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nativeInteractiveRebaseRewordUsesEditedTodoMessage() {
|
||||||
|
val git = testGitBinary()
|
||||||
|
assumeTrue(git.exists() && git.canExecute())
|
||||||
|
val root = Files.createTempDirectory("githug-reword-editor").toFile()
|
||||||
|
try {
|
||||||
|
val runtime = GitRepositoryRuntime(root, git)
|
||||||
|
val level = renameCommitLevel()
|
||||||
|
val repo = runtime.prepareLevel(level)
|
||||||
|
val invocation = parseGitEditorInvocation("git rebase -i HEAD~2")
|
||||||
|
?: error("Expected interactive rebase editor invocation")
|
||||||
|
val todo = runtime.gitEditorInitialContent(level, repo, invocation)
|
||||||
|
val rewordedTodo = todo.replace(
|
||||||
|
Regex("(?m)^pick (\\S+) First coommit$"),
|
||||||
|
"reword $1 First commit",
|
||||||
|
)
|
||||||
|
|
||||||
|
val (updatedRepo, output) = runtime.executeGitEditorCommand(level, repo, invocation, rewordedTodo)
|
||||||
|
|
||||||
|
assertFalse(output.any { it.contains("error:", ignoreCase = true) || it.contains("fatal:", ignoreCase = true) })
|
||||||
|
assertTrue(updatedRepo.commits.any { it.message == "First commit" })
|
||||||
|
assertFalse(updatedRepo.commits.any { it.message == "First coommit" })
|
||||||
|
assertTrue(level.validator(updatedRepo, invocation.command))
|
||||||
|
} finally {
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nativeExecutableShortcutRunsScriptThroughShell() {
|
fun nativeExecutableShortcutRunsScriptThroughShell() {
|
||||||
val git = testGitBinary()
|
val git = testGitBinary()
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ class LevelSolutionsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun levelOrderMatchesUpstreamRubyGithug() {
|
fun implementedLevelOrderMatchesUpstreamExercises() {
|
||||||
assertEquals(UPSTREAM_LEVEL_ORDER, allGithugLevels().map { it.id })
|
assertEquals(IMPLEMENTED_UPSTREAM_LEVEL_ORDER, allGithugLevels().map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -240,7 +240,7 @@ class LevelSolutionsTest {
|
|||||||
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
|
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
val UPSTREAM_LEVEL_ORDER = listOf(
|
val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf(
|
||||||
"init",
|
"init",
|
||||||
"config",
|
"config",
|
||||||
"add",
|
"add",
|
||||||
@@ -296,7 +296,6 @@ class LevelSolutionsTest {
|
|||||||
"restore",
|
"restore",
|
||||||
"conflict",
|
"conflict",
|
||||||
"submodule",
|
"submodule",
|
||||||
"contribute",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user