Changed files:\napp/build.gradle.kts app/src/main/java/com/kawomi/githugandroid/GameModels.kt app/src/main/java/com/kawomi/githugandroid/GitRuntime.kt
323 lines
13 KiB
Kotlin
323 lines
13 KiB
Kotlin
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<GitFile> = emptyList(),
|
|
val commits: List<CommitNode> = emptyList(),
|
|
val headBranch: String = "master",
|
|
val branches: Map<String, Int> = emptyMap(),
|
|
val currentDir: String = ".",
|
|
)
|
|
|
|
data class Level(
|
|
val id: String,
|
|
val title: String,
|
|
val description: String,
|
|
val hints: List<String>,
|
|
val commandSuggestions: List<String>,
|
|
val validator: (RepoState) -> Boolean,
|
|
val setup: () -> RepoState,
|
|
)
|
|
|
|
fun sampleLevels(): List<Level> = 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<RepoState, Any>(
|
|
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<String> = listOf(
|
|
"Available sandbox commands:",
|
|
" git ",
|
|
" ls",
|
|
" touch <file>",
|
|
" help",
|
|
" pwd ",
|
|
" cat <file>",
|
|
" touch <file>",
|
|
" mkdir <directory>",
|
|
" cd <directory>",
|
|
" 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 }) 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<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 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.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<String>): 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<String> {
|
|
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,
|
|
)
|
|
} |