Split oversized runtime, sandbox, and interactive add files into focused helpers
This commit is contained in:
@@ -128,6 +128,8 @@ The runtime exposes a `RepoState` surface to validators. In addition to files, c
|
||||
|
||||
Helper shell-like commands (`ls`, `pwd`, `cat`, `sh <script>`, `./<script>`, `touch`, `mkdir`, `rm`, `echo`, `cd`) remain implemented in Kotlin so the mobile terminal behaves consistently across devices.
|
||||
|
||||
Source files should stay comfortably reviewable. Treat files approaching roughly 700 lines as refactor candidates, and prefer extracting cohesive runtime helpers, command handlers, or focused test classes over letting orchestration classes absorb unrelated responsibilities.
|
||||
|
||||
## Known Level Differences From Upstream
|
||||
|
||||
The Android port keeps the upstream GitHug level order, but some upstream fixtures assume desktop tools, network access, Ruby, Perl/Python helpers, or direct filesystem behavior that should not be required in a mobile learning sandbox. Differences must be documented here when they are intentional.
|
||||
|
||||
@@ -19,8 +19,8 @@ android {
|
||||
applicationId = "solutions.tretter.githugandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 157
|
||||
versionName = "0.1.156"
|
||||
versionCode = 158
|
||||
versionName = "0.1.157"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal class GitHelperCommands(
|
||||
private val processRunner: GitProcessRunner,
|
||||
) {
|
||||
fun executeExecutableShortcut(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>>? {
|
||||
val executable = tokens.firstOrNull() ?: return null
|
||||
if (!executable.startsWith("./") || executable.length <= 2) return null
|
||||
return executeShellScript(sandboxRoot, workingDir, currentRepo, listOf("sh", executable) + tokens.drop(1))
|
||||
}
|
||||
|
||||
fun execute(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>> {
|
||||
return when (tokens.first()) {
|
||||
"ls", "dir" -> currentRepo to workingDir.listFiles()
|
||||
?.sortedBy { it.name }
|
||||
?.map { it.name }
|
||||
.orEmpty()
|
||||
"pwd" -> currentRepo to listOf(
|
||||
"/sandbox" + if (workingDir == sandboxRoot) "" else "/${workingDir.relativeTo(sandboxRoot).path}"
|
||||
)
|
||||
"cat" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: cat <file>")
|
||||
val file = File(workingDir, target)
|
||||
if (!file.exists() || file.isDirectory) currentRepo to listOf("cat: $target: No such file")
|
||||
else currentRepo to file.readLines().ifEmpty { listOf("") }
|
||||
}
|
||||
"sh" -> executeShellScript(sandboxRoot, workingDir, currentRepo, tokens)
|
||||
"touch" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: touch <file>")
|
||||
val file = File(workingDir, target)
|
||||
if (file.exists()) {
|
||||
currentRepo to listOf("$target already exists")
|
||||
} else {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText("")
|
||||
currentRepo to emptyList()
|
||||
}
|
||||
}
|
||||
"mkdir", "md" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: mkdir <dir>")
|
||||
val dir = File(workingDir, target)
|
||||
if (dir.exists()) currentRepo to listOf("mkdir: $target: File exists") else {
|
||||
dir.mkdirs()
|
||||
currentRepo to emptyList()
|
||||
}
|
||||
}
|
||||
"cd", "cd.." -> {
|
||||
val target = if (tokens.first() == "cd..") ".." else tokens.getOrNull(1)
|
||||
?: return currentRepo to listOf("usage: cd <dir>")
|
||||
val dir = File(workingDir, target).canonicalFile
|
||||
when {
|
||||
!dir.exists() -> currentRepo to listOf("cd: $target: does not exist")
|
||||
!dir.isDirectory -> currentRepo to listOf("cd: $target: Not a directory")
|
||||
!dir.path.startsWith(sandboxRoot.path) -> currentRepo to listOf("cd: $target: Permission denied")
|
||||
else -> {
|
||||
val relativeDir = sandboxRoot.toPath().relativize(dir.toPath()).toString().ifEmpty { "." }
|
||||
currentRepo.copy(currentDir = relativeDir) to emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
"rm", "del" -> {
|
||||
val targets = tokens.drop(1)
|
||||
if (targets.isEmpty()) return currentRepo to listOf("usage: rm <path>")
|
||||
val errors = targets.mapNotNull { target ->
|
||||
val file = File(workingDir, target)
|
||||
if (!file.exists()) {
|
||||
"${tokens.first()}: $target: No such file or directory"
|
||||
} else {
|
||||
file.deleteRecursively()
|
||||
null
|
||||
}
|
||||
}
|
||||
currentRepo to errors
|
||||
}
|
||||
"echo" -> executeEcho(workingDir, currentRepo, tokens)
|
||||
else -> currentRepo to listOf("Command not supported in prototype runtime. Try a git command or helper command.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeShellScript(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val script = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: sh <script>")
|
||||
val scriptFile = File(workingDir, script).canonicalFile
|
||||
if (!scriptFile.path.startsWith(sandboxRoot.path)) {
|
||||
return currentRepo to listOf("sh: $script: Permission denied")
|
||||
}
|
||||
if (!scriptFile.isFile) {
|
||||
return currentRepo to listOf("sh: $script: No such file")
|
||||
}
|
||||
val result = processRunner.runShellProcess(
|
||||
File(processRunner.shellExecutable()),
|
||||
workingDir,
|
||||
tokens.drop(1),
|
||||
)
|
||||
return currentRepo to result.outputLines
|
||||
}
|
||||
|
||||
private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
||||
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
|
||||
if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) {
|
||||
return currentRepo to listOf(tokens.drop(1).joinToString(" "))
|
||||
}
|
||||
|
||||
val file = File(workingDir, tokens[redirectIndex + 1])
|
||||
file.parentFile?.mkdirs()
|
||||
val content = tokens.subList(1, redirectIndex).joinToString(" ")
|
||||
if (tokens[redirectIndex] == ">>") {
|
||||
if (file.exists() && file.length() > 0L) {
|
||||
file.appendText("\n$content")
|
||||
} else {
|
||||
file.writeText(content)
|
||||
}
|
||||
} else {
|
||||
file.writeText(content)
|
||||
}
|
||||
return currentRepo to emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal const val SyntheticRemoteUrl = "remote"
|
||||
|
||||
internal class GitLevelMaterializer(
|
||||
private val runGitCommand: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
|
||||
) {
|
||||
fun materialize(nativeGit: File, sandbox: File, desired: RepoState, level: Level) {
|
||||
level.nativeSetup?.let { setup ->
|
||||
val nativeSetup = NativeLevelSetup(sandbox) { directory, arguments, environment ->
|
||||
runGit(nativeGit, directory, arguments, environment).exitCode
|
||||
}
|
||||
if (nativeSetup.setup()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
desired.config.forEach { (key, value) ->
|
||||
runGit(nativeGit, sandbox, listOf("config", key, value))
|
||||
}
|
||||
|
||||
val trackedFiles = desired.files.filter { it.tracked }
|
||||
val desiredCommitCount = maxOf(
|
||||
desired.commits.size,
|
||||
desired.branches.values.maxOrNull() ?: 0,
|
||||
if (desired.tags.isNotEmpty()) 1 else 0,
|
||||
)
|
||||
if (desiredCommitCount > 0) {
|
||||
repeat(desiredCommitCount) { index ->
|
||||
filesForSetupCommit(trackedFiles, index, desiredCommitCount).forEach { file ->
|
||||
runGit(nativeGit, sandbox, listOf("add", file.name))
|
||||
}
|
||||
val message = desired.commits.getOrNull(index)?.message ?: "Setup commit ${index + 1}"
|
||||
runGit(nativeGit, sandbox, listOf("commit", "--allow-empty", "-m", message))
|
||||
}
|
||||
|
||||
desired.branches
|
||||
.keys
|
||||
.filterNot { it == desired.headBranch }
|
||||
.forEach { branch -> runGit(nativeGit, sandbox, listOf("branch", branch)) }
|
||||
if (desired.branches.containsKey(desired.headBranch)) {
|
||||
runGit(nativeGit, sandbox, listOf("checkout", desired.headBranch))
|
||||
}
|
||||
}
|
||||
|
||||
desired.tags.forEach { tag -> runGit(nativeGit, sandbox, listOf("tag", tag)) }
|
||||
desired.remotes.forEach { (name, url) ->
|
||||
val materializedUrl = if (url == SyntheticRemoteUrl) {
|
||||
materializeSyntheticRemote(nativeGit, sandbox, name)
|
||||
} else {
|
||||
url
|
||||
}
|
||||
runGit(nativeGit, sandbox, listOf("remote", "add", name, materializedUrl))
|
||||
if (url == SyntheticRemoteUrl) {
|
||||
runGit(nativeGit, sandbox, listOf("fetch", name))
|
||||
runGit(nativeGit, sandbox, listOf("branch", "--set-upstream-to=$name/master", desired.headBranch))
|
||||
}
|
||||
}
|
||||
|
||||
val stagedTargets = desired.files.filter { it.staged }.map { it.name }
|
||||
if (stagedTargets.isNotEmpty()) {
|
||||
runGit(nativeGit, sandbox, listOf("add") + stagedTargets)
|
||||
}
|
||||
desired.files.filter { it.deleted }.forEach { file ->
|
||||
File(sandbox, file.name).delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun filesForSetupCommit(files: List<GitFile>, index: Int, commitCount: Int): List<GitFile> {
|
||||
if (files.isEmpty()) return emptyList()
|
||||
if (commitCount <= 1) return files
|
||||
if (index == 0) return files.take(1)
|
||||
if (index == commitCount - 1) return files.drop(index)
|
||||
return files.getOrNull(index)?.let { listOf(it) }.orEmpty()
|
||||
}
|
||||
|
||||
private fun materializeSyntheticRemote(nativeGit: File, sandbox: File, remoteName: String): String {
|
||||
val remoteDir = File(sandbox.parentFile, "${sandbox.name}-$remoteName.git")
|
||||
remoteDir.deleteRecursively()
|
||||
val cloneResult = runGit(nativeGit, sandbox.parentFile ?: sandbox, listOf("clone", "--bare", sandbox.absolutePath, remoteDir.absolutePath))
|
||||
if (cloneResult.exitCode != 0) {
|
||||
remoteDir.mkdirs()
|
||||
runGit(nativeGit, remoteDir, listOf("init", "--bare"))
|
||||
}
|
||||
return remoteDir.absolutePath
|
||||
}
|
||||
|
||||
private fun runGit(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult = runGitCommand(binary, workingDir, arguments, environment)
|
||||
}
|
||||
128
app/src/main/java/solutions/tretter/githugandroid/GitManPages.kt
Normal file
128
app/src/main/java/solutions/tretter/githugandroid/GitManPages.kt
Normal file
@@ -0,0 +1,128 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
internal fun bundledGitManPage(context: Context?, topic: String): String? {
|
||||
val assetNames = listOf(
|
||||
"manpages/git-$topic.txt",
|
||||
"manpages/git${topic.removePrefix("-")}.txt",
|
||||
"manpages/$topic.txt",
|
||||
).distinct()
|
||||
|
||||
if (context != null) {
|
||||
assetNames.forEach { assetName ->
|
||||
try {
|
||||
context.assets.open(assetName).bufferedReader().use { reader ->
|
||||
return reader.readText().takeIf { it.isNotBlank() }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Try the next bundled filename.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val repoAssetRoot = File("src/main/assets")
|
||||
assetNames.forEach { assetName ->
|
||||
val file = File(repoAssetRoot, assetName)
|
||||
if (file.exists()) {
|
||||
return file.readText().takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun placeholderGitManPage(context: Context?, topic: String): String {
|
||||
bundledGitManPage(context, topic)?.let { return it }
|
||||
return when (topic) {
|
||||
"tag" -> """
|
||||
NAME
|
||||
git-tag - Create, list, delete or verify a tag object
|
||||
|
||||
SYNOPSIS
|
||||
git tag <tagname>
|
||||
git tag -d <tagname>
|
||||
git tag
|
||||
|
||||
DESCRIPTION
|
||||
Tags name specific points in history. In GitHug Android,
|
||||
tag levels usually expect you to create or reference a tag
|
||||
by name.
|
||||
|
||||
EXAMPLES
|
||||
git tag new_tag
|
||||
git tag v1.2
|
||||
""".trimIndent()
|
||||
"branch" -> """
|
||||
NAME
|
||||
git-branch - List, create, or delete branches
|
||||
|
||||
SYNOPSIS
|
||||
git branch <branchname>
|
||||
git branch -d <branchname>
|
||||
|
||||
EXAMPLES
|
||||
git branch test_code
|
||||
git branch -d delete_me
|
||||
""".trimIndent()
|
||||
"checkout" -> """
|
||||
NAME
|
||||
git-checkout - Switch branches or restore files
|
||||
|
||||
SYNOPSIS
|
||||
git checkout <branch>
|
||||
git checkout -b <branch>
|
||||
git checkout -- <file>
|
||||
|
||||
EXAMPLES
|
||||
git checkout -b my_branch
|
||||
git checkout -- config.rb
|
||||
""".trimIndent()
|
||||
"add" -> """
|
||||
NAME
|
||||
git-add - Add file contents to the index
|
||||
|
||||
SYNOPSIS
|
||||
git add <path>
|
||||
git add .
|
||||
git add -p <path>
|
||||
""".trimIndent()
|
||||
"commit" -> """
|
||||
NAME
|
||||
git-commit - Record changes to the repository
|
||||
|
||||
SYNOPSIS
|
||||
git commit -m <message>
|
||||
git commit --amend
|
||||
""".trimIndent()
|
||||
"ignore" -> """
|
||||
NAME
|
||||
gitignore - Specifies intentionally untracked files to ignore
|
||||
|
||||
SYNOPSIS
|
||||
$'GIT_DIR/info/exclude', .gitignore
|
||||
|
||||
DESCRIPTION
|
||||
A gitignore file specifies intentionally untracked files
|
||||
that Git should ignore. Each line contains a pattern.
|
||||
|
||||
Blank lines are ignored. Lines beginning with # are comments.
|
||||
An optional ! prefix negates a pattern and re-includes a path.
|
||||
A pattern ending with / matches directories.
|
||||
|
||||
EXAMPLES
|
||||
*.swp
|
||||
*.a
|
||||
!lib.a
|
||||
""".trimIndent()
|
||||
else -> """
|
||||
NAME
|
||||
git-$topic
|
||||
|
||||
DESCRIPTION
|
||||
No bundled manpage is available for git $topic.
|
||||
Try the command suggestions or use git help for another command.
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
internal class GitProcessRunner(
|
||||
private val context: Context?,
|
||||
private val sandboxesRoot: File,
|
||||
) {
|
||||
private companion object {
|
||||
val RequiredGitCommandAliases = listOf(
|
||||
"add",
|
||||
"branch",
|
||||
"checkout",
|
||||
"commit",
|
||||
"commit-tree",
|
||||
"config",
|
||||
"diff",
|
||||
"fetch",
|
||||
"index-pack",
|
||||
"merge",
|
||||
"merge-base",
|
||||
"pack-objects",
|
||||
"pull",
|
||||
"push",
|
||||
"read-tree",
|
||||
"rebase",
|
||||
"receive-pack",
|
||||
"rev-list",
|
||||
"rev-parse",
|
||||
"show-ref",
|
||||
"stash",
|
||||
"status",
|
||||
"symbolic-ref",
|
||||
"unpack-objects",
|
||||
"update-ref",
|
||||
"upload-pack",
|
||||
)
|
||||
}
|
||||
|
||||
fun shellExecutable(): String {
|
||||
return when {
|
||||
context != null -> "/system/bin/sh"
|
||||
File("/bin/sh").exists() -> "/bin/sh"
|
||||
else -> "sh"
|
||||
}
|
||||
}
|
||||
|
||||
fun runGit(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
return runProcess(binary, workingDir, arguments, environment)
|
||||
}
|
||||
|
||||
fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply {
|
||||
environment()["HOME"] = workingDir.absolutePath
|
||||
environment()["LC_ALL"] = "C"
|
||||
}
|
||||
.start()
|
||||
|
||||
val output = process.inputStream.bufferedReader().readLines()
|
||||
val exit = process.waitFor()
|
||||
ProcessExecutionResult(exitCode = exit, outputLines = output)
|
||||
} catch (error: Exception) {
|
||||
ProcessExecutionResult(exitCode = -1, outputLines = listOf("Shell execution failed: ${error.message ?: error::class.java.simpleName}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun runProcess(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
extraEnvironment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
return try {
|
||||
val gitExecPath = gitExecDirectory(binary)
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply {
|
||||
environment()["HOME"] = workingDir.absolutePath
|
||||
environment()["GIT_EXEC_PATH"] = gitExecPath.absolutePath
|
||||
environment()["PATH"] = listOfNotNull(
|
||||
gitExecPath.absolutePath,
|
||||
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
||||
).joinToString(File.pathSeparator)
|
||||
environment()["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||
environment()["GIT_AUTHOR_NAME"] = "GitHug"
|
||||
environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com"
|
||||
environment()["GIT_COMMITTER_NAME"] = "GitHug"
|
||||
environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com"
|
||||
environment()["LC_ALL"] = "C"
|
||||
environment().putAll(extraEnvironment)
|
||||
}
|
||||
.start()
|
||||
|
||||
val output = process.inputStream.bufferedReader().readLines()
|
||||
val exit = process.waitFor()
|
||||
ProcessExecutionResult(exitCode = exit, outputLines = output)
|
||||
} catch (error: Exception) {
|
||||
ProcessExecutionResult(exitCode = -1, outputLines = listOf("Native Git execution failed: ${error.message ?: error::class.java.simpleName}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun gitExecDirectory(binary: File): File {
|
||||
val directory = if (context != null) {
|
||||
File(context.filesDir, "git-exec")
|
||||
} else {
|
||||
File(binary.parentFile ?: binary.absoluteFile.parentFile ?: sandboxesRoot, "git-exec")
|
||||
}
|
||||
refreshGitExecDirectoryIfNeeded(directory, binary)
|
||||
directory.mkdirs()
|
||||
|
||||
File(directory, "git").also { alias ->
|
||||
if (!alias.exists()) {
|
||||
createGitAlias(binary, alias)
|
||||
}
|
||||
}
|
||||
RequiredGitCommandAliases.forEach { command ->
|
||||
listOf("git-$command", command).forEach { aliasName ->
|
||||
val alias = File(directory, aliasName)
|
||||
if (!alias.exists()) {
|
||||
createGitAlias(binary, alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
private fun refreshGitExecDirectoryIfNeeded(directory: File, binary: File) {
|
||||
val fingerprint = buildString {
|
||||
append(binary.absolutePath)
|
||||
append('\n')
|
||||
append(binary.length())
|
||||
append('\n')
|
||||
append(binary.lastModified())
|
||||
}
|
||||
val stamp = File(directory, ".binary-fingerprint")
|
||||
val previousFingerprint = stamp.takeIf { it.isFile }?.readText()
|
||||
if (previousFingerprint != fingerprint) {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
directory.mkdirs()
|
||||
stamp.writeText(fingerprint)
|
||||
}
|
||||
|
||||
private fun createGitAlias(binary: File, alias: File) {
|
||||
if (alias.exists() || Files.isSymbolicLink(alias.toPath())) {
|
||||
alias.delete()
|
||||
}
|
||||
if (context != null) {
|
||||
try {
|
||||
Os.symlink(binary.absolutePath, alias.absolutePath)
|
||||
alias.setExecutable(true, false)
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
// Fall through to the portable options below.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Files.createSymbolicLink(alias.toPath(), binary.toPath())
|
||||
} catch (_: Exception) {
|
||||
binary.copyTo(alias, overwrite = true)
|
||||
}
|
||||
alias.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ProcessExecutionResult(
|
||||
val exitCode: Int,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
@@ -2,46 +2,16 @@ package solutions.tretter.githugandroid
|
||||
|
||||
import android.os.Build
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class GitRepositoryRuntime private constructor(
|
||||
private val context: Context?,
|
||||
private val nativeGitOverride: File?,
|
||||
private val sandboxesRoot: File,
|
||||
) {
|
||||
private companion object {
|
||||
const val SyntheticRemoteUrl = "remote"
|
||||
val RequiredGitCommandAliases = listOf(
|
||||
"add",
|
||||
"branch",
|
||||
"checkout",
|
||||
"commit",
|
||||
"commit-tree",
|
||||
"config",
|
||||
"diff",
|
||||
"fetch",
|
||||
"index-pack",
|
||||
"merge",
|
||||
"merge-base",
|
||||
"pack-objects",
|
||||
"pull",
|
||||
"push",
|
||||
"read-tree",
|
||||
"rebase",
|
||||
"receive-pack",
|
||||
"rev-list",
|
||||
"rev-parse",
|
||||
"show-ref",
|
||||
"stash",
|
||||
"status",
|
||||
"symbolic-ref",
|
||||
"unpack-objects",
|
||||
"update-ref",
|
||||
"upload-pack",
|
||||
)
|
||||
}
|
||||
private val processRunner = GitProcessRunner(context, sandboxesRoot)
|
||||
private val helperCommands = GitHelperCommands(processRunner)
|
||||
private val levelMaterializer = GitLevelMaterializer(::runGit)
|
||||
|
||||
constructor(context: Context) : this(
|
||||
context = context.applicationContext,
|
||||
@@ -107,7 +77,7 @@ class GitRepositoryRuntime private constructor(
|
||||
runGit(nativeGit, sandbox, listOf("checkout", "-B", desired.headBranch))
|
||||
}
|
||||
|
||||
materializeNativeGitState(nativeGit, sandbox, desired, level)
|
||||
levelMaterializer.materialize(nativeGit, sandbox, desired, level)
|
||||
}
|
||||
|
||||
return inspectSandbox(level)
|
||||
@@ -157,25 +127,14 @@ class GitRepositoryRuntime private constructor(
|
||||
val result = when (expandedTokens.first()) {
|
||||
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
|
||||
"help", "?" -> currentRepo to commandReferenceLines()
|
||||
else -> executeExecutableShortcut(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
?: executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
else -> helperCommands.executeExecutableShortcut(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
}
|
||||
|
||||
val inspectedRepo = inspectSandbox(level).copy(currentDir = result.first.currentDir)
|
||||
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) to result.second
|
||||
}
|
||||
|
||||
private fun executeExecutableShortcut(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>>? {
|
||||
val executable = tokens.firstOrNull() ?: return null
|
||||
if (!executable.startsWith("./") || executable.length <= 2) return null
|
||||
return executeShellScript(sandboxRoot, workingDir, currentRepo, listOf("sh", executable) + tokens.drop(1))
|
||||
}
|
||||
|
||||
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
|
||||
requireNativeGit()
|
||||
val sandbox = sandboxDir(level)
|
||||
@@ -213,7 +172,7 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
|
||||
fun gitManPage(level: Level, currentRepo: RepoState, topic: String): String {
|
||||
bundledManPage(topic)?.let { return it }
|
||||
bundledGitManPage(context, topic)?.let { return it }
|
||||
|
||||
val nativeGit = requireNativeGit()
|
||||
|
||||
@@ -224,7 +183,7 @@ class GitRepositoryRuntime private constructor(
|
||||
return result.outputLines
|
||||
.filterNot { it.contains("no man viewer", ignoreCase = true) }
|
||||
.joinToString("\n")
|
||||
.ifBlank { placeholderManPage(topic) }
|
||||
.ifBlank { placeholderGitManPage(context, topic) }
|
||||
}
|
||||
|
||||
fun readEditorFile(level: Level, currentRepo: RepoState, path: String): Pair<String, List<String>> {
|
||||
@@ -382,301 +341,6 @@ class GitRepositoryRuntime private constructor(
|
||||
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||
}
|
||||
|
||||
private fun executeHelperCommand(sandboxRoot: File, workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
||||
return when (tokens.first()) {
|
||||
"ls", "dir" -> currentRepo to workingDir.listFiles()
|
||||
?.sortedBy { it.name }
|
||||
?.map { it.name }
|
||||
.orEmpty()
|
||||
"pwd" -> currentRepo to listOf(
|
||||
"/sandbox" + if (workingDir == sandboxRoot) "" else "/${workingDir.relativeTo(sandboxRoot).path}"
|
||||
)
|
||||
"cat" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: cat <file>")
|
||||
val file = File(workingDir, target)
|
||||
if (!file.exists() || file.isDirectory) currentRepo to listOf("cat: $target: No such file")
|
||||
else currentRepo to file.readLines().ifEmpty { listOf("") }
|
||||
}
|
||||
"sh" -> executeShellScript(sandboxRoot, workingDir, currentRepo, tokens)
|
||||
"touch" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: touch <file>")
|
||||
val file = File(workingDir, target)
|
||||
if (file.exists()) {
|
||||
currentRepo to listOf("$target already exists")
|
||||
} else {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText("")
|
||||
currentRepo to emptyList()
|
||||
}
|
||||
}
|
||||
"mkdir", "md" -> {
|
||||
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: mkdir <dir>")
|
||||
val dir = File(workingDir, target)
|
||||
if (dir.exists()) currentRepo to listOf("mkdir: $target: File exists") else {
|
||||
dir.mkdirs()
|
||||
currentRepo to emptyList()
|
||||
}
|
||||
}
|
||||
"cd", "cd.." -> {
|
||||
val target = if (tokens.first() == "cd..") ".." else tokens.getOrNull(1)
|
||||
?: return currentRepo to listOf("usage: cd <dir>")
|
||||
val dir = File(workingDir, target).canonicalFile
|
||||
when {
|
||||
!dir.exists() -> currentRepo to listOf("cd: $target: does not exist")
|
||||
!dir.isDirectory -> currentRepo to listOf("cd: $target: Not a directory")
|
||||
!dir.path.startsWith(sandboxRoot.path) -> currentRepo to listOf("cd: $target: Permission denied")
|
||||
else -> {
|
||||
val relativeDir = sandboxRoot.toPath().relativize(dir.toPath()).toString().ifEmpty { "." }
|
||||
currentRepo.copy(currentDir = relativeDir) to emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
"rm", "del" -> {
|
||||
val targets = tokens.drop(1)
|
||||
if (targets.isEmpty()) return currentRepo to listOf("usage: rm <path>")
|
||||
val errors = targets.mapNotNull { target ->
|
||||
val file = File(workingDir, target)
|
||||
if (!file.exists()) {
|
||||
"${tokens.first()}: $target: No such file or directory"
|
||||
} else {
|
||||
file.deleteRecursively()
|
||||
null
|
||||
}
|
||||
}
|
||||
currentRepo to errors
|
||||
}
|
||||
"echo" -> executeEcho(workingDir, currentRepo, tokens)
|
||||
else -> currentRepo to listOf("Command not supported in prototype runtime. Try a git command or helper command.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeShellScript(
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
currentRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val script = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: sh <script>")
|
||||
val scriptFile = File(workingDir, script).canonicalFile
|
||||
if (!scriptFile.path.startsWith(sandboxRoot.path)) {
|
||||
return currentRepo to listOf("sh: $script: Permission denied")
|
||||
}
|
||||
if (!scriptFile.isFile) {
|
||||
return currentRepo to listOf("sh: $script: No such file")
|
||||
}
|
||||
val result = runShellProcess(
|
||||
File(shellExecutable()),
|
||||
workingDir,
|
||||
tokens.drop(1),
|
||||
)
|
||||
return currentRepo to result.outputLines
|
||||
}
|
||||
|
||||
private fun placeholderManPage(topic: String): String {
|
||||
bundledManPage(topic)?.let { return it }
|
||||
val body = when (topic) {
|
||||
"tag" -> """
|
||||
NAME
|
||||
git-tag - Create, list, delete or verify a tag object
|
||||
|
||||
SYNOPSIS
|
||||
git tag <tagname>
|
||||
git tag -d <tagname>
|
||||
git tag
|
||||
|
||||
DESCRIPTION
|
||||
Tags name specific points in history. In GitHug Android,
|
||||
tag levels usually expect you to create or reference a tag
|
||||
by name.
|
||||
|
||||
EXAMPLES
|
||||
git tag new_tag
|
||||
git tag v1.2
|
||||
""".trimIndent()
|
||||
"branch" -> """
|
||||
NAME
|
||||
git-branch - List, create, or delete branches
|
||||
|
||||
SYNOPSIS
|
||||
git branch <branchname>
|
||||
git branch -d <branchname>
|
||||
|
||||
EXAMPLES
|
||||
git branch test_code
|
||||
git branch -d delete_me
|
||||
""".trimIndent()
|
||||
"checkout" -> """
|
||||
NAME
|
||||
git-checkout - Switch branches or restore files
|
||||
|
||||
SYNOPSIS
|
||||
git checkout <branch>
|
||||
git checkout -b <branch>
|
||||
git checkout -- <file>
|
||||
|
||||
EXAMPLES
|
||||
git checkout -b my_branch
|
||||
git checkout -- config.rb
|
||||
""".trimIndent()
|
||||
"add" -> """
|
||||
NAME
|
||||
git-add - Add file contents to the index
|
||||
|
||||
SYNOPSIS
|
||||
git add <path>
|
||||
git add .
|
||||
git add -p <path>
|
||||
""".trimIndent()
|
||||
"commit" -> """
|
||||
NAME
|
||||
git-commit - Record changes to the repository
|
||||
|
||||
SYNOPSIS
|
||||
git commit -m <message>
|
||||
git commit --amend
|
||||
""".trimIndent()
|
||||
"ignore" -> """
|
||||
NAME
|
||||
gitignore - Specifies intentionally untracked files to ignore
|
||||
|
||||
SYNOPSIS
|
||||
$'GIT_DIR/info/exclude', .gitignore
|
||||
|
||||
DESCRIPTION
|
||||
A gitignore file specifies intentionally untracked files
|
||||
that Git should ignore. Each line contains a pattern.
|
||||
|
||||
Blank lines are ignored. Lines beginning with # are comments.
|
||||
An optional ! prefix negates a pattern and re-includes a path.
|
||||
A pattern ending with / matches directories.
|
||||
|
||||
EXAMPLES
|
||||
*.swp
|
||||
*.a
|
||||
!lib.a
|
||||
""".trimIndent()
|
||||
else -> """
|
||||
NAME
|
||||
git-$topic
|
||||
|
||||
DESCRIPTION
|
||||
No bundled manpage is available for git $topic.
|
||||
Try the command suggestions or use git help for another command.
|
||||
""".trimIndent()
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
private fun bundledManPage(topic: String): String? {
|
||||
val assetNames = listOf(
|
||||
"manpages/git-$topic.txt",
|
||||
"manpages/git${topic.removePrefix("-")}.txt",
|
||||
"manpages/$topic.txt",
|
||||
).distinct()
|
||||
|
||||
if (context != null) {
|
||||
assetNames.forEach { assetName ->
|
||||
try {
|
||||
context.assets.open(assetName).bufferedReader().use { reader ->
|
||||
return reader.readText().takeIf { it.isNotBlank() }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Try the next bundled filename.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val repoAssetRoot = File("src/main/assets")
|
||||
assetNames.forEach { assetName ->
|
||||
val file = File(repoAssetRoot, assetName)
|
||||
if (file.exists()) {
|
||||
return file.readText().takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun materializeNativeGitState(nativeGit: File, sandbox: File, desired: RepoState, level: Level) {
|
||||
level.nativeSetup?.let { setup ->
|
||||
val nativeSetup = NativeLevelSetup(sandbox) { directory, arguments, environment ->
|
||||
runGit(nativeGit, directory, arguments, environment).exitCode
|
||||
}
|
||||
if (nativeSetup.setup()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
desired.config.forEach { (key, value) ->
|
||||
runGit(nativeGit, sandbox, listOf("config", key, value))
|
||||
}
|
||||
|
||||
val trackedFiles = desired.files.filter { it.tracked }
|
||||
val desiredCommitCount = maxOf(
|
||||
desired.commits.size,
|
||||
desired.branches.values.maxOrNull() ?: 0,
|
||||
if (desired.tags.isNotEmpty()) 1 else 0,
|
||||
)
|
||||
if (desiredCommitCount > 0) {
|
||||
repeat(desiredCommitCount) { index ->
|
||||
filesForSetupCommit(trackedFiles, index, desiredCommitCount).forEach { file ->
|
||||
runGit(nativeGit, sandbox, listOf("add", file.name))
|
||||
}
|
||||
val message = desired.commits.getOrNull(index)?.message ?: "Setup commit ${index + 1}"
|
||||
runGit(nativeGit, sandbox, listOf("commit", "--allow-empty", "-m", message))
|
||||
}
|
||||
|
||||
desired.branches
|
||||
.keys
|
||||
.filterNot { it == desired.headBranch }
|
||||
.forEach { branch -> runGit(nativeGit, sandbox, listOf("branch", branch)) }
|
||||
if (desired.branches.containsKey(desired.headBranch)) {
|
||||
runGit(nativeGit, sandbox, listOf("checkout", desired.headBranch))
|
||||
}
|
||||
}
|
||||
|
||||
desired.tags.forEach { tag -> runGit(nativeGit, sandbox, listOf("tag", tag)) }
|
||||
desired.remotes.forEach { (name, url) ->
|
||||
val materializedUrl = if (url == SyntheticRemoteUrl) {
|
||||
materializeSyntheticRemote(nativeGit, sandbox, name)
|
||||
} else {
|
||||
url
|
||||
}
|
||||
runGit(nativeGit, sandbox, listOf("remote", "add", name, materializedUrl))
|
||||
if (url == SyntheticRemoteUrl) {
|
||||
runGit(nativeGit, sandbox, listOf("fetch", name))
|
||||
runGit(nativeGit, sandbox, listOf("branch", "--set-upstream-to=$name/master", desired.headBranch))
|
||||
}
|
||||
}
|
||||
|
||||
val stagedTargets = desired.files.filter { it.staged }.map { it.name }
|
||||
if (stagedTargets.isNotEmpty()) {
|
||||
runGit(nativeGit, sandbox, listOf("add") + stagedTargets)
|
||||
}
|
||||
desired.files.filter { it.deleted }.forEach { file ->
|
||||
File(sandbox, file.name).delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun filesForSetupCommit(files: List<GitFile>, index: Int, commitCount: Int): List<GitFile> {
|
||||
if (files.isEmpty()) return emptyList()
|
||||
if (commitCount <= 1) return files
|
||||
if (index == 0) return files.take(1)
|
||||
if (index == commitCount - 1) return files.drop(index)
|
||||
return files.getOrNull(index)?.let { listOf(it) }.orEmpty()
|
||||
}
|
||||
|
||||
private fun materializeSyntheticRemote(nativeGit: File, sandbox: File, remoteName: String): String {
|
||||
val remoteDir = File(sandbox.parentFile, "${sandbox.name}-$remoteName.git")
|
||||
remoteDir.deleteRecursively()
|
||||
val cloneResult = runGit(nativeGit, sandbox.parentFile ?: sandbox, listOf("clone", "--bare", sandbox.absolutePath, remoteDir.absolutePath))
|
||||
if (cloneResult.exitCode != 0) {
|
||||
remoteDir.mkdirs()
|
||||
runGit(nativeGit, remoteDir, listOf("init", "--bare"))
|
||||
}
|
||||
return remoteDir.absolutePath
|
||||
}
|
||||
|
||||
private fun executeSyntheticGitCommand(
|
||||
currentRepo: RepoState,
|
||||
command: String,
|
||||
@@ -730,27 +394,6 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeEcho(workingDir: File, currentRepo: RepoState, tokens: List<String>): Pair<RepoState, List<String>> {
|
||||
val redirectIndex = tokens.indexOfFirst { it == ">" || it == ">>" }
|
||||
if (redirectIndex == -1 || redirectIndex == tokens.lastIndex) {
|
||||
return currentRepo to listOf(tokens.drop(1).joinToString(" "))
|
||||
}
|
||||
|
||||
val file = File(workingDir, tokens[redirectIndex + 1])
|
||||
file.parentFile?.mkdirs()
|
||||
val content = tokens.subList(1, redirectIndex).joinToString(" ")
|
||||
if (tokens[redirectIndex] == ">>") {
|
||||
if (file.exists() && file.length() > 0L) {
|
||||
file.appendText("\n$content")
|
||||
} else {
|
||||
file.writeText(content)
|
||||
}
|
||||
} else {
|
||||
file.writeText(content)
|
||||
}
|
||||
return currentRepo to emptyList()
|
||||
}
|
||||
|
||||
private fun inspectSandbox(level: Level): RepoState {
|
||||
val sandbox = sandboxDir(level)
|
||||
val nativeGit = requireNativeGit()
|
||||
@@ -1031,144 +674,13 @@ class GitRepositoryRuntime private constructor(
|
||||
return "'" + replace("'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
private fun shellExecutable(): String {
|
||||
return when {
|
||||
context != null -> "/system/bin/sh"
|
||||
File("/bin/sh").exists() -> "/bin/sh"
|
||||
else -> "sh"
|
||||
}
|
||||
}
|
||||
private fun shellExecutable(): String = processRunner.shellExecutable()
|
||||
|
||||
private fun runGit(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
return runProcess(binary, workingDir, arguments, environment)
|
||||
}
|
||||
): ProcessExecutionResult = processRunner.runGit(binary, workingDir, arguments, environment)
|
||||
|
||||
private fun runProcess(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
extraEnvironment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
return try {
|
||||
val gitExecPath = gitExecDirectory(binary)
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply {
|
||||
environment()["HOME"] = workingDir.absolutePath
|
||||
environment()["GIT_EXEC_PATH"] = gitExecPath.absolutePath
|
||||
environment()["PATH"] = listOfNotNull(
|
||||
gitExecPath.absolutePath,
|
||||
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
||||
).joinToString(File.pathSeparator)
|
||||
environment()["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||
environment()["GIT_AUTHOR_NAME"] = "GitHug"
|
||||
environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com"
|
||||
environment()["GIT_COMMITTER_NAME"] = "GitHug"
|
||||
environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com"
|
||||
environment()["LC_ALL"] = "C"
|
||||
environment().putAll(extraEnvironment)
|
||||
}
|
||||
.start()
|
||||
|
||||
val output = process.inputStream.bufferedReader().readLines()
|
||||
val exit = process.waitFor()
|
||||
ProcessExecutionResult(exitCode = exit, outputLines = output)
|
||||
} catch (error: Exception) {
|
||||
ProcessExecutionResult(exitCode = -1, outputLines = listOf("Native Git execution failed: ${error.message ?: error::class.java.simpleName}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply {
|
||||
environment()["HOME"] = workingDir.absolutePath
|
||||
environment()["LC_ALL"] = "C"
|
||||
}
|
||||
.start()
|
||||
|
||||
val output = process.inputStream.bufferedReader().readLines()
|
||||
val exit = process.waitFor()
|
||||
ProcessExecutionResult(exitCode = exit, outputLines = output)
|
||||
} catch (error: Exception) {
|
||||
ProcessExecutionResult(exitCode = -1, outputLines = listOf("Shell execution failed: ${error.message ?: error::class.java.simpleName}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun gitExecDirectory(binary: File): File {
|
||||
val directory = if (context != null) {
|
||||
File(context.filesDir, "git-exec")
|
||||
} else {
|
||||
File(binary.parentFile ?: binary.absoluteFile.parentFile ?: sandboxesRoot, "git-exec")
|
||||
}
|
||||
refreshGitExecDirectoryIfNeeded(directory, binary)
|
||||
directory.mkdirs()
|
||||
|
||||
File(directory, "git").also { alias ->
|
||||
if (!alias.exists()) {
|
||||
createGitAlias(binary, alias)
|
||||
}
|
||||
}
|
||||
RequiredGitCommandAliases.forEach { command ->
|
||||
listOf("git-$command", command).forEach { aliasName ->
|
||||
val alias = File(directory, aliasName)
|
||||
if (!alias.exists()) {
|
||||
createGitAlias(binary, alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
private fun refreshGitExecDirectoryIfNeeded(directory: File, binary: File) {
|
||||
val fingerprint = buildString {
|
||||
append(binary.absolutePath)
|
||||
append('\n')
|
||||
append(binary.length())
|
||||
append('\n')
|
||||
append(binary.lastModified())
|
||||
}
|
||||
val stamp = File(directory, ".binary-fingerprint")
|
||||
val previousFingerprint = stamp.takeIf { it.isFile }?.readText()
|
||||
if (previousFingerprint != fingerprint) {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
directory.mkdirs()
|
||||
stamp.writeText(fingerprint)
|
||||
}
|
||||
|
||||
private fun createGitAlias(binary: File, alias: File) {
|
||||
if (alias.exists() || Files.isSymbolicLink(alias.toPath())) {
|
||||
alias.delete()
|
||||
}
|
||||
if (context != null) {
|
||||
try {
|
||||
Os.symlink(binary.absolutePath, alias.absolutePath)
|
||||
alias.setExecutable(true, false)
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
// Fall through to the portable options below.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Files.createSymbolicLink(alias.toPath(), binary.toPath())
|
||||
} catch (_: Exception) {
|
||||
binary.copyTo(alias, overwrite = true)
|
||||
}
|
||||
alias.setExecutable(true, false)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProcessExecutionResult(
|
||||
val exitCode: Int,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
object GitSandboxEngine {
|
||||
private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?"
|
||||
|
||||
data class ShellToken(
|
||||
val value: String,
|
||||
val quoted: Boolean = false,
|
||||
)
|
||||
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? {
|
||||
val session = repo.interactiveAddSession ?: return null
|
||||
if (session.selectionAction != "patch-hunk") return null
|
||||
if (command.trim().lowercase() != "e") return null
|
||||
val target = session.target ?: return null
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted } ?: return null
|
||||
return GitEditorInvocation(
|
||||
command = command,
|
||||
kind = GitEditorCommandKind.PATCH_HUNK,
|
||||
title = "Edit Patch Hunk",
|
||||
initialContent = patchHunkLines(file)
|
||||
.dropLastWhile { it == PatchHunkPrompt }
|
||||
.joinToString("\n"),
|
||||
)
|
||||
}
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? =
|
||||
InteractiveAddEngine.parsePatchHunkEditorInvocation(repo, command)
|
||||
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to listOf("No patch hunk is active.")
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No patch hunk is active.")
|
||||
if (session.selectionAction != "patch-hunk") return repo to listOf("No patch hunk is active.")
|
||||
if (content.isBlank()) return repo to listOf("Edited hunk was empty; patch was not applied.", PatchHunkPrompt)
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
return repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf(
|
||||
"$PatchHunkPrompt e",
|
||||
"Applied edited hunk.",
|
||||
)
|
||||
}
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> =
|
||||
InteractiveAddEngine.applyPatchHunkEdit(repo, content)
|
||||
|
||||
fun commandReferenceLines(): List<String> = listOf(
|
||||
"Available sandbox commands:",
|
||||
@@ -56,837 +30,26 @@ object GitSandboxEngine {
|
||||
|
||||
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||
val shellParts = tokenizeShellCommand(command)
|
||||
val parts = shellParts.map { it.value }
|
||||
if (parts.isEmpty()) return repo to emptyList()
|
||||
if (shellParts.isEmpty()) return repo to emptyList()
|
||||
repo.interactiveAddSession?.let {
|
||||
return handleInteractiveAddInput(repo, command)
|
||||
return InteractiveAddEngine.handleInput(repo, command)
|
||||
}
|
||||
return when {
|
||||
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
|
||||
parts[0] == "touch" && parts.size >= 2 -> {
|
||||
val name = parts[1]
|
||||
if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists")
|
||||
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
|
||||
}
|
||||
(parts[0] == "mkdir" || parts[0] == "md") && parts.size >= 2 -> repo to emptyList()
|
||||
(parts[0] == "rm" || parts[0] == "del") && parts.size >= 2 -> {
|
||||
val target = parts[1]
|
||||
repo.copy(files = repo.files.mapNotNull { file ->
|
||||
when {
|
||||
file.name != target -> file
|
||||
file.tracked -> file.copy(deleted = true, staged = false)
|
||||
else -> null
|
||||
}
|
||||
}) to emptyList()
|
||||
}
|
||||
parts[0] == "echo" -> writeEcho(repo, shellParts)
|
||||
parts[0] == "ls" || parts[0] == "dir" -> repo to (
|
||||
if (repo.initialized) listOf(".git") else emptyList()
|
||||
) + repo.files.filterNot { it.deleted }.map { it.name }
|
||||
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
|
||||
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
|
||||
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
|
||||
!repo.initialized -> repo to listOf("fatal: not a git repository")
|
||||
parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines()
|
||||
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
|
||||
parts.size >= 2 && parts[1] == "stash" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.tracked && !file.staged) file.copy(content = "") else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, stashes = repo.stashes + "stash@{${repo.stashes.size}}") to listOf("Saved working directory and index state")
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "fetch" -> {
|
||||
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
repo.copy(
|
||||
fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/new_branch"),
|
||||
fetchHeadCount = 2,
|
||||
maintenanceActions = repo.maintenanceActions + "fetch",
|
||||
) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "pull" -> {
|
||||
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
val branch = parts.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: repo.headBranch
|
||||
repo.copy(
|
||||
fetchedBranches = repo.fetchedBranches + "$remote/$branch",
|
||||
fetchHeadCount = 1,
|
||||
branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)),
|
||||
maintenanceActions = repo.maintenanceActions + "pull",
|
||||
) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "push" -> pushRefs(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "submodule" && parts[2] == "add" -> {
|
||||
val url = parts.getOrNull(3)
|
||||
val path = parts.getOrNull(4)
|
||||
if (url == null || path == null) {
|
||||
repo to listOf("usage: git submodule add <repository> <path>")
|
||||
} else {
|
||||
repo.copy(submodules = repo.submodules + (path.trimEnd('/') to url)) to emptyList()
|
||||
}
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "repack" -> {
|
||||
repo.copy(maintenanceActions = repo.maintenanceActions + "repack") to emptyList()
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "tag" -> {
|
||||
val tag = parts[2]
|
||||
if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists")
|
||||
else repo.copy(tags = repo.tags + tag) to listOf(tag)
|
||||
}
|
||||
parts.size >= 4 && parts[1] == "config" -> {
|
||||
val key = parts[2]
|
||||
val value = parts.drop(3).joinToString(" ")
|
||||
repo.copy(config = repo.config + (key to value)) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> {
|
||||
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
|
||||
return interactiveAdd(repo, parts.drop(2))
|
||||
}
|
||||
if (parts.drop(2).any { it == "-p" || it == "--patch" }) {
|
||||
return interactiveAddPatch(repo, parts.drop(2))
|
||||
}
|
||||
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git add <path>")
|
||||
if (target != "." && repo.files.none { it.name == target && !it.deleted }) {
|
||||
repo to listOf("fatal: pathspec '$target' did not match any files")
|
||||
} else {
|
||||
val updated = repo.files.map { if ((target == "." || it.name == target) && !it.deleted) it.copy(staged = true) else it }
|
||||
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
|
||||
}
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
|
||||
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, expandPathspecTokens(repo, shellParts.drop(2)))
|
||||
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "log" -> {
|
||||
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
|
||||
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> {
|
||||
val name = parts.getOrNull(3)
|
||||
val url = parts.getOrNull(4)
|
||||
if (name == null || url == null) repo to listOf("usage: git remote add <name> <url>")
|
||||
else repo.copy(remotes = repo.remotes + (name to url)) to emptyList()
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "branch" -> {
|
||||
when (parts[2]) {
|
||||
"-d", "-D", "--delete" -> {
|
||||
val branch = parts.getOrNull(3)
|
||||
if (branch == null) repo to listOf("usage: git branch -d <branch>")
|
||||
else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch")
|
||||
}
|
||||
else -> {
|
||||
val branch = parts[2]
|
||||
val base = parts.getOrNull(3)
|
||||
val baseIndex = if (base == "HEAD~1" || base == "HEAD^") {
|
||||
(repo.branches[repo.headBranch] ?: repo.commits.size) - 1
|
||||
} else {
|
||||
repo.commits.size
|
||||
}.coerceAtLeast(0)
|
||||
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
|
||||
else repo.copy(branches = repo.branches + (branch to baseIndex)) to listOf("Created branch $branch")
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "checkout" -> {
|
||||
checkout(repo, parts.drop(2))
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "cherry-pick" -> {
|
||||
val files = if (repo.files.none { it.name == "README.md" }) {
|
||||
repo.files + GitFile("README.md", "Proper input instructions\n", tracked = true)
|
||||
} else {
|
||||
repo.files.map { if (it.name == "README.md") it.copy(tracked = true) else it }
|
||||
}
|
||||
repo.copy(files = files, commits = listOf(CommitNode("${repo.commits.size + 1}", "Filled in README.md with proper input")) + repo.commits) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "revert" -> {
|
||||
repo.copy(commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Revert \"Bad commit\"")) to emptyList()
|
||||
}
|
||||
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAdd(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val candidates = repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
}
|
||||
return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates)
|
||||
}
|
||||
|
||||
private fun interactiveAddPatch(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-p" || it == "--patch" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val patchFile = interactiveAddCandidates(repo, target).firstOrNull()
|
||||
?: return repo to listOf("No changes.")
|
||||
return startPatchHunkSession(repo, patchFile.name)
|
||||
}
|
||||
|
||||
private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
|
||||
return buildList {
|
||||
add(" staged unstaged path")
|
||||
candidates.forEachIndexed { index, file ->
|
||||
val staged = if (file.staged) "unchanged" else "+0/-0"
|
||||
val unstaged = when {
|
||||
file.tracked -> "+1/-0"
|
||||
else -> "+0/-0"
|
||||
}
|
||||
add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}")
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
add("No changes.")
|
||||
}
|
||||
add("*** Commands ***")
|
||||
add(" 1: status 2: update 3: revert 4: add untracked")
|
||||
add(" 5: patch 6: diff 7: quit 8: help")
|
||||
add("What now>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to emptyList()
|
||||
val answer = input.trim()
|
||||
if (session.selectionAction == "patch-hunk") {
|
||||
return handlePatchHunkInput(repo, session, answer)
|
||||
}
|
||||
return if (session.awaitingUpdateSelection) {
|
||||
applyInteractiveAddUpdateSelection(repo, session, answer)
|
||||
} else {
|
||||
when (answer.lowercase()) {
|
||||
"1", "s", "status" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
"2", "u", "update" -> interactiveAddSelectionPrompt(repo, session, answer, "Update>>", "update")
|
||||
"3", "r", "revert" -> interactiveAddSelectionPrompt(repo, session, answer, "Revert>>", "revert")
|
||||
"4", "a", "add untracked", "add-untracked" -> interactiveAddSelectionPrompt(repo, session, answer, "Add untracked>>", "add-untracked")
|
||||
"5", "p", "patch" -> interactiveAddSelectionPrompt(repo, session, answer, "Patch update>>", "patch")
|
||||
"6", "d", "diff" -> interactiveAddSelectionPrompt(repo, session, answer, "Diff>>", "diff")
|
||||
"7", "q", "quit" -> repo.copy(interactiveAddSession = null) to listOf("What now> $answer", "Bye.")
|
||||
"8", "h", "help" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
else -> repo to listOf("What now> $answer", "Huh ($answer)?") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionPrompt(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
prompt: String,
|
||||
action: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
return repo.copy(
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = true,
|
||||
selectionPrompt = prompt,
|
||||
selectionAction = action,
|
||||
),
|
||||
) to listOf("What now> $answer", prompt)
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddUpdateSelection(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val candidates = interactiveAddCandidates(repo, session.target)
|
||||
val selectedNames = selectedInteractiveAddNames(candidates, answer)
|
||||
val prompt = session.selectionPrompt
|
||||
if (selectedNames.isEmpty()) {
|
||||
return repo to listOf("$prompt $answer", "No files selected.", prompt)
|
||||
}
|
||||
|
||||
if (session.selectionAction == "patch" && selectedNames.size == 1) {
|
||||
return startPatchHunkSession(repo, selectedNames.single(), "$prompt $answer")
|
||||
}
|
||||
|
||||
val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction)
|
||||
val updatedRepo = repo.copy(
|
||||
files = updatedFiles,
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = "Update>>",
|
||||
selectionAction = "update",
|
||||
),
|
||||
)
|
||||
val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction)
|
||||
return updatedRepo to listOf(
|
||||
"$prompt $answer",
|
||||
summary,
|
||||
) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target))
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set<String>, action: String): List<GitFile> {
|
||||
return when (action) {
|
||||
"revert" -> repo.files.mapNotNull { file ->
|
||||
if (file.name !in selectedNames || file.deleted) {
|
||||
file
|
||||
} else if (file.tracked) {
|
||||
file.copy(content = "", staged = false, deleted = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
"diff" -> repo.files
|
||||
else -> repo.files.map { file ->
|
||||
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionSummary(
|
||||
repo: RepoState,
|
||||
updatedFiles: List<GitFile>,
|
||||
selectedNames: Set<String>,
|
||||
action: String,
|
||||
): String {
|
||||
return when (action) {
|
||||
"revert" -> "reverted ${selectedNames.size} path(s)"
|
||||
"diff" -> selectedNames.joinToString("\n") { "diff -- $it" }
|
||||
else -> {
|
||||
val stagedCount = updatedFiles.count { updatedFile ->
|
||||
val before = repo.files.firstOrNull { it.name == updatedFile.name }
|
||||
updatedFile.staged && before?.staged != true
|
||||
}
|
||||
"updated $stagedCount path(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair<RepoState, List<String>> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
?: return repo to listOfNotNull(prefixLine, "No changes.")
|
||||
val session = InteractiveAddSession(
|
||||
target = target,
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = PatchHunkPrompt,
|
||||
selectionAction = "patch-hunk",
|
||||
)
|
||||
val output = listOfNotNull(prefixLine) + patchHunkLines(file)
|
||||
return repo.copy(interactiveAddSession = session) to output
|
||||
}
|
||||
|
||||
private fun handlePatchHunkInput(repo: RepoState, session: InteractiveAddSession, answer: String): Pair<RepoState, List<String>> {
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
return when (answer.lowercase()) {
|
||||
"y", "a" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
}
|
||||
"n", "d" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
"q" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer", "Quit")
|
||||
"?" -> repo to listOf(
|
||||
"$PatchHunkPrompt $answer",
|
||||
"y - stage this hunk",
|
||||
"n - do not stage this hunk",
|
||||
"q - quit; do not stage this hunk or any remaining ones",
|
||||
"a - stage this hunk and all later hunks in the file",
|
||||
"d - do not stage this hunk or any later hunks in the file",
|
||||
"s - split the current hunk into smaller hunks",
|
||||
"e - manually edit the current hunk",
|
||||
"p - print the current hunk",
|
||||
"? - print help",
|
||||
PatchHunkPrompt,
|
||||
)
|
||||
"p" -> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
if (file == null) {
|
||||
repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
} else {
|
||||
repo to listOf("$PatchHunkPrompt $answer") + patchHunkLines(file)
|
||||
}
|
||||
}
|
||||
"s" -> repo to listOf("$PatchHunkPrompt $answer", "Sorry, cannot split this hunk", PatchHunkPrompt)
|
||||
"e" -> repo to listOf("$PatchHunkPrompt $answer", "Opening patch editor")
|
||||
else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchHunkLines(file: GitFile): List<String> {
|
||||
val lines = file.content.lines()
|
||||
val nonEmptyLines = lines.dropLastWhile { it.isEmpty() }
|
||||
val addedCount = nonEmptyLines.size.coerceAtLeast(1)
|
||||
return buildList {
|
||||
add("diff --git a/${file.name} b/${file.name}")
|
||||
add("index 0000000..0000001 100644")
|
||||
add("--- a/${file.name}")
|
||||
add("+++ b/${file.name}")
|
||||
add("@@ -1 +1,$addedCount @@")
|
||||
if (nonEmptyLines.isEmpty()) {
|
||||
add("+")
|
||||
} else {
|
||||
nonEmptyLines.forEach { line -> add("+$line") }
|
||||
}
|
||||
add(PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddCandidates(repo: RepoState, target: String?): List<GitFile> {
|
||||
return repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedInteractiveAddNames(candidates: List<GitFile>, answer: String): Set<String> {
|
||||
if (answer == "*") return candidates.map { it.name }.toSet()
|
||||
return answer.split(Regex("[,\\s]+"))
|
||||
.mapNotNull { token ->
|
||||
token.toIntOrNull()
|
||||
?.takeIf { it in 1..candidates.size }
|
||||
?.let { candidates[it - 1].name }
|
||||
}
|
||||
.toSet()
|
||||
return SandboxCommandEngine.execute(repo, shellParts)
|
||||
}
|
||||
|
||||
fun tokenizeCommand(command: String): List<String> {
|
||||
return tokenizeShellCommand(command).map { it.value }
|
||||
return SandboxShell.tokenizeCommand(command)
|
||||
}
|
||||
|
||||
fun tokenizeShellCommand(command: String): List<ShellToken> {
|
||||
val result = mutableListOf<ShellToken>()
|
||||
val current = StringBuilder()
|
||||
var quoteChar: Char? = null
|
||||
var escaping = false
|
||||
var currentQuoted = false
|
||||
var skipNext = false
|
||||
|
||||
fun emitCurrent(force: Boolean = false) {
|
||||
if (current.isNotEmpty() || force && currentQuoted) {
|
||||
result += ShellToken(value = current.toString(), quoted = currentQuoted)
|
||||
current.clear()
|
||||
currentQuoted = false
|
||||
}
|
||||
}
|
||||
|
||||
command.forEachIndexed { index, char ->
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
return@forEachIndexed
|
||||
}
|
||||
when {
|
||||
escaping -> {
|
||||
current.append(char)
|
||||
escaping = false
|
||||
}
|
||||
char == '\\' && quoteChar != '\'' -> {
|
||||
escaping = true
|
||||
}
|
||||
quoteChar != null -> {
|
||||
if (char == quoteChar) {
|
||||
quoteChar = null
|
||||
} else {
|
||||
current.append(char)
|
||||
}
|
||||
}
|
||||
char == '"' || char == '\'' -> {
|
||||
quoteChar = char
|
||||
currentQuoted = true
|
||||
}
|
||||
char.isWhitespace() -> {
|
||||
emitCurrent()
|
||||
}
|
||||
char == '>' -> {
|
||||
emitCurrent()
|
||||
if (command.getOrNull(index + 1) == '>') {
|
||||
result += ShellToken(">>")
|
||||
skipNext = true
|
||||
} else if (command.getOrNull(index - 1) != '>') {
|
||||
result += ShellToken(">")
|
||||
}
|
||||
}
|
||||
else -> current.append(char)
|
||||
}
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current.append('\\')
|
||||
}
|
||||
emitCurrent()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun writeEcho(repo: RepoState, shellParts: List<ShellToken>): Pair<RepoState, List<String>> {
|
||||
val parts = shellParts.map { it.value }
|
||||
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
|
||||
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
|
||||
return repo to listOf(parts.drop(1).joinToString(" "))
|
||||
}
|
||||
|
||||
val append = parts[redirectIndex] == ">>"
|
||||
val content = parts.subList(1, redirectIndex).joinToString(" ")
|
||||
val target = parts[redirectIndex + 1]
|
||||
val updatedFiles = repo.files.toMutableList()
|
||||
val index = updatedFiles.indexOfFirst { it.name == target }
|
||||
if (index == -1) {
|
||||
updatedFiles += GitFile(name = target, content = content)
|
||||
} else {
|
||||
val current = updatedFiles[index]
|
||||
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
|
||||
updatedFiles[index] = current.copy(content = nextContent, deleted = false)
|
||||
}
|
||||
|
||||
return repo.copy(files = updatedFiles) to emptyList()
|
||||
}
|
||||
|
||||
private fun parentDirectory(currentDir: String): String {
|
||||
if (currentDir == ".") return "."
|
||||
return currentDir.substringBeforeLast('/', missingDelimiterValue = ".").ifBlank { "." }
|
||||
return SandboxShell.tokenizeShellCommand(command)
|
||||
}
|
||||
|
||||
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
|
||||
return expandPathspecTokens(repo, arguments.map { ShellToken(it) })
|
||||
return SandboxShell.expandPathspecs(repo, arguments)
|
||||
}
|
||||
|
||||
fun expandPathspecTokens(repo: RepoState, arguments: List<ShellToken>): List<String> {
|
||||
return arguments.flatMap { token ->
|
||||
val argument = token.value
|
||||
if (token.quoted || !argument.hasGlob()) {
|
||||
listOf(argument)
|
||||
} else {
|
||||
val regex = argument.globToRegex()
|
||||
repo.files
|
||||
.filterNot { it.deleted }
|
||||
.map { it.name }
|
||||
.filter { regex.matches(it) }
|
||||
.sorted()
|
||||
.ifEmpty { listOf(argument) }
|
||||
return SandboxShell.expandPathspecTokens(repo, arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pushRefs(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val remote = arguments.firstOrNull { !it.startsWith("-") } ?: "origin"
|
||||
val explicitBranches = arguments
|
||||
.dropWhile { it.startsWith("-") }
|
||||
.drop(1)
|
||||
.filter { !it.startsWith("-") }
|
||||
val pushedBranches = when {
|
||||
arguments.any { it == "--all" } -> repo.branches.keys.map { "$remote/$it" }
|
||||
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
|
||||
else -> listOf("$remote/${repo.headBranch}")
|
||||
}
|
||||
val pushedTags = if (arguments.any { it == "--tags" || it == "--follow-tags" }) {
|
||||
repo.tags.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
return repo.copy(
|
||||
pushedBranches = repo.pushedBranches + pushedBranches,
|
||||
pushedTags = repo.pushedTags + pushedTags,
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val cached = "--cached" in arguments
|
||||
val target = arguments.lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git rm [--cached] <path>")
|
||||
val updated = repo.files.mapNotNull { file ->
|
||||
if (file.name != target) {
|
||||
file
|
||||
} else if (cached) {
|
||||
file.copy(staged = false, tracked = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
|
||||
private fun moveGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv <source> <destination>")
|
||||
val sources = arguments.dropLast(1)
|
||||
if (sources.isEmpty()) return repo to listOf("usage: git mv <source> <destination>")
|
||||
val destinationIsDirectory = sources.size > 1 || destination.endsWith("/")
|
||||
val updated = repo.files.map { file ->
|
||||
if (file.name in sources) {
|
||||
val target = if (destinationIsDirectory) {
|
||||
destination.trimEnd('/') + "/" + file.name.substringAfterLast('/')
|
||||
} else {
|
||||
destination
|
||||
}
|
||||
file.copy(name = target, staged = true)
|
||||
} else {
|
||||
file
|
||||
}
|
||||
}
|
||||
return repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
|
||||
private fun checkout(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return when {
|
||||
arguments.firstOrNull() == "-b" -> {
|
||||
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b <branch>")
|
||||
repo.copy(
|
||||
headBranch = branch,
|
||||
branches = repo.branches + (branch to repo.commits.size),
|
||||
) to listOf("Switched to a new branch '$branch'")
|
||||
}
|
||||
arguments.firstOrNull() == "-B" -> {
|
||||
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -B <branch>")
|
||||
repo.copy(
|
||||
headBranch = branch,
|
||||
branches = repo.branches + (branch to repo.commits.size),
|
||||
) to listOf("Switched to branch '$branch'")
|
||||
}
|
||||
"--" in arguments -> {
|
||||
val target = arguments.last()
|
||||
val updated = repo.files.map { file ->
|
||||
when (file.name) {
|
||||
target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!"))
|
||||
"file3" -> file
|
||||
else -> file
|
||||
}
|
||||
}.let { files ->
|
||||
if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files
|
||||
}
|
||||
repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
arguments.any { it == "file3" } -> {
|
||||
repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList()
|
||||
}
|
||||
else -> {
|
||||
val branch = arguments.first()
|
||||
val normalizedTag = branch.removePrefix("tags/").removePrefix("refs/tags/")
|
||||
when {
|
||||
repo.branches.containsKey(branch) -> repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
|
||||
normalizedTag in repo.tags -> repo.copy(headBranch = "tags/$normalizedTag") to listOf("HEAD is now at $normalizedTag")
|
||||
else -> repo to listOf("error: pathspec '$branch' did not match any branch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return if ("--soft" in arguments) {
|
||||
repo.copy(
|
||||
commits = repo.commits.dropLast(1),
|
||||
files = repo.files.map { if (it.tracked) it.copy(staged = true) else it },
|
||||
) to emptyList()
|
||||
} else {
|
||||
val target = arguments.last()
|
||||
repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun merge(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val branch = arguments.lastOrNull().orEmpty()
|
||||
val squash = "--squash" in arguments
|
||||
val files = when {
|
||||
branch == "feature" && repo.files.none { it.name == "file2" } -> repo.files + GitFile("file2", tracked = true)
|
||||
branch == "long-feature-branch" && repo.files.none { it.name == "file3" } -> repo.files + GitFile("file3", staged = true)
|
||||
branch == "mybranch" -> repo.files.map {
|
||||
if (it.name == "poem.txt") it.copy(content = "Humpty Dumpty sat on a wall\nHumpty Dumpty had a great fall", staged = true)
|
||||
else it
|
||||
}
|
||||
else -> repo.files
|
||||
}
|
||||
return repo.copy(
|
||||
files = files,
|
||||
maintenanceActions = repo.maintenanceActions + if (squash) "merge-squash" else "merge",
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val interactive = "-i" in arguments || "--interactive" in arguments
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
|
||||
if (interactive && target == null) {
|
||||
return repo to listOf("fatal: No rebase upstream specified")
|
||||
}
|
||||
val originalCommits = repo.commits
|
||||
val commits = repo.commits
|
||||
val updatedBranches = when {
|
||||
"--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1)
|
||||
arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0))
|
||||
else -> repo.branches
|
||||
}
|
||||
val maintenanceActions = if ("--onto" in arguments) {
|
||||
repo.maintenanceActions + "rebase-onto"
|
||||
} else {
|
||||
repo.maintenanceActions
|
||||
}
|
||||
val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, arguments, repo.headBranch) else emptyList()
|
||||
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to output
|
||||
}
|
||||
|
||||
private fun interactiveRebaseConsoleLines(originalCommits: List<CommitNode>, arguments: List<String>, branch: String): List<String> {
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty()
|
||||
return buildList {
|
||||
originalCommits.forEach { commit ->
|
||||
add("pick ${commit.id} ${commit.message}")
|
||||
}
|
||||
add("")
|
||||
add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD")
|
||||
add("# Commands:")
|
||||
add("# p, pick <commit> = use commit")
|
||||
add("# r, reword <commit> = use commit, but edit the commit message")
|
||||
add("# s, squash <commit> = use commit, but meld into previous commit")
|
||||
add("# d, drop <commit> = remove commit")
|
||||
add("Successfully rebased and updated refs/heads/$branch.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val parsed = parseCommitArguments(arguments)
|
||||
if (parsed.error != null) {
|
||||
return repo to listOf(parsed.error)
|
||||
}
|
||||
|
||||
val repoForCommit = if (parsed.stageAllTracked) {
|
||||
repo.copy(files = repo.files.map { file -> if (file.tracked) file.copy(staged = true) else file })
|
||||
} else {
|
||||
repo
|
||||
}
|
||||
|
||||
val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty()
|
||||
val staged = repoForCommit.files.filter { it.staged }
|
||||
if (staged.isEmpty()) return repo to listOf("nothing to commit")
|
||||
|
||||
val cleanedFiles = repoForCommit.files.map { file ->
|
||||
if (file.staged) file.copy(staged = false, tracked = true) else file
|
||||
}
|
||||
val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) {
|
||||
repo.commits.dropLast(1) + repo.commits.last().copy(message = message)
|
||||
} else {
|
||||
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
|
||||
repo.commits + CommitNode(nextId, message)
|
||||
}
|
||||
|
||||
return repoForCommit.copy(
|
||||
files = cleanedFiles,
|
||||
commits = nextCommits,
|
||||
branches = repo.branches + (repo.headBranch to nextCommits.size),
|
||||
) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message")
|
||||
}
|
||||
|
||||
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
|
||||
var message: String? = null
|
||||
var stageAllTracked = false
|
||||
var amend = false
|
||||
var index = 0
|
||||
|
||||
while (index < arguments.size) {
|
||||
val argument = arguments[index]
|
||||
when {
|
||||
argument == "-a" || argument == "--all" -> {
|
||||
stageAllTracked = true
|
||||
}
|
||||
argument == "--amend" -> {
|
||||
amend = true
|
||||
}
|
||||
argument == "--no-edit" -> {
|
||||
// Keep the previous commit message when amending.
|
||||
}
|
||||
argument == "--date" -> {
|
||||
if (arguments.getOrNull(index + 1) == null) {
|
||||
return ParsedCommitArguments(error = "error: option '--date' requires a value")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
argument.startsWith("--date=") -> {
|
||||
// The sandbox records commit structure, not timestamps.
|
||||
}
|
||||
argument == "-m" || argument == "--message" -> {
|
||||
val next = arguments.getOrNull(index + 1)
|
||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
message = next
|
||||
index += 1
|
||||
}
|
||||
argument.startsWith("--message=") -> {
|
||||
message = argument.substringAfter('=')
|
||||
}
|
||||
argument.startsWith("-") && !argument.startsWith("--") && argument.length > 2 -> {
|
||||
val shortFlags = argument.drop(1)
|
||||
var shortIndex = 0
|
||||
while (shortIndex < shortFlags.length) {
|
||||
when (val flag = shortFlags[shortIndex]) {
|
||||
'a' -> stageAllTracked = true
|
||||
'm' -> {
|
||||
val attachedValue = shortFlags.substring(shortIndex + 1)
|
||||
if (attachedValue.isNotEmpty()) {
|
||||
message = attachedValue
|
||||
} else {
|
||||
val next = arguments.getOrNull(index + 1)
|
||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
message = next
|
||||
index += 1
|
||||
}
|
||||
break
|
||||
}
|
||||
else -> return ParsedCommitArguments(error = "error: unsupported commit option '-$flag'")
|
||||
}
|
||||
shortIndex += 1
|
||||
}
|
||||
}
|
||||
else -> return ParsedCommitArguments(error = "error: unsupported commit argument '$argument'")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (!amend && message.isNullOrBlank()) {
|
||||
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
}
|
||||
|
||||
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend)
|
||||
}
|
||||
|
||||
private fun statusLines(repo: RepoState): List<String> {
|
||||
val staged = repo.files.filter { it.staged }.map {
|
||||
when {
|
||||
it.deleted -> "deleted: ${it.name}"
|
||||
it.tracked -> "modified: ${it.name}"
|
||||
else -> "new file: ${it.name}"
|
||||
}
|
||||
}
|
||||
val deleted = repo.files.filter { it.deleted && it.tracked && !it.staged }.map { "deleted: ${it.name}" }
|
||||
val unstaged = repo.files.filterNot { it.staged || it.tracked || it.deleted }.map { "untracked: ${it.name}" }
|
||||
return buildList {
|
||||
add("On branch ${repo.headBranch}")
|
||||
if (staged.isEmpty() && deleted.isEmpty() && unstaged.isEmpty()) {
|
||||
add("nothing to commit, working tree clean")
|
||||
} else {
|
||||
if (staged.isNotEmpty()) {
|
||||
add("Changes to be committed:")
|
||||
addAll(staged)
|
||||
}
|
||||
if (deleted.isNotEmpty()) {
|
||||
add("Changes not staged for commit:")
|
||||
addAll(deleted)
|
||||
}
|
||||
if (unstaged.isNotEmpty()) {
|
||||
add("Untracked files:")
|
||||
addAll(unstaged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hasGlob(): Boolean = any { it == '*' || it == '?' }
|
||||
|
||||
private fun String.globToRegex(): Regex {
|
||||
val pattern = buildString {
|
||||
append('^')
|
||||
this@globToRegex.forEach { char ->
|
||||
when (char) {
|
||||
'*' -> append("[^/]*")
|
||||
'?' -> append("[^/]")
|
||||
'.', '(', ')', '+', '|', '^', '$', '@', '%', '{', '}', '[', ']', '\\' -> {
|
||||
append('\\')
|
||||
append(char)
|
||||
}
|
||||
else -> append(char)
|
||||
}
|
||||
}
|
||||
append('$')
|
||||
}
|
||||
return Regex(pattern)
|
||||
}
|
||||
|
||||
private data class ParsedCommitArguments(
|
||||
val message: String? = null,
|
||||
val stageAllTracked: Boolean = false,
|
||||
val amend: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal object InteractiveAddEngine {
|
||||
private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?"
|
||||
|
||||
fun start(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val candidates = interactiveAddCandidates(repo, target)
|
||||
return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates)
|
||||
}
|
||||
|
||||
fun startPatch(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-p" || it == "--patch" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val patchFile = interactiveAddCandidates(repo, target).firstOrNull()
|
||||
?: return repo to listOf("No changes.")
|
||||
return startPatchHunkSession(repo, patchFile.name)
|
||||
}
|
||||
|
||||
fun handleInput(repo: RepoState, input: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to emptyList()
|
||||
val answer = input.trim()
|
||||
if (session.selectionAction == "patch-hunk") {
|
||||
return handlePatchHunkInput(repo, session, answer)
|
||||
}
|
||||
return if (session.awaitingUpdateSelection) {
|
||||
applyInteractiveAddUpdateSelection(repo, session, answer)
|
||||
} else {
|
||||
when (answer.lowercase()) {
|
||||
"1", "s", "status" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
"2", "u", "update" -> interactiveAddSelectionPrompt(repo, session, answer, "Update>>", "update")
|
||||
"3", "r", "revert" -> interactiveAddSelectionPrompt(repo, session, answer, "Revert>>", "revert")
|
||||
"4", "a", "add untracked", "add-untracked" -> interactiveAddSelectionPrompt(repo, session, answer, "Add untracked>>", "add-untracked")
|
||||
"5", "p", "patch" -> interactiveAddSelectionPrompt(repo, session, answer, "Patch update>>", "patch")
|
||||
"6", "d", "diff" -> interactiveAddSelectionPrompt(repo, session, answer, "Diff>>", "diff")
|
||||
"7", "q", "quit" -> repo.copy(interactiveAddSession = null) to listOf("What now> $answer", "Bye.")
|
||||
"8", "h", "help" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
else -> repo to listOf("What now> $answer", "Huh ($answer)?") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? {
|
||||
val session = repo.interactiveAddSession ?: return null
|
||||
if (session.selectionAction != "patch-hunk") return null
|
||||
if (command.trim().lowercase() != "e") return null
|
||||
val target = session.target ?: return null
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted } ?: return null
|
||||
return GitEditorInvocation(
|
||||
command = command,
|
||||
kind = GitEditorCommandKind.PATCH_HUNK,
|
||||
title = "Edit Patch Hunk",
|
||||
initialContent = patchHunkLines(file)
|
||||
.dropLastWhile { it == PatchHunkPrompt }
|
||||
.joinToString("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to listOf("No patch hunk is active.")
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No patch hunk is active.")
|
||||
if (session.selectionAction != "patch-hunk") return repo to listOf("No patch hunk is active.")
|
||||
if (content.isBlank()) return repo to listOf("Edited hunk was empty; patch was not applied.", PatchHunkPrompt)
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
return repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf(
|
||||
"$PatchHunkPrompt e",
|
||||
"Applied edited hunk.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionPrompt(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
prompt: String,
|
||||
action: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
return repo.copy(
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = true,
|
||||
selectionPrompt = prompt,
|
||||
selectionAction = action,
|
||||
),
|
||||
) to listOf("What now> $answer", prompt)
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddUpdateSelection(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val candidates = interactiveAddCandidates(repo, session.target)
|
||||
val selectedNames = selectedInteractiveAddNames(candidates, answer)
|
||||
val prompt = session.selectionPrompt
|
||||
if (selectedNames.isEmpty()) {
|
||||
return repo to listOf("$prompt $answer", "No files selected.", prompt)
|
||||
}
|
||||
|
||||
if (session.selectionAction == "patch" && selectedNames.size == 1) {
|
||||
return startPatchHunkSession(repo, selectedNames.single(), "$prompt $answer")
|
||||
}
|
||||
|
||||
val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction)
|
||||
val updatedRepo = repo.copy(
|
||||
files = updatedFiles,
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = "Update>>",
|
||||
selectionAction = "update",
|
||||
),
|
||||
)
|
||||
val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction)
|
||||
return updatedRepo to listOf(
|
||||
"$prompt $answer",
|
||||
summary,
|
||||
) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target))
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set<String>, action: String): List<GitFile> {
|
||||
return when (action) {
|
||||
"revert" -> repo.files.mapNotNull { file ->
|
||||
if (file.name !in selectedNames || file.deleted) {
|
||||
file
|
||||
} else if (file.tracked) {
|
||||
file.copy(content = "", staged = false, deleted = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
"diff" -> repo.files
|
||||
else -> repo.files.map { file ->
|
||||
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionSummary(
|
||||
repo: RepoState,
|
||||
updatedFiles: List<GitFile>,
|
||||
selectedNames: Set<String>,
|
||||
action: String,
|
||||
): String {
|
||||
return when (action) {
|
||||
"revert" -> "reverted ${selectedNames.size} path(s)"
|
||||
"diff" -> selectedNames.joinToString("\n") { "diff -- $it" }
|
||||
else -> {
|
||||
val stagedCount = updatedFiles.count { updatedFile ->
|
||||
val before = repo.files.firstOrNull { it.name == updatedFile.name }
|
||||
updatedFile.staged && before?.staged != true
|
||||
}
|
||||
"updated $stagedCount path(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair<RepoState, List<String>> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
?: return repo to listOfNotNull(prefixLine, "No changes.")
|
||||
val session = InteractiveAddSession(
|
||||
target = target,
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = PatchHunkPrompt,
|
||||
selectionAction = "patch-hunk",
|
||||
)
|
||||
val output = listOfNotNull(prefixLine) + patchHunkLines(file)
|
||||
return repo.copy(interactiveAddSession = session) to output
|
||||
}
|
||||
|
||||
private fun handlePatchHunkInput(repo: RepoState, session: InteractiveAddSession, answer: String): Pair<RepoState, List<String>> {
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
return when (answer.lowercase()) {
|
||||
"y", "a" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
}
|
||||
"n", "d" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
"q" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer", "Quit")
|
||||
"?" -> repo to listOf(
|
||||
"$PatchHunkPrompt $answer",
|
||||
"y - stage this hunk",
|
||||
"n - do not stage this hunk",
|
||||
"q - quit; do not stage this hunk or any remaining ones",
|
||||
"a - stage this hunk and all later hunks in the file",
|
||||
"d - do not stage this hunk or any later hunks in the file",
|
||||
"s - split the current hunk into smaller hunks",
|
||||
"e - manually edit the current hunk",
|
||||
"p - print the current hunk",
|
||||
"? - print help",
|
||||
PatchHunkPrompt,
|
||||
)
|
||||
"p" -> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
if (file == null) {
|
||||
repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
} else {
|
||||
repo to listOf("$PatchHunkPrompt $answer") + patchHunkLines(file)
|
||||
}
|
||||
}
|
||||
"s" -> repo to listOf("$PatchHunkPrompt $answer", "Sorry, cannot split this hunk", PatchHunkPrompt)
|
||||
"e" -> repo to listOf("$PatchHunkPrompt $answer", "Opening patch editor")
|
||||
else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchHunkLines(file: GitFile): List<String> {
|
||||
val lines = file.content.lines()
|
||||
val nonEmptyLines = lines.dropLastWhile { it.isEmpty() }
|
||||
val addedCount = nonEmptyLines.size.coerceAtLeast(1)
|
||||
return buildList {
|
||||
add("diff --git a/${file.name} b/${file.name}")
|
||||
add("index 0000000..0000001 100644")
|
||||
add("--- a/${file.name}")
|
||||
add("+++ b/${file.name}")
|
||||
add("@@ -1 +1,$addedCount @@")
|
||||
if (nonEmptyLines.isEmpty()) {
|
||||
add("+")
|
||||
} else {
|
||||
nonEmptyLines.forEach { line -> add("+$line") }
|
||||
}
|
||||
add(PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
|
||||
return buildList {
|
||||
add(" staged unstaged path")
|
||||
candidates.forEachIndexed { index, file ->
|
||||
val staged = if (file.staged) "unchanged" else "+0/-0"
|
||||
val unstaged = when {
|
||||
file.tracked -> "+1/-0"
|
||||
else -> "+0/-0"
|
||||
}
|
||||
add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}")
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
add("No changes.")
|
||||
}
|
||||
add("*** Commands ***")
|
||||
add(" 1: status 2: update 3: revert 4: add untracked")
|
||||
add(" 5: patch 6: diff 7: quit 8: help")
|
||||
add("What now>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddCandidates(repo: RepoState, target: String?): List<GitFile> {
|
||||
return repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedInteractiveAddNames(candidates: List<GitFile>, answer: String): Set<String> {
|
||||
if (answer == "*") return candidates.map { it.name }.toSet()
|
||||
return answer.split(Regex("[,\\s]+"))
|
||||
.mapNotNull { token ->
|
||||
token.toIntOrNull()
|
||||
?.takeIf { it in 1..candidates.size }
|
||||
?.let { candidates[it - 1].name }
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal object SandboxCommandEngine {
|
||||
fun execute(repo: RepoState, shellParts: List<GitSandboxEngine.ShellToken>): Pair<RepoState, List<String>> {
|
||||
val parts = shellParts.map { it.value }
|
||||
return when {
|
||||
parts[0] == "help" || parts[0] == "?" -> repo to GitSandboxEngine.commandReferenceLines()
|
||||
parts[0] == "touch" && parts.size >= 2 -> {
|
||||
val name = parts[1]
|
||||
if (repo.files.any { it.name == name && !it.deleted }) repo to listOf("$name already exists")
|
||||
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
|
||||
}
|
||||
(parts[0] == "mkdir" || parts[0] == "md") && parts.size >= 2 -> repo to emptyList()
|
||||
(parts[0] == "rm" || parts[0] == "del") && parts.size >= 2 -> {
|
||||
val target = parts[1]
|
||||
repo.copy(files = repo.files.mapNotNull { file ->
|
||||
when {
|
||||
file.name != target -> file
|
||||
file.tracked -> file.copy(deleted = true, staged = false)
|
||||
else -> null
|
||||
}
|
||||
}) to emptyList()
|
||||
}
|
||||
parts[0] == "echo" -> writeEcho(repo, shellParts)
|
||||
parts[0] == "ls" || parts[0] == "dir" -> repo to (
|
||||
if (repo.initialized) listOf(".git") else emptyList()
|
||||
) + repo.files.filterNot { it.deleted }.map { it.name }
|
||||
parts[0] == "cd.." -> repo.copy(currentDir = parentDirectory(repo.currentDir)) to emptyList()
|
||||
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
|
||||
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
|
||||
!repo.initialized -> repo to listOf("fatal: not a git repository")
|
||||
parts.size >= 2 && parts[1] == "help" -> repo to GitSandboxEngine.commandReferenceLines()
|
||||
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
|
||||
parts.size >= 2 && parts[1] == "stash" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.tracked && !file.staged) file.copy(content = "") else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, stashes = repo.stashes + "stash@{${repo.stashes.size}}") to listOf("Saved working directory and index state")
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "fetch" -> {
|
||||
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
repo.copy(
|
||||
fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/new_branch"),
|
||||
fetchHeadCount = 2,
|
||||
maintenanceActions = repo.maintenanceActions + "fetch",
|
||||
) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "pull" -> {
|
||||
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
val branch = parts.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: repo.headBranch
|
||||
repo.copy(
|
||||
fetchedBranches = repo.fetchedBranches + "$remote/$branch",
|
||||
fetchHeadCount = 1,
|
||||
branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)),
|
||||
maintenanceActions = repo.maintenanceActions + "pull",
|
||||
) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "push" -> pushRefs(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "submodule" && parts[2] == "add" -> {
|
||||
val url = parts.getOrNull(3)
|
||||
val path = parts.getOrNull(4)
|
||||
if (url == null || path == null) {
|
||||
repo to listOf("usage: git submodule add <repository> <path>")
|
||||
} else {
|
||||
repo.copy(submodules = repo.submodules + (path.trimEnd('/') to url)) to emptyList()
|
||||
}
|
||||
}
|
||||
parts.size >= 2 && parts[1] == "repack" -> {
|
||||
repo.copy(maintenanceActions = repo.maintenanceActions + "repack") to emptyList()
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "tag" -> {
|
||||
val tag = parts[2]
|
||||
if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists")
|
||||
else repo.copy(tags = repo.tags + tag) to listOf(tag)
|
||||
}
|
||||
parts.size >= 4 && parts[1] == "config" -> {
|
||||
val key = parts[2]
|
||||
val value = parts.drop(3).joinToString(" ")
|
||||
repo.copy(config = repo.config + (key to value)) to emptyList()
|
||||
}
|
||||
parts.size >= 2 && (parts[1] == "stage" || parts[1] == "add") -> stagePaths(repo, parts)
|
||||
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
|
||||
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, SandboxShell.expandPathspecTokens(repo, shellParts.drop(2)))
|
||||
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "log" -> {
|
||||
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
|
||||
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> {
|
||||
val name = parts.getOrNull(3)
|
||||
val url = parts.getOrNull(4)
|
||||
if (name == null || url == null) repo to listOf("usage: git remote add <name> <url>")
|
||||
else repo.copy(remotes = repo.remotes + (name to url)) to emptyList()
|
||||
}
|
||||
parts.size >= 3 && parts[1] == "branch" -> branch(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "checkout" -> checkout(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2))
|
||||
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
|
||||
parts.size >= 2 && parts[1] == "cherry-pick" -> cherryPick(repo)
|
||||
parts.size >= 2 && parts[1] == "revert" -> {
|
||||
repo.copy(commits = repo.commits + CommitNode("${repo.commits.size + 1}", "Revert \"Bad commit\"")) to emptyList()
|
||||
}
|
||||
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeEcho(repo: RepoState, shellParts: List<GitSandboxEngine.ShellToken>): Pair<RepoState, List<String>> {
|
||||
val parts = shellParts.map { it.value }
|
||||
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
|
||||
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
|
||||
return repo to listOf(parts.drop(1).joinToString(" "))
|
||||
}
|
||||
|
||||
val append = parts[redirectIndex] == ">>"
|
||||
val content = parts.subList(1, redirectIndex).joinToString(" ")
|
||||
val target = parts[redirectIndex + 1]
|
||||
val updatedFiles = repo.files.toMutableList()
|
||||
val index = updatedFiles.indexOfFirst { it.name == target }
|
||||
if (index == -1) {
|
||||
updatedFiles += GitFile(name = target, content = content)
|
||||
} else {
|
||||
val current = updatedFiles[index]
|
||||
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
|
||||
updatedFiles[index] = current.copy(content = nextContent, deleted = false)
|
||||
}
|
||||
|
||||
return repo.copy(files = updatedFiles) to emptyList()
|
||||
}
|
||||
|
||||
private fun parentDirectory(currentDir: String): String {
|
||||
if (currentDir == ".") return "."
|
||||
return currentDir.substringBeforeLast('/', missingDelimiterValue = ".").ifBlank { "." }
|
||||
}
|
||||
|
||||
private fun pushRefs(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val remote = arguments.firstOrNull { !it.startsWith("-") } ?: "origin"
|
||||
val explicitBranches = arguments
|
||||
.dropWhile { it.startsWith("-") }
|
||||
.drop(1)
|
||||
.filter { !it.startsWith("-") }
|
||||
val pushedBranches = when {
|
||||
arguments.any { it == "--all" } -> repo.branches.keys.map { "$remote/$it" }
|
||||
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
|
||||
else -> listOf("$remote/${repo.headBranch}")
|
||||
}
|
||||
val pushedTags = if (arguments.any { it == "--tags" || it == "--follow-tags" }) {
|
||||
repo.tags.toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
return repo.copy(
|
||||
pushedBranches = repo.pushedBranches + pushedBranches,
|
||||
pushedTags = repo.pushedTags + pushedTags,
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun stagePaths(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
|
||||
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
|
||||
return InteractiveAddEngine.start(repo, parts.drop(2))
|
||||
}
|
||||
if (parts.drop(2).any { it == "-p" || it == "--patch" }) {
|
||||
return InteractiveAddEngine.startPatch(repo, parts.drop(2))
|
||||
}
|
||||
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git add <path>")
|
||||
return if (target != "." && repo.files.none { it.name == target && !it.deleted }) {
|
||||
repo to listOf("fatal: pathspec '$target' did not match any files")
|
||||
} else {
|
||||
val updated = repo.files.map { if ((target == "." || it.name == target) && !it.deleted) it.copy(staged = true) else it }
|
||||
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val cached = "--cached" in arguments
|
||||
val target = arguments.lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git rm [--cached] <path>")
|
||||
val updated = repo.files.mapNotNull { file ->
|
||||
if (file.name != target) {
|
||||
file
|
||||
} else if (cached) {
|
||||
file.copy(staged = false, tracked = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
|
||||
private fun moveGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv <source> <destination>")
|
||||
val sources = arguments.dropLast(1)
|
||||
if (sources.isEmpty()) return repo to listOf("usage: git mv <source> <destination>")
|
||||
val destinationIsDirectory = sources.size > 1 || destination.endsWith("/")
|
||||
val updated = repo.files.map { file ->
|
||||
if (file.name in sources) {
|
||||
val target = if (destinationIsDirectory) {
|
||||
destination.trimEnd('/') + "/" + file.name.substringAfterLast('/')
|
||||
} else {
|
||||
destination
|
||||
}
|
||||
file.copy(name = target, staged = true)
|
||||
} else {
|
||||
file
|
||||
}
|
||||
}
|
||||
return repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
|
||||
private fun branch(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return when (arguments.firstOrNull()) {
|
||||
"-d", "-D", "--delete" -> {
|
||||
val branch = arguments.getOrNull(1)
|
||||
if (branch == null) repo to listOf("usage: git branch -d <branch>")
|
||||
else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch")
|
||||
}
|
||||
else -> {
|
||||
val branch = arguments.first()
|
||||
val base = arguments.getOrNull(1)
|
||||
val baseIndex = if (base == "HEAD~1" || base == "HEAD^") {
|
||||
(repo.branches[repo.headBranch] ?: repo.commits.size) - 1
|
||||
} else {
|
||||
repo.commits.size
|
||||
}.coerceAtLeast(0)
|
||||
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
|
||||
else repo.copy(branches = repo.branches + (branch to baseIndex)) to listOf("Created branch $branch")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkout(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return when {
|
||||
arguments.firstOrNull() == "-b" -> {
|
||||
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b <branch>")
|
||||
repo.copy(
|
||||
headBranch = branch,
|
||||
branches = repo.branches + (branch to repo.commits.size),
|
||||
) to listOf("Switched to a new branch '$branch'")
|
||||
}
|
||||
arguments.firstOrNull() == "-B" -> {
|
||||
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -B <branch>")
|
||||
repo.copy(
|
||||
headBranch = branch,
|
||||
branches = repo.branches + (branch to repo.commits.size),
|
||||
) to listOf("Switched to branch '$branch'")
|
||||
}
|
||||
"--" in arguments -> {
|
||||
val target = arguments.last()
|
||||
val updated = repo.files.map { file ->
|
||||
when (file.name) {
|
||||
target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!"))
|
||||
"file3" -> file
|
||||
else -> file
|
||||
}
|
||||
}.let { files ->
|
||||
if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files
|
||||
}
|
||||
repo.copy(files = updated) to emptyList()
|
||||
}
|
||||
arguments.any { it == "file3" } -> {
|
||||
repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList()
|
||||
}
|
||||
else -> {
|
||||
val branch = arguments.first()
|
||||
val normalizedTag = branch.removePrefix("tags/").removePrefix("refs/tags/")
|
||||
when {
|
||||
repo.branches.containsKey(branch) -> repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
|
||||
normalizedTag in repo.tags -> repo.copy(headBranch = "tags/$normalizedTag") to listOf("HEAD is now at $normalizedTag")
|
||||
else -> repo to listOf("error: pathspec '$branch' did not match any branch")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reset(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
return if ("--soft" in arguments) {
|
||||
repo.copy(
|
||||
commits = repo.commits.dropLast(1),
|
||||
files = repo.files.map { if (it.tracked) it.copy(staged = true) else it },
|
||||
) to emptyList()
|
||||
} else {
|
||||
val target = arguments.last()
|
||||
repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun merge(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val branch = arguments.lastOrNull().orEmpty()
|
||||
val squash = "--squash" in arguments
|
||||
val files = when {
|
||||
branch == "feature" && repo.files.none { it.name == "file2" } -> repo.files + GitFile("file2", tracked = true)
|
||||
branch == "long-feature-branch" && repo.files.none { it.name == "file3" } -> repo.files + GitFile("file3", staged = true)
|
||||
branch == "mybranch" -> repo.files.map {
|
||||
if (it.name == "poem.txt") it.copy(content = "Humpty Dumpty sat on a wall\nHumpty Dumpty had a great fall", staged = true)
|
||||
else it
|
||||
}
|
||||
else -> repo.files
|
||||
}
|
||||
return repo.copy(
|
||||
files = files,
|
||||
maintenanceActions = repo.maintenanceActions + if (squash) "merge-squash" else "merge",
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val interactive = "-i" in arguments || "--interactive" in arguments
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
|
||||
if (interactive && target == null) {
|
||||
return repo to listOf("fatal: No rebase upstream specified")
|
||||
}
|
||||
val originalCommits = repo.commits
|
||||
val commits = repo.commits
|
||||
val updatedBranches = when {
|
||||
"--onto" in arguments -> repo.branches + (repo.headBranch to (repo.branches["master"] ?: 0) + 1)
|
||||
arguments.isNotEmpty() -> repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, repo.branches[arguments.last()] ?: 0))
|
||||
else -> repo.branches
|
||||
}
|
||||
val maintenanceActions = if ("--onto" in arguments) {
|
||||
repo.maintenanceActions + "rebase-onto"
|
||||
} else {
|
||||
repo.maintenanceActions
|
||||
}
|
||||
val output = if (interactive) interactiveRebaseConsoleLines(originalCommits, arguments, repo.headBranch) else emptyList()
|
||||
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) to output
|
||||
}
|
||||
|
||||
private fun interactiveRebaseConsoleLines(originalCommits: List<CommitNode>, arguments: List<String>, branch: String): List<String> {
|
||||
val target = arguments.lastOrNull { it != "-i" && it != "--interactive" }.orEmpty()
|
||||
return buildList {
|
||||
originalCommits.forEach { commit ->
|
||||
add("pick ${commit.id} ${commit.message}")
|
||||
}
|
||||
add("")
|
||||
add("# Rebase ${target.ifBlank { "HEAD" }} in progress; onto HEAD")
|
||||
add("# Commands:")
|
||||
add("# p, pick <commit> = use commit")
|
||||
add("# r, reword <commit> = use commit, but edit the commit message")
|
||||
add("# s, squash <commit> = use commit, but meld into previous commit")
|
||||
add("# d, drop <commit> = remove commit")
|
||||
add("Successfully rebased and updated refs/heads/$branch.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cherryPick(repo: RepoState): Pair<RepoState, List<String>> {
|
||||
val files = if (repo.files.none { it.name == "README.md" }) {
|
||||
repo.files + GitFile("README.md", "Proper input instructions\n", tracked = true)
|
||||
} else {
|
||||
repo.files.map { if (it.name == "README.md") it.copy(tracked = true) else it }
|
||||
}
|
||||
return repo.copy(files = files, commits = listOf(CommitNode("${repo.commits.size + 1}", "Filled in README.md with proper input")) + repo.commits) to emptyList()
|
||||
}
|
||||
|
||||
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val parsed = parseCommitArguments(arguments)
|
||||
if (parsed.error != null) {
|
||||
return repo to listOf(parsed.error)
|
||||
}
|
||||
|
||||
val repoForCommit = if (parsed.stageAllTracked) {
|
||||
repo.copy(files = repo.files.map { file -> if (file.tracked) file.copy(staged = true) else file })
|
||||
} else {
|
||||
repo
|
||||
}
|
||||
|
||||
val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty()
|
||||
val staged = repoForCommit.files.filter { it.staged }
|
||||
if (staged.isEmpty()) return repo to listOf("nothing to commit")
|
||||
|
||||
val cleanedFiles = repoForCommit.files.map { file ->
|
||||
if (file.staged) file.copy(staged = false, tracked = true) else file
|
||||
}
|
||||
val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) {
|
||||
repo.commits.dropLast(1) + repo.commits.last().copy(message = message)
|
||||
} else {
|
||||
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
|
||||
repo.commits + CommitNode(nextId, message)
|
||||
}
|
||||
|
||||
return repoForCommit.copy(
|
||||
files = cleanedFiles,
|
||||
commits = nextCommits,
|
||||
branches = repo.branches + (repo.headBranch to nextCommits.size),
|
||||
) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message")
|
||||
}
|
||||
|
||||
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
|
||||
var message: String? = null
|
||||
var stageAllTracked = false
|
||||
var amend = false
|
||||
var index = 0
|
||||
|
||||
while (index < arguments.size) {
|
||||
val argument = arguments[index]
|
||||
when {
|
||||
argument == "-a" || argument == "--all" -> {
|
||||
stageAllTracked = true
|
||||
}
|
||||
argument == "--amend" -> {
|
||||
amend = true
|
||||
}
|
||||
argument == "--no-edit" -> {
|
||||
// Keep the previous commit message when amending.
|
||||
}
|
||||
argument == "--date" -> {
|
||||
if (arguments.getOrNull(index + 1) == null) {
|
||||
return ParsedCommitArguments(error = "error: option '--date' requires a value")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
argument.startsWith("--date=") -> {
|
||||
// The sandbox records commit structure, not timestamps.
|
||||
}
|
||||
argument == "-m" || argument == "--message" -> {
|
||||
val next = arguments.getOrNull(index + 1)
|
||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
message = next
|
||||
index += 1
|
||||
}
|
||||
argument.startsWith("--message=") -> {
|
||||
message = argument.substringAfter('=')
|
||||
}
|
||||
argument.startsWith("-") && !argument.startsWith("--") && argument.length > 2 -> {
|
||||
val shortFlags = argument.drop(1)
|
||||
var shortIndex = 0
|
||||
while (shortIndex < shortFlags.length) {
|
||||
when (val flag = shortFlags[shortIndex]) {
|
||||
'a' -> stageAllTracked = true
|
||||
'm' -> {
|
||||
val attachedValue = shortFlags.substring(shortIndex + 1)
|
||||
if (attachedValue.isNotEmpty()) {
|
||||
message = attachedValue
|
||||
} else {
|
||||
val next = arguments.getOrNull(index + 1)
|
||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
message = next
|
||||
index += 1
|
||||
}
|
||||
break
|
||||
}
|
||||
else -> return ParsedCommitArguments(error = "error: unsupported commit option '-$flag'")
|
||||
}
|
||||
shortIndex += 1
|
||||
}
|
||||
}
|
||||
else -> return ParsedCommitArguments(error = "error: unsupported commit argument '$argument'")
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (!amend && message.isNullOrBlank()) {
|
||||
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||
}
|
||||
|
||||
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend)
|
||||
}
|
||||
|
||||
private fun statusLines(repo: RepoState): List<String> {
|
||||
val staged = repo.files.filter { it.staged }.map {
|
||||
when {
|
||||
it.deleted -> "deleted: ${it.name}"
|
||||
it.tracked -> "modified: ${it.name}"
|
||||
else -> "new file: ${it.name}"
|
||||
}
|
||||
}
|
||||
val deleted = repo.files.filter { it.deleted && it.tracked && !it.staged }.map { "deleted: ${it.name}" }
|
||||
val unstaged = repo.files.filterNot { it.staged || it.tracked || it.deleted }.map { "untracked: ${it.name}" }
|
||||
return buildList {
|
||||
add("On branch ${repo.headBranch}")
|
||||
if (staged.isEmpty() && deleted.isEmpty() && unstaged.isEmpty()) {
|
||||
add("nothing to commit, working tree clean")
|
||||
} else {
|
||||
if (staged.isNotEmpty()) {
|
||||
add("Changes to be committed:")
|
||||
addAll(staged)
|
||||
}
|
||||
if (deleted.isNotEmpty()) {
|
||||
add("Changes not staged for commit:")
|
||||
addAll(deleted)
|
||||
}
|
||||
if (unstaged.isNotEmpty()) {
|
||||
add("Untracked files:")
|
||||
addAll(unstaged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ParsedCommitArguments(
|
||||
val message: String? = null,
|
||||
val stageAllTracked: Boolean = false,
|
||||
val amend: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal object SandboxShell {
|
||||
fun tokenizeCommand(command: String): List<String> {
|
||||
return tokenizeShellCommand(command).map { it.value }
|
||||
}
|
||||
|
||||
fun tokenizeShellCommand(command: String): List<GitSandboxEngine.ShellToken> {
|
||||
val result = mutableListOf<GitSandboxEngine.ShellToken>()
|
||||
val current = StringBuilder()
|
||||
var quoteChar: Char? = null
|
||||
var escaping = false
|
||||
var currentQuoted = false
|
||||
var skipNext = false
|
||||
|
||||
fun emitCurrent(force: Boolean = false) {
|
||||
if (current.isNotEmpty() || force && currentQuoted) {
|
||||
result += GitSandboxEngine.ShellToken(value = current.toString(), quoted = currentQuoted)
|
||||
current.clear()
|
||||
currentQuoted = false
|
||||
}
|
||||
}
|
||||
|
||||
command.forEachIndexed { index, char ->
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
return@forEachIndexed
|
||||
}
|
||||
when {
|
||||
escaping -> {
|
||||
current.append(char)
|
||||
escaping = false
|
||||
}
|
||||
char == '\\' && quoteChar != '\'' -> {
|
||||
escaping = true
|
||||
}
|
||||
quoteChar != null -> {
|
||||
if (char == quoteChar) {
|
||||
quoteChar = null
|
||||
} else {
|
||||
current.append(char)
|
||||
}
|
||||
}
|
||||
char == '"' || char == '\'' -> {
|
||||
quoteChar = char
|
||||
currentQuoted = true
|
||||
}
|
||||
char.isWhitespace() -> {
|
||||
emitCurrent()
|
||||
}
|
||||
char == '>' -> {
|
||||
emitCurrent()
|
||||
if (command.getOrNull(index + 1) == '>') {
|
||||
result += GitSandboxEngine.ShellToken(">>")
|
||||
skipNext = true
|
||||
} else if (command.getOrNull(index - 1) != '>') {
|
||||
result += GitSandboxEngine.ShellToken(">")
|
||||
}
|
||||
}
|
||||
else -> current.append(char)
|
||||
}
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current.append('\\')
|
||||
}
|
||||
emitCurrent()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
|
||||
return expandPathspecTokens(repo, arguments.map { GitSandboxEngine.ShellToken(it) })
|
||||
}
|
||||
|
||||
fun expandPathspecTokens(repo: RepoState, arguments: List<GitSandboxEngine.ShellToken>): List<String> {
|
||||
return arguments.flatMap { token ->
|
||||
val argument = token.value
|
||||
if (token.quoted || !argument.hasGlob()) {
|
||||
listOf(argument)
|
||||
} else {
|
||||
val regex = argument.globToRegex()
|
||||
repo.files
|
||||
.filterNot { it.deleted }
|
||||
.map { it.name }
|
||||
.filter { regex.matches(it) }
|
||||
.sorted()
|
||||
.ifEmpty { listOf(argument) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hasGlob(): Boolean = any { it == '*' || it == '?' }
|
||||
|
||||
private fun String.globToRegex(): Regex {
|
||||
val pattern = buildString {
|
||||
append('^')
|
||||
this@globToRegex.forEach { char ->
|
||||
when (char) {
|
||||
'*' -> append("[^/]*")
|
||||
'?' -> append("[^/]")
|
||||
'.', '(', ')', '+', '|', '^', '$', '@', '%', '{', '}', '[', ']', '\\' -> {
|
||||
append('\\')
|
||||
append(char)
|
||||
}
|
||||
else -> append(char)
|
||||
}
|
||||
}
|
||||
append('$')
|
||||
}
|
||||
return Regex(pattern)
|
||||
}
|
||||
}
|
||||
@@ -325,280 +325,6 @@ class GitSandboxEngineTest {
|
||||
assertFalse(updatedRepo.files.any { it.name == "deleteme.txt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
assertFalse(output.any { it.contains("GitHug Android") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession != null)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddAcceptsUpdateSelectionFromNextInput() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1")
|
||||
val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7")
|
||||
|
||||
assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(updateOutput.any { it.contains("Update>>") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false)
|
||||
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(quitOutput.any { it.contains("Bye.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesEveryDisplayedMenuCommand() {
|
||||
val menuCommands = listOf(
|
||||
"1" to "What now> 1",
|
||||
"2" to "Update>>",
|
||||
"3" to "Revert>>",
|
||||
"4" to "Add untracked>>",
|
||||
"5" to "Patch update>>",
|
||||
"6" to "Diff>>",
|
||||
"7" to "Bye.",
|
||||
"8" to "What now> 8",
|
||||
)
|
||||
|
||||
menuCommands.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("2", "3", "4", "5", "6")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesMenuCommandAliases() {
|
||||
val aliases = listOf(
|
||||
"status" to "What now> status",
|
||||
"update" to "Update>>",
|
||||
"revert" to "Revert>>",
|
||||
"add untracked" to "Add untracked>>",
|
||||
"patch" to "Patch update>>",
|
||||
"diff" to "Diff>>",
|
||||
"quit" to "Bye.",
|
||||
"help" to "What now> help",
|
||||
)
|
||||
|
||||
aliases.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddPatchSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
|
||||
val (hunkRepo, hunkOutput) = GitSandboxEngine.execute(patchRepo, "1")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(hunkRepo, "y")
|
||||
|
||||
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(patchOutput.any { it.contains("Patch update>>") })
|
||||
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddStartsPatchHunkDialogWithoutStagingImmediately() {
|
||||
listOf("git add -p README", "git add --patch README").forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, output) = GitSandboxEngine.execute(repo, command)
|
||||
|
||||
assertFalse("$command should not stage before a selection", patchRepo.files.single { it.name == "README" }.staged)
|
||||
assertEquals("patch-hunk", patchRepo.interactiveAddSession?.selectionAction)
|
||||
assertTrue(output.any { it.startsWith("diff --git a/README b/README") })
|
||||
assertTrue(output.any { it.contains("Stage this hunk") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (selectedRepo, output) = GitSandboxEngine.execute(patchRepo, "y")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkEditOpensPatchEditorInvocation() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
|
||||
assertEquals(GitEditorCommandKind.PATCH_HUNK, invocation?.kind)
|
||||
assertEquals("Edit Patch Hunk", invocation?.title)
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("diff --git a/README b/README"))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("+A"))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("Stage this hunk"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editedPatchHunkStagesCurrentPatchTarget() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
|
||||
val (selectedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(patchRepo, invocation.initialContent)
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkDialogHandlesAdvertisedCommands() {
|
||||
val commands = listOf("y", "n", "q", "a", "d", "s", "e", "p", "P", "?")
|
||||
|
||||
commands.forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(patchRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Unknown command '$command'") })
|
||||
assertTrue("$command should echo hunk prompt", output.any { it.contains("Stage this hunk") })
|
||||
if (command == "e") {
|
||||
assertTrue(output.any { it.contains("Opening patch editor") })
|
||||
}
|
||||
if (command in listOf("y", "a")) {
|
||||
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession == null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeInteractiveAddSelectionUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-interactive-add").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "interactive-add-test",
|
||||
title = "Interactive Add Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (menuRepo, _) = runtime.execute(level, repo, "git stage -i")
|
||||
val (updateRepo, _) = runtime.execute(level, menuRepo, "2")
|
||||
val (selectedRepo, _) = runtime.execute(level, updateRepo, "1")
|
||||
val (quitRepo, _) = runtime.execute(level, selectedRepo, "7")
|
||||
val (_, statusOutput) = runtime.execute(level, quitRepo, "git status")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(statusOutput.any { it.contains("new file:") && it.contains("README") })
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchAddSelectionUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-patch-add").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "patch-add-test",
|
||||
title = "Patch Add Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (patchRepo, patchOutput) = runtime.execute(level, repo, "git add -p README")
|
||||
val (selectedRepo, _) = runtime.execute(level, patchRepo, "y")
|
||||
|
||||
assertTrue(patchOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchHunkEditorSaveUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-patch-edit").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "patch-edit-test",
|
||||
title = "Patch Edit Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (patchRepo, _) = runtime.execute(level, repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
val (selectedRepo, output) = runtime.executeGitEditorCommand(level, patchRepo, invocation, invocation.initialContent)
|
||||
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeInteractiveRebaseUsesAppSequenceEditorContent() {
|
||||
val git = testGitBinary()
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class InteractiveAddEngineTest {
|
||||
@Test
|
||||
fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
assertFalse(output.any { it.contains("GitHug Android") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession != null)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddAcceptsUpdateSelectionFromNextInput() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1")
|
||||
val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7")
|
||||
|
||||
assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(updateOutput.any { it.contains("Update>>") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false)
|
||||
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(quitOutput.any { it.contains("Bye.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesEveryDisplayedMenuCommand() {
|
||||
val menuCommands = listOf(
|
||||
"1" to "What now> 1",
|
||||
"2" to "Update>>",
|
||||
"3" to "Revert>>",
|
||||
"4" to "Add untracked>>",
|
||||
"5" to "Patch update>>",
|
||||
"6" to "Diff>>",
|
||||
"7" to "Bye.",
|
||||
"8" to "What now> 8",
|
||||
)
|
||||
|
||||
menuCommands.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("2", "3", "4", "5", "6")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesMenuCommandAliases() {
|
||||
val aliases = listOf(
|
||||
"status" to "What now> status",
|
||||
"update" to "Update>>",
|
||||
"revert" to "Revert>>",
|
||||
"add untracked" to "Add untracked>>",
|
||||
"patch" to "Patch update>>",
|
||||
"diff" to "Diff>>",
|
||||
"quit" to "Bye.",
|
||||
"help" to "What now> help",
|
||||
)
|
||||
|
||||
aliases.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddPatchSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
|
||||
val (hunkRepo, hunkOutput) = GitSandboxEngine.execute(patchRepo, "1")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(hunkRepo, "y")
|
||||
|
||||
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(patchOutput.any { it.contains("Patch update>>") })
|
||||
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddStartsPatchHunkDialogWithoutStagingImmediately() {
|
||||
listOf("git add -p README", "git add --patch README").forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, output) = GitSandboxEngine.execute(repo, command)
|
||||
|
||||
assertFalse("$command should not stage before a selection", patchRepo.files.single { it.name == "README" }.staged)
|
||||
assertEquals("patch-hunk", patchRepo.interactiveAddSession?.selectionAction)
|
||||
assertTrue(output.any { it.startsWith("diff --git a/README b/README") })
|
||||
assertTrue(output.any { it.contains("Stage this hunk") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (selectedRepo, output) = GitSandboxEngine.execute(patchRepo, "y")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkEditOpensPatchEditorInvocation() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
|
||||
assertEquals(GitEditorCommandKind.PATCH_HUNK, invocation?.kind)
|
||||
assertEquals("Edit Patch Hunk", invocation?.title)
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("diff --git a/README b/README"))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("+A"))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("Stage this hunk"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editedPatchHunkStagesCurrentPatchTarget() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
|
||||
val (selectedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(patchRepo, invocation.initialContent)
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkDialogHandlesAdvertisedCommands() {
|
||||
val commands = listOf("y", "n", "q", "a", "d", "s", "e", "p", "P", "?")
|
||||
|
||||
commands.forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(patchRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Unknown command '$command'") })
|
||||
assertTrue("$command should echo hunk prompt", output.any { it.contains("Stage this hunk") })
|
||||
if (command == "e") {
|
||||
assertTrue(output.any { it.contains("Opening patch editor") })
|
||||
}
|
||||
if (command in listOf("y", "a")) {
|
||||
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession == null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeInteractiveAddSelectionUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-interactive-add").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "interactive-add-test",
|
||||
title = "Interactive Add Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (menuRepo, _) = runtime.execute(level, repo, "git stage -i")
|
||||
val (updateRepo, _) = runtime.execute(level, menuRepo, "2")
|
||||
val (selectedRepo, _) = runtime.execute(level, updateRepo, "1")
|
||||
val (quitRepo, _) = runtime.execute(level, selectedRepo, "7")
|
||||
val (_, statusOutput) = runtime.execute(level, quitRepo, "git status")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(statusOutput.any { it.contains("new file:") && it.contains("README") })
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchAddSelectionUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-patch-add").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "patch-add-test",
|
||||
title = "Patch Add Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (patchRepo, patchOutput) = runtime.execute(level, repo, "git add -p README")
|
||||
val (selectedRepo, _) = runtime.execute(level, patchRepo, "y")
|
||||
|
||||
assertTrue(patchOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchHunkEditorSaveUpdatesGitIndex() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-patch-edit").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = level(
|
||||
id = "patch-edit-test",
|
||||
title = "Patch Edit Test",
|
||||
description = "",
|
||||
hints = emptyList(),
|
||||
commandSuggestions = emptyList(),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
||||
validator = { _, _ -> false },
|
||||
)
|
||||
val repo = runtime.prepareLevel(level)
|
||||
val (patchRepo, _) = runtime.execute(level, repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
val (selectedRepo, output) = runtime.executeGitEditorCommand(level, patchRepo, invocation, invocation.initialContent)
|
||||
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testGitBinary(): File {
|
||||
System.getenv("GITHUG_TEST_GIT_BINARY")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { File(it) }
|
||||
?.takeIf { it.exists() && it.canExecute() }
|
||||
?.let { return it }
|
||||
|
||||
val repoHostGit = File(repoRoot(), "build/host-git/libgit.so")
|
||||
if (repoHostGit.exists() && repoHostGit.canExecute()) return repoHostGit
|
||||
|
||||
return File("/usr/bin/git")
|
||||
}
|
||||
|
||||
private fun repoRoot(): File {
|
||||
val userDir = File(System.getProperty("user.dir") ?: ".")
|
||||
return if (File(userDir, "app/build.gradle.kts").exists()) userDir else userDir.parentFile ?: userDir
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user