Files
Githug-Android/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt

687 lines
31 KiB
Kotlin

package solutions.tretter.githugandroid
import android.os.Build
import android.content.Context
import java.io.File
class GitRepositoryRuntime private constructor(
private val context: Context?,
private val nativeGitOverride: File?,
private val sandboxesRoot: File,
) {
private val processRunner = GitProcessRunner(context, sandboxesRoot)
private val helperCommands = GitHelperCommands(processRunner)
private val levelMaterializer = GitLevelMaterializer(::runGit)
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))
}
levelMaterializer.materialize(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 invocation = parseEnvironmentPrefixedCommand(shellTokens)
val tokens = invocation.command.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, invocation.command)
}
.normalizeGitStageAlias()
.normalizeGitBisectRunScriptShortcut()
executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it }
val result = when (expandedTokens.first()) {
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
"help", "?" -> currentRepo to commandReferenceLines()
else -> helperCommands.executeExecutableShortcut(sandboxRoot, workingDir, currentRepo, expandedTokens)
?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens)
}
val inspectedRepo = inspectSandbox(level).copy(currentDir = result.first.currentDir)
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) 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()
val relativeCandidates = 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()
return relativeCandidates + relativeCandidates.map { "./$it" }
}
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, sh <script>, ./<script>, 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 {
bundledGitManPage(context, 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 { placeholderGitManPage(context, 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 gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
if (invocation.kind != GitEditorCommandKind.REBASE_TODO) return invocation.initialContent
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 target = rebaseTarget(invocation.command) ?: return invocation.initialContent
val result = runGit(nativeGit, workingDir, listOf("log", "--reverse", "--format=%h %s", "$target..HEAD"))
if (result.exitCode != 0 || result.outputLines.isEmpty()) return invocation.initialContent
return buildString {
result.outputLines
.filter { it.isNotBlank() }
.forEach { line -> appendLine("pick $line") }
appendLine()
appendLine("# Rebase $target..HEAD onto $target")
appendLine("#")
appendLine("# Commands:")
appendLine("# p, pick <commit> = use commit")
appendLine("# r, reword <commit> = use commit, but edit the commit message")
appendLine("# e, edit <commit> = use commit, but stop for amending")
appendLine("# s, squash <commit> = use commit, but meld into previous commit")
appendLine("# f, fixup [-C | -c] <commit> = like squash but keep only the previous commit's log message")
appendLine("# d, drop <commit> = remove commit")
appendLine("#")
appendLine("# These lines can be re-ordered; they are executed from top to bottom.")
}
}
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
if (invocation.kind == GitEditorCommandKind.REBASE_TODO) {
return executeInteractiveRebaseEditorCommand(level, currentRepo, nativeGit, sandboxRoot, workingDir, invocation, message)
}
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) {
val (updatedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(currentRepo, message)
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 inspectedRepo to output
}
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 executeInteractiveRebaseEditorCommand(
level: Level,
currentRepo: RepoState,
nativeGit: File,
sandboxRoot: File,
workingDir: File,
invocation: GitEditorInvocation,
todo: String,
): Pair<RepoState, List<String>> {
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
writeText(todo)
}
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
writeText(
"""
|#!/bin/sh
|cat ${todoFile.absolutePath.toShellSingleQuoted()} > "$1"
|""".trimMargin(),
)
setReadable(true, true)
}
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command).drop(1)
val result = runGit(
nativeGit,
workingDir,
arguments,
mapOf(
"GIT_SEQUENCE_EDITOR" to "${shellExecutable().toShellSingleQuoted()} ${editorScript.absolutePath.toShellSingleQuoted()}",
"GIT_EDITOR" to "true",
),
)
todoFile.delete()
editorScript.delete()
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines
}
private fun executeSyntheticGitCommand(
currentRepo: RepoState,
command: String,
tokens: List<String>,
environment: Map<String, String> = emptyMap(),
): Pair<RepoState, List<String>>? {
if (environment.isNotEmpty()) return null
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" -> "--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
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 List<String>.normalizeGitBisectRunScriptShortcut(): List<String> {
return if (
size >= 4 &&
this[0] == "git" &&
this[1] == "bisect" &&
this[2] == "run" &&
this[3].startsWith("./")
) {
take(3) + listOf("sh") + drop(3)
} else {
this
}
}
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%at\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 = 3)
if (parts.isEmpty()) {
null
} else {
CommitNode(
id = parts[0],
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
message = parts.getOrElse(2) { "" },
)
}
}
} 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>,
outputLines: List<String> = emptyList(),
): RepoState {
if (tokens.firstOrNull() != "git") return inspectedRepo
return when (tokens.getOrNull(1)) {
"bisect" -> {
if (tokens.getOrNull(2) == "run" && outputLines.any { it.contains("is the first bad commit") }) {
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions + "bisect-found")
} else {
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions)
}
}
"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 data class EnvironmentPrefixedCommand(
val environment: Map<String, String>,
val command: List<GitSandboxEngine.ShellToken>,
)
private fun parseEnvironmentPrefixedCommand(tokens: List<GitSandboxEngine.ShellToken>): EnvironmentPrefixedCommand {
val environment = linkedMapOf<String, String>()
var commandStart = 0
while (commandStart < tokens.size) {
val value = tokens[commandStart].value
val assignmentIndex = value.indexOf('=')
if (assignmentIndex <= 0) break
val name = value.take(assignmentIndex)
if (!name.isShellEnvironmentName()) break
environment[name] = value.substring(assignmentIndex + 1)
commandStart += 1
}
return EnvironmentPrefixedCommand(environment, tokens.drop(commandStart))
}
private fun rebaseTarget(command: String): String? {
val tokens = GitSandboxEngine.tokenizeCommand(command)
if (tokens.size < 3 || tokens[0] != "git" || tokens[1] != "rebase") return null
return tokens.drop(2).lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
}
private fun String.isShellEnvironmentName(): Boolean {
if (isEmpty()) return false
if (first() != '_' && !first().isLetter()) return false
return all { it == '_' || it.isLetterOrDigit() }
}
private fun String.toShellSingleQuoted(): String {
return "'" + replace("'", "'\"'\"'") + "'"
}
private fun shellExecutable(): String = processRunner.shellExecutable()
private fun runGit(
binary: File,
workingDir: File,
arguments: List<String>,
environment: Map<String, String> = emptyMap(),
): ProcessExecutionResult = processRunner.runGit(binary, workingDir, arguments, environment)
}