package com.kawomi.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, ) data class CommitNode( val id: String, val message: String, ) data class RepoState( val initialized: Boolean = false, val files: List = emptyList(), val commits: List = emptyList(), val headBranch: String = "master", val branches: Map = emptyMap(), val currentDir: String = ".", ) data class Level( val id: String, val title: String, val description: String, val hints: List, val commandSuggestions: List, val validator: (RepoState) -> Boolean, val setup: () -> RepoState, ) fun sampleLevels(): List = listOf( Level( id = "init", title = "Init", description = "A new directory, git_hug, has been created. Initialize an empty repository in it.", hints = listOf("Use git init to create a new repository.", "Try `git init` in the command area."), commandSuggestions = listOf("git init", "git status"), validator = { it.initialized }, setup = { RepoState() }, ), Level( id = "add", title = "Add", description = "There is a file in your folder called README; add it to your staging area.", hints = listOf("You want to stage README.", "Use `git add README`."), commandSuggestions = listOf("git status", "git add README", "ls"), validator = { repo -> repo.files.any { it.name == "README" && it.staged } }, setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) }, ), Level( id = "commit", title = "Commit", description = "The README file has been added to your staging area, now commit it.", hints = listOf("You must include a message when you commit.", "Use `git commit -m \"message\"`."), commandSuggestions = listOf("git status", "git commit -m \"Initial commit\"", "git log"), validator = { repo -> repo.commits.isNotEmpty() }, setup = { RepoState(initialized = true, files = listOf(GitFile("README", staged = true)), branches = mapOf("master" to 0)) }, ), ) val RepoStateSaver = listSaver( save = { state -> listOf( state.initialized, state.headBranch, state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString()) }, state.commits.flatMap { listOf(it.id, it.message) }, state.branches.flatMap { listOf(it.key, it.value.toString()) }, state.currentDir, ) }, 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<*> RepoState( initialized = initialized, headBranch = headBranch, files = fileParts.chunked(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(), ) }, 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, ) } ) object GitSandboxEngine { fun commandReferenceLines(): List = listOf( "Available sandbox commands:", " git ", " ls", " touch ", " help", " pwd ", " cat ", " touch ", " mkdir ", " cd ", " rm ", " echo ", ) fun execute(repo: RepoState, command: String): Pair> { 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 }) repo to listOf("$name already exists") else repo.copy(files = repo.files + GitFile(name = name)) to emptyList() } parts[0] == "ls" -> repo to repo.files.map { it.name }.ifEmpty { listOf() } 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] == "add" -> { val target = parts[2] if (target != "." && repo.files.none { it.name == target }) { repo to listOf("fatal: pathspec '$target' did not match any files") } else { val updated = repo.files.map { if (target == "." || it.name == target) it.copy(staged = true) else it } repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}") } } 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] == "branch" -> { 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" -> { val branch = parts[2] 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'") } else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}") } } fun tokenizeCommand(command: String): List { val result = mutableListOf() 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 commit(repo: RepoState, arguments: List): Pair> { 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.orEmpty() val staged = repoForCommit.files.filter { it.staged } if (staged.isEmpty()) return repo to listOf("nothing to commit") val nextId = "${repo.commits.size + 1}".padStart(7, '0') val cleanedFiles = repoForCommit.files.map { file -> if (file.staged) file.copy(staged = false, tracked = true) else file } return repoForCommit.copy( files = cleanedFiles, commits = repo.commits + CommitNode(nextId, message), branches = repo.branches + (repo.headBranch to (repo.commits.size + 1)), ) to listOf("[$nextId] $message") } private fun parseCommitArguments(arguments: List): ParsedCommitArguments { var message: String? = null var stageAllTracked = false var index = 0 while (index < arguments.size) { val argument = arguments[index] when { argument == "-a" || argument == "--all" -> { stageAllTracked = true } 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 (message.isNullOrBlank()) { return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"") } return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked) } private fun statusLines(repo: RepoState): List { val staged = repo.files.filter { it.staged }.map { if (it.tracked) "modified: ${it.name}" else "new file: ${it.name}" } val unstaged = repo.files.filterNot { it.staged || it.tracked }.map { "untracked: ${it.name}" } return buildList { add("On branch ${repo.headBranch}") if (staged.isEmpty() && unstaged.isEmpty()) { add("nothing to commit, working tree clean") } else { if (staged.isNotEmpty()) { add("Changes to be committed:") addAll(staged) } if (unstaged.isNotEmpty()) { add("Untracked files:") addAll(unstaged) } } } } private data class ParsedCommitArguments( val message: String? = null, val stageAllTracked: Boolean = false, val error: String? = null, ) }