Split oversized runtime, sandbox, and interactive add files into focused helpers
This commit is contained in:
@@ -19,8 +19,8 @@ android {
|
||||
applicationId = "solutions.tretter.githugandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 157
|
||||
versionName = "0.1.156"
|
||||
versionCode = 158
|
||||
versionName = "0.1.157"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal class GitHelperCommands(
|
||||
private val processRunner: GitProcessRunner,
|
||||
) {
|
||||
fun executeExecutableShortcut(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>>? {
|
||||
val executable = tokens.firstOrNull() ?: return null
|
||||
if (!executable.startsWith("./") || executable.length <= 2) return null
|
||||
return executeShellScript(sandboxRoot, workingDir, currentRepo, listOf("sh", executable) + tokens.drop(1))
|
||||
}
|
||||
|
||||
fun execute(
|
||||
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("") }
|
||||
}
|
||||
"sh" -> executeShellScript(sandboxRoot, workingDir, currentRepo, tokens)
|
||||
"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 executeShellScript(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val script = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: sh <script>")
|
||||
val scriptFile = File(workingDir, script).canonicalFile
|
||||
if (!scriptFile.path.startsWith(sandboxRoot.path)) {
|
||||
return currentRepo to listOf("sh: $script: Permission denied")
|
||||
}
|
||||
if (!scriptFile.isFile) {
|
||||
return currentRepo to listOf("sh: $script: No such file")
|
||||
}
|
||||
val result = processRunner.runShellProcess(
|
||||
File(processRunner.shellExecutable()),
|
||||
workingDir,
|
||||
tokens.drop(1),
|
||||
)
|
||||
return currentRepo to result.outputLines
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal const val SyntheticRemoteUrl = "remote"
|
||||
|
||||
internal class GitLevelMaterializer(
|
||||
private val runGitCommand: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
|
||||
) {
|
||||
fun materialize(nativeGit: File, sandbox: File, desired: RepoState, level: Level) {
|
||||
level.nativeSetup?.let { setup ->
|
||||
val nativeSetup = NativeLevelSetup(sandbox) { directory, arguments, environment ->
|
||||
runGit(nativeGit, directory, arguments, environment).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 runGit(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult = runGitCommand(binary, workingDir, arguments, environment)
|
||||
}
|
||||
128
app/src/main/java/solutions/tretter/githugandroid/GitManPages.kt
Normal file
128
app/src/main/java/solutions/tretter/githugandroid/GitManPages.kt
Normal file
@@ -0,0 +1,128 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
internal fun bundledGitManPage(context: Context?, 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
|
||||
}
|
||||
|
||||
internal fun placeholderGitManPage(context: Context?, topic: String): String {
|
||||
bundledGitManPage(context, topic)?.let { return it }
|
||||
return 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
internal class GitProcessRunner(
|
||||
private val context: Context?,
|
||||
private val sandboxesRoot: File,
|
||||
) {
|
||||
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 {
|
||||
return runProcess(binary, workingDir, arguments, environment)
|
||||
}
|
||||
|
||||
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>,
|
||||
extraEnvironment: Map<String, String> = emptyMap(),
|
||||
): 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"] = listOfNotNull(
|
||||
gitExecPath.absolutePath,
|
||||
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
||||
).joinToString(File.pathSeparator)
|
||||
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"
|
||||
environment().putAll(extraEnvironment)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ProcessExecutionResult(
|
||||
val exitCode: Int,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
@@ -2,46 +2,16 @@ 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",
|
||||
)
|
||||
}
|
||||
private val processRunner = GitProcessRunner(context, sandboxesRoot)
|
||||
private val helperCommands = GitHelperCommands(processRunner)
|
||||
private val levelMaterializer = GitLevelMaterializer(::runGit)
|
||||
|
||||
constructor(context: Context) : this(
|
||||
context = context.applicationContext,
|
||||
@@ -107,7 +77,7 @@ class GitRepositoryRuntime private constructor(
|
||||
runGit(nativeGit, sandbox, listOf("checkout", "-B", desired.headBranch))
|
||||
}
|
||||
|
||||
materializeNativeGitState(nativeGit, sandbox, desired, level)
|
||||
levelMaterializer.materialize(nativeGit, sandbox, desired, level)
|
||||
}
|
||||
|
||||
return inspectSandbox(level)
|
||||
@@ -157,25 +127,14 @@ class GitRepositoryRuntime private constructor(
|
||||
val result = when (expandedTokens.first()) {
|
||||
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
|
||||
"help", "?" -> currentRepo to commandReferenceLines()
|
||||
else -> executeExecutableShortcut(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
?: executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
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
|
||||
}
|
||||
|
||||
private fun executeExecutableShortcut(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>>? {
|
||||
val executable = tokens.firstOrNull() ?: return null
|
||||
if (!executable.startsWith("./") || executable.length <= 2) return null
|
||||
return executeShellScript(sandboxRoot, workingDir, currentRepo, listOf("sh", executable) + tokens.drop(1))
|
||||
}
|
||||
|
||||
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
|
||||
requireNativeGit()
|
||||
val sandbox = sandboxDir(level)
|
||||
@@ -213,7 +172,7 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
|
||||
fun gitManPage(level: Level, currentRepo: RepoState, topic: String): String {
|
||||
bundledManPage(topic)?.let { return it }
|
||||
bundledGitManPage(context, topic)?.let { return it }
|
||||
|
||||
val nativeGit = requireNativeGit()
|
||||
|
||||
@@ -224,7 +183,7 @@ class GitRepositoryRuntime private constructor(
|
||||
return result.outputLines
|
||||
.filterNot { it.contains("no man viewer", ignoreCase = true) }
|
||||
.joinToString("\n")
|
||||
.ifBlank { placeholderManPage(topic) }
|
||||
.ifBlank { placeholderGitManPage(context, topic) }
|
||||
}
|
||||
|
||||
fun readEditorFile(level: Level, currentRepo: RepoState, path: String): Pair<String, List<String>> {
|
||||
@@ -382,301 +341,6 @@ class GitRepositoryRuntime private constructor(
|
||||
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("") }
|
||||
}
|
||||
"sh" -> executeShellScript(sandboxRoot, workingDir, currentRepo, tokens)
|
||||
"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 executeShellScript(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val script = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: sh <script>")
|
||||
val scriptFile = File(workingDir, script).canonicalFile
|
||||
if (!scriptFile.path.startsWith(sandboxRoot.path)) {
|
||||
return currentRepo to listOf("sh: $script: Permission denied")
|
||||
}
|
||||
if (!scriptFile.isFile) {
|
||||
return currentRepo to listOf("sh: $script: No such file")
|
||||
}
|
||||
val result = runShellProcess(
|
||||
File(shellExecutable()),
|
||||
workingDir,
|
||||
tokens.drop(1),
|
||||
)
|
||||
return currentRepo to result.outputLines
|
||||
}
|
||||
|
||||
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, environment ->
|
||||
runGit(nativeGit, directory, arguments, environment).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,
|
||||
@@ -730,27 +394,6 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -1031,144 +674,13 @@ class GitRepositoryRuntime private constructor(
|
||||
return "'" + replace("'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
private fun shellExecutable(): String {
|
||||
return when {
|
||||
context != null -> "/system/bin/sh"
|
||||
File("/bin/sh").exists() -> "/bin/sh"
|
||||
else -> "sh"
|
||||
}
|
||||
}
|
||||
private fun shellExecutable(): String = processRunner.shellExecutable()
|
||||
|
||||
private fun runGit(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
return runProcess(binary, workingDir, arguments, environment)
|
||||
}
|
||||
): ProcessExecutionResult = processRunner.runGit(binary, workingDir, arguments, environment)
|
||||
|
||||
private fun runProcess(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
extraEnvironment: Map<String, String> = emptyMap(),
|
||||
): 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"] = listOfNotNull(
|
||||
gitExecPath.absolutePath,
|
||||
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
||||
).joinToString(File.pathSeparator)
|
||||
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"
|
||||
environment().putAll(extraEnvironment)
|
||||
}
|
||||
.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 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 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>,
|
||||
)
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
object GitSandboxEngine {
|
||||
private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?"
|
||||
|
||||
data class ShellToken(
|
||||
val value: String,
|
||||
val quoted: Boolean = false,
|
||||
)
|
||||
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? {
|
||||
val session = repo.interactiveAddSession ?: return null
|
||||
if (session.selectionAction != "patch-hunk") return null
|
||||
if (command.trim().lowercase() != "e") return null
|
||||
val target = session.target ?: return null
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted } ?: return null
|
||||
return GitEditorInvocation(
|
||||
command = command,
|
||||
kind = GitEditorCommandKind.PATCH_HUNK,
|
||||
title = "Edit Patch Hunk",
|
||||
initialContent = patchHunkLines(file)
|
||||
.dropLastWhile { it == PatchHunkPrompt }
|
||||
.joinToString("\n"),
|
||||
)
|
||||
}
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? =
|
||||
InteractiveAddEngine.parsePatchHunkEditorInvocation(repo, command)
|
||||
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to listOf("No patch hunk is active.")
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No patch hunk is active.")
|
||||
if (session.selectionAction != "patch-hunk") return repo to listOf("No patch hunk is active.")
|
||||
if (content.isBlank()) return repo to listOf("Edited hunk was empty; patch was not applied.", PatchHunkPrompt)
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
return repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf(
|
||||
"$PatchHunkPrompt e",
|
||||
"Applied edited hunk.",
|
||||
)
|
||||
}
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> =
|
||||
InteractiveAddEngine.applyPatchHunkEdit(repo, content)
|
||||
|
||||
fun commandReferenceLines(): List<String> = listOf(
|
||||
"Available sandbox commands:",
|
||||
@@ -56,837 +30,26 @@ object GitSandboxEngine {
|
||||
|
||||
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||
val shellParts = tokenizeShellCommand(command)
|
||||
val parts = shellParts.map { it.value }
|
||||
if (parts.isEmpty()) return repo to emptyList()
|
||||
if (shellParts.isEmpty()) return repo to emptyList()
|
||||
repo.interactiveAddSession?.let {
|
||||
return handleInteractiveAddInput(repo, command)
|
||||
return InteractiveAddEngine.handleInput(repo, command)
|
||||
}
|
||||
return when {
|
||||
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
|
||||
parts[0] == "touch" && parts.size >= 2 -> {
|
||||
val name = parts[1]
|
||||
if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists")
|
||||
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
|
||||
}
|
||||
(parts[0] == "mkdir" || parts[0] == "md") && parts.size >= 2 -> repo to emptyList()
|
||||
(parts[0] == "rm" || parts[0] == "del") && parts.size >= 2 -> {
|
||||
val target = parts[1]
|
||||
repo.copy(files = repo.files.mapNotNull { file ->
|
||||
when {
|
||||
file.name != target -> file
|
||||
file.tracked -> file.copy(deleted = true, staged = false)
|
||||
else -> null
|
||||
}
|
||||
}) to emptyList()
|
||||
}
|
||||
parts[0] == "echo" -> writeEcho(repo, shellParts)
|
||||
parts[0] == "ls" || parts[0] == "dir" -> repo to (
|
||||
if (repo.initialized) listOf(".git") else emptyList()
|
||||
) + repo.files.filterNot { it.deleted }.map { it.name }
|
||||
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
|
||||
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
|
||||
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
|
||||
!repo.initialized -> repo to listOf("fatal: not a git repository")
|
||||
parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines()
|
||||
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
|
||||
parts.size >= 2 && parts[1] == "stash" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.tracked && !file.staged) file.copy(content = "") else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, stashes = repo.stashes + "stash@{${repo.stashes.size}}") to listOf("Saved working directory and index state")
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "fetch" -> {
|
||||
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
repo.copy(
|
||||
fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/new_branch"),
|
||||
fetchHeadCount = 2,
|
||||
maintenanceActions = repo.maintenanceActions + "fetch",
|
||||
) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "pull" -> {
|
||||
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
val branch = parts.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: repo.headBranch
|
||||
repo.copy(
|
||||
fetchedBranches = repo.fetchedBranches + "$remote/$branch",
|
||||
fetchHeadCount = 1,
|
||||
branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)),
|
||||
maintenanceActions = repo.maintenanceActions + "pull",
|
||||
) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "push" -> pushRefs(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "submodule" && parts[2] == "add" -> {
|
||||
val url = parts.getOrNull(3)
|
||||
val path = parts.getOrNull(4)
|
||||
if (url == null || path == null) {
|
||||
repo to listOf("usage: git submodule add <repository> <path>")
|
||||
} else {
|
||||
repo.copy(submodules = repo.submodules + (path.trimEnd('/') to url)) to emptyList()
|
||||
}
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "repack" -> {
|
||||
repo.copy(maintenanceActions = repo.maintenanceActions + "repack") to emptyList()
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "tag" -> {
|
||||
val tag = parts[2]
|
||||
if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists")
|
||||
else repo.copy(tags = repo.tags + tag) to listOf(tag)
|
||||
}
|
||||
parts.size >= 4 && parts[1] == "config" -> {
|
||||
val key = parts[2]
|
||||
val value = parts.drop(3).joinToString(" ")
|
||||
repo.copy(config = repo.config + (key to value)) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> {
|
||||
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
|
||||
return interactiveAdd(repo, parts.drop(2))
|
||||
}
|
||||
if (parts.drop(2).any { it == "-p" || it == "--patch" }) {
|
||||
return interactiveAddPatch(repo, parts.drop(2))
|
||||
}
|
||||
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git add <path>")
|
||||
if (target != "." && repo.files.none { it.name == target && !it.deleted }) {
|
||||
repo to listOf("fatal: pathspec '$target' did not match any files")
|
||||
} else {
|
||||
val updated = repo.files.map { if ((target == "." || it.name == target) && !it.deleted) it.copy(staged = true) else it }
|
||||
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
|
||||
}
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
|
||||
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecTokens(repo, shellParts.drop(2)))
|
||||
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "log" -> {
|
||||
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
|
||||
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> {
|
||||
val name = parts.getOrNull(3)
|
||||
val url = parts.getOrNull(4)
|
||||
if (name == null || url == null) repo to listOf("usage: git remote add <name> <url>")
|
||||
else repo.copy(remotes = repo.remotes + (name to url)) to emptyList()
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "branch" -> {
|
||||
when (parts[2]) {
|
||||
"-d", "-D", "--delete" -> {
|
||||
val branch = parts.getOrNull(3)
|
||||
if (branch == null) repo to listOf("usage: git branch -d <branch>")
|
||||
else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch")
|
||||
}
|
||||
else -> {
|
||||
val branch = parts[2]
|
||||
val base = parts.getOrNull(3)
|
||||
val baseIndex = if (base == "HEAD~1" || base == "HEAD^") {
|
||||
(repo.branches[repo.headBranch] ?: repo.commits.size) - 1
|
||||
} else {
|
||||
repo.commits.size
|
||||
}.coerceAtLeast(0)
|
||||
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
|
||||
else repo.copy(branches = repo.branches + (branch to baseIndex)) to listOf("Created branch $branch")
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "checkout" -> {
|
||||
checkout(repo, parts.drop(2))
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "cherry-pick" -> {
|
||||
val files = if (repo.files.none { it.name == "README.md" }) {
|
||||
repo.files + GitFile("README.md", "Proper input instructions\n", tracked = true)
|
||||
} else {
|
||||
repo.files.map { if (it.name == "README.md") it.copy(tracked = true) else it }
|
||||
}
|
||||
repo.copy(files = files, commits = listOf(CommitNode("${repo.commits.size + 1}", "Filled in README.md with proper input")) + repo.commits) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "revert" -> {
|
||||
repo.copy(commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Revert \"Bad commit\"")) to emptyList()
|
||||
}
|
||||
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAdd(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val candidates = repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
}
|
||||
return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates)
|
||||
}
|
||||
|
||||
private fun interactiveAddPatch(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-p" || it == "--patch" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val patchFile = interactiveAddCandidates(repo, target).firstOrNull()
|
||||
?: return repo to listOf("No changes.")
|
||||
return startPatchHunkSession(repo, patchFile.name)
|
||||
}
|
||||
|
||||
private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
|
||||
return buildList {
|
||||
add(" staged unstaged path")
|
||||
candidates.forEachIndexed { index, file ->
|
||||
val staged = if (file.staged) "unchanged" else "+0/-0"
|
||||
val unstaged = when {
|
||||
file.tracked -> "+1/-0"
|
||||
else -> "+0/-0"
|
||||
}
|
||||
add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}")
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
add("No changes.")
|
||||
}
|
||||
add("*** Commands ***")
|
||||
add(" 1: status 2: update 3: revert 4: add untracked")
|
||||
add(" 5: patch 6: diff 7: quit 8: help")
|
||||
add("What now>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to emptyList()
|
||||
val answer = input.trim()
|
||||
if (session.selectionAction == "patch-hunk") {
|
||||
return handlePatchHunkInput(repo, session, answer)
|
||||
}
|
||||
return if (session.awaitingUpdateSelection) {
|
||||
applyInteractiveAddUpdateSelection(repo, session, answer)
|
||||
} else {
|
||||
when (answer.lowercase()) {
|
||||
"1", "s", "status" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
"2", "u", "update" -> interactiveAddSelectionPrompt(repo, session, answer, "Update>>", "update")
|
||||
"3", "r", "revert" -> interactiveAddSelectionPrompt(repo, session, answer, "Revert>>", "revert")
|
||||
"4", "a", "add untracked", "add-untracked" -> interactiveAddSelectionPrompt(repo, session, answer, "Add untracked>>", "add-untracked")
|
||||
"5", "p", "patch" -> interactiveAddSelectionPrompt(repo, session, answer, "Patch update>>", "patch")
|
||||
"6", "d", "diff" -> interactiveAddSelectionPrompt(repo, session, answer, "Diff>>", "diff")
|
||||
"7", "q", "quit" -> repo.copy(interactiveAddSession = null) to listOf("What now> $answer", "Bye.")
|
||||
"8", "h", "help" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
else -> repo to listOf("What now> $answer", "Huh ($answer)?") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionPrompt(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
prompt: String,
|
||||
action: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
return repo.copy(
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = true,
|
||||
selectionPrompt = prompt,
|
||||
selectionAction = action,
|
||||
),
|
||||
) to listOf("What now> $answer", prompt)
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddUpdateSelection(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val candidates = interactiveAddCandidates(repo, session.target)
|
||||
val selectedNames = selectedInteractiveAddNames(candidates, answer)
|
||||
val prompt = session.selectionPrompt
|
||||
if (selectedNames.isEmpty()) {
|
||||
return repo to listOf("$prompt $answer", "No files selected.", prompt)
|
||||
}
|
||||
|
||||
if (session.selectionAction == "patch" && selectedNames.size == 1) {
|
||||
return startPatchHunkSession(repo, selectedNames.single(), "$prompt $answer")
|
||||
}
|
||||
|
||||
val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction)
|
||||
val updatedRepo = repo.copy(
|
||||
files = updatedFiles,
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = "Update>>",
|
||||
selectionAction = "update",
|
||||
),
|
||||
)
|
||||
val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction)
|
||||
return updatedRepo to listOf(
|
||||
"$prompt $answer",
|
||||
summary,
|
||||
) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target))
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set<String>, action: String): List<GitFile> {
|
||||
return when (action) {
|
||||
"revert" -> repo.files.mapNotNull { file ->
|
||||
if (file.name !in selectedNames || file.deleted) {
|
||||
file
|
||||
} else if (file.tracked) {
|
||||
file.copy(content = "", staged = false, deleted = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
"diff" -> repo.files
|
||||
else -> repo.files.map { file ->
|
||||
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionSummary(
|
||||
repo: RepoState,
|
||||
updatedFiles: List<GitFile>,
|
||||
selectedNames: Set<String>,
|
||||
action: String,
|
||||
): String {
|
||||
return when (action) {
|
||||
"revert" -> "reverted ${selectedNames.size} path(s)"
|
||||
"diff" -> selectedNames.joinToString("\n") { "diff -- $it" }
|
||||
else -> {
|
||||
val stagedCount = updatedFiles.count { updatedFile ->
|
||||
val before = repo.files.firstOrNull { it.name == updatedFile.name }
|
||||
updatedFile.staged && before?.staged != true
|
||||
}
|
||||
"updated $stagedCount path(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair<RepoState, List<String>> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
?: return repo to listOfNotNull(prefixLine, "No changes.")
|
||||
val session = InteractiveAddSession(
|
||||
target = target,
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = PatchHunkPrompt,
|
||||
selectionAction = "patch-hunk",
|
||||
)
|
||||
val output = listOfNotNull(prefixLine) + patchHunkLines(file)
|
||||
return repo.copy(interactiveAddSession = session) to output
|
||||
}
|
||||
|
||||
private fun handlePatchHunkInput(repo: RepoState, session: InteractiveAddSession, answer: String): Pair<RepoState, List<String>> {
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
return when (answer.lowercase()) {
|
||||
"y", "a" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
}
|
||||
"n", "d" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
"q" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer", "Quit")
|
||||
"?" -> repo to listOf(
|
||||
"$PatchHunkPrompt $answer",
|
||||
"y - stage this hunk",
|
||||
"n - do not stage this hunk",
|
||||
"q - quit; do not stage this hunk or any remaining ones",
|
||||
"a - stage this hunk and all later hunks in the file",
|
||||
"d - do not stage this hunk or any later hunks in the file",
|
||||
"s - split the current hunk into smaller hunks",
|
||||
"e - manually edit the current hunk",
|
||||
"p - print the current hunk",
|
||||
"? - print help",
|
||||
PatchHunkPrompt,
|
||||
)
|
||||
"p" -> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
if (file == null) {
|
||||
repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
} else {
|
||||
repo to listOf("$PatchHunkPrompt $answer") + patchHunkLines(file)
|
||||
}
|
||||
}
|
||||
"s" -> repo to listOf("$PatchHunkPrompt $answer", "Sorry, cannot split this hunk", PatchHunkPrompt)
|
||||
"e" -> repo to listOf("$PatchHunkPrompt $answer", "Opening patch editor")
|
||||
else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchHunkLines(file: GitFile): List<String> {
|
||||
val lines = file.content.lines()
|
||||
val nonEmptyLines = lines.dropLastWhile { it.isEmpty() }
|
||||
val addedCount = nonEmptyLines.size.coerceAtLeast(1)
|
||||
return buildList {
|
||||
add("diff --git a/${file.name} b/${file.name}")
|
||||
add("index 0000000..0000001 100644")
|
||||
add("--- a/${file.name}")
|
||||
add("+++ b/${file.name}")
|
||||
add("@@ -1 +1,$addedCount @@")
|
||||
if (nonEmptyLines.isEmpty()) {
|
||||
add("+")
|
||||
} else {
|
||||
nonEmptyLines.forEach { line -> add("+$line") }
|
||||
}
|
||||
add(PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddCandidates(repo: RepoState, target: String?): List<GitFile> {
|
||||
return repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedInteractiveAddNames(candidates: List<GitFile>, answer: String): Set<String> {
|
||||
if (answer == "*") return candidates.map { it.name }.toSet()
|
||||
return answer.split(Regex("[,\\s]+"))
|
||||
.mapNotNull { token ->
|
||||
token.toIntOrNull()
|
||||
?.takeIf { it in 1..candidates.size }
|
||||
?.let { candidates[it - 1].name }
|
||||
}
|
||||
.toSet()
|
||||
return SandboxCommandEngine.execute(repo, shellParts)
|
||||
}
|
||||
|
||||
fun tokenizeCommand(command: String): List<String> {
|
||||
return tokenizeShellCommand(command).map { it.value }
|
||||
return SandboxShell.tokenizeCommand(command)
|
||||
}
|
||||
|
||||
fun tokenizeShellCommand(command: String): List<ShellToken> {
|
||||
val result = mutableListOf<ShellToken>()
|
||||
val current = StringBuilder()
|
||||
var quoteChar: Char? = null
|
||||
var escaping = false
|
||||
var currentQuoted = false
|
||||
var skipNext = false
|
||||
|
||||
fun emitCurrent(force: Boolean = false) {
|
||||
if (current.isNotEmpty() || force && currentQuoted) {
|
||||
result += ShellToken(value = current.toString(), quoted = currentQuoted)
|
||||
current.clear()
|
||||
currentQuoted = false
|
||||
}
|
||||
}
|
||||
|
||||
command.forEachIndexed { index, char ->
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
return@forEachIndexed
|
||||
}
|
||||
when {
|
||||
escaping -> {
|
||||
current.append(char)
|
||||
escaping = false
|
||||
}
|
||||
char == '\\' && quoteChar != '\'' -> {
|
||||
escaping = true
|
||||
}
|
||||
quoteChar != null -> {
|
||||
if (char == quoteChar) {
|
||||
quoteChar = null
|
||||
} else {
|
||||
current.append(char)
|
||||
}
|
||||
}
|
||||
char == '"' || char == '\'' -> {
|
||||
quoteChar = char
|
||||
currentQuoted = true
|
||||
}
|
||||
char.isWhitespace() -> {
|
||||
emitCurrent()
|
||||
}
|
||||
char == '>' -> {
|
||||
emitCurrent()
|
||||
if (command.getOrNull(index + 1) == '>') {
|
||||
result += ShellToken(">>")
|
||||
skipNext = true
|
||||
} else if (command.getOrNull(index - 1) != '>') {
|
||||
result += ShellToken(">")
|
||||
}
|
||||
}
|
||||
else -> current.append(char)
|
||||
}
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current.append('\\')
|
||||
}
|
||||
emitCurrent()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun writeEcho(repo: RepoState, shellParts: List<ShellToken>): Pair<RepoState, List<String>> {
|
||||
val parts = shellParts.map { it.value }
|
||||
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
|
||||
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
|
||||
return repo to listOf(parts.drop(1).joinToString(" "))
|
||||
}
|
||||
|
||||
val append = parts[redirectIndex] == ">>"
|
||||
val content = parts.subList(1, redirectIndex).joinToString(" ")
|
||||
val target = parts[redirectIndex + 1]
|
||||
val updatedFiles = repo.files.toMutableList()
|
||||
val index = updatedFiles.indexOfFirst { it.name == target }
|
||||
if (index == -1) {
|
||||
updatedFiles += GitFile(name = target, content = content)
|
||||
} else {
|
||||
val current = updatedFiles[index]
|
||||
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
|
||||
updatedFiles[index] = current.copy(content = nextContent, deleted = false)
|
||||
}
|
||||
|
||||
return repo.copy(files = updatedFiles) to emptyList()
|
||||
}
|
||||
|
||||
private fun parentDirectory(currentDir: String): String {
|
||||
if (currentDir == ".") return "."
|
||||
return currentDir.substringBeforeLast('/', missingDelimiterValue = ".").ifBlank { "." }
|
||||
return SandboxShell.tokenizeShellCommand(command)
|
||||
}
|
||||
|
||||
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
|
||||
return expandPathspecTokens(repo, arguments.map { ShellToken(it) })
|
||||
return SandboxShell.expandPathspecs(repo, arguments)
|
||||
}
|
||||
|
||||
fun expandPathspecTokens(repo: RepoState, arguments: List<ShellToken>): List<String> {
|
||||
return arguments.flatMap { token ->
|
||||
val argument = token.value
|
||||
if (token.quoted || !argument.hasGlob()) {
|
||||
listOf(argument)
|
||||
} else {
|
||||
val regex = argument.globToRegex()
|
||||
repo.files
|
||||
.filterNot { it.deleted }
|
||||
.map { it.name }
|
||||
.filter { regex.matches(it) }
|
||||
.sorted()
|
||||
.ifEmpty { listOf(argument) }
|
||||
}
|
||||
}
|
||||
return SandboxShell.expandPathspecTokens(repo, arguments)
|
||||
}
|
||||
|
||||
private fun pushRefs(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val remote = arguments.firstOrNull { !it.startsWith("-") } ?: "origin"
|
||||
val explicitBranches = arguments
|
||||
.dropWhile { it.startsWith("-") }
|
||||
.drop(1)
|
||||
.filter { !it.startsWith("-") }
|
||||
val pushedBranches = when {
|
||||
arguments.any { it == "--all" } -> repo.branches.keys.map { "$remote/$it" }
|
||||
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
|
||||
else -> listOf("$remote/${repo.headBranch}")
|
||||
}
|
||||
val pushedTags = if (arguments.any { it == "--tags" || it == "--follow-tags" }) {
|
||||
repo.tags.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
return repo.copy(
|
||||
pushedBranches = repo.pushedBranches + pushedBranches,
|
||||
pushedTags = repo.pushedTags + pushedTags,
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val cached = "--cached" in arguments
|
||||
val target = arguments.lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git rm [--cached] <path>")
|
||||
val updated = repo.files.mapNotNull { file ->
|
||||
if (file.name != target) {
|
||||
file
|
||||
} else if (cached) {
|
||||
file.copy(staged = false, tracked = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
|
||||
private fun moveGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv <source> <destination>")
|
||||
val sources = arguments.dropLast(1)
|
||||
if (sources.isEmpty()) return repo to listOf("usage: git mv <source> <destination>")
|
||||
val destinationIsDirectory = sources.size > 1 || destination.endsWith("/")
|
||||
val updated = repo.files.map { file ->
|
||||
if (file.name in sources) {
|
||||
val target = if (destinationIsDirectory) {
|
||||
destination.trimEnd('/') + "/" + file.name.substringAfterLast('/')
|
||||
} else {
|
||||
destination
|
||||
}
|
||||
file.copy(name = target, staged = true)
|
||||
} else {
|
||||
file
|
||||
}
|
||||
}
|
||||
return repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
|
||||
private fun checkout(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return when {
|
||||
arguments.firstOrNull() == "-b" -> {
|
||||
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b <branch>")
|
||||
repo.copy(
|
||||
headBranch = branch,
|
||||
branches = repo.branches + (branch to repo.commits.size),
|
||||
) to listOf("Switched to a new branch '$branch'")
|
||||
}
|
||||
arguments.firstOrNull() == "-B" -> {
|
||||
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -B <branch>")
|
||||
repo.copy(
|
||||
headBranch = branch,
|
||||
branches = repo.branches + (branch to repo.commits.size),
|
||||
) to listOf("Switched to branch '$branch'")
|
||||
}
|
||||
"--" in arguments -> {
|
||||
val target = arguments.last()
|
||||
val updated = repo.files.map { file ->
|
||||
when (file.name) {
|
||||
target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!"))
|
||||
"file3" -> file
|
||||
else -> file
|
||||
}
|
||||
}.let { files ->
|
||||
if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files
|
||||
}
|
||||
repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
arguments.any { it == "file3" } -> {
|
||||
repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList()
|
||||
}
|
||||
else -> {
|
||||
val branch = arguments.first()
|
||||
val normalizedTag = branch.removePrefix("tags/").removePrefix("refs/tags/")
|
||||
when {
|
||||
repo.branches.containsKey(branch) -> repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
|
||||
normalizedTag in repo.tags -> repo.copy(headBranch = "tags/$normalizedTag") to listOf("HEAD is now at $normalizedTag")
|
||||
else -> repo to listOf("error: pathspec '$branch' did not match any branch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return if ("--soft" in arguments) {
|
||||
repo.copy(
|
||||
commits = repo.commits.dropLast(1),
|
||||
files = repo.files.map { if (it.tracked) it.copy(staged = true) else it },
|
||||
) to emptyList()
|
||||
} else {
|
||||
val target = arguments.last()
|
||||
repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun merge(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val branch = arguments.lastOrNull().orEmpty()
|
||||
val squash = "--squash" in arguments
|
||||
val files = when {
|
||||
branch == "feature" && repo.files.none { it.name == "file2" } -> repo.files + GitFile("file2", tracked = true)
|
||||
branch == "long-feature-branch" && repo.files.none { it.name == "file3" } -> repo.files + GitFile("file3", staged = true)
|
||||
branch == "mybranch" -> repo.files.map {
|
||||
if (it.name == "poem.txt") it.copy(content = "Humpty Dumpty sat on a wall\nHumpty Dumpty had a great fall", staged = true)
|
||||
else it
|
||||
}
|
||||
else -> repo.files
|
||||
}
|
||||
return repo.copy(
|
||||
files = files,
|
||||
maintenanceActions = repo.maintenanceActions + if (squash) "merge-squash" else "merge",
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val interactive = "-i" in arguments || "--interactive" in arguments
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
|
||||
if (interactive && target == null) {
|
||||
return repo to listOf("fatal: No rebase upstream specified")
|
||||
}
|
||||
val originalCommits = repo.commits
|
||||
val commits = repo.commits
|
||||
val updatedBranches = when {
|
||||
"--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1)
|
||||
arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0))
|
||||
else -> repo.branches
|
||||
}
|
||||
val maintenanceActions = if ("--onto" in arguments) {
|
||||
repo.maintenanceActions + "rebase-onto"
|
||||
} else {
|
||||
repo.maintenanceActions
|
||||
}
|
||||
val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, arguments, repo.headBranch) else emptyList()
|
||||
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to output
|
||||
}
|
||||
|
||||
private fun interactiveRebaseConsoleLines(originalCommits: List<CommitNode>, arguments: List<String>, branch: String): List<String> {
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty()
|
||||
return buildList {
|
||||
originalCommits.forEach { commit ->
|
||||
add("pick ${commit.id} ${commit.message}")
|
||||
}
|
||||
add("")
|
||||
add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD")
|
||||
add("# Commands:")
|
||||
add("# p, pick <commit> = use commit")
|
||||
add("# r, reword <commit> = use commit, but edit the commit message")
|
||||
add("# s, squash <commit> = use commit, but meld into previous commit")
|
||||
add("# d, drop <commit> = remove commit")
|
||||
add("Successfully rebased and updated refs/heads/$branch.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val parsed = parseCommitArguments(arguments)
|
||||
if (parsed.error != null) {
|
||||
return repo to listOf(parsed.error)
|
||||
}
|
||||
|
||||
val repoForCommit = if (parsed.stageAllTracked) {
|
||||
repo.copy(files = repo.files.map { file -> if (file.tracked) file.copy(staged = true) else file })
|
||||
} else {
|
||||
repo
|
||||
}
|
||||
|
||||
val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty()
|
||||
val staged = repoForCommit.files.filter { it.staged }
|
||||
if (staged.isEmpty()) return repo to listOf("nothing to commit")
|
||||
|
||||
val cleanedFiles = repoForCommit.files.map { file ->
|
||||
if (file.staged) file.copy(staged = false, tracked = true) else file
|
||||
}
|
||||
val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) {
|
||||
repo.commits.dropLast(1) + repo.commits.last().copy(message = message)
|
||||
} else {
|
||||
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
|
||||
repo.commits + CommitNode(nextId, message)
|
||||
}
|
||||
|
||||
return repoForCommit.copy(
|
||||
files = cleanedFiles,
|
||||
commits = nextCommits,
|
||||
branches = repo.branches + (repo.headBranch to nextCommits.size),
|
||||
) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message")
|
||||
}
|
||||
|
||||
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
|
||||
var message: String? = null
|
||||
var stageAllTracked = false
|
||||
var amend = false
|
||||
var index = 0
|
||||
|
||||
while (index < arguments.size) {
|
||||
val argument = arguments[index]
|
||||
when {
|
||||
argument == "-a" || argument == "--all" -> {
|
||||
stageAllTracked = true
|
||||
}
|
||||
argument == "--amend" -> {
|
||||
amend = true
|
||||
}
|
||||
argument == "--no-edit" -> {
|
||||
// Keep the previous commit message when amending.
|
||||
}
|
||||
argument == "--date" -> {
|
||||
if (arguments.getOrNull(index + 1) == null) {
|
||||
return ParsedCommitArguments(error = "error: option '--date' requires a value")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
argument.startsWith("--date=") -> {
|
||||
// The sandbox records commit structure, not timestamps.
|
||||
}
|
||||
argument == "-m" || argument == "--message" -> {
|
||||
val next = arguments.getOrNull(index + 1)
|
||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
message = next
|
||||
index += 1
|
||||
}
|
||||
argument.startsWith("--message=") -> {
|
||||
message = argument.substringAfter('=')
|
||||
}
|
||||
argument.startsWith("-") && !argument.startsWith("--") && argument.length > 2 -> {
|
||||
val shortFlags = argument.drop(1)
|
||||
var shortIndex = 0
|
||||
while (shortIndex < shortFlags.length) {
|
||||
when (val flag = shortFlags[shortIndex]) {
|
||||
'a' -> stageAllTracked = true
|
||||
'm' -> {
|
||||
val attachedValue = shortFlags.substring(shortIndex + 1)
|
||||
if (attachedValue.isNotEmpty()) {
|
||||
message = attachedValue
|
||||
} else {
|
||||
val next = arguments.getOrNull(index + 1)
|
||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
message = next
|
||||
index += 1
|
||||
}
|
||||
break
|
||||
}
|
||||
else -> return ParsedCommitArguments(error = "error: unsupported commit option '-$flag'")
|
||||
}
|
||||
shortIndex += 1
|
||||
}
|
||||
}
|
||||
else -> return ParsedCommitArguments(error = "error: unsupported commit argument '$argument'")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (!amend && message.isNullOrBlank()) {
|
||||
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
}
|
||||
|
||||
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend)
|
||||
}
|
||||
|
||||
private fun statusLines(repo: RepoState): List<String> {
|
||||
val staged = repo.files.filter { it.staged }.map {
|
||||
when {
|
||||
it.deleted -> "deleted: ${it.name}"
|
||||
it.tracked -> "modified: ${it.name}"
|
||||
else -> "new file: ${it.name}"
|
||||
}
|
||||
}
|
||||
val deleted = repo.files.filter { it.deleted && it.tracked && !it.staged }.map { "deleted: ${it.name}" }
|
||||
val unstaged = repo.files.filterNot { it.staged || it.tracked || it.deleted }.map { "untracked: ${it.name}" }
|
||||
return buildList {
|
||||
add("On branch ${repo.headBranch}")
|
||||
if (staged.isEmpty() && deleted.isEmpty() && unstaged.isEmpty()) {
|
||||
add("nothing to commit, working tree clean")
|
||||
} else {
|
||||
if (staged.isNotEmpty()) {
|
||||
add("Changes to be committed:")
|
||||
addAll(staged)
|
||||
}
|
||||
if (deleted.isNotEmpty()) {
|
||||
add("Changes not staged for commit:")
|
||||
addAll(deleted)
|
||||
}
|
||||
if (unstaged.isNotEmpty()) {
|
||||
add("Untracked files:")
|
||||
addAll(unstaged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hasGlob(): Boolean = any { it == '*' || it == '?' }
|
||||
|
||||
private fun String.globToRegex(): Regex {
|
||||
val pattern = buildString {
|
||||
append('^')
|
||||
this@globToRegex.forEach { char ->
|
||||
when (char) {
|
||||
'*' -> append("[^/]*")
|
||||
'?' -> append("[^/]")
|
||||
'.', '(', ')', '+', '|', '^', '$', '@', '%', '{', '}', '[', ']', '\\' -> {
|
||||
append('\\')
|
||||
append(char)
|
||||
}
|
||||
else -> append(char)
|
||||
}
|
||||
}
|
||||
append('$')
|
||||
}
|
||||
return Regex(pattern)
|
||||
}
|
||||
|
||||
private data class ParsedCommitArguments(
|
||||
val message: String? = null,
|
||||
val stageAllTracked: Boolean = false,
|
||||
val amend: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal object InteractiveAddEngine {
|
||||
private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?"
|
||||
|
||||
fun start(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val candidates = interactiveAddCandidates(repo, target)
|
||||
return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates)
|
||||
}
|
||||
|
||||
fun startPatch(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-p" || it == "--patch" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val patchFile = interactiveAddCandidates(repo, target).firstOrNull()
|
||||
?: return repo to listOf("No changes.")
|
||||
return startPatchHunkSession(repo, patchFile.name)
|
||||
}
|
||||
|
||||
fun handleInput(repo: RepoState, input: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to emptyList()
|
||||
val answer = input.trim()
|
||||
if (session.selectionAction == "patch-hunk") {
|
||||
return handlePatchHunkInput(repo, session, answer)
|
||||
}
|
||||
return if (session.awaitingUpdateSelection) {
|
||||
applyInteractiveAddUpdateSelection(repo, session, answer)
|
||||
} else {
|
||||
when (answer.lowercase()) {
|
||||
"1", "s", "status" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
"2", "u", "update" -> interactiveAddSelectionPrompt(repo, session, answer, "Update>>", "update")
|
||||
"3", "r", "revert" -> interactiveAddSelectionPrompt(repo, session, answer, "Revert>>", "revert")
|
||||
"4", "a", "add untracked", "add-untracked" -> interactiveAddSelectionPrompt(repo, session, answer, "Add untracked>>", "add-untracked")
|
||||
"5", "p", "patch" -> interactiveAddSelectionPrompt(repo, session, answer, "Patch update>>", "patch")
|
||||
"6", "d", "diff" -> interactiveAddSelectionPrompt(repo, session, answer, "Diff>>", "diff")
|
||||
"7", "q", "quit" -> repo.copy(interactiveAddSession = null) to listOf("What now> $answer", "Bye.")
|
||||
"8", "h", "help" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
else -> repo to listOf("What now> $answer", "Huh ($answer)?") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? {
|
||||
val session = repo.interactiveAddSession ?: return null
|
||||
if (session.selectionAction != "patch-hunk") return null
|
||||
if (command.trim().lowercase() != "e") return null
|
||||
val target = session.target ?: return null
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted } ?: return null
|
||||
return GitEditorInvocation(
|
||||
command = command,
|
||||
kind = GitEditorCommandKind.PATCH_HUNK,
|
||||
title = "Edit Patch Hunk",
|
||||
initialContent = patchHunkLines(file)
|
||||
.dropLastWhile { it == PatchHunkPrompt }
|
||||
.joinToString("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to listOf("No patch hunk is active.")
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No patch hunk is active.")
|
||||
if (session.selectionAction != "patch-hunk") return repo to listOf("No patch hunk is active.")
|
||||
if (content.isBlank()) return repo to listOf("Edited hunk was empty; patch was not applied.", PatchHunkPrompt)
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
return repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf(
|
||||
"$PatchHunkPrompt e",
|
||||
"Applied edited hunk.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionPrompt(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
prompt: String,
|
||||
action: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
return repo.copy(
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = true,
|
||||
selectionPrompt = prompt,
|
||||
selectionAction = action,
|
||||
),
|
||||
) to listOf("What now> $answer", prompt)
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddUpdateSelection(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val candidates = interactiveAddCandidates(repo, session.target)
|
||||
val selectedNames = selectedInteractiveAddNames(candidates, answer)
|
||||
val prompt = session.selectionPrompt
|
||||
if (selectedNames.isEmpty()) {
|
||||
return repo to listOf("$prompt $answer", "No files selected.", prompt)
|
||||
}
|
||||
|
||||
if (session.selectionAction == "patch" && selectedNames.size == 1) {
|
||||
return startPatchHunkSession(repo, selectedNames.single(), "$prompt $answer")
|
||||
}
|
||||
|
||||
val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction)
|
||||
val updatedRepo = repo.copy(
|
||||
files = updatedFiles,
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = "Update>>",
|
||||
selectionAction = "update",
|
||||
),
|
||||
)
|
||||
val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction)
|
||||
return updatedRepo to listOf(
|
||||
"$prompt $answer",
|
||||
summary,
|
||||
) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target))
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set<String>, action: String): List<GitFile> {
|
||||
return when (action) {
|
||||
"revert" -> repo.files.mapNotNull { file ->
|
||||
if (file.name !in selectedNames || file.deleted) {
|
||||
file
|
||||
} else if (file.tracked) {
|
||||
file.copy(content = "", staged = false, deleted = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
"diff" -> repo.files
|
||||
else -> repo.files.map { file ->
|
||||
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionSummary(
|
||||
repo: RepoState,
|
||||
updatedFiles: List<GitFile>,
|
||||
selectedNames: Set<String>,
|
||||
action: String,
|
||||
): String {
|
||||
return when (action) {
|
||||
"revert" -> "reverted ${selectedNames.size} path(s)"
|
||||
"diff" -> selectedNames.joinToString("\n") { "diff -- $it" }
|
||||
else -> {
|
||||
val stagedCount = updatedFiles.count { updatedFile ->
|
||||
val before = repo.files.firstOrNull { it.name == updatedFile.name }
|
||||
updatedFile.staged && before?.staged != true
|
||||
}
|
||||
"updated $stagedCount path(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair<RepoState, List<String>> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
?: return repo to listOfNotNull(prefixLine, "No changes.")
|
||||
val session = InteractiveAddSession(
|
||||
target = target,
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = PatchHunkPrompt,
|
||||
selectionAction = "patch-hunk",
|
||||
)
|
||||
val output = listOfNotNull(prefixLine) + patchHunkLines(file)
|
||||
return repo.copy(interactiveAddSession = session) to output
|
||||
}
|
||||
|
||||
private fun handlePatchHunkInput(repo: RepoState, session: InteractiveAddSession, answer: String): Pair<RepoState, List<String>> {
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
return when (answer.lowercase()) {
|
||||
"y", "a" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
}
|
||||
"n", "d" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
"q" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer", "Quit")
|
||||
"?" -> repo to listOf(
|
||||
"$PatchHunkPrompt $answer",
|
||||
"y - stage this hunk",
|
||||
"n - do not stage this hunk",
|
||||
"q - quit; do not stage this hunk or any remaining ones",
|
||||
"a - stage this hunk and all later hunks in the file",
|
||||
"d - do not stage this hunk or any later hunks in the file",
|
||||
"s - split the current hunk into smaller hunks",
|
||||
"e - manually edit the current hunk",
|
||||
"p - print the current hunk",
|
||||
"? - print help",
|
||||
PatchHunkPrompt,
|
||||
)
|
||||
"p" -> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
if (file == null) {
|
||||
repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
} else {
|
||||
repo to listOf("$PatchHunkPrompt $answer") + patchHunkLines(file)
|
||||
}
|
||||
}
|
||||
"s" -> repo to listOf("$PatchHunkPrompt $answer", "Sorry, cannot split this hunk", PatchHunkPrompt)
|
||||
"e" -> repo to listOf("$PatchHunkPrompt $answer", "Opening patch editor")
|
||||
else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchHunkLines(file: GitFile): List<String> {
|
||||
val lines = file.content.lines()
|
||||
val nonEmptyLines = lines.dropLastWhile { it.isEmpty() }
|
||||
val addedCount = nonEmptyLines.size.coerceAtLeast(1)
|
||||
return buildList {
|
||||
add("diff --git a/${file.name} b/${file.name}")
|
||||
add("index 0000000..0000001 100644")
|
||||
add("--- a/${file.name}")
|
||||
add("+++ b/${file.name}")
|
||||
add("@@ -1 +1,$addedCount @@")
|
||||
if (nonEmptyLines.isEmpty()) {
|
||||
add("+")
|
||||
} else {
|
||||
nonEmptyLines.forEach { line -> add("+$line") }
|
||||
}
|
||||
add(PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
|
||||
return buildList {
|
||||
add(" staged unstaged path")
|
||||
candidates.forEachIndexed { index, file ->
|
||||
val staged = if (file.staged) "unchanged" else "+0/-0"
|
||||
val unstaged = when {
|
||||
file.tracked -> "+1/-0"
|
||||
else -> "+0/-0"
|
||||
}
|
||||
add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}")
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
add("No changes.")
|
||||
}
|
||||
add("*** Commands ***")
|
||||
add(" 1: status 2: update 3: revert 4: add untracked")
|
||||
add(" 5: patch 6: diff 7: quit 8: help")
|
||||
add("What now>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddCandidates(repo: RepoState, target: String?): List<GitFile> {
|
||||
return repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedInteractiveAddNames(candidates: List<GitFile>, answer: String): Set<String> {
|
||||
if (answer == "*") return candidates.map { it.name }.toSet()
|
||||
return answer.split(Regex("[,\\s]+"))
|
||||
.mapNotNull { token ->
|
||||
token.toIntOrNull()
|
||||
?.takeIf { it in 1..candidates.size }
|
||||
?.let { candidates[it - 1].name }
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal object SandboxCommandEngine {
|
||||
fun execute(repo: RepoState, shellParts: List<GitSandboxEngine.ShellToken>): Pair<RepoState, List<String>> {
|
||||
val parts = shellParts.map { it.value }
|
||||
return when {
|
||||
parts[0] == "help" || parts[0] == "?" -> repo to GitSandboxEngine.commandReferenceLines()
|
||||
parts[0] == "touch" && parts.size >= 2 -> {
|
||||
val name = parts[1]
|
||||
if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists")
|
||||
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
|
||||
}
|
||||
(parts[0] == "mkdir" || parts[0] == "md") && parts.size >= 2 -> repo to emptyList()
|
||||
(parts[0] == "rm" || parts[0] == "del") && parts.size >= 2 -> {
|
||||
val target = parts[1]
|
||||
repo.copy(files = repo.files.mapNotNull { file ->
|
||||
when {
|
||||
file.name != target -> file
|
||||
file.tracked -> file.copy(deleted = true, staged = false)
|
||||
else -> null
|
||||
}
|
||||
}) to emptyList()
|
||||
}
|
||||
parts[0] == "echo" -> writeEcho(repo, shellParts)
|
||||
parts[0] == "ls" || parts[0] == "dir" -> repo to (
|
||||
if (repo.initialized) listOf(".git") else emptyList()
|
||||
) + repo.files.filterNot { it.deleted }.map { it.name }
|
||||
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
|
||||
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
|
||||
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
|
||||
!repo.initialized -> repo to listOf("fatal: not a git repository")
|
||||
parts.size >= 2 && parts[1] == "help" -> repo to GitSandboxEngine.commandReferenceLines()
|
||||
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
|
||||
parts.size >= 2 && parts[1] == "stash" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.tracked && !file.staged) file.copy(content = "") else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, stashes = repo.stashes + "stash@{${repo.stashes.size}}") to listOf("Saved working directory and index state")
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "fetch" -> {
|
||||
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
repo.copy(
|
||||
fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/new_branch"),
|
||||
fetchHeadCount = 2,
|
||||
maintenanceActions = repo.maintenanceActions + "fetch",
|
||||
) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "pull" -> {
|
||||
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
val branch = parts.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: repo.headBranch
|
||||
repo.copy(
|
||||
fetchedBranches = repo.fetchedBranches + "$remote/$branch",
|
||||
fetchHeadCount = 1,
|
||||
branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)),
|
||||
maintenanceActions = repo.maintenanceActions + "pull",
|
||||
) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "push" -> pushRefs(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "submodule" && parts[2] == "add" -> {
|
||||
val url = parts.getOrNull(3)
|
||||
val path = parts.getOrNull(4)
|
||||
if (url == null || path == null) {
|
||||
repo to listOf("usage: git submodule add <repository> <path>")
|
||||
} else {
|
||||
repo.copy(submodules = repo.submodules + (path.trimEnd('/') to url)) to emptyList()
|
||||
}
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "repack" -> {
|
||||
repo.copy(maintenanceActions = repo.maintenanceActions + "repack") to emptyList()
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "tag" -> {
|
||||
val tag = parts[2]
|
||||
if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists")
|
||||
else repo.copy(tags = repo.tags + tag) to listOf(tag)
|
||||
}
|
||||
parts.size >= 4 && parts[1] == "config" -> {
|
||||
val key = parts[2]
|
||||
val value = parts.drop(3).joinToString(" ")
|
||||
repo.copy(config = repo.config + (key to value)) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> stagePaths(repo, parts)
|
||||
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
|
||||
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, SandboxShell.expandPathspecTokens(repo, shellParts.drop(2)))
|
||||
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "log" -> {
|
||||
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
|
||||
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> {
|
||||
val name = parts.getOrNull(3)
|
||||
val url = parts.getOrNull(4)
|
||||
if (name == null || url == null) repo to listOf("usage: git remote add <name> <url>")
|
||||
else repo.copy(remotes = repo.remotes + (name to url)) to emptyList()
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "branch" -> branch(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "checkout" -> checkout(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "cherry-pick" -> cherryPick(repo)
|
||||
parts.size >= 2 && parts[1] == "revert" -> {
|
||||
repo.copy(commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Revert \"Bad commit\"")) to emptyList()
|
||||
}
|
||||
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeEcho(repo: RepoState, shellParts: List<GitSandboxEngine.ShellToken>): Pair<RepoState, List<String>> {
|
||||
val parts = shellParts.map { it.value }
|
||||
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
|
||||
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
|
||||
return repo to listOf(parts.drop(1).joinToString(" "))
|
||||
}
|
||||
|
||||
val append = parts[redirectIndex] == ">>"
|
||||
val content = parts.subList(1, redirectIndex).joinToString(" ")
|
||||
val target = parts[redirectIndex + 1]
|
||||
val updatedFiles = repo.files.toMutableList()
|
||||
val index = updatedFiles.indexOfFirst { it.name == target }
|
||||
if (index == -1) {
|
||||
updatedFiles += GitFile(name = target, content = content)
|
||||
} else {
|
||||
val current = updatedFiles[index]
|
||||
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
|
||||
updatedFiles[index] = current.copy(content = nextContent, deleted = false)
|
||||
}
|
||||
|
||||
return repo.copy(files = updatedFiles) to emptyList()
|
||||
}
|
||||
|
||||
private fun parentDirectory(currentDir: String): String {
|
||||
if (currentDir == ".") return "."
|
||||
return currentDir.substringBeforeLast('/', missingDelimiterValue = ".").ifBlank { "." }
|
||||
}
|
||||
|
||||
private fun pushRefs(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val remote = arguments.firstOrNull { !it.startsWith("-") } ?: "origin"
|
||||
val explicitBranches = arguments
|
||||
.dropWhile { it.startsWith("-") }
|
||||
.drop(1)
|
||||
.filter { !it.startsWith("-") }
|
||||
val pushedBranches = when {
|
||||
arguments.any { it == "--all" } -> repo.branches.keys.map { "$remote/$it" }
|
||||
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
|
||||
else -> listOf("$remote/${repo.headBranch}")
|
||||
}
|
||||
val pushedTags = if (arguments.any { it == "--tags" || it == "--follow-tags" }) {
|
||||
repo.tags.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
return repo.copy(
|
||||
pushedBranches = repo.pushedBranches + pushedBranches,
|
||||
pushedTags = repo.pushedTags + pushedTags,
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun stagePaths(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
|
||||
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
|
||||
return InteractiveAddEngine.start(repo, parts.drop(2))
|
||||
}
|
||||
if (parts.drop(2).any { it == "-p" || it == "--patch" }) {
|
||||
return InteractiveAddEngine.startPatch(repo, parts.drop(2))
|
||||
}
|
||||
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git add <path>")
|
||||
return if (target != "." && repo.files.none { it.name == target && !it.deleted }) {
|
||||
repo to listOf("fatal: pathspec '$target' did not match any files")
|
||||
} else {
|
||||
val updated = repo.files.map { if ((target == "." || it.name == target) && !it.deleted) it.copy(staged = true) else it }
|
||||
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val cached = "--cached" in arguments
|
||||
val target = arguments.lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git rm [--cached] <path>")
|
||||
val updated = repo.files.mapNotNull { file ->
|
||||
if (file.name != target) {
|
||||
file
|
||||
} else if (cached) {
|
||||
file.copy(staged = false, tracked = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
|
||||
private fun moveGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv <source> <destination>")
|
||||
val sources = arguments.dropLast(1)
|
||||
if (sources.isEmpty()) return repo to listOf("usage: git mv <source> <destination>")
|
||||
val destinationIsDirectory = sources.size > 1 || destination.endsWith("/")
|
||||
val updated = repo.files.map { file ->
|
||||
if (file.name in sources) {
|
||||
val target = if (destinationIsDirectory) {
|
||||
destination.trimEnd('/') + "/" + file.name.substringAfterLast('/')
|
||||
} else {
|
||||
destination
|
||||
}
|
||||
file.copy(name = target, staged = true)
|
||||
} else {
|
||||
file
|
||||
}
|
||||
}
|
||||
return repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
|
||||
private fun branch(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return when (arguments.firstOrNull()) {
|
||||
"-d", "-D", "--delete" -> {
|
||||
val branch = arguments.getOrNull(1)
|
||||
if (branch == null) repo to listOf("usage: git branch -d <branch>")
|
||||
else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch")
|
||||
}
|
||||
else -> {
|
||||
val branch = arguments.first()
|
||||
val base = arguments.getOrNull(1)
|
||||
val baseIndex = if (base == "HEAD~1" || base == "HEAD^") {
|
||||
(repo.branches[repo.headBranch] ?: repo.commits.size) - 1
|
||||
} else {
|
||||
repo.commits.size
|
||||
}.coerceAtLeast(0)
|
||||
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
|
||||
else repo.copy(branches = repo.branches + (branch to baseIndex)) to listOf("Created branch $branch")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkout(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return when {
|
||||
arguments.firstOrNull() == "-b" -> {
|
||||
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b <branch>")
|
||||
repo.copy(
|
||||
headBranch = branch,
|
||||
branches = repo.branches + (branch to repo.commits.size),
|
||||
) to listOf("Switched to a new branch '$branch'")
|
||||
}
|
||||
arguments.firstOrNull() == "-B" -> {
|
||||
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -B <branch>")
|
||||
repo.copy(
|
||||
headBranch = branch,
|
||||
branches = repo.branches + (branch to repo.commits.size),
|
||||
) to listOf("Switched to branch '$branch'")
|
||||
}
|
||||
"--" in arguments -> {
|
||||
val target = arguments.last()
|
||||
val updated = repo.files.map { file ->
|
||||
when (file.name) {
|
||||
target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!"))
|
||||
"file3" -> file
|
||||
else -> file
|
||||
}
|
||||
}.let { files ->
|
||||
if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files
|
||||
}
|
||||
repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
arguments.any { it == "file3" } -> {
|
||||
repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList()
|
||||
}
|
||||
else -> {
|
||||
val branch = arguments.first()
|
||||
val normalizedTag = branch.removePrefix("tags/").removePrefix("refs/tags/")
|
||||
when {
|
||||
repo.branches.containsKey(branch) -> repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
|
||||
normalizedTag in repo.tags -> repo.copy(headBranch = "tags/$normalizedTag") to listOf("HEAD is now at $normalizedTag")
|
||||
else -> repo to listOf("error: pathspec '$branch' did not match any branch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return if ("--soft" in arguments) {
|
||||
repo.copy(
|
||||
commits = repo.commits.dropLast(1),
|
||||
files = repo.files.map { if (it.tracked) it.copy(staged = true) else it },
|
||||
) to emptyList()
|
||||
} else {
|
||||
val target = arguments.last()
|
||||
repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun merge(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val branch = arguments.lastOrNull().orEmpty()
|
||||
val squash = "--squash" in arguments
|
||||
val files = when {
|
||||
branch == "feature" && repo.files.none { it.name == "file2" } -> repo.files + GitFile("file2", tracked = true)
|
||||
branch == "long-feature-branch" && repo.files.none { it.name == "file3" } -> repo.files + GitFile("file3", staged = true)
|
||||
branch == "mybranch" -> repo.files.map {
|
||||
if (it.name == "poem.txt") it.copy(content = "Humpty Dumpty sat on a wall\nHumpty Dumpty had a great fall", staged = true)
|
||||
else it
|
||||
}
|
||||
else -> repo.files
|
||||
}
|
||||
return repo.copy(
|
||||
files = files,
|
||||
maintenanceActions = repo.maintenanceActions + if (squash) "merge-squash" else "merge",
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val interactive = "-i" in arguments || "--interactive" in arguments
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
|
||||
if (interactive && target == null) {
|
||||
return repo to listOf("fatal: No rebase upstream specified")
|
||||
}
|
||||
val originalCommits = repo.commits
|
||||
val commits = repo.commits
|
||||
val updatedBranches = when {
|
||||
"--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1)
|
||||
arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0))
|
||||
else -> repo.branches
|
||||
}
|
||||
val maintenanceActions = if ("--onto" in arguments) {
|
||||
repo.maintenanceActions + "rebase-onto"
|
||||
} else {
|
||||
repo.maintenanceActions
|
||||
}
|
||||
val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, arguments, repo.headBranch) else emptyList()
|
||||
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to output
|
||||
}
|
||||
|
||||
private fun interactiveRebaseConsoleLines(originalCommits: List<CommitNode>, arguments: List<String>, branch: String): List<String> {
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty()
|
||||
return buildList {
|
||||
originalCommits.forEach { commit ->
|
||||
add("pick ${commit.id} ${commit.message}")
|
||||
}
|
||||
add("")
|
||||
add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD")
|
||||
add("# Commands:")
|
||||
add("# p, pick <commit> = use commit")
|
||||
add("# r, reword <commit> = use commit, but edit the commit message")
|
||||
add("# s, squash <commit> = use commit, but meld into previous commit")
|
||||
add("# d, drop <commit> = remove commit")
|
||||
add("Successfully rebased and updated refs/heads/$branch.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cherryPick(repo: RepoState): Pair<RepoState, List<String>> {
|
||||
val files = if (repo.files.none { it.name == "README.md" }) {
|
||||
repo.files + GitFile("README.md", "Proper input instructions\n", tracked = true)
|
||||
} else {
|
||||
repo.files.map { if (it.name == "README.md") it.copy(tracked = true) else it }
|
||||
}
|
||||
return repo.copy(files = files, commits = listOf(CommitNode("${repo.commits.size + 1}", "Filled in README.md with proper input")) + repo.commits) to emptyList()
|
||||
}
|
||||
|
||||
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val parsed = parseCommitArguments(arguments)
|
||||
if (parsed.error != null) {
|
||||
return repo to listOf(parsed.error)
|
||||
}
|
||||
|
||||
val repoForCommit = if (parsed.stageAllTracked) {
|
||||
repo.copy(files = repo.files.map { file -> if (file.tracked) file.copy(staged = true) else file })
|
||||
} else {
|
||||
repo
|
||||
}
|
||||
|
||||
val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty()
|
||||
val staged = repoForCommit.files.filter { it.staged }
|
||||
if (staged.isEmpty()) return repo to listOf("nothing to commit")
|
||||
|
||||
val cleanedFiles = repoForCommit.files.map { file ->
|
||||
if (file.staged) file.copy(staged = false, tracked = true) else file
|
||||
}
|
||||
val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) {
|
||||
repo.commits.dropLast(1) + repo.commits.last().copy(message = message)
|
||||
} else {
|
||||
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
|
||||
repo.commits + CommitNode(nextId, message)
|
||||
}
|
||||
|
||||
return repoForCommit.copy(
|
||||
files = cleanedFiles,
|
||||
commits = nextCommits,
|
||||
branches = repo.branches + (repo.headBranch to nextCommits.size),
|
||||
) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message")
|
||||
}
|
||||
|
||||
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
|
||||
var message: String? = null
|
||||
var stageAllTracked = false
|
||||
var amend = false
|
||||
var index = 0
|
||||
|
||||
while (index < arguments.size) {
|
||||
val argument = arguments[index]
|
||||
when {
|
||||
argument == "-a" || argument == "--all" -> {
|
||||
stageAllTracked = true
|
||||
}
|
||||
argument == "--amend" -> {
|
||||
amend = true
|
||||
}
|
||||
argument == "--no-edit" -> {
|
||||
// Keep the previous commit message when amending.
|
||||
}
|
||||
argument == "--date" -> {
|
||||
if (arguments.getOrNull(index + 1) == null) {
|
||||
return ParsedCommitArguments(error = "error: option '--date' requires a value")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
argument.startsWith("--date=") -> {
|
||||
// The sandbox records commit structure, not timestamps.
|
||||
}
|
||||
argument == "-m" || argument == "--message" -> {
|
||||
val next = arguments.getOrNull(index + 1)
|
||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
message = next
|
||||
index += 1
|
||||
}
|
||||
argument.startsWith("--message=") -> {
|
||||
message = argument.substringAfter('=')
|
||||
}
|
||||
argument.startsWith("-") && !argument.startsWith("--") && argument.length > 2 -> {
|
||||
val shortFlags = argument.drop(1)
|
||||
var shortIndex = 0
|
||||
while (shortIndex < shortFlags.length) {
|
||||
when (val flag = shortFlags[shortIndex]) {
|
||||
'a' -> stageAllTracked = true
|
||||
'm' -> {
|
||||
val attachedValue = shortFlags.substring(shortIndex + 1)
|
||||
if (attachedValue.isNotEmpty()) {
|
||||
message = attachedValue
|
||||
} else {
|
||||
val next = arguments.getOrNull(index + 1)
|
||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
message = next
|
||||
index += 1
|
||||
}
|
||||
break
|
||||
}
|
||||
else -> return ParsedCommitArguments(error = "error: unsupported commit option '-$flag'")
|
||||
}
|
||||
shortIndex += 1
|
||||
}
|
||||
}
|
||||
else -> return ParsedCommitArguments(error = "error: unsupported commit argument '$argument'")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (!amend && message.isNullOrBlank()) {
|
||||
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
}
|
||||
|
||||
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend)
|
||||
}
|
||||
|
||||
private fun statusLines(repo: RepoState): List<String> {
|
||||
val staged = repo.files.filter { it.staged }.map {
|
||||
when {
|
||||
it.deleted -> "deleted: ${it.name}"
|
||||
it.tracked -> "modified: ${it.name}"
|
||||
else -> "new file: ${it.name}"
|
||||
}
|
||||
}
|
||||
val deleted = repo.files.filter { it.deleted && it.tracked && !it.staged }.map { "deleted: ${it.name}" }
|
||||
val unstaged = repo.files.filterNot { it.staged || it.tracked || it.deleted }.map { "untracked: ${it.name}" }
|
||||
return buildList {
|
||||
add("On branch ${repo.headBranch}")
|
||||
if (staged.isEmpty() && deleted.isEmpty() && unstaged.isEmpty()) {
|
||||
add("nothing to commit, working tree clean")
|
||||
} else {
|
||||
if (staged.isNotEmpty()) {
|
||||
add("Changes to be committed:")
|
||||
addAll(staged)
|
||||
}
|
||||
if (deleted.isNotEmpty()) {
|
||||
add("Changes not staged for commit:")
|
||||
addAll(deleted)
|
||||
}
|
||||
if (unstaged.isNotEmpty()) {
|
||||
add("Untracked files:")
|
||||
addAll(unstaged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ParsedCommitArguments(
|
||||
val message: String? = null,
|
||||
val stageAllTracked: Boolean = false,
|
||||
val amend: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal object SandboxShell {
|
||||
fun tokenizeCommand(command: String): List<String> {
|
||||
return tokenizeShellCommand(command).map { it.value }
|
||||
}
|
||||
|
||||
fun tokenizeShellCommand(command: String): List<GitSandboxEngine.ShellToken> {
|
||||
val result = mutableListOf<GitSandboxEngine.ShellToken>()
|
||||
val current = StringBuilder()
|
||||
var quoteChar: Char? = null
|
||||
var escaping = false
|
||||
var currentQuoted = false
|
||||
var skipNext = false
|
||||
|
||||
fun emitCurrent(force: Boolean = false) {
|
||||
if (current.isNotEmpty() || force && currentQuoted) {
|
||||
result += GitSandboxEngine.ShellToken(value = current.toString(), quoted = currentQuoted)
|
||||
current.clear()
|
||||
currentQuoted = false
|
||||
}
|
||||
}
|
||||
|
||||
command.forEachIndexed { index, char ->
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
return@forEachIndexed
|
||||
}
|
||||
when {
|
||||
escaping -> {
|
||||
current.append(char)
|
||||
escaping = false
|
||||
}
|
||||
char == '\\' && quoteChar != '\'' -> {
|
||||
escaping = true
|
||||
}
|
||||
quoteChar != null -> {
|
||||
if (char == quoteChar) {
|
||||
quoteChar = null
|
||||
} else {
|
||||
current.append(char)
|
||||
}
|
||||
}
|
||||
char == '"' || char == '\'' -> {
|
||||
quoteChar = char
|
||||
currentQuoted = true
|
||||
}
|
||||
char.isWhitespace() -> {
|
||||
emitCurrent()
|
||||
}
|
||||
char == '>' -> {
|
||||
emitCurrent()
|
||||
if (command.getOrNull(index + 1) == '>') {
|
||||
result += GitSandboxEngine.ShellToken(">>")
|
||||
skipNext = true
|
||||
} else if (command.getOrNull(index - 1) != '>') {
|
||||
result += GitSandboxEngine.ShellToken(">")
|
||||
}
|
||||
}
|
||||
else -> current.append(char)
|
||||
}
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current.append('\\')
|
||||
}
|
||||
emitCurrent()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
|
||||
return expandPathspecTokens(repo, arguments.map { GitSandboxEngine.ShellToken(it) })
|
||||
}
|
||||
|
||||
fun expandPathspecTokens(repo: RepoState, arguments: List<GitSandboxEngine.ShellToken>): List<String> {
|
||||
return arguments.flatMap { token ->
|
||||
val argument = token.value
|
||||
if (token.quoted || !argument.hasGlob()) {
|
||||
listOf(argument)
|
||||
} else {
|
||||
val regex = argument.globToRegex()
|
||||
repo.files
|
||||
.filterNot { it.deleted }
|
||||
.map { it.name }
|
||||
.filter { regex.matches(it) }
|
||||
.sorted()
|
||||
.ifEmpty { listOf(argument) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hasGlob(): Boolean = any { it == '*' || it == '?' }
|
||||
|
||||
private fun String.globToRegex(): Regex {
|
||||
val pattern = buildString {
|
||||
append('^')
|
||||
this@globToRegex.forEach { char ->
|
||||
when (char) {
|
||||
'*' -> append("[^/]*")
|
||||
'?' -> append("[^/]")
|
||||
'.', '(', ')', '+', '|', '^', '$', '@', '%', '{', '}', '[', ']', '\\' -> {
|
||||
append('\\')
|
||||
append(char)
|
||||
}
|
||||
else -> append(char)
|
||||
}
|
||||
}
|
||||
append('$')
|
||||
}
|
||||
return Regex(pattern)
|
||||
}
|
||||
}
|
||||
@@ -325,280 +325,6 @@ class GitSandboxEngineTest {
|
||||
assertFalse(updatedRepo.files.any { it.name == "deleteme.txt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
assertFalse(output.any { it.contains("GitHug Android") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession != null)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddAcceptsUpdateSelectionFromNextInput() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1")
|
||||
val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7")
|
||||
|
||||
assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(updateOutput.any { it.contains("Update>>") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false)
|
||||
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(quitOutput.any { it.contains("Bye.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesEveryDisplayedMenuCommand() {
|
||||
val menuCommands = listOf(
|
||||
"1" to "What now> 1",
|
||||
"2" to "Update>>",
|
||||
"3" to "Revert>>",
|
||||
"4" to "Add untracked>>",
|
||||
"5" to "Patch update>>",
|
||||
"6" to "Diff>>",
|
||||
"7" to "Bye.",
|
||||
"8" to "What now> 8",
|
||||
)
|
||||
|
||||
menuCommands.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("2", "3", "4", "5", "6")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesMenuCommandAliases() {
|
||||
val aliases = listOf(
|
||||
"status" to "What now> status",
|
||||
"update" to "Update>>",
|
||||
"revert" to "Revert>>",
|
||||
"add untracked" to "Add untracked>>",
|
||||
"patch" to "Patch update>>",
|
||||
"diff" to "Diff>>",
|
||||
"quit" to "Bye.",
|
||||
"help" to "What now> help",
|
||||
)
|
||||
|
||||
aliases.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddPatchSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
|
||||
val (hunkRepo, hunkOutput) = GitSandboxEngine.execute(patchRepo, "1")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(hunkRepo, "y")
|
||||
|
||||
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(patchOutput.any { it.contains("Patch update>>") })
|
||||
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddStartsPatchHunkDialogWithoutStagingImmediately() {
|
||||
listOf("git add -p README", "git add --patch README").forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, output) = GitSandboxEngine.execute(repo, command)
|
||||
|
||||
assertFalse("$command should not stage before a selection", patchRepo.files.single { it.name == "README" }.staged)
|
||||
assertEquals("patch-hunk", patchRepo.interactiveAddSession?.selectionAction)
|
||||
assertTrue(output.any { it.startsWith("diff --git a/README b/README") })
|
||||
assertTrue(output.any { it.contains("Stage this hunk") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (selectedRepo, output) = GitSandboxEngine.execute(patchRepo, "y")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkEditOpensPatchEditorInvocation() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
|
||||
assertEquals(GitEditorCommandKind.PATCH_HUNK, invocation?.kind)
|
||||
assertEquals("Edit Patch Hunk", invocation?.title)
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("diff --git a/README b/README"))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("+A"))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("Stage this hunk"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editedPatchHunkStagesCurrentPatchTarget() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
|
||||
val (selectedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(patchRepo, invocation.initialContent)
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkDialogHandlesAdvertisedCommands() {
|
||||
val commands = listOf("y", "n", "q", "a", "d", "s", "e", "p", "P", "?")
|
||||
|
||||
commands.forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(patchRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Unknown command '$command'") })
|
||||
assertTrue("$command should echo hunk prompt", output.any { it.contains("Stage this hunk") })
|
||||
if (command == "e") {
|
||||
assertTrue(output.any { it.contains("Opening patch editor") })
|
||||
}
|
||||
if (command in listOf("y", "a")) {
|
||||
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession == null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeInteractiveAddSelectionUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-interactive-add").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "interactive-add-test",
|
||||
title = "Interactive Add Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (menuRepo, _) = runtime.execute(level, repo, "git stage -i")
|
||||
val (updateRepo, _) = runtime.execute(level, menuRepo, "2")
|
||||
val (selectedRepo, _) = runtime.execute(level, updateRepo, "1")
|
||||
val (quitRepo, _) = runtime.execute(level, selectedRepo, "7")
|
||||
val (_, statusOutput) = runtime.execute(level, quitRepo, "git status")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(statusOutput.any { it.contains("new file:") && it.contains("README") })
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchAddSelectionUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-patch-add").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "patch-add-test",
|
||||
title = "Patch Add Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (patchRepo, patchOutput) = runtime.execute(level, repo, "git add -p README")
|
||||
val (selectedRepo, _) = runtime.execute(level, patchRepo, "y")
|
||||
|
||||
assertTrue(patchOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchHunkEditorSaveUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-patch-edit").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "patch-edit-test",
|
||||
title = "Patch Edit Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (patchRepo, _) = runtime.execute(level, repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
val (selectedRepo, output) = runtime.executeGitEditorCommand(level, patchRepo, invocation, invocation.initialContent)
|
||||
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeInteractiveRebaseUsesAppSequenceEditorContent() {
|
||||
val git = testGitBinary()
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class InteractiveAddEngineTest {
|
||||
@Test
|
||||
fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
assertFalse(output.any { it.contains("GitHug Android") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession != null)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddAcceptsUpdateSelectionFromNextInput() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1")
|
||||
val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7")
|
||||
|
||||
assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(updateOutput.any { it.contains("Update>>") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false)
|
||||
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(quitOutput.any { it.contains("Bye.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesEveryDisplayedMenuCommand() {
|
||||
val menuCommands = listOf(
|
||||
"1" to "What now> 1",
|
||||
"2" to "Update>>",
|
||||
"3" to "Revert>>",
|
||||
"4" to "Add untracked>>",
|
||||
"5" to "Patch update>>",
|
||||
"6" to "Diff>>",
|
||||
"7" to "Bye.",
|
||||
"8" to "What now> 8",
|
||||
)
|
||||
|
||||
menuCommands.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("2", "3", "4", "5", "6")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesMenuCommandAliases() {
|
||||
val aliases = listOf(
|
||||
"status" to "What now> status",
|
||||
"update" to "Update>>",
|
||||
"revert" to "Revert>>",
|
||||
"add untracked" to "Add untracked>>",
|
||||
"patch" to "Patch update>>",
|
||||
"diff" to "Diff>>",
|
||||
"quit" to "Bye.",
|
||||
"help" to "What now> help",
|
||||
)
|
||||
|
||||
aliases.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddPatchSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
|
||||
val (hunkRepo, hunkOutput) = GitSandboxEngine.execute(patchRepo, "1")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(hunkRepo, "y")
|
||||
|
||||
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(patchOutput.any { it.contains("Patch update>>") })
|
||||
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddStartsPatchHunkDialogWithoutStagingImmediately() {
|
||||
listOf("git add -p README", "git add --patch README").forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, output) = GitSandboxEngine.execute(repo, command)
|
||||
|
||||
assertFalse("$command should not stage before a selection", patchRepo.files.single { it.name == "README" }.staged)
|
||||
assertEquals("patch-hunk", patchRepo.interactiveAddSession?.selectionAction)
|
||||
assertTrue(output.any { it.startsWith("diff --git a/README b/README") })
|
||||
assertTrue(output.any { it.contains("Stage this hunk") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (selectedRepo, output) = GitSandboxEngine.execute(patchRepo, "y")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkEditOpensPatchEditorInvocation() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
|
||||
assertEquals(GitEditorCommandKind.PATCH_HUNK, invocation?.kind)
|
||||
assertEquals("Edit Patch Hunk", invocation?.title)
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("diff --git a/README b/README"))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("+A"))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("Stage this hunk"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editedPatchHunkStagesCurrentPatchTarget() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
|
||||
val (selectedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(patchRepo, invocation.initialContent)
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkDialogHandlesAdvertisedCommands() {
|
||||
val commands = listOf("y", "n", "q", "a", "d", "s", "e", "p", "P", "?")
|
||||
|
||||
commands.forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(patchRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Unknown command '$command'") })
|
||||
assertTrue("$command should echo hunk prompt", output.any { it.contains("Stage this hunk") })
|
||||
if (command == "e") {
|
||||
assertTrue(output.any { it.contains("Opening patch editor") })
|
||||
}
|
||||
if (command in listOf("y", "a")) {
|
||||
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession == null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeInteractiveAddSelectionUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-interactive-add").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "interactive-add-test",
|
||||
title = "Interactive Add Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (menuRepo, _) = runtime.execute(level, repo, "git stage -i")
|
||||
val (updateRepo, _) = runtime.execute(level, menuRepo, "2")
|
||||
val (selectedRepo, _) = runtime.execute(level, updateRepo, "1")
|
||||
val (quitRepo, _) = runtime.execute(level, selectedRepo, "7")
|
||||
val (_, statusOutput) = runtime.execute(level, quitRepo, "git status")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(statusOutput.any { it.contains("new file:") && it.contains("README") })
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchAddSelectionUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-patch-add").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "patch-add-test",
|
||||
title = "Patch Add Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (patchRepo, patchOutput) = runtime.execute(level, repo, "git add -p README")
|
||||
val (selectedRepo, _) = runtime.execute(level, patchRepo, "y")
|
||||
|
||||
assertTrue(patchOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchHunkEditorSaveUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-patch-edit").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "patch-edit-test",
|
||||
title = "Patch Edit Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (patchRepo, _) = runtime.execute(level, repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
val (selectedRepo, output) = runtime.executeGitEditorCommand(level, patchRepo, invocation, invocation.initialContent)
|
||||
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testGitBinary(): File {
|
||||
System.getenv("GITHUG_TEST_GIT_BINARY")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { File(it) }
|
||||
?.takeIf { it.exists() && it.canExecute() }
|
||||
?.let { return it }
|
||||
|
||||
val repoHostGit = File(repoRoot(), "build/host-git/libgit.so")
|
||||
if (repoHostGit.exists() && repoHostGit.canExecute()) return repoHostGit
|
||||
|
||||
return File("/usr/bin/git")
|
||||
}
|
||||
|
||||
private fun repoRoot(): File {
|
||||
val userDir = File(System.getProperty("user.dir") ?: ".")
|
||||
return if (File(userDir, "app/build.gradle.kts").exists()) userDir else userDir.parentFile ?: userDir
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user