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

@@ -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() }
}