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.
This commit is contained in:
Joe Tretter
2026-05-06 22:12:25 -05:00
parent e1b2d7f7a1
commit 7bb14216ef
14 changed files with 241 additions and 69 deletions

View File

@@ -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<String>): Pair<RepoState, List<String>> {

View File

@@ -629,22 +629,34 @@ fun GitHugApp() {
}
}
private fun fileCompletionCandidates(repo: RepoState): List<String> {
internal fun fileCompletionCandidates(repo: RepoState): List<String> {
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<String> {
internal fun directoryCompletionCandidates(repo: RepoState): List<String> {
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,
)
}
}
}

View File

@@ -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<String>): Pair<RepoState, List<String>> {
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<String>,
): Pair<RepoState, List<String>>? {
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<String>): Pair<RepoState, List<String>> {
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() }
}

View File

@@ -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^"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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