Files
Githug-Android/app/src/main/java/solutions/tretter/githugandroid/GitProcessRunner.kt
Joe Tretter 943bf03ca0 Route interactive Git through native terminal sessions
Remove Kotlin interactive-add emulation and run patch add via the native Git runtime with PTY-backed session IO.
2026-06-27 13:07:35 -05:00

355 lines
12 KiB
Kotlin

package solutions.tretter.githugandroid
import android.content.Context
import android.system.Os
import java.io.File
import java.io.InputStream
import java.nio.file.Files
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
internal class GitProcessRunner(
private val context: Context?,
private val sandboxesRoot: File,
) {
private val nextHostSessionId = AtomicInteger(1)
private val hostSessions = ConcurrentHashMap<Int, HostGitSession>()
private companion object {
val RequiredGitCommandAliases = listOf(
"add",
"branch",
"checkout",
"commit",
"commit-tree",
"config",
"diff",
"fetch",
"index-pack",
"merge",
"merge-base",
"pack-objects",
"pull",
"push",
"read-tree",
"rebase",
"receive-pack",
"rev-list",
"rev-parse",
"show-ref",
"stash",
"status",
"symbolic-ref",
"unpack-objects",
"update-ref",
"upload-pack",
)
}
fun shellExecutable(): String {
return when {
context != null -> "/system/bin/sh"
File("/bin/sh").exists() -> "/bin/sh"
else -> "sh"
}
}
fun runGit(
binary: File,
workingDir: File,
arguments: List<String>,
environment: Map<String, String> = emptyMap(),
): ProcessExecutionResult {
val startedAt = System.nanoTime()
val execStartedAt = System.nanoTime()
val gitExecPath = gitExecDirectory(binary)
val execDirectoryMs = elapsedMillisSince(execStartedAt)
val fullEnvironment = gitEnvironment(binary, workingDir, environment, gitExecPath)
AppLog.d(
"GitProcess",
"runGit start cwd=${workingDir.absolutePath} argv=${formatGitArgv(arguments)} " +
"extraEnvKeys=${environment.keys.sorted()} execDirectoryMs=$execDirectoryMs",
)
val result = if (context != null) {
NativeGitBridge.runGitMain(binary, workingDir, arguments, fullEnvironment)
} else {
runProcess(binary, workingDir, arguments, fullEnvironment)
}
AppLog.d(
"GitProcess",
"runGit finish exit=${result.exitCode} durationMs=${elapsedMillisSince(startedAt)} " +
"output=${formatOutputPreview(result.outputLines)}",
)
return result
}
fun startGitSession(
binary: File,
workingDir: File,
arguments: List<String>,
environment: Map<String, String> = emptyMap(),
): GitSessionResult {
val gitExecPath = gitExecDirectory(binary)
val fullEnvironment = gitEnvironment(binary, workingDir, environment, gitExecPath)
return if (context != null) {
NativeGitBridge.startGitSession(binary, workingDir, arguments, fullEnvironment)
} else {
startHostGitSession(binary, workingDir, arguments, fullEnvironment)
}
}
fun writeGitSession(sessionId: Int, input: String): GitSessionResult {
return if (context != null) {
NativeGitBridge.writeGitSession(sessionId, input)
} else {
writeHostGitSession(sessionId, input)
}
}
fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
return try {
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
.directory(workingDir)
.redirectErrorStream(true)
.apply {
environment()["HOME"] = workingDir.absolutePath
environment()["LC_ALL"] = "C"
}
.start()
val output = process.inputStream.bufferedReader().readLines()
val exit = process.waitFor()
ProcessExecutionResult(exitCode = exit, outputLines = output)
} catch (error: Exception) {
ProcessExecutionResult(exitCode = -1, outputLines = listOf("Shell execution failed: ${error.message ?: error::class.java.simpleName}"))
}
}
private fun runProcess(
binary: File,
workingDir: File,
arguments: List<String>,
environment: Map<String, String>,
): ProcessExecutionResult {
return try {
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
.directory(workingDir)
.redirectErrorStream(true)
.apply {
environment().putAll(environment)
}
.start()
val output = process.inputStream.bufferedReader().readLines()
val exit = process.waitFor()
ProcessExecutionResult(exitCode = exit, outputLines = output)
} catch (error: Exception) {
ProcessExecutionResult(exitCode = -1, outputLines = listOf("Native Git execution failed: ${error.message ?: error::class.java.simpleName}"))
}
}
private fun startHostGitSession(
binary: File,
workingDir: File,
arguments: List<String>,
environment: Map<String, String>,
): GitSessionResult {
return try {
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
.directory(workingDir)
.redirectErrorStream(true)
.apply { environment().putAll(environment) }
.start()
val sessionId = nextHostSessionId.getAndIncrement()
hostSessions[sessionId] = HostGitSession(process)
readHostGitSessionResult(sessionId, process)
} catch (error: Exception) {
GitSessionResult(
sessionId = 0,
running = false,
exitCode = -1,
outputLines = listOf("Native Git session failed: ${error.message ?: error::class.java.simpleName}"),
)
}
}
private fun writeHostGitSession(sessionId: Int, input: String): GitSessionResult {
val session = hostSessions[sessionId]
?: return GitSessionResult(sessionId, running = false, exitCode = -1, outputLines = listOf("Native Git session is not running."))
return try {
session.process.outputStream.write(input.toByteArray())
session.process.outputStream.flush()
readHostGitSessionResult(sessionId, session.process)
} catch (error: Exception) {
hostSessions.remove(sessionId)
GitSessionResult(
sessionId = sessionId,
running = false,
exitCode = -1,
outputLines = listOf("Native Git session failed: ${error.message ?: error::class.java.simpleName}"),
)
}
}
private fun readHostGitSessionResult(sessionId: Int, process: Process): GitSessionResult {
val output = StringBuilder()
val deadline = System.nanoTime() + 1_000_000_000L
do {
output.append(process.inputStream.readAvailableText())
if (!process.isAlive || output.isNotEmpty()) break
Thread.sleep(25)
} while (System.nanoTime() < deadline)
output.append(process.inputStream.readAvailableText())
return if (process.isAlive) {
GitSessionResult(sessionId = sessionId, running = true, exitCode = null, outputLines = output.toString().toOutputLines())
} else {
hostSessions.remove(sessionId)
GitSessionResult(sessionId = sessionId, running = false, exitCode = process.exitValue(), outputLines = output.toString().toOutputLines())
}
}
private fun gitExecDirectory(binary: File): File {
val directory = if (context != null) {
File(context.filesDir, "git-exec")
} else {
File(binary.parentFile ?: binary.absoluteFile.parentFile ?: sandboxesRoot, "git-exec")
}
refreshGitExecDirectoryIfNeeded(directory, binary)
directory.mkdirs()
File(directory, "git").also { alias ->
if (!alias.exists()) {
createGitAlias(binary, alias)
}
}
RequiredGitCommandAliases.forEach { command ->
listOf("git-$command", command).forEach { aliasName ->
val alias = File(directory, aliasName)
if (!alias.exists()) {
createGitAlias(binary, alias)
}
}
}
return directory
}
private fun gitEnvironment(
binary: File,
workingDir: File,
extraEnvironment: Map<String, String>,
gitExecPath: File = gitExecDirectory(binary),
): Map<String, String> {
return buildMap {
put("HOME", workingDir.absolutePath)
put("GIT_EXEC_PATH", gitExecPath.absolutePath)
put(
"PATH",
listOfNotNull(
gitExecPath.absolutePath,
System.getenv("PATH")?.takeIf { it.isNotBlank() },
).joinToString(File.pathSeparator),
)
put("GIT_CONFIG_NOSYSTEM", "1")
put("GIT_AUTHOR_NAME", "GitHug")
put("GIT_AUTHOR_EMAIL", "githug@example.com")
put("GIT_COMMITTER_NAME", "GitHug")
put("GIT_COMMITTER_EMAIL", "githug@example.com")
put("LC_ALL", "C")
put("TERM", "xterm-256color")
putAll(extraEnvironment)
}
}
private fun refreshGitExecDirectoryIfNeeded(directory: File, binary: File) {
val fingerprint = buildString {
append(binary.absolutePath)
append('\n')
append(binary.length())
append('\n')
append(binary.lastModified())
}
val stamp = File(directory, ".binary-fingerprint")
val previousFingerprint = stamp.takeIf { it.isFile }?.readText()
if (previousFingerprint != fingerprint) {
directory.deleteRecursively()
}
directory.mkdirs()
stamp.writeText(fingerprint)
}
private fun createGitAlias(binary: File, alias: File) {
if (alias.exists() || Files.isSymbolicLink(alias.toPath())) {
alias.delete()
}
if (context != null) {
try {
Os.symlink(binary.absolutePath, alias.absolutePath)
return
} catch (_: Exception) {
// Fall through to the portable options below.
}
}
try {
Files.createSymbolicLink(alias.toPath(), binary.toPath())
} catch (_: Exception) {
binary.copyTo(alias, overwrite = true)
}
alias.setExecutable(true, false)
}
private fun formatGitArgv(arguments: List<String>): String =
(listOf("git") + arguments).joinToString(" ") { argument ->
if (argument.any { it.isWhitespace() || it == '"' || it == '\'' }) {
"'" + argument.replace("'", "'\\''") + "'"
} else {
argument
}
}
private fun formatOutputPreview(lines: List<String>): String {
if (lines.isEmpty()) return "[]"
val preview = lines
.take(8)
.joinToString(" | ")
.take(1_000)
val suffix = if (lines.size > 8) " ... (${lines.size} lines)" else " (${lines.size} lines)"
return "[$preview]$suffix"
}
}
internal data class ProcessExecutionResult(
val exitCode: Int,
val outputLines: List<String>,
)
internal data class GitSessionResult(
val sessionId: Int,
val running: Boolean,
val exitCode: Int?,
val outputLines: List<String>,
)
private data class HostGitSession(
val process: Process,
)
private fun InputStream.readAvailableText(): String {
val output = StringBuilder()
val buffer = ByteArray(4096)
while (available() > 0) {
val count = read(buffer)
if (count <= 0) break
output.append(String(buffer, 0, count))
}
return output.toString()
}
private fun String.toOutputLines(): List<String> =
replace("\r\n", "\n")
.replace('\r', '\n')
.lineSequence()
.toList()
.dropLastWhile { it.isEmpty() }