932 lines
39 KiB
Kotlin
932 lines
39 KiB
Kotlin
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<RepoState, List<String>> {
|
|
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<String> {
|
|
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<String> {
|
|
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 <command>")
|
|
}
|
|
}
|
|
|
|
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<String, List<String>> {
|
|
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<RepoState, List<String>> {
|
|
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<RepoState, List<String>> {
|
|
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<String>): Pair<RepoState, List<String>> {
|
|
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 <file>")
|
|
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 <file>")
|
|
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 <dir>")
|
|
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 <dir>")
|
|
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 <path>")
|
|
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 <tagname>
|
|
git tag -d <tagname>
|
|
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 <branchname>
|
|
git branch -d <branchname>
|
|
|
|
EXAMPLES
|
|
git branch test_code
|
|
git branch -d delete_me
|
|
""".trimIndent()
|
|
"checkout" -> """
|
|
NAME
|
|
git-checkout - Switch branches or restore files
|
|
|
|
SYNOPSIS
|
|
git checkout <branch>
|
|
git checkout -b <branch>
|
|
git checkout -- <file>
|
|
|
|
EXAMPLES
|
|
git checkout -b my_branch
|
|
git checkout -- config.rb
|
|
""".trimIndent()
|
|
"add" -> """
|
|
NAME
|
|
git-add - Add file contents to the index
|
|
|
|
SYNOPSIS
|
|
git add <path>
|
|
git add .
|
|
git add -p <path>
|
|
""".trimIndent()
|
|
"commit" -> """
|
|
NAME
|
|
git-commit - Record changes to the repository
|
|
|
|
SYNOPSIS
|
|
git commit -m <message>
|
|
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<GitFile>, index: Int, commitCount: Int): List<GitFile> {
|
|
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<String>,
|
|
): Pair<RepoState, List<String>>? {
|
|
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<String>.normalizeGitStageAlias(): List<String> {
|
|
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<String>): Pair<RepoState, List<String>> {
|
|
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<String, Pair<Boolean, Boolean>>()
|
|
val deletedStatusPaths = mutableSetOf<String>()
|
|
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<GitSandboxEngine.ShellToken>): List<String> {
|
|
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<String>): 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<String>, second: List<String>): List<String> {
|
|
return (first + second).distinct()
|
|
}
|
|
|
|
private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
|
return runProcess(binary, workingDir, arguments)
|
|
}
|
|
|
|
private fun runProcess(binary: File, workingDir: File, arguments: List<String>): 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<String>,
|
|
)
|