543 lines
23 KiB
Kotlin
543 lines
23 KiB
Kotlin
package solutions.tretter.githugandroid
|
|
|
|
import androidx.compose.runtime.saveable.listSaver
|
|
|
|
enum class PlayMode(val label: String) {
|
|
CLI_ONLY("CLI ONLY"),
|
|
VISUAL("VISUAL"),
|
|
}
|
|
|
|
data class GitFile(
|
|
val name: String,
|
|
val content: String = "",
|
|
val staged: Boolean = false,
|
|
val tracked: Boolean = false,
|
|
val deleted: Boolean = false,
|
|
)
|
|
|
|
data class CommitNode(
|
|
val id: String,
|
|
val message: String,
|
|
)
|
|
|
|
data class RepoState(
|
|
val initialized: Boolean = false,
|
|
val files: List<GitFile> = emptyList(),
|
|
val commits: List<CommitNode> = emptyList(),
|
|
val headBranch: String = "master",
|
|
val branches: Map<String, Int> = emptyMap(),
|
|
val currentDir: String = ".",
|
|
val tags: List<String> = emptyList(),
|
|
val remotes: Map<String, String> = emptyMap(),
|
|
val config: Map<String, String> = emptyMap(),
|
|
)
|
|
|
|
data class Level(
|
|
val id: String,
|
|
val title: String,
|
|
val description: String,
|
|
val hints: List<String>,
|
|
val commandSuggestions: List<String>,
|
|
val validator: (RepoState, String) -> Boolean,
|
|
val setup: () -> RepoState,
|
|
)
|
|
|
|
fun sampleLevels(): List<Level> = allGithugLevels()
|
|
|
|
val RepoStateSaver = listSaver<RepoState, Any>(
|
|
save = { state ->
|
|
listOf(
|
|
state.initialized,
|
|
state.headBranch,
|
|
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
|
|
state.commits.flatMap { listOf(it.id, it.message) },
|
|
state.branches.flatMap { listOf(it.key, it.value.toString()) },
|
|
state.currentDir,
|
|
state.tags,
|
|
state.remotes.flatMap { listOf(it.key, it.value) },
|
|
state.config.flatMap { listOf(it.key, it.value) },
|
|
)
|
|
},
|
|
restore = { saved ->
|
|
val initialized = saved[0] as Boolean
|
|
val headBranch = saved[1] as String
|
|
val fileParts = saved[2] as List<*>
|
|
val commitParts = saved[3] as List<*>
|
|
val branchParts = saved[4] as List<*>
|
|
val tags = saved[6] as List<*>
|
|
val remoteParts = saved[7] as List<*>
|
|
val configParts = saved.getOrNull(8) as? List<*> ?: emptyList<Any>()
|
|
RepoState(
|
|
initialized = initialized,
|
|
headBranch = headBranch,
|
|
files = fileParts.chunked(if (fileParts.size % 5 == 0) 5 else 4).map {
|
|
GitFile(
|
|
name = it[0] as String,
|
|
content = it[1] as String,
|
|
staged = (it[2] as String).toBoolean(),
|
|
tracked = (it[3] as String).toBoolean(),
|
|
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
|
|
)
|
|
},
|
|
commits = commitParts.chunked(2).map {
|
|
CommitNode(it[0] as String, it[1] as String)
|
|
},
|
|
branches = branchParts.chunked(2).associate { (it[0] as String) to (it[1] as String).toInt() },
|
|
currentDir = saved[5] as String,
|
|
tags = tags.filterIsInstance<String>(),
|
|
remotes = remoteParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
|
|
config = configParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
|
|
)
|
|
}
|
|
)
|
|
|
|
object GitSandboxEngine {
|
|
fun commandReferenceLines(): List<String> = listOf(
|
|
"Available sandbox commands:",
|
|
" git ",
|
|
" ls|dir",
|
|
" touch <file>",
|
|
" help",
|
|
" pwd ",
|
|
" cat <file>",
|
|
" touch <file>",
|
|
" mkdir|md <directory>",
|
|
" cd <directory>",
|
|
" cd..",
|
|
" rm|del <file>",
|
|
" echo <message>",
|
|
)
|
|
|
|
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
|
val parts = tokenizeCommand(command)
|
|
if (parts.isEmpty()) return repo to emptyList()
|
|
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, parts)
|
|
parts[0] == "ls" || parts[0] == "dir" -> repo to repo.files.filterNot { it.deleted }.map { it.name }.ifEmpty { listOf() }
|
|
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 >= 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 >= 3 && parts[1] == "add" -> {
|
|
val target = parts[2]
|
|
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, expandPathspecs(repo, parts.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]
|
|
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
|
|
else repo.copy(branches = repo.branches + (branch to repo.commits.size)) 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))
|
|
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
|
|
}
|
|
}
|
|
|
|
fun tokenizeCommand(command: String): List<String> {
|
|
val result = mutableListOf<String>()
|
|
val current = StringBuilder()
|
|
var quoteChar: Char? = null
|
|
var escaping = false
|
|
|
|
command.forEach { char ->
|
|
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
|
|
}
|
|
char.isWhitespace() -> {
|
|
if (current.isNotEmpty()) {
|
|
result += current.toString()
|
|
current.clear()
|
|
}
|
|
}
|
|
else -> current.append(char)
|
|
}
|
|
}
|
|
|
|
if (escaping) {
|
|
current.append('\\')
|
|
}
|
|
if (current.isNotEmpty()) {
|
|
result += current.toString()
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private fun writeEcho(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
|
|
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 { "." }
|
|
}
|
|
|
|
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
|
|
return arguments.flatMap { argument ->
|
|
if (!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 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'")
|
|
}
|
|
"--" 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()
|
|
if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch")
|
|
else repo.copy(headBranch = branch) to listOf("Switched to branch '$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 files = if (branch == "feature" && repo.files.none { it.name == "file2" }) {
|
|
repo.files + GitFile("file2", tracked = true)
|
|
} else {
|
|
repo.files
|
|
}
|
|
return repo.copy(files = files) to emptyList()
|
|
}
|
|
|
|
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
|
val commits = if ("-i" in arguments && repo.commits.size > 2) repo.commits.take(2) else repo.commits
|
|
return repo.copy(commits = 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 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,
|
|
)
|
|
}
|