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() 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, environment: Map = 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, environment: Map = 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): 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, environment: Map, ): 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, environment: Map, ): 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, gitExecPath: File = gitExecDirectory(binary), ): Map { 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 = (listOf("git") + arguments).joinToString(" ") { argument -> if (argument.any { it.isWhitespace() || it == '"' || it == '\'' }) { "'" + argument.replace("'", "'\\''") + "'" } else { argument } } private fun formatOutputPreview(lines: List): 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, ) internal data class GitSessionResult( val sessionId: Int, val running: Boolean, val exitCode: Int?, val outputLines: List, ) 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 = replace("\r\n", "\n") .replace('\r', '\n') .lineSequence() .toList() .dropLastWhile { it.isEmpty() }