package solutions.tretter.githugandroid import android.os.Build import android.content.Context import android.system.Os import java.io.File import java.nio.file.Files class GitRepositoryRuntime private constructor( private val context: Context?, private val nativeGitOverride: File?, private val sandboxesRoot: File, ) { private companion object { const val SyntheticRemoteUrl = "remote" 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", ) } constructor(context: Context) : this( context = context.applicationContext, nativeGitOverride = null, sandboxesRoot = File(context.applicationContext.filesDir, "githug-sandboxes"), ) internal constructor(sandboxesRoot: File, nativeGitBinary: File) : this( context = null, nativeGitOverride = nativeGitBinary, sandboxesRoot = sandboxesRoot, ) fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null fun unavailableMessage(): String { val selectedAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown" val nativeLibraryDir = context?.applicationInfo?.nativeLibraryDir ?: "unknown" return buildString { append("GitHug Android cannot start because this build does not include a native Git binary for this device ABI.") append("\n\nCurrent device ABI: ") append(selectedAbi) append("\nSupported device ABIs: ") append(Build.SUPPORTED_ABIS.joinToString(", ").ifBlank { "unknown" }) append("\nSupported 64-bit ABIs: ") append(Build.SUPPORTED_64_BIT_ABIS.joinToString(", ").ifBlank { "none" }) append("\nSupported 32-bit ABIs: ") append(Build.SUPPORTED_32_BIT_ABIS.joinToString(", ").ifBlank { "none" }) append("\nPlatform: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})") append("\nDevice: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}; ${Build.HARDWARE})") append("\nExpected binary: $nativeLibraryDir/libgit.so") append("\n\nPlease report this information to the developer so support can be added for this device/platform.") append("\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 = requireNativeGit() val sandbox = sandboxDir(level) sandbox.deleteRecursively() sandbox.mkdirs() val desired = level.setup() desired.files.forEach { file -> File(sandbox, file.name).apply { parentFile?.mkdirs() writeText(file.content) } } val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty() if (needsGit) { val initResult = runGit(nativeGit, sandbox, listOf("init", "-b", desired.headBranch)) if (initResult.exitCode != 0) { runGit(nativeGit, sandbox, listOf("init")) runGit(nativeGit, sandbox, listOf("checkout", "-B", desired.headBranch)) } materializeNativeGitState(nativeGit, sandbox, desired, level) } return inspectSandbox(level) } fun execute(level: Level, currentRepo: RepoState, command: String): Pair> { AppLog.d("GitRuntime", "Executing command for level=${level.id}: $command") val nativeGit = requireNativeGit() val sandbox = sandboxDir(level) if (!sandbox.exists()) { prepareLevel(level) } val sandboxRoot = sandbox.canonicalFile val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot val shellTokens = GitSandboxEngine.tokenizeShellCommand(command) val tokens = shellTokens.map { it.value } if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList() if (currentRepo.interactiveAddSession != null) { val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command) val newlyStagedPaths = updatedRepo.files.filter { updatedFile -> updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true }.map { it.name } if (newlyStagedPaths.isNotEmpty()) { runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths) } val inspectedRepo = inspectSandbox(level).copy( currentDir = updatedRepo.currentDir, interactiveAddSession = updatedRepo.interactiveAddSession, ) return augmentObservedRepoFacts(updatedRepo, inspectedRepo, tokens) to output } val expandedTokens = if (tokens.firstOrNull() == "echo") { tokens } else { expandShellPathspecs(currentRepo, shellTokens) }.normalizeGitStageAlias() executeSyntheticGitCommand(currentRepo, command, expandedTokens)?.let { return it } val result = when (expandedTokens.first()) { "git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1)).outputLines "help", "?" -> currentRepo to commandReferenceLines() else -> executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens) } val inspectedRepo = inspectSandbox(level).copy(currentDir = result.first.currentDir) return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens) to result.second } fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List { requireNativeGit() val sandbox = sandboxDir(level) if (!sandbox.exists()) return emptyList() val sandboxRoot = sandbox.canonicalFile val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile .takeIf { it.path == sandboxRoot.path || it.path.startsWith(sandboxRoot.path + File.separator) } ?: sandboxRoot if (!workingDir.exists() || !workingDir.isDirectory) return emptyList() return workingDir.walkTopDown() .drop(1) .filter { file -> !directoriesOnly || file.isDirectory } .map { file -> val relativePath = file.relativeTo(workingDir).path if (file.isDirectory) "$relativePath/" else relativePath } .filter { it.isNotBlank() } .toList() } fun commandReferenceLines(): List { return buildList { addAll(GitSandboxEngine.commandReferenceLines()) 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") add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad") add(" git help ") } } fun gitManPage(level: Level, currentRepo: RepoState, topic: String): String { bundledManPage(topic)?.let { return it } val nativeGit = requireNativeGit() val sandboxRoot = sandboxDir(level).canonicalFile val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot val result = runGit(nativeGit, workingDir, listOf(topic, "-h")) return result.outputLines .filterNot { it.contains("no man viewer", ignoreCase = true) } .joinToString("\n") .ifBlank { placeholderManPage(topic) } } fun readEditorFile(level: Level, currentRepo: RepoState, path: String): Pair> { requireNativeGit() val sandboxRoot = sandboxDir(level).canonicalFile val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot val file = File(workingDir, path).canonicalFile if (!file.path.startsWith(sandboxRoot.path)) { return "" to listOf("editor: $path: Permission denied") } if (file.exists() && file.isDirectory) { return "" to listOf("editor: $path: Is a directory") } return if (file.exists()) file.readText() to emptyList() else "" to emptyList() } fun writeEditorFile(level: Level, currentRepo: RepoState, path: String, content: String): Pair> { requireNativeGit() val sandboxRoot = sandboxDir(level).canonicalFile val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot val file = File(workingDir, path).canonicalFile if (!file.path.startsWith(sandboxRoot.path)) { return currentRepo to listOf("editor: $path: Permission denied") } if (file.exists() && file.isDirectory) { return currentRepo to listOf("editor: $path: Is a directory") } file.parentFile?.mkdirs() file.writeText(content) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path") } fun executeGitEditorCommand( level: Level, currentRepo: RepoState, invocation: GitEditorInvocation, message: String, ): Pair> { val nativeGit = requireNativeGit() val sandboxRoot = sandboxDir(level).canonicalFile if (!sandboxRoot.exists()) { prepareLevel(level) } val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile .takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply { parentFile?.mkdirs() writeText(message) } val arguments = GitSandboxEngine.tokenizeCommand(invocation.command) .drop(1) .toMutableList() .apply { addAll(listOf("-F", messageFile.absolutePath)) } val result = runGit(nativeGit, workingDir, arguments) messageFile.delete() return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines } private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List): Pair> { return when (tokens.first()) { "ls", "dir" -> currentRepo to workingDir.listFiles() ?.sortedBy { it.name } ?.map { it.name } .orEmpty() "pwd" -> currentRepo to listOf( "/sandbox" + if (workingDir == sandboxRoot) "" else "/${workingDir.relativeTo(sandboxRoot).path}" ) "cat" -> { val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: cat ") val file = File(workingDir, target) if (!file.exists() || file.isDirectory) currentRepo to listOf("cat: $target: No such file") else currentRepo to file.readLines().ifEmpty { listOf("") } } "touch" -> { val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: touch ") val file = File(workingDir, target) if (file.exists()) { currentRepo to listOf("$target already exists") } else { file.parentFile?.mkdirs() file.writeText("") currentRepo to emptyList() } } "mkdir", "md" -> { val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: mkdir ") val dir = File(workingDir, target) if (dir.exists()) currentRepo to listOf("mkdir: $target: File exists") else { dir.mkdirs() currentRepo to emptyList() } } "cd", "cd.." -> { val target = if (tokens.first() == "cd..") ".." else tokens.getOrNull(1) ?: return currentRepo to listOf("usage: cd ") val dir = File(workingDir, target).canonicalFile when { !dir.exists() -> currentRepo to listOf("cd: $target: does not exist") !dir.isDirectory -> currentRepo to listOf("cd: $target: Not a directory") !dir.path.startsWith(sandboxRoot.path) -> currentRepo to listOf("cd: $target: Permission denied") else -> { val relativeDir = sandboxRoot.toPath().relativize(dir.toPath()).toString().ifEmpty { "." } currentRepo.copy(currentDir = relativeDir) to emptyList() } } } "rm", "del" -> { val targets = tokens.drop(1) if (targets.isEmpty()) return currentRepo to listOf("usage: rm ") val errors = targets.mapNotNull { target -> val file = File(workingDir, target) if (!file.exists()) { "${tokens.first()}: $target: No such file or directory" } else { file.deleteRecursively() null } } currentRepo to errors } "echo" -> executeEcho(workingDir, currentRepo, tokens) else -> currentRepo to listOf("Command not supported in prototype runtime. Try a git command or helper command.") } } private fun placeholderManPage(topic: String): String { bundledManPage(topic)?.let { return it } val body = when (topic) { "tag" -> """ NAME git-tag - Create, list, delete or verify a tag object SYNOPSIS git tag git tag -d git tag DESCRIPTION Tags name specific points in history. In GitHug Android, tag levels usually expect you to create or reference a tag by name. EXAMPLES git tag new_tag git tag v1.2 """.trimIndent() "branch" -> """ NAME git-branch - List, create, or delete branches SYNOPSIS git branch git branch -d EXAMPLES git branch test_code git branch -d delete_me """.trimIndent() "checkout" -> """ NAME git-checkout - Switch branches or restore files SYNOPSIS git checkout git checkout -b git checkout -- EXAMPLES git checkout -b my_branch git checkout -- config.rb """.trimIndent() "add" -> """ NAME git-add - Add file contents to the index SYNOPSIS git add git add . git add -p """.trimIndent() "commit" -> """ NAME git-commit - Record changes to the repository SYNOPSIS git commit -m git commit --amend """.trimIndent() "ignore" -> """ NAME gitignore - Specifies intentionally untracked files to ignore SYNOPSIS $'GIT_DIR/info/exclude', .gitignore DESCRIPTION A gitignore file specifies intentionally untracked files that Git should ignore. Each line contains a pattern. Blank lines are ignored. Lines beginning with # are comments. An optional ! prefix negates a pattern and re-includes a path. A pattern ending with / matches directories. EXAMPLES *.swp *.a !lib.a """.trimIndent() else -> """ NAME git-$topic DESCRIPTION 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 -> runGit(nativeGit, directory, arguments).exitCode } if (nativeSetup.setup()) { return } } desired.config.forEach { (key, value) -> runGit(nativeGit, sandbox, listOf("config", key, value)) } val trackedFiles = desired.files.filter { it.tracked } val desiredCommitCount = maxOf( desired.commits.size, desired.branches.values.maxOrNull() ?: 0, if (desired.tags.isNotEmpty()) 1 else 0, ) if (desiredCommitCount > 0) { repeat(desiredCommitCount) { index -> filesForSetupCommit(trackedFiles, index, desiredCommitCount).forEach { file -> runGit(nativeGit, sandbox, listOf("add", file.name)) } val message = desired.commits.getOrNull(index)?.message ?: "Setup commit ${index + 1}" runGit(nativeGit, sandbox, listOf("commit", "--allow-empty", "-m", message)) } desired.branches .keys .filterNot { it == desired.headBranch } .forEach { branch -> runGit(nativeGit, sandbox, listOf("branch", branch)) } if (desired.branches.containsKey(desired.headBranch)) { runGit(nativeGit, sandbox, listOf("checkout", desired.headBranch)) } } desired.tags.forEach { tag -> runGit(nativeGit, sandbox, listOf("tag", tag)) } desired.remotes.forEach { (name, url) -> val materializedUrl = if (url == SyntheticRemoteUrl) { materializeSyntheticRemote(nativeGit, sandbox, name) } else { url } runGit(nativeGit, sandbox, listOf("remote", "add", name, materializedUrl)) if (url == SyntheticRemoteUrl) { runGit(nativeGit, sandbox, listOf("fetch", name)) runGit(nativeGit, sandbox, listOf("branch", "--set-upstream-to=$name/master", desired.headBranch)) } } val stagedTargets = desired.files.filter { it.staged }.map { it.name } if (stagedTargets.isNotEmpty()) { runGit(nativeGit, sandbox, listOf("add") + stagedTargets) } desired.files.filter { it.deleted }.forEach { file -> File(sandbox, file.name).delete() } } private fun filesForSetupCommit(files: List, index: Int, commitCount: Int): List { if (files.isEmpty()) return emptyList() if (commitCount <= 1) return files if (index == 0) return files.take(1) if (index == commitCount - 1) return files.drop(index) return files.getOrNull(index)?.let { listOf(it) }.orEmpty() } private fun materializeSyntheticRemote(nativeGit: File, sandbox: File, remoteName: String): String { val remoteDir = File(sandbox.parentFile, "${sandbox.name}-$remoteName.git") remoteDir.deleteRecursively() val cloneResult = runGit(nativeGit, sandbox.parentFile ?: sandbox, listOf("clone", "--bare", sandbox.absolutePath, remoteDir.absolutePath)) if (cloneResult.exitCode != 0) { remoteDir.mkdirs() runGit(nativeGit, remoteDir, listOf("init", "--bare")) } return remoteDir.absolutePath } private fun executeSyntheticGitCommand( currentRepo: RepoState, command: String, tokens: List, ): Pair>? { if (tokens.firstOrNull() != "git") return null val gitCommand = tokens.getOrNull(1) ?: return null if (gitCommand == "clone" && tokens.getOrNull(2)?.startsWith("https://github.com/Gazler/cloneme") == true) { val target = tokens.getOrNull(3) ?: "cloneme" return currentRepo.copy( files = currentRepo.files + GitFile("$target/README", tracked = true), ) to listOf("Cloned ${tokens[2]} into $target") } val shouldUseSandboxSemantics = when (gitCommand) { "add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" } "rebase" -> "-i" in tokens || "--interactive" in tokens || "--onto" in tokens "merge" -> "--squash" in tokens || tokens.lastOrNull() == "mybranch" || tokens.lastOrNull() == "feature" "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=") } else -> false } if (!shouldUseSandboxSemantics) return null val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command) return updatedRepo to output } private fun List.normalizeGitStageAlias(): List { return if (size >= 2 && this[0] == "git" && this[1] == "stage") { toMutableList().also { it[1] = "add" } } else { this } } private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List): Pair> { val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" } if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) { return currentRepo to listOf(tokens.drop(1).joinToString(" ")) } val file = File(workingDir, tokens[redirectIndex + 1]) file.parentFile?.mkdirs() val content = tokens.subList(1, redirectIndex).joinToString(" ") if (tokens[redirectIndex] == ">>") { if (file.exists() && file.length() > 0L) { file.appendText("\n$content") } else { file.writeText(content) } } else { file.writeText(content) } return currentRepo to emptyList() } private fun inspectSandbox(level: Level): RepoState { val sandbox = sandboxDir(level) val nativeGit = requireNativeGit() val filesOnDisk = sandbox.walkTopDown() .filter { it.isFile && !it.relativeTo(sandbox).path.startsWith(".git/") } .orEmpty() .toList() if (!File(sandbox, ".git").exists()) { return RepoState( initialized = File(sandbox, ".git").exists(), files = filesOnDisk.map { GitFile(name = it.name, content = it.readText()) }, ) } val statusResult = runGit(nativeGit, sandbox, listOf("status", "--porcelain")) val statusMap = mutableMapOf>() val deletedStatusPaths = mutableSetOf() statusResult.outputLines.forEach { line -> if (line.length < 4) return@forEach val x = line[0] val y = line[1] val path = line.substring(3).trim() val staged = x != ' ' && x != '?' val tracked = x != '?' || y != '?' statusMap[path] = staged to tracked if (x == 'D' || y == 'D') { deletedStatusPaths += path } } val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%s")) val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list")) val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list")) val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list")) val remoteResult = runGit(nativeGit, sandbox, listOf("remote", "-v")) val headResult = runGit(nativeGit, sandbox, listOf("branch", "--show-current")) val exactTagResult = runGit(nativeGit, sandbox, listOf("describe", "--tags", "--exact-match")) val userNameResult = runGit(nativeGit, sandbox, listOf("config", "--get", "user.name")) val userEmailResult = runGit(nativeGit, sandbox, listOf("config", "--get", "user.email")) val fetchHeadCount = File(sandbox, ".git/FETCH_HEAD") .takeIf { it.isFile } ?.readLines() ?.count { it.isNotBlank() } ?: 0 val config = buildMap { userNameResult.outputLines.firstOrNull() ?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() } ?.let { put("user.name", it) } userEmailResult.outputLines.firstOrNull() ?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() } ?.let { put("user.email", it) } } AppLog.d( "GitRuntime", "inspectSandbox level=${level.id} config=$config user.name.exit=${userNameResult.exitCode} user.name.output=${userNameResult.outputLines} user.email.exit=${userEmailResult.exitCode} user.email.output=${userEmailResult.outputLines}", ) val commits = if (logResult.exitCode == 0) { logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line -> val parts = line.split('\t', limit = 2) if (parts.isEmpty()) null else CommitNode(parts[0], parts.getOrElse(1) { "" }) } } else { emptyList() } val branches = branchResult.outputLines .map { it.removePrefix("*").trim() } .filter { it.isNotBlank() && !it.startsWith("(") } .associateWith { branch -> runGit(nativeGit, sandbox, listOf("rev-list", "--count", branch)) .outputLines .firstOrNull() ?.toIntOrNull() ?: 0 } val headBranch = headResult.outputLines.firstOrNull() ?.ifBlank { null } ?: exactTagResult.outputLines.firstOrNull() ?.takeIf { exactTagResult.exitCode == 0 && it.isNotBlank() } ?.let { "tags/$it" } ?: "DETACHED" return RepoState( initialized = true, files = filesOnDisk.map { file -> val relativePath = file.relativeTo(sandbox).path val (staged, tracked) = statusMap[relativePath] ?: (false to true) GitFile( name = relativePath, content = file.readText(), staged = staged, tracked = tracked, ) } + deletedStatusPaths .filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(sandbox).path == deletedPath } } .map { deletedPath -> val (staged, tracked) = statusMap[deletedPath] ?: (false to true) GitFile( name = deletedPath, staged = staged, tracked = tracked, deleted = true, ) }, commits = commits, headBranch = headBranch, branches = branches, tags = tagResult.outputLines.filter { it.isNotBlank() }, remotes = remoteResult.outputLines.mapNotNull { line -> val parts = line.trim().split(Regex("\\s+")) if (parts.size >= 2) parts[0] to parts[1] else null }.toMap(), config = config, fetchedBranches = remoteBranchResult.outputLines .map { it.removePrefix("*").trim() } .filter { it.isNotBlank() && " -> " !in it } .toSet(), fetchHeadCount = fetchHeadCount, ) } private fun nativeGitBinary(): File? { return nativeGitOverride ?.takeIf { it.exists() && it.canExecute() } ?: packagedNativeGitBinary() } private fun requireNativeGit(): File { return nativeGitBinary() ?: error(unavailableMessage()) } private fun packagedNativeGitBinary(): File? { val nativeLibraryDir = context?.applicationInfo?.nativeLibraryDir ?: return null val candidate = File(nativeLibraryDir, "libgit.so") return candidate.takeIf { it.exists() && it.canExecute() } } private fun sandboxDir(level: Level): File = File(sandboxesRoot, level.id) private fun expandShellPathspecs(repo: RepoState, tokens: List): List { if (tokens.size <= 1) return tokens.map { it.value } return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1)) } private fun augmentObservedRepoFacts(previousRepo: RepoState, inspectedRepo: RepoState, tokens: List): RepoState { if (tokens.firstOrNull() != "git") return inspectedRepo return when (tokens.getOrNull(1)) { "stash" -> inspectedRepo.copy( stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes + "stash@{${previousRepo.stashes.size}}"), ) "fetch" -> { inspectedRepo.copy( fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches, maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "fetch", ) } "pull" -> { val remote = tokens.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin" val branch = tokens.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: inspectedRepo.headBranch inspectedRepo.copy( fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches + "$remote/$branch", fetchHeadCount = inspectedRepo.fetchHeadCount, maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "pull", ) } "push" -> { val remote = tokens.drop(2).firstOrNull { !it.startsWith("-") } ?: "origin" val explicitBranches = tokens .drop(2) .dropWhile { it.startsWith("-") } .drop(1) .filter { !it.startsWith("-") } val pushedBranches = when { tokens.any { it == "--all" } -> inspectedRepo.branches.keys.map { "$remote/$it" } explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" } else -> listOf("$remote/${inspectedRepo.headBranch}") } val pushedTags = if (tokens.any { it == "--tags" || it == "--follow-tags" }) inspectedRepo.tags.toSet() else emptySet() inspectedRepo.copy( pushedBranches = inspectedRepo.pushedBranches + previousRepo.pushedBranches + pushedBranches, pushedTags = inspectedRepo.pushedTags + previousRepo.pushedTags + pushedTags, ) } "submodule" -> { if (tokens.getOrNull(2) == "add") { val url = tokens.getOrNull(3) val path = tokens.getOrNull(4)?.trimEnd('/') if (url != null && path != null) { inspectedRepo.copy(submodules = previousRepo.submodules + inspectedRepo.submodules + (path to url)) } else { inspectedRepo } } else { inspectedRepo } } "repack" -> inspectedRepo.copy( maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "repack", ) "merge" -> inspectedRepo.copy( maintenanceActions = if ("--squash" in tokens) { inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "merge-squash" } else { inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "merge" }, ) else -> inspectedRepo.copy( stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes), fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches, fetchHeadCount = inspectedRepo.fetchHeadCount, pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches, pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags, submodules = previousRepo.submodules + inspectedRepo.submodules, maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions, ) } } private fun mergeDistinct(first: List, second: List): List { return (first + second).distinct() } private fun runGit(binary: File, workingDir: File, arguments: List): ProcessExecutionResult { return runProcess(binary, workingDir, arguments) } private fun runProcess(binary: File, workingDir: File, arguments: List): ProcessExecutionResult { return try { val gitExecPath = gitExecDirectory(binary) val process = ProcessBuilder(listOf(binary.absolutePath) + arguments) .directory(workingDir) .redirectErrorStream(true) .apply { environment()["HOME"] = workingDir.absolutePath environment()["GIT_EXEC_PATH"] = gitExecPath.absolutePath environment()["PATH"] = gitExecPath.absolutePath environment()["GIT_CONFIG_NOSYSTEM"] = "1" environment()["GIT_AUTHOR_NAME"] = "GitHug" environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com" environment()["GIT_COMMITTER_NAME"] = "GitHug" environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com" 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("Native Git execution failed: ${error.message ?: error::class.java.simpleName}")) } } 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 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) alias.setExecutable(true, false) 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 data class ProcessExecutionResult( val exitCode: Int, val outputLines: List, )