Remove Kotlin interactive-add emulation and run patch add via the native Git runtime with PTY-backed session IO.
355 lines
12 KiB
Kotlin
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() }
|