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 = emptyList(), val commits: List = emptyList(), val headBranch: String = "master", val branches: Map = emptyMap(), val currentDir: String = ".", val tags: List = emptyList(), val remotes: Map = emptyMap(), val config: Map = emptyMap(), ) data class Level( val id: String, val title: String, val description: String, val hints: List, val commandSuggestions: List, val validator: (RepoState, String) -> Boolean, val setup: () -> RepoState, ) fun sampleLevels(): List = allGithugLevels() val RepoStateSaver = listSaver( 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() 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(), 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 = listOf( "Available sandbox commands:", " git ", " ls|dir", " touch ", " help", " pwd ", " cat ", " touch ", " mkdir|md ", " cd ", " cd..", " rm|del ", " 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 && !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 ") 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 ") 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 { 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 writeEcho(repo: RepoState, parts: List): Pair> { 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): List { 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): Pair> { val cached = "--cached" in arguments val target = arguments.lastOrNull { !it.startsWith("-") } ?: return repo to listOf("usage: git rm [--cached] ") 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): Pair> { val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv ") val sources = arguments.dropLast(1) if (sources.isEmpty()) return repo to listOf("usage: git mv ") 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): Pair> { return when { arguments.firstOrNull() == "-b" -> { val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b ") 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): Pair> { 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): Pair> { 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): Pair> { 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): 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 ?: 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): 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 { 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, ) }