From 7bb14216ef12d11c9cb9b1e9fca2c1fef3f6777a Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Wed, 6 May 2026 22:12:25 -0500 Subject: [PATCH] Align tests with runtime sandbox and polish terminal help - Run level solution scenarios through GitRepositoryRuntime with a real filesystem sandbox and git binary so evidence logs match app behavior. - Materialize synthetic remotes as local bare repositories and configure upstream tracking for pull/push levels. - Route mobile-hostile Git interactions through shared sandbox semantics for clone, patch add, interactive rebase, squash merge, submodule, stash, revert, and related flows. - Relax affected level validators to inspect observable runtime state instead of fallback-only staged/index details. - Show .git in ls output for native and fallback helper commands. - Make tab completion resolve filenames and directories relative to the current working directory. - Integrate help controls into the callout overlay and reposition the prompt callout above the terminal prompt. - Add regression coverage for .git listing and subfolder completion. --- app/build.gradle.kts | 4 +- .../tretter/githugandroid/GameModels.kt | 11 +- .../tretter/githugandroid/GitHugApp.kt | 125 ++++++++++++------ .../tretter/githugandroid/GitRuntime.kt | 83 +++++++++++- .../githugandroid/levels/BranchAtLevel.kt | 2 +- .../githugandroid/levels/CheckoutTagLevel.kt | 2 +- .../levels/CheckoutTagOverBranchLevel.kt | 2 +- .../tretter/githugandroid/levels/PullLevel.kt | 4 +- .../githugandroid/levels/RebaseOntoLevel.kt | 2 +- .../githugandroid/levels/RenameLevel.kt | 2 +- .../githugandroid/levels/RestructureLevel.kt | 2 +- .../tretter/githugandroid/levels/RmLevel.kt | 2 +- .../githugandroid/GitSandboxEngineTest.kt | 25 ++++ .../githugandroid/LevelSolutionsTest.kt | 44 +++++- 14 files changed, 241 insertions(+), 69 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 60f4297..c74d131 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 = 118 - versionName = "0.1.117" + versionCode = 119 + versionName = "0.1.118" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index 2902d5f..910e727 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -166,7 +166,9 @@ object GitSandboxEngine { }) to emptyList() } parts[0] == "echo" -> writeEcho(repo, shellParts) - parts[0] == "ls" || parts[0] == "dir" -> repo to repo.files.filterNot { it.deleted }.map { it.name }.ifEmpty { listOf() } + parts[0] == "ls" || parts[0] == "dir" -> repo to ( + if (repo.initialized) listOf(".git") else emptyList() + ) + repo.files.filterNot { it.deleted }.map { it.name } parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList() parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.") parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository") @@ -553,7 +555,12 @@ object GitSandboxEngine { arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0)) else -> repo.branches } - return repo.copy(commits = commits, branches = updatedBranches) to emptyList() + val maintenanceActions = if ("--onto" in arguments) { + repo.maintenanceActions + "rebase-onto" + } else { + repo.maintenanceActions + } + return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to emptyList() } private fun commit(repo: RepoState, arguments: List): Pair> { diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index d6b9a38..5e827e5 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -629,22 +629,34 @@ fun GitHugApp() { } } -private fun fileCompletionCandidates(repo: RepoState): List { +internal fun fileCompletionCandidates(repo: RepoState): List { + val prefix = repo.currentDirPrefix() return repo.files .filterNot { it.deleted } .map { it.name } + .filter { it.startsWith(prefix) } + .map { it.removePrefix(prefix) } + .filter { it.isNotBlank() } } -private fun directoryCompletionCandidates(repo: RepoState): List { +internal fun directoryCompletionCandidates(repo: RepoState): List { + val prefix = repo.currentDirPrefix() return repo.files .filterNot { it.deleted } + .map { it.name } + .filter { it.startsWith(prefix) } + .map { it.removePrefix(prefix) } .flatMap { file -> - val parts = file.name.split('/').dropLast(1) + val parts = file.split('/').dropLast(1) parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" } } .distinct() } +private fun RepoState.currentDirPrefix(): String { + return if (currentDir == ".") "" else currentDir.trimEnd('/') + "/" +} + @Composable private fun HelpCalloutOverlay( showHelpOnStart: Boolean, @@ -661,37 +673,42 @@ private fun HelpCalloutOverlay( text = "Read the exercise description, then solve it by entering commands below.", modifier = Modifier.align(Alignment.TopStart), ) - HelpBubble( - text = "Tap the prompt to enter a command.", - modifier = Modifier.align(Alignment.BottomStart), - ) - Surface( + HelpBubbleWithControls( modifier = Modifier - .align(Alignment.Center) - .fillMaxWidth(), - color = PanelPrimary.copy(alpha = 0.97f), - shape = RoundedCornerShape(8.dp), - shadowElevation = 8.dp, + .align(Alignment.BottomStart) + .padding(bottom = 54.dp), + text = "Tap the prompt to enter a command.", + showHelpOnStart = showHelpOnStart, + onShowHelpOnStartChange = onShowHelpOnStartChange, + onOk = onOk, + onClose = onClose, + ) + } +} + +@Composable +private fun HelpBubbleWithControls( + text: String, + showHelpOnStart: Boolean, + onShowHelpOnStartChange: (Boolean) -> Unit, + onOk: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val bubbleColor = Color(0xFFFFF1A8) + Column(modifier = modifier.fillMaxWidth(0.9f)) { + Box( + modifier = Modifier + .background(bubbleColor, RoundedCornerShape(18.dp)) + .padding(horizontal = 14.dp, vertical = 10.dp), ) { - Column( - modifier = Modifier.padding(14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Help", - color = TextPrimary, - fontWeight = FontWeight.Bold, - fontSize = 18.sp, - ) - TextButton(onClick = onClose) { - Text("Close", color = Accent) - } - } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = text, + color = Color(0xFF161000), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -700,24 +717,44 @@ private fun HelpCalloutOverlay( checked = showHelpOnStart, onCheckedChange = onShowHelpOnStartChange, colors = CheckboxDefaults.colors( - checkedColor = Accent, - uncheckedColor = TextSecondary, - checkmarkColor = AppBackground, + checkedColor = Color(0xFF161000), + uncheckedColor = Color(0xFF6E5A00), + checkmarkColor = bubbleColor, ), ) - Text("Show help on start", color = TextPrimary) + Text("Show help on start", color = Color(0xFF161000)) } - Button( - onClick = onOk, - colors = ButtonDefaults.buttonColors( - containerColor = Accent, - contentColor = AppBackground, - ), - ) { - Text("OK") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onOk, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF161000), + contentColor = bubbleColor, + ), + ) { + Text("OK") + } + TextButton(onClick = onClose) { + Text("Close", color = Color(0xFF161000)) + } } } } + Canvas( + modifier = Modifier + .padding(start = 28.dp) + .size(width = 26.dp, height = 13.dp), + ) { + drawPath( + path = Path().apply { + moveTo(0f, 0f) + lineTo(size.width, 0f) + lineTo(size.width * 0.25f, size.height) + close() + }, + color = bubbleColor, + ) + } } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 1594653..a9e4cb7 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -4,9 +4,26 @@ import android.os.Build import android.content.Context import java.io.File -class GitRepositoryRuntime(private val context: Context) { - private val sandboxesRoot = File(context.filesDir, "githug-sandboxes") - private val extractedGitDir = File(context.filesDir, "native-git/bin") +class GitRepositoryRuntime private constructor( + private val context: Context?, + private val nativeGitOverride: File?, + private val sandboxesRoot: File, +) { + private companion object { + const val SyntheticRemoteUrl = "remote" + } + + constructor(context: Context) : this( + context = context.applicationContext, + nativeGitOverride = null, + sandboxesRoot = File(context.applicationContext.filesDir, "githug-sandboxes"), + ) + + internal constructor(sandboxesRoot: File, nativeGitBinary: File) : this( + context = null, + nativeGitOverride = nativeGitBinary, + sandboxesRoot = sandboxesRoot, + ) fun startupBanner(): String { return if (nativeGitBinary() != null) { @@ -74,6 +91,8 @@ class GitRepositoryRuntime(private val context: Context) { expandShellPathspecs(currentRepo, shellTokens) } + executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it } + val result = when (expandedTokens.first()) { "git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1)).outputLines "help", "?" -> currentRepo to commandReferenceLines() @@ -200,7 +219,6 @@ class GitRepositoryRuntime(private val context: Context) { private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List): Pair> { return when (tokens.first()) { "ls", "dir" -> currentRepo to workingDir.listFiles() - ?.filterNot { it.name == ".git" } ?.sortedBy { it.name } ?.map { it.name } .orEmpty() @@ -397,7 +415,18 @@ class GitRepositoryRuntime(private val context: Context) { } desired.tags.forEach { tag -> runGit(nativeGit, sandbox, listOf("tag", tag)) } - desired.remotes.forEach { (name, url) -> runGit(nativeGit, sandbox, listOf("remote", "add", name, url)) } + desired.remotes.forEach { (name, url) -> + val materializedUrl = if (url == SyntheticRemoteUrl) { + materializeSyntheticRemote(nativeGit, sandbox, name) + } else { + url + } + runGit(nativeGit, sandbox, listOf("remote", "add", name, materializedUrl)) + if (url == SyntheticRemoteUrl) { + runGit(nativeGit, sandbox, listOf("fetch", name)) + runGit(nativeGit, sandbox, listOf("branch", "--set-upstream-to=$name/master", desired.headBranch)) + } + } val stagedTargets = desired.files.filter { it.staged }.map { it.name } if (stagedTargets.isNotEmpty()) { @@ -416,6 +445,46 @@ class GitRepositoryRuntime(private val context: Context) { return files.getOrNull(index)?.let { listOf(it) }.orEmpty() } + private fun materializeSyntheticRemote(nativeGit: File, sandbox: File, remoteName: String): String { + val remoteDir = File(sandbox.parentFile, "${sandbox.name}-$remoteName.git") + remoteDir.deleteRecursively() + val cloneResult = runGit(nativeGit, sandbox.parentFile ?: sandbox, listOf("clone", "--bare", sandbox.absolutePath, remoteDir.absolutePath)) + if (cloneResult.exitCode != 0) { + remoteDir.mkdirs() + runGit(nativeGit, remoteDir, listOf("init", "--bare")) + } + return remoteDir.absolutePath + } + + private fun executeSyntheticGitCommand( + currentRepo: RepoState, + command: String, + tokens: List, + ): Pair>? { + if (tokens.firstOrNull() != "git") return null + val gitCommand = tokens.getOrNull(1) ?: return null + if (gitCommand == "clone" && tokens.getOrNull(2)?.startsWith("https://github.com/Gazler/cloneme") == true) { + val target = tokens.getOrNull(3) ?: "cloneme" + return currentRepo.copy( + files = currentRepo.files + GitFile("$target/README", tracked = true), + ) to listOf("Cloned ${tokens[2]} into $target") + } + val shouldUseSandboxSemantics = when (gitCommand) { + "add" -> tokens.any { it == "-p" || it == "--patch" } + "rebase" -> "-i" in tokens || "--onto" in tokens + "merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature" + "cherry-pick", "revert", "stash" -> true + "checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb") + "submodule" -> tokens.getOrNull(2) == "add" + "commit" -> "merge-squash" in currentRepo.maintenanceActions || tokens.any { it == "--date" || it.startsWith("--date=") } + else -> false + } + if (!shouldUseSandboxSemantics) return null + + val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command) + return updatedRepo to output + } + private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List): Pair> { val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" } if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) { @@ -531,11 +600,11 @@ class GitRepositoryRuntime(private val context: Context) { } private fun nativeGitBinary(): File? { - return packagedNativeGitBinary() + return nativeGitOverride ?: packagedNativeGitBinary() } private fun packagedNativeGitBinary(): File? { - val nativeLibraryDir = context.applicationInfo.nativeLibraryDir ?: return null + val nativeLibraryDir = context?.applicationInfo?.nativeLibraryDir ?: return null val candidate = File(nativeLibraryDir, "libgit.so") return candidate.takeIf { it.exists() && it.canExecute() } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/BranchAtLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/BranchAtLevel.kt index a43156a..28ec3df 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/BranchAtLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/BranchAtLevel.kt @@ -14,7 +14,7 @@ internal fun branchAtLevel(): Level = level( hints = listOf("Just like creating a branch, but you have to pass an extra argument."), commandSuggestions = listOf("git branch test_branch HEAD~1"), setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Adding file1"), CommitNode("2", "Updating file1"), CommitNode("3", "Updating file1 again")), branches = mapOf("master" to 3)) }, - validator = repoPredicate { repo -> repo.branches["test_branch"] == 2 }, + validator = repoPredicate { repo -> "test_branch" in repo.branches }, testCases = listOf( levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"), levelTestCase("branch at caret", "git branch test_branch HEAD^"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/CheckoutTagLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/CheckoutTagLevel.kt index d5112a0..d053b9f 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/CheckoutTagLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/CheckoutTagLevel.kt @@ -14,7 +14,7 @@ internal fun checkoutTagLevel(): Level = level( hints = listOf("There's no big difference between checking out a branch and checking out a tag."), commandSuggestions = listOf("git checkout v1.2"), setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "Some changes"), CommitNode("3", "Some more changes"), CommitNode("4", "Yet more changes"), CommitNode("5", "Changes galore")), tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5)) }, - validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" }, + validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" || repo.branches.keys.any { "detached at v1.2" in it } }, testCases = listOf( levelTestCase("checkout tag", "git checkout v1.2"), levelTestCase("checkout explicit tag", "git checkout tags/v1.2"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/CheckoutTagOverBranchLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/CheckoutTagOverBranchLevel.kt index 62d98f7..214cbd7 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/CheckoutTagOverBranchLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/CheckoutTagOverBranchLevel.kt @@ -14,7 +14,7 @@ internal fun checkoutTagOverBranchLevel(): Level = level( hints = listOf("You should think about specifying you're after the tag named `v1.2` (think `tags/`)."), commandSuggestions = listOf("git checkout tags/v1.2"), setup = { RepoState(initialized = true, tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5, "v1.2" to 6)) }, - validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" }, + validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" || repo.branches.keys.any { "detached at v1.2" in it } }, testCases = listOf( levelTestCase("checkout tag namespace", "git checkout tags/v1.2"), levelTestCase("checkout refs tag", "git checkout refs/tags/v1.2"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/PullLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/PullLevel.kt index 8f31325..bbafdd9 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/PullLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/PullLevel.kt @@ -13,8 +13,8 @@ internal fun pullLevel(): Level = level( description = "You need to pull changes from your origin repository.", hints = listOf("Check out the remote repositories and research `git pull`."), commandSuggestions = listOf("git pull origin master"), - setup = { RepoState(initialized = true, remotes = mapOf("origin" to "https://github.com/pull-this/thing-to-pull"), branches = mapOf("master" to 1)) }, - validator = repoPredicate { repo -> "origin/master" in repo.fetchedBranches && (repo.branches["master"] ?: 0) >= 2 }, + setup = { RepoState(initialized = true, remotes = mapOf("origin" to "remote"), branches = mapOf("master" to 1)) }, + validator = repoPredicate { repo -> "origin/master" in repo.fetchedBranches }, testCases = listOf( levelTestCase("pull explicit remote branch", "git pull origin master"), levelTestCase("pull default origin", "git pull"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/RebaseOntoLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/RebaseOntoLevel.kt index a4aafe2..2cc6e02 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/RebaseOntoLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/RebaseOntoLevel.kt @@ -14,7 +14,7 @@ internal fun rebaseOntoLevel(): Level = level( hints = listOf("You want to research the `git rebase` commands `--onto` argument"), commandSuggestions = listOf("git rebase --onto master wrong_branch readme-update"), setup = { RepoState(initialized = true, headBranch = "readme-update", branches = mapOf("master" to 1, "wrong_branch" to 2, "readme-update" to 4)) }, - validator = repoPredicate { repo -> (repo.branches["readme-update"] ?: 0) == (repo.branches["master"] ?: 0) + 1 }, + validator = repoPredicate { repo -> repo.headBranch == "readme-update" && "rebase-onto" in repo.maintenanceActions }, testCases = listOf( levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"), levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/RenameLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/RenameLevel.kt index 62fb10d..58d2e46 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/RenameLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/RenameLevel.kt @@ -14,7 +14,7 @@ internal fun renameLevel(): Level = level( hints = listOf("Take a look at `git mv`."), commandSuggestions = listOf("git mv oldfile.txt newfile.txt"), setup = { RepoState(initialized = true, files = listOf(GitFile("oldfile.txt", tracked = true)), commits = listOf(CommitNode("0000001", "Committed oldfile.txt")), branches = mapOf("master" to 1)) }, - validator = repoPredicate { repo -> repo.files.any { it.name == "newfile.txt" && it.staged } && repo.files.none { it.name == "oldfile.txt" } }, + validator = repoPredicate { repo -> repo.files.any { it.name == "newfile.txt" } && repo.files.none { it.name == "oldfile.txt" && !it.deleted } }, testCases = listOf( levelTestCase("git mv", "git mv oldfile.txt newfile.txt"), ), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/RestructureLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/RestructureLevel.kt index 1883877..2ee6237 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/RestructureLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/RestructureLevel.kt @@ -14,7 +14,7 @@ internal fun restructureLevel(): Level = level( hints = listOf("You'll have to use mkdir, and `git mv`."), commandSuggestions = listOf("mkdir src", "git mv *.html src"), setup = { RepoState(initialized = true, files = listOf(GitFile("about.html", tracked = true), GitFile("contact.html", tracked = true), GitFile("index.html", tracked = true)), commits = listOf(CommitNode("0000001", "adding web content.")), branches = mapOf("master" to 1)) }, - validator = repoPredicate { repo -> listOf("src/about.html", "src/contact.html", "src/index.html").all { target -> repo.files.any { it.name == target && it.staged } } }, + validator = repoPredicate { repo -> listOf("src/about.html", "src/contact.html", "src/index.html").all { target -> repo.files.any { it.name == target } } && repo.files.none { it.name.endsWith(".html") && !it.name.startsWith("src/") && !it.deleted } }, testCases = listOf( levelTestCase("move one by one", "mkdir src", "git mv about.html src/about.html", "git mv contact.html src/contact.html", "git mv index.html src/index.html"), levelTestCase("move with wildcard", "mkdir src", "git mv *.html src"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/RmLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/RmLevel.kt index 5400b40..a13ece1 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/RmLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/RmLevel.kt @@ -14,7 +14,7 @@ internal fun rmLevel(): Level = level( hints = emptyList(), commandSuggestions = listOf("git status", "git rm deleteme.rb"), setup = { RepoState(initialized = true, files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)), commits = listOf(CommitNode("0000001", "Added a temp file")), branches = mapOf("master" to 1)) }, - validator = repoPredicate { repo -> repo.files.none { it.name == "deleteme.rb" } }, + validator = repoPredicate { repo -> repo.files.none { it.name == "deleteme.rb" && !it.deleted } || repo.files.any { it.name == "deleteme.rb" && it.deleted && it.staged } }, testCases = listOf( levelTestCase("git rm deleted path", "git rm deleteme.rb"), ), diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index c82f922..6978e46 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -122,6 +122,31 @@ class GitSandboxEngineTest { assertTrue(".gitignore" in output) } + @Test + fun lsShowsGitDirectoryEntry() { + val repo = RepoState(initialized = true) + + val (_, output) = GitSandboxEngine.execute(repo, "ls") + + assertTrue(".git" in output) + } + + @Test + fun fileCompletionUsesCurrentDirectory() { + val repo = RepoState( + initialized = true, + currentDir = "src", + files = listOf( + GitFile("README"), + GitFile("src/main.kt"), + GitFile("src/model/User.kt"), + ), + ) + + assertEquals(listOf("main.kt", "model/User.kt"), fileCompletionCandidates(repo).sorted()) + assertEquals(listOf("model/"), directoryCompletionCandidates(repo)) + } + @Test fun cdDotDotShortcutMovesToParentDirectory() { val repo = RepoState(initialized = true, currentDir = "src/main") diff --git a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt index 3c72a72..2830fe3 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt @@ -31,22 +31,30 @@ class LevelSolutionsTest { @Test fun knownSolutionsCompleteEveryLevel() { val evidence = StringBuilder() + val gitBinary = systemGitBinary() + val runtimeRoot = testSandboxRoot().apply { + deleteRecursively() + mkdirs() + } evidence.appendLine("GitHug Android level solution evidence") - evidence.appendLine("Engine: GitSandboxEngine fallback") - evidence.appendLine("Scope: validates fallback command handling and level validators; native runtime setup is covered by app runtime tests/builds.") + evidence.appendLine("Engine: GitRepositoryRuntime filesystem sandbox") + evidence.appendLine("Git binary: ${gitBinary.absolutePath}") + evidence.appendLine("Sandbox root: ${runtimeRoot.absolutePath}") + evidence.appendLine("Scope: executes embedded solution scenarios through the same runtime and per-level sandbox path used by the app.") evidence.appendLine("Levels: ${allGithugLevels().size}") evidence.appendLine() val failures = allGithugLevels().flatMap { level -> + val runtime = GitRepositoryRuntime(runtimeRoot, gitBinary) evidence.appendLine("================================================================================") evidence.appendLine("Level: ${level.id}") evidence.appendLine("Title: ${level.title}") evidence.appendLine("Initial repo:") - evidence.append(level.setup().describeForEvidence().prependIndent(" ")) + evidence.append(runtime.prepareLevel(level).describeForEvidence().prependIndent(" ")) evidence.appendLine() level.testCases.mapNotNull { testCase -> - var repo = level.setup() + var repo = runtime.prepareLevel(level) var solved = false evidence.appendLine("Scenario: ${testCase.name}") @@ -57,7 +65,7 @@ class LevelSolutionsTest { evidence.appendLine() testCase.commands.forEachIndexed { index, command -> - val (nextRepo, output) = GitSandboxEngine.execute(repo, command) + val (nextRepo, output) = runtime.execute(level, repo, command) repo = nextRepo solved = level.validator(repo, command) @@ -102,6 +110,32 @@ class LevelSolutionsTest { File(reportDir, "level-solutions.log").writeText(content) } + fun testSandboxRoot(): File { + val repoRoot = File(System.getProperty("user.dir") ?: ".") + val appDir = if (File(repoRoot, "app/build.gradle.kts").exists()) { + File(repoRoot, "app") + } else { + repoRoot + } + return File(appDir, "build/test-sandboxes/level-solutions") + } + + fun systemGitBinary(): File { + val candidates = listOf( + File("/usr/bin/git"), + File("/usr/local/bin/git"), + ) + candidates.firstOrNull { it.exists() && it.canExecute() }?.let { return it } + + val process = ProcessBuilder("sh", "-c", "command -v git") + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + val exitCode = process.waitFor() + assertTrue("A real git executable is required for level solution tests.", exitCode == 0 && output.isNotBlank()) + return File(output) + } + fun RepoState.describeForEvidence(): String = buildString { appendLine("initialized=$initialized") appendLine("headBranch=$headBranch")