Split oversized runtime, sandbox, and interactive add files into focused helpers

This commit is contained in:
Joe Tretter
2026-05-18 21:49:43 -05:00
parent ed0b81e587
commit bea901acfe
13 changed files with 1743 additions and 1622 deletions

View File

@@ -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()
}
}

View File

@@ -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)
}

View 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()
}
}

View File

@@ -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>,
)

View File

@@ -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>,
)

View File

@@ -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,
)
}

View File

@@ -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()
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}