Refactor large Kotlin source files

- Split core game models from sandbox engine, native level setup, and repo state saving.

- Move app overlays and completion helpers out of GitHugApp.

- Extract the terminal special key bar into its own component.
This commit is contained in:
Joe Tretter
2026-05-09 13:52:13 -05:00
parent 300bd262dd
commit edec66bfe6
9 changed files with 1139 additions and 1110 deletions

View File

@@ -0,0 +1,611 @@
package solutions.tretter.githugandroid
object GitSandboxEngine {
data class ShellToken(
val value: String,
val quoted: Boolean = false,
)
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 shellParts = tokenizeShellCommand(command)
val parts = shellParts.map { it.value }
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, 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/feature_branch")) 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",
branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)),
) 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 repo to listOf("Interactive staging is not supported in the mobile terminal. Use git add <path> or git add -p <path>.")
}
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(" ")}")
}
}
fun tokenizeCommand(command: String): List<String> {
return tokenizeShellCommand(command).map { it.value }
}
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 { "." }
}
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
return expandPathspecTokens(repo, arguments.map { ShellToken(it) })
}
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) }
}
}
}
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 = if (squash) repo.maintenanceActions + "merge-squash" else repo.maintenanceActions,
) 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
.filterNot { it.message.contains("squash this commit", ignoreCase = true) }
.map { if (it.message == "First coommit") it.copy(message = "First commit") else it }
.let { ordered ->
if (ordered.map { it.message }.containsAll(listOf("First commit", "Second commit", "Third commit"))) {
ordered.sortedBy { commit ->
when (commit.message) {
"First commit" -> 1
"Second commit" -> 2
"Third commit" -> 3
else -> 0
}
}
} else {
ordered
}
}
} else {
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
}
return repo.copy(commits = commits, branches = updatedBranches, maintenanceActions = maintenanceActions) 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,
)
}