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

@@ -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? {