Require native Git runtime and bundle full manpages

- Remove the app fallback path when native Git is unavailable and show a startup blocker instead
- Fix native level setup for staged file counting and cherry-pick parity
- Improve manpage loading/search and bundle full Git documentation assets
- Integrate Git cross-compilation into AndroidProjectTooling.sh and remove debug AAB support
This commit is contained in:
Joe Tretter
2026-05-08 17:43:46 -05:00
parent 64743ff4a9
commit 4fab442939
219 changed files with 58133 additions and 129 deletions

View File

@@ -303,8 +303,12 @@ object GitSandboxEngine {
val value = parts.drop(3).joinToString(" ")
repo.copy(config = repo.config + (key to value)) to emptyList()
}
parts.size >= 3 && parts[1] == "add" -> {
val target = parts.drop(2).last { !it.startsWith("-") }
parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> {
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
return repo to listOf("Interactive staging is not supported in the mobile terminal. Use git add <path> or git add -p <path>.")
}
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git add <path>")
if (target != "." && repo.files.none { it.name == target && !it.deleted }) {
repo to listOf("fatal: pathspec '$target' did not match any files")
} else {
@@ -352,12 +356,12 @@ object GitSandboxEngine {
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "cherry-pick" -> {
val files = if (repo.files.none { it.name == "README" }) {
repo.files + GitFile("README", tracked = true)
val files = if (repo.files.none { it.name == "README.md" }) {
repo.files + GitFile("README.md", "Proper input instructions\n", tracked = true)
} else {
repo.files.map { if (it.name == "README") it.copy(tracked = true) else it }
repo.files.map { if (it.name == "README.md") it.copy(tracked = true) else it }
}
repo.copy(files = files, commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Cherry-picked feature")) to emptyList()
repo.copy(files = files, commits = listOf(CommitNode("${repo.commits.size + 1}", "Filled in README.md with proper input")) + repo.commits) to emptyList()
}
parts.size >= 2 && parts[1] == "revert" -> {
repo.copy(commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Revert \"Bad commit\"")) to emptyList()

View File

@@ -7,11 +7,22 @@ data class GitHelpInvocation(
fun parseGitHelpInvocation(command: String): GitHelpInvocation? {
val tokens = GitSandboxEngine.tokenizeCommand(command)
if (tokens.size >= 2 && tokens[0] == "man") {
val topic = when {
tokens[1] == "git" -> tokens.getOrNull(2) ?: "git"
tokens[1].startsWith("git-") -> tokens[1].removePrefix("git-")
tokens[1].startsWith("git") && tokens[1].length > 3 -> tokens[1].removePrefix("git")
else -> tokens[1]
}
.takeIf { it.matches(Regex("[A-Za-z0-9_-]+")) }
?: return null
return GitHelpInvocation(topic = topic, command = command)
}
if (tokens.size < 3 || tokens[0] != "git") return null
val topic = when {
tokens[1] == "help" -> tokens[2]
tokens[1] == "-h" || tokens[1] == "--help" -> tokens[2]
tokens.drop(2).any { it == "-h" || it == "--help" } -> tokens[1]
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
return GitHelpInvocation(topic = topic, command = command)

View File

@@ -64,8 +64,13 @@ fun GitHugApp() {
MaterialTheme(colorScheme = GitHugColorScheme) {
val context = LocalContext.current
val configuration = LocalConfiguration.current
val levels = remember { sampleLevels() }
val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) }
if (!runtime.isNativeGitAvailable()) {
MissingNativeGitScreen(message = runtime.unavailableMessage())
return@MaterialTheme
}
val levels = remember { sampleLevels() }
val paneLayoutStore = remember(context) { PaneLayoutStore(context.applicationContext) }
val gameProgressStore = remember(context) { GameProgressStore(context.applicationContext) }
val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout())
@@ -718,7 +723,7 @@ private fun HelpCalloutOverlay(
} ?: Modifier
.align(Alignment.BottomStart)
.padding(start = 14.dp, bottom = 96.dp),
text = "Tap the prompt to enter a command.",
text = "Tap the prompt to enter a command or answer.",
showHelpOnStart = showHelpOnStart,
onShowHelpOnStartChange = onShowHelpOnStartChange,
onOk = onOk,
@@ -836,6 +841,36 @@ private fun HelpBubble(
}
}
@Composable
private fun MissingNativeGitScreen(message: String) {
Surface(
modifier = Modifier
.fillMaxSize()
.background(AppBackground)
.padding(24.dp),
color = AppBackground,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
) {
Text(
text = "Native Git unavailable",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
Text(
text = message,
modifier = Modifier.padding(top = 12.dp),
color = TextSecondary,
fontSize = 15.sp,
)
}
}
}
@Composable
private fun SolvedCelebrationOverlay(title: String) {
val transition = rememberInfiniteTransition(label = "solved-celebration")

View File

@@ -55,20 +55,26 @@ class GitRepositoryRuntime private constructor(
sandboxesRoot = sandboxesRoot,
)
fun startupBanner(): String {
return if (nativeGitBinary() != null) {
"Welcome to GitHug Android. Native Git prototype ready."
} else {
"Welcome to GitHug Android. Native Git binary not bundled for this ABI yet; using in-memory fallback."
fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null
fun unavailableMessage(): String {
return buildString {
append("GitHug Android cannot start because this build does not include a native Git binary for this device ABI.")
append("\n\nSupported device ABIs: ")
append(Build.SUPPORTED_ABIS.joinToString(", ").ifBlank { "unknown" })
append("\nExpected binary: nativeLibraryDir/libgit.so")
append("\n\nInstall a build that bundles the cross-compiled Git binary for this device.")
}
}
fun startupBanner(): String {
requireNativeGit()
return "Welcome to GitHug Android. Native Git ready."
}
fun prepareLevel(level: Level): RepoState {
AppLog.d("GitRuntime", "Preparing level id=${level.id} title=${level.title}")
val nativeGit = nativeGitBinary()
if (nativeGit == null) {
return level.setup()
}
val nativeGit = requireNativeGit()
val sandbox = sandboxDir(level)
sandbox.deleteRecursively()
@@ -98,10 +104,7 @@ class GitRepositoryRuntime private constructor(
fun execute(level: Level, currentRepo: RepoState, command: String): Pair<RepoState, List<String>> {
AppLog.d("GitRuntime", "Executing command for level=${level.id}: $command")
val nativeGit = nativeGitBinary()
if (nativeGit == null) {
return GitSandboxEngine.execute(currentRepo, command)
}
val nativeGit = requireNativeGit()
val sandbox = sandboxDir(level)
if (!sandbox.exists()) {
@@ -119,7 +122,9 @@ class GitRepositoryRuntime private constructor(
tokens
} else {
expandShellPathspecs(currentRepo, shellTokens)
}
}.normalizeGitStageAlias()
rejectUnsupportedInteractiveGitCommand(currentRepo, expandedTokens)?.let { return it }
executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it }
@@ -134,15 +139,9 @@ class GitRepositoryRuntime private constructor(
}
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
val nativeGit = nativeGitBinary()
requireNativeGit()
val sandbox = sandboxDir(level)
if (nativeGit == null || !sandbox.exists()) {
return if (directoriesOnly) {
directoryCompletionCandidates(currentRepo)
} else {
fileCompletionCandidates(currentRepo)
}
}
if (!sandbox.exists()) return emptyList()
val sandboxRoot = sandbox.canonicalFile
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
@@ -164,7 +163,7 @@ class GitRepositoryRuntime private constructor(
fun commandReferenceLines(): List<String> {
return buildList {
addAll(GitSandboxEngine.commandReferenceLines())
add("Native Git prototype:")
add("Native Git runtime:")
add(" binary path: nativeLibraryDir/libgit.so")
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
add(" helper commands: ls/dir, pwd, cat, touch, mkdir/md, cd.., rm/del, echo")
@@ -174,14 +173,9 @@ class GitRepositoryRuntime private constructor(
}
fun gitManPage(level: Level, currentRepo: RepoState, topic: String): String {
if (topic == "ignore") {
return fallbackManPage(topic)
}
bundledManPage(topic)?.let { return it }
val nativeGit = nativeGitBinary()
if (nativeGit == null) {
return fallbackManPage(topic)
}
val nativeGit = requireNativeGit()
val sandboxRoot = sandboxDir(level).canonicalFile
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
@@ -190,14 +184,11 @@ class GitRepositoryRuntime private constructor(
return result.outputLines
.filterNot { it.contains("no man viewer", ignoreCase = true) }
.joinToString("\n")
.ifBlank { fallbackManPage(topic) }
.ifBlank { placeholderManPage(topic) }
}
fun readEditorFile(level: Level, currentRepo: RepoState, path: String): Pair<String, List<String>> {
val nativeGit = nativeGitBinary()
if (nativeGit == null) {
return currentRepo.files.find { it.name == path }?.content.orEmpty() to emptyList()
}
requireNativeGit()
val sandboxRoot = sandboxDir(level).canonicalFile
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
@@ -214,17 +205,7 @@ class GitRepositoryRuntime private constructor(
}
fun writeEditorFile(level: Level, currentRepo: RepoState, path: String, content: String): Pair<RepoState, List<String>> {
val nativeGit = nativeGitBinary()
if (nativeGit == null) {
val updatedFiles = currentRepo.files.toMutableList()
val index = updatedFiles.indexOfFirst { it.name == path }
if (index == -1) {
updatedFiles += GitFile(name = path, content = content)
} else {
updatedFiles[index] = updatedFiles[index].copy(content = content)
}
return currentRepo.copy(files = updatedFiles) to listOf("Saved $path")
}
requireNativeGit()
val sandboxRoot = sandboxDir(level).canonicalFile
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
@@ -248,10 +229,7 @@ class GitRepositoryRuntime private constructor(
invocation: GitEditorInvocation,
message: String,
): Pair<RepoState, List<String>> {
val nativeGit = nativeGitBinary()
if (nativeGit == null) {
return GitSandboxEngine.execute(currentRepo, invocation.command.withFallbackMessage(invocation.kind, message))
}
val nativeGit = requireNativeGit()
val sandboxRoot = sandboxDir(level).canonicalFile
if (!sandboxRoot.exists()) {
@@ -341,15 +319,8 @@ class GitRepositoryRuntime private constructor(
}
}
private fun String.withFallbackMessage(kind: GitEditorCommandKind, message: String): String {
val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"").lineSequence().firstOrNull().orEmpty()
return when (kind) {
GitEditorCommandKind.COMMIT_MESSAGE -> "$this -m \"$escaped\""
GitEditorCommandKind.TAG_MESSAGE -> "$this -m \"$escaped\""
}
}
private fun fallbackManPage(topic: String): String {
private fun placeholderManPage(topic: String): String {
bundledManPage(topic)?.let { return it }
val body = when (topic) {
"tag" -> """
NAME
@@ -436,13 +407,43 @@ class GitRepositoryRuntime private constructor(
git-$topic
DESCRIPTION
No bundled fallback manpage is available for git $topic.
No bundled manpage is available for git $topic.
Try the command suggestions or use git help for another command.
""".trimIndent()
}
return body
}
private fun bundledManPage(topic: String): String? {
val assetNames = listOf(
"manpages/git-$topic.txt",
"manpages/git${topic.removePrefix("-")}.txt",
"manpages/$topic.txt",
).distinct()
if (context != null) {
assetNames.forEach { assetName ->
try {
context.assets.open(assetName).bufferedReader().use { reader ->
return reader.readText().takeIf { it.isNotBlank() }
}
} catch (_: Exception) {
// Try the next bundled filename.
}
}
}
val repoAssetRoot = File("src/main/assets")
assetNames.forEach { assetName ->
val file = File(repoAssetRoot, assetName)
if (file.exists()) {
return file.readText().takeIf { it.isNotBlank() }
}
}
return null
}
private fun materializeNativeGitState(nativeGit: File, sandbox: File, desired: RepoState, level: Level) {
level.nativeSetup?.let { setup ->
val nativeSetup = NativeLevelSetup(sandbox) { directory, arguments ->
@@ -540,7 +541,7 @@ class GitRepositoryRuntime private constructor(
"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
"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=") }
@@ -552,6 +553,27 @@ class GitRepositoryRuntime private constructor(
return updatedRepo to output
}
private fun List<String>.normalizeGitStageAlias(): List<String> {
return if (size >= 2 && this[0] == "git" && this[1] == "stage") {
toMutableList().also { it[1] = "add" }
} else {
this
}
}
private fun rejectUnsupportedInteractiveGitCommand(
currentRepo: RepoState,
tokens: List<String>,
): Pair<RepoState, List<String>>? {
if (tokens.firstOrNull() != "git") return null
val subcommand = tokens.getOrNull(1) ?: return null
val interactive = tokens.drop(2).any { it == "-i" || it == "--interactive" }
if (subcommand == "add" && interactive) {
return currentRepo to listOf("Interactive staging is not supported in the mobile terminal. Use git add <path> or git add -p <path>.")
}
return null
}
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) {
@@ -575,13 +597,13 @@ class GitRepositoryRuntime private constructor(
private fun inspectSandbox(level: Level): RepoState {
val sandbox = sandboxDir(level)
val nativeGit = nativeGitBinary()
val nativeGit = requireNativeGit()
val filesOnDisk = sandbox.walkTopDown()
.filter { it.isFile && !it.relativeTo(sandbox).path.startsWith(".git/") }
.orEmpty()
.toList()
if (nativeGit == null || !File(sandbox, ".git").exists()) {
if (!File(sandbox, ".git").exists()) {
return RepoState(
initialized = File(sandbox, ".git").exists(),
files = filesOnDisk.map { GitFile(name = it.name, content = it.readText()) },
@@ -690,7 +712,13 @@ class GitRepositoryRuntime private constructor(
}
private fun nativeGitBinary(): File? {
return nativeGitOverride ?: packagedNativeGitBinary()
return nativeGitOverride
?.takeIf { it.exists() && it.canExecute() }
?: packagedNativeGitBinary()
}
private fun requireNativeGit(): File {
return nativeGitBinary() ?: error(unavailableMessage())
}
private fun packagedNativeGitBinary(): File? {

View File

@@ -27,6 +27,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -38,6 +41,13 @@ data class ManPageState(
val content: String,
)
private data class ManPageMatch(
val start: Int,
val end: Int,
val lineIndex: Int,
val columnIndex: Int,
)
@Composable
fun ManPageDialog(
state: ManPageState,
@@ -52,21 +62,52 @@ fun ManPageDialog(
if (searchQuery.isBlank()) {
emptyList()
} else {
contentLines.mapIndexedNotNull { index, line ->
index.takeIf { line.contains(searchQuery, ignoreCase = true) }
val result = mutableListOf<ManPageMatch>()
var absoluteLineStart = 0
contentLines.forEachIndexed { lineIndex, line ->
var searchFrom = 0
while (searchFrom <= line.length) {
val columnIndex = line.indexOf(searchQuery, searchFrom, ignoreCase = true)
if (columnIndex == -1) break
val start = absoluteLineStart + columnIndex
result += ManPageMatch(
start = start,
end = start + searchQuery.length,
lineIndex = lineIndex,
columnIndex = columnIndex,
)
searchFrom = columnIndex + searchQuery.length.coerceAtLeast(1)
}
absoluteLineStart += line.length + 1
}
result
}
}
val matchCount = matches.size
val currentMatchNumber = if (matchCount == 0) 0 else selectedMatch + 1
val highlightedContent = remember(state.content, matches, selectedMatch) {
AnnotatedString.Builder(state.content).apply {
matches.forEachIndexed { index, match ->
addStyle(
SpanStyle(
color = if (index == selectedMatch) Color.White else Color.Black,
background = if (index == selectedMatch) Color(0xFFD32F2F) else Color(0xFFFFEB3B),
),
match.start,
match.end,
)
}
}.toAnnotatedString()
}
LaunchedEffect(searchQuery, matchCount) {
selectedMatch = selectedMatch.coerceIn(0, (matchCount - 1).coerceAtLeast(0))
}
LaunchedEffect(selectedMatch, matches) {
val lineIndex = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect
verticalScrollState.animateScrollTo((lineIndex * 18).coerceAtMost(verticalScrollState.maxValue))
val match = matches.getOrNull(selectedMatch) ?: return@LaunchedEffect
verticalScrollState.animateScrollTo((match.lineIndex * 18).coerceAtMost(verticalScrollState.maxValue))
horizontalScrollState.animateScrollTo((match.columnIndex * 8).coerceAtMost(horizontalScrollState.maxValue))
}
Dialog(onDismissRequest = onClose) {
@@ -170,7 +211,7 @@ fun ManPageDialog(
) {
SelectionContainer {
Text(
text = state.content,
text = highlightedContent,
modifier = Modifier.widthIn(min = 1200.dp),
color = TextPrimary,
fontFamily = FontFamily.Monospace,

View File

@@ -241,7 +241,7 @@ private fun RowScope.TerminalInputField(
) {
if (commandInput.text.isEmpty()) {
Text(
text = "Enter git command",
text = "Enter git command or answer",
color = TextMuted.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
@@ -269,7 +269,7 @@ private fun TerminalInputHintBubble() {
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = "Tap here to enter a command",
text = "Tap here to enter a command or answer.",
color = Color(0xFF161000),
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,

View File

@@ -12,10 +12,26 @@ internal fun cherryPickLevel(): Level = level(
title = "Cherry Pick",
description = "Your new feature isn't worth the time and you're going to delete it. But it has one commit that fills in `README` file, and you want this commit to be on the master as well.",
hints = listOf("Sneak a peek at the `git help cherry-pick` command."),
commandSuggestions = listOf("git cherry-pick feature"),
setup = { RepoState(initialized = true, files = listOf(GitFile("nokia.js", tracked = true)), commits = listOf(CommitNode("1", "Initial"), CommitNode("2", "Master work")), branches = mapOf("master" to 2, "feature" to 3)) },
validator = repoPredicate { repo -> repo.files.any { it.name == "README" && it.tracked } && repo.headBranch == "master" },
commandSuggestions = listOf("git log --oneline new-feature", "git cherry-pick new-feature"),
setup = { RepoState(initialized = true, files = listOf(GitFile("nokia.js", tracked = true)), commits = listOf(CommitNode("1", "Added fancy branded output"), CommitNode("2", "Filled in README.md with proper input")), branches = mapOf("master" to 1, "new-feature" to 2)) },
validator = repoPredicate { repo ->
repo.files.any { it.name == "README.md" && it.tracked } &&
repo.headBranch == "master" &&
repo.commits.take(2).map { it.message } == listOf("Filled in README.md with proper input", "Added fancy branded output")
},
nativeSetup = {
resetFiles()
write("nokia.js", "console.log('Nokia tune')\n")
addCommit("Added fancy branded output", "nokia.js")
checkoutNew("new-feature")
write("README.md", "Proper input instructions\n")
addCommit("Filled in README.md with proper input", "README.md")
checkout("master")
git("branch", "-f", "feature", "new-feature")
true
},
testCases = listOf(
levelTestCase("cherry pick feature tip", "git cherry-pick feature"),
levelTestCase("cherry pick new feature tip", "git cherry-pick new-feature"),
levelTestCase("cherry pick feature alias", "git cherry-pick feature"),
),
)

View File

@@ -13,8 +13,22 @@ internal fun numberOfFilesCommittedLevel(): Level = level(
description = "There are some files in this repository; how many of them are staged for a commit?",
hints = listOf("You are looking for a command to identify the status of the repository (resembles a Linux command)."),
commandSuggestions = listOf("git status"),
setup = { RepoState(initialized = true, files = listOf(GitFile("rubyfile1.rb", staged = true), GitFile("rubyfile4.rb", staged = true, tracked = true), GitFile("rubyfile5.rb", tracked = true), GitFile("rubyfile6.rb"), GitFile("rubyfile7.rb")), branches = mapOf("master" to 1)) },
setup = { RepoState(initialized = true, files = listOf(GitFile("rubyfile1.rb", staged = true), GitFile("rubyfile4.rb", "#Changes", staged = true, tracked = true), GitFile("rubyfile5.rb", "#Changes", tracked = true), GitFile("rubyfile6.rb"), GitFile("rubyfile7.rb")), branches = mapOf("master" to 1)) },
validator = commandAnswer("2"),
nativeSetup = {
resetFiles()
write("rubyfile4.rb")
write("rubyfile5.rb")
addCommit("Commit", "rubyfile4.rb", "rubyfile5.rb")
write("rubyfile4.rb", "#Changes")
add("rubyfile4.rb")
write("rubyfile5.rb", "#Changes")
write("rubyfile1.rb")
add("rubyfile1.rb")
write("rubyfile6.rb")
write("rubyfile7.rb")
true
},
testCases = listOf(
levelTestCase("answer staged count", "2"),
),