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, ) 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(), ) 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()) }, state.commits.flatMap { listOf(it.id, it.message) }, state.branches.flatMap { listOf(it.key, it.value.toString()) }, ) }, 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(3).map { GitFile(it[0] as String, it[1] as String, (it[2] 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() } ) } ) object GitSandboxEngine { fun execute(repo: RepoState, command: String): Pair> { val parts = command.split(" ").filter { it.isNotBlank() } if (parts.isEmpty()) return repo to emptyList() return when { 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] == "status" -> repo to statusLines(repo) parts.size >= 3 && parts[1] == "add" -> { val target = parts[2] 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) 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(" ")}") } } private fun commit(repo: RepoState, parts: List): Pair> { val messageIndex = parts.indexOf("-m") if (messageIndex == -1 || messageIndex == parts.lastIndex) { return repo to listOf("error: commit message required. Use git commit -m \"message\"") } val message = parts.drop(messageIndex + 1).joinToString(" ").trim('"') val staged = repo.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 = repo.files.map { it.copy(staged = false) } return repo.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 statusLines(repo: RepoState): List { val staged = repo.files.filter { it.staged }.map { "new file: ${it.name}" } val unstaged = repo.files.filterNot { it.staged }.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) } } } } }