From 02a23694c3e2f6b9aa38ae1f9a5a311c8a0af63e Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Mon, 8 Jun 2026 14:21:21 -0500 Subject: [PATCH] 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 --- LevelsCompare.md | 10 +- README.md | 5 +- app/build.gradle.kts | 4 +- .../tretter/githugandroid/GitHelpCommands.kt | 2 +- .../tretter/githugandroid/GitRuntime.kt | 113 +++++++++++++----- .../githugandroid/levels/ContributeLevel.kt | 21 ---- .../tretter/githugandroid/levels/InitLevel.kt | 8 +- .../githugandroid/levels/LevelCatalog.kt | 1 - .../githugandroid/GitHelpCommandsTest.kt | 8 ++ .../githugandroid/GitSandboxEngineTest.kt | 83 +++++++++++++ .../githugandroid/LevelSolutionsTest.kt | 7 +- 11 files changed, 194 insertions(+), 68 deletions(-) delete mode 100644 app/src/main/java/solutions/tretter/githugandroid/levels/ContributeLevel.kt diff --git a/LevelsCompare.md b/LevelsCompare.md index b04631a..2cea25f 100644 --- a/LevelsCompare.md +++ b/LevelsCompare.md @@ -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 | | --- | --- | --- | --- | -| `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. | | `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. | @@ -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. | | `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. | -| `contribute` | No local setup; solution clones upstream externally. | Empty state. | Deliberate simplification for mobile/offline final prompt. | ## 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. | | `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. | -| `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. | | `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. | @@ -126,7 +125,6 @@ This file records the upstream Ruby setup and validation intent beside the Andro | `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. | | `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 @@ -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. - `merge_squash`: verify the exact squashed file/content effects. - `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. diff --git a/README.md b/README.md index c64b7ab..ce22d5c 100644 --- a/README.md +++ b/README.md @@ -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 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 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. @@ -133,14 +133,13 @@ Source files should stay comfortably reviewable. Treat files approaching roughly ## 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 | | --- | --- | --- | --- | | `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. | | `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`, `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. | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0093aa..5d0ec81 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 167 - versionName = "0.1.166" + versionCode = 168 + versionName = "0.1.167" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt index 7b1ed61..eeaf686 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt @@ -24,6 +24,6 @@ fun parseGitHelpInvocation(command: String): GitHelpInvocation? { tokens[1] == "--help" -> tokens[2] tokens.drop(2).any { it == "--help" } -> tokens[1] 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) } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index c60e7a6..2f9db6f 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -68,6 +68,7 @@ class GitRepositoryRuntime private constructor( writeText(file.content) } } + File(sandbox, desired.currentDir).mkdirs() val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty() if (needsGit) { @@ -80,7 +81,7 @@ class GitRepositoryRuntime private constructor( 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> { @@ -99,7 +100,7 @@ class GitRepositoryRuntime private constructor( val shellTokens = GitSandboxEngine.tokenizeShellCommand(command) val invocation = parseEnvironmentPrefixedCommand(shellTokens) 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) { val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command) val newlyStagedPaths = updatedRepo.files.filter { updatedFile -> @@ -108,7 +109,7 @@ class GitRepositoryRuntime private constructor( if (newlyStagedPaths.isNotEmpty()) { runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths) } - val inspectedRepo = inspectSandbox(level).copy( + val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy( currentDir = updatedRepo.currentDir, interactiveAddSession = updatedRepo.interactiveAddSession, ) @@ -131,7 +132,7 @@ class GitRepositoryRuntime private constructor( ?: 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 } @@ -219,7 +220,7 @@ class GitRepositoryRuntime private constructor( file.parentFile?.mkdirs() 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 { @@ -245,7 +246,7 @@ class GitRepositoryRuntime private constructor( appendLine("#") appendLine("# Commands:") appendLine("# p, pick = use commit") - appendLine("# r, reword = use commit, but edit the commit message") + appendLine("# r, reword = use commit and replace its message with the text on this line") appendLine("# e, edit = use commit, but stop for amending") appendLine("# s, squash = use commit, but meld into previous commit") appendLine("# f, fixup [-C | -c] = like squash but keep only the previous commit's log message") @@ -282,7 +283,7 @@ class GitRepositoryRuntime private constructor( if (newlyStagedPaths.isNotEmpty()) { runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths) } - val inspectedRepo = inspectSandbox(level).copy( + val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy( currentDir = updatedRepo.currentDir, interactiveAddSession = updatedRepo.interactiveAddSession, ) @@ -300,7 +301,7 @@ class GitRepositoryRuntime private constructor( val result = runGit(nativeGit, workingDir, arguments) 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( @@ -316,6 +317,7 @@ class GitRepositoryRuntime private constructor( val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply { writeText(todo) } + val rewordMessages = todo.lineSequence().mapNotNull(::rewordMessageFromTodoLine).toList() val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply { writeText( """ @@ -325,6 +327,31 @@ class GitRepositoryRuntime private constructor( ) 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 result = runGit( nativeGit, @@ -332,13 +359,28 @@ class GitRepositoryRuntime private constructor( arguments, mapOf( "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() 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( @@ -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 nativeGit = requireNativeGit() - val filesOnDisk = sandbox.walkTopDown() - .filter { it.isFile && !it.relativeTo(sandbox).path.startsWith(".git/") } + val workingDir = File(sandbox, currentDir).canonicalFile + .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() .toList() - if (!File(sandbox, ".git").exists()) { + if (repositoryRoot == null) { return RepoState( - initialized = File(sandbox, ".git").exists(), - files = filesOnDisk.map { GitFile(name = it.name, content = it.readText()) }, + initialized = false, + 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>() val deletedStatusPaths = mutableSetOf() 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 branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list")) - val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list")) - val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list")) - val remoteResult = runGit(nativeGit, sandbox, listOf("remote", "-v")) - val headResult = runGit(nativeGit, sandbox, listOf("branch", "--show-current")) - val exactTagResult = runGit(nativeGit, sandbox, listOf("describe", "--tags", "--exact-match")) - val userNameResult = runGit(nativeGit, sandbox, listOf("config", "--get", "user.name")) - val userEmailResult = runGit(nativeGit, sandbox, listOf("config", "--get", "user.email")) - val fetchHeadCount = File(sandbox, ".git/FETCH_HEAD") + val logResult = runGit(nativeGit, inspectionRoot, listOf("log", "--pretty=format:%h\t%at\t%P\t%s")) + val branchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--list")) + val remoteBranchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "-r", "--list")) + val tagResult = runGit(nativeGit, inspectionRoot, listOf("tag", "--list")) + val remoteResult = runGit(nativeGit, inspectionRoot, listOf("remote", "-v")) + val headResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--show-current")) + val exactTagResult = runGit(nativeGit, inspectionRoot, listOf("describe", "--tags", "--exact-match")) + val userNameResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.name")) + val userEmailResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.email")) + val fetchHeadCount = File(inspectionRoot, ".git/FETCH_HEAD") .takeIf { it.isFile } ?.readLines() ?.count { it.isNotBlank() } @@ -474,7 +527,7 @@ class GitRepositoryRuntime private constructor( .map { it.removePrefix("*").trim() } .filter { it.isNotBlank() && !it.startsWith("(") } .associateWith { branch -> - runGit(nativeGit, sandbox, listOf("rev-list", "--count", branch)) + runGit(nativeGit, inspectionRoot, listOf("rev-list", "--count", branch)) .outputLines .firstOrNull() ?.toIntOrNull() @@ -490,7 +543,7 @@ class GitRepositoryRuntime private constructor( return RepoState( initialized = true, files = filesOnDisk.map { file -> - val relativePath = file.relativeTo(sandbox).path + val relativePath = file.relativeTo(inspectionRoot).path val (staged, tracked) = statusMap[relativePath] ?: (false to true) GitFile( name = relativePath, @@ -499,7 +552,7 @@ class GitRepositoryRuntime private constructor( tracked = tracked, ) } + deletedStatusPaths - .filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(sandbox).path == deletedPath } } + .filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } } .map { deletedPath -> val (staged, tracked) = statusMap[deletedPath] ?: (false to true) GitFile( diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/ContributeLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/ContributeLevel.kt deleted file mode 100644 index 2c27bf4..0000000 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/ContributeLevel.kt +++ /dev/null @@ -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"), - ), -) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/InitLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/InitLevel.kt index 93787b8..ed6e923 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/InitLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/InitLevel.kt @@ -13,9 +13,15 @@ internal fun initLevel(): Level = level( 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."), commandSuggestions = listOf("git init", "git status"), - setup = { RepoState() }, + setup = { RepoState(currentDir = "git_hug") }, validator = repoPredicate { it.initialized }, testCases = listOf( 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 }, + ), ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt index 9a475f2..0ac6d20 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt @@ -56,7 +56,6 @@ fun allGithugLevels(): List = listOf( restoreLevel(), conflictLevel(), submoduleLevel(), - contributeLevel(), ) internal fun level( diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt index 1c4555c..0005e83 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt @@ -26,6 +26,14 @@ class GitHelpCommandsTest { 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 fun parsesManGitCommand() { val invocation = parseGitHelpInvocation("man git-cherry-pick") diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index b75ad59..04cda5e 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -9,6 +9,31 @@ import java.io.File import java.nio.file.Files 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 fun statusShowsDeletedTrackedFiles() { 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 fun nativeGitExecAliasesRefreshWhenBinaryChanges() { 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 fun nativeExecutableShortcutRunsScriptThroughShell() { val git = testGitBinary() diff --git a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt index 4168443..3b0d4b7 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt @@ -24,8 +24,8 @@ class LevelSolutionsTest { } @Test - fun levelOrderMatchesUpstreamRubyGithug() { - assertEquals(UPSTREAM_LEVEL_ORDER, allGithugLevels().map { it.id }) + fun implementedLevelOrderMatchesUpstreamExercises() { + assertEquals(IMPLEMENTED_UPSTREAM_LEVEL_ORDER, allGithugLevels().map { it.id }) } @Test @@ -240,7 +240,7 @@ class LevelSolutionsTest { return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"") } - val UPSTREAM_LEVEL_ORDER = listOf( + val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf( "init", "config", "add", @@ -296,7 +296,6 @@ class LevelSolutionsTest { "restore", "conflict", "submodule", - "contribute", ) }