Files
Githug-Android/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt
Joe Tretter b8c4096123 Add exercise description onboarding bubble
Remove duplicate IME padding that caused keyboard gap
Add md alias for mkdir helper command
Add dir alias for ls helper command
Add cd.. shortcut for cd .. helper command
2026-05-05 20:08:51 -05:00

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