diff --git a/README.md b/README.md index fbad945..4bb136d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This repository contains a starter Android app with: - Kotlin + Jetpack Compose project scaffolding - CLI-first gameplay shell - Optional hideable visualization panel -- In-memory Git sandbox engine for early commands +- A native-Git runtime scaffold with in-memory fallback while no bundled Git binary is present - First playable GitHug-inspired levels: `init`, `add`, and `commit` ## Product direction @@ -75,9 +75,28 @@ It will also automatically bump the Android app version in `app/build.gradle.kts - incrementing `versionCode` by 1 - incrementing the patch component of `versionName` (for example `0.1.0` → `0.1.1`) +## Native Git prototype status + +The app now includes a **filesystem-backed runtime scaffold** for moving from the custom Kotlin Git emulator toward a real native Git backend. + +Current prototype behavior: + +- the app prepares per-level sandbox directories in app-private storage +- terminal commands are routed through a runtime abstraction instead of directly calling the old in-memory engine +- if a bundled native Git binary is available at runtime, Git commands are executed against a real repository sandbox +- if no bundled native Git binary is present yet, the app automatically falls back to the existing in-memory sandbox so development can continue + +Planned native Git packaging path: + +- cross-compile Git for Android ABIs with the NDK +- bundle one binary payload per ABI +- extract the correct executable into app-private storage on first launch +- keep helper shell-like commands (`ls`, `pwd`, `cat`, `touch`, `mkdir`, `rm`, `echo`) implemented in Kotlin + ## Next steps toward full GitHug parity -- Expand the Git sandbox to cover merge, rebase, stash, tags, remotes, reset, revert, cherry-pick, bisect, and more +- Replace the fallback in-memory Git engine with a packaged native Git binary +- Expand real repository-backed level validation beyond `init`, `add`, and `commit` - Port all original levels and hints into structured content files - Add richer validation rules and per-level explanations - Add onboarding, accessibility polish, icons, tests, and Play Store assets \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c528f19..9b3eb21 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.kawomi.githugandroid" minSdk = 26 targetSdk = 34 - versionCode = 7 - versionName = "0.1.6" + versionCode = 8 + versionName = "0.1.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/com/kawomi/githugandroid/GameModels.kt b/app/src/main/java/com/kawomi/githugandroid/GameModels.kt index 64223b4..6632a3b 100644 --- a/app/src/main/java/com/kawomi/githugandroid/GameModels.kt +++ b/app/src/main/java/com/kawomi/githugandroid/GameModels.kt @@ -164,6 +164,51 @@ object GitSandboxEngine { } } + 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) { @@ -269,51 +314,6 @@ object GitSandboxEngine { } } - private 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 data class ParsedCommitArguments( val message: String? = null, val stageAllTracked: Boolean = false, diff --git a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt index 8f0d5bd..5f92ab6 100644 --- a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt +++ b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.TextRange @@ -73,15 +74,17 @@ private val GitHugColorScheme = darkColorScheme( @Composable fun GitHugApp() { MaterialTheme(colorScheme = GitHugColorScheme) { + val context = LocalContext.current val levels = remember { sampleLevels() } + val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) } val screenScrollState = rememberScrollState() var currentLevelIndex by remember { mutableStateOf(0) } var mode by remember { mutableStateOf(PlayMode.CLI_ONLY) } - var repo by remember { mutableStateOf(levels.first().setup()) } + var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) } var commandInput by remember { mutableStateOf(TextFieldValue("")) } var inputFieldVersion by remember { mutableStateOf(0) } var suppressedImeEcho by remember { mutableStateOf(null) } - var output by remember { mutableStateOf(listOf("Welcome to GitHug Android.")) } + var output by remember { mutableStateOf(listOf(runtime.startupBanner())) } var hintIndex by remember { mutableStateOf(0) } var completedLevels by remember { mutableStateOf(setOf()) } var commandHistory by remember { mutableStateOf(listOf()) } @@ -101,7 +104,7 @@ fun GitHugApp() { } fun resetCurrentLevel(message: String = "Level reset.") { - repo = currentLevel.setup() + repo = runtime.prepareLevel(currentLevel) clearCommandInput() suppressedImeEcho = null output = listOf(message) @@ -176,7 +179,7 @@ fun GitHugApp() { historyIndex = -1 historyDraft = "" - val (newRepo, lines) = GitSandboxEngine.execute(repo, raw) + val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) val solvedAfterCommand = currentLevel.validator(newRepo) val wasAlreadyCompleted = currentLevel.id in completedLevels val newOutput = buildList { @@ -194,7 +197,7 @@ fun GitHugApp() { val nextLevelIndex = currentLevelIndex + 1 val nextLevel = levels[nextLevelIndex] currentLevelIndex = nextLevelIndex - repo = nextLevel.setup() + repo = runtime.prepareLevel(nextLevel) output = listOf("Loaded level: ${nextLevel.title}") clearCommandInput() suppressedImeEcho = null @@ -212,7 +215,7 @@ fun GitHugApp() { } fun showCommandHelp() { - output = output + GitSandboxEngine.commandReferenceLines() + output = output + runtime.commandReferenceLines() } Scaffold { padding -> @@ -231,7 +234,7 @@ fun GitHugApp() { ) { Header(levels, currentLevelIndex, completedLevels) { index -> currentLevelIndex = index - repo = levels[index].setup() + repo = runtime.prepareLevel(levels[index]) output = listOf("Loaded level: ${levels[index].title}") clearCommandInput() suppressedImeEcho = null diff --git a/app/src/main/java/com/kawomi/githugandroid/GitRuntime.kt b/app/src/main/java/com/kawomi/githugandroid/GitRuntime.kt new file mode 100644 index 0000000..aac28a0 --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/GitRuntime.kt @@ -0,0 +1,222 @@ +package com.kawomi.githugandroid + +import android.content.Context +import java.io.File + +class GitRepositoryRuntime(private val context: Context) { + private val sandboxesRoot = File(context.filesDir, "githug-sandboxes") + + fun startupBanner(): String { + return if (nativeGitBinary() != null) { + "Welcome to GitHug Android. Native Git prototype ready." + } else { + "Welcome to GitHug Android. Native Git prototype scaffolded; using in-memory fallback until a bundled Git binary is available." + } + } + + fun prepareLevel(level: Level): RepoState { + val nativeGit = nativeGitBinary() + if (nativeGit == null) { + return level.setup() + } + + val sandbox = sandboxDir(level) + sandbox.deleteRecursively() + sandbox.mkdirs() + + val desired = level.setup() + desired.files.forEach { file -> + File(sandbox, file.name).apply { + parentFile?.mkdirs() + writeText(file.content) + } + } + + val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty() + if (needsGit) { + val initResult = runGit(nativeGit, sandbox, listOf("init", "-b", desired.headBranch)) + if (initResult.exitCode != 0) { + runGit(nativeGit, sandbox, listOf("init")) + runGit(nativeGit, sandbox, listOf("checkout", "-B", desired.headBranch)) + } + + val stageTargets = desired.files.filter { it.staged || it.tracked }.map { it.name } + if (stageTargets.isNotEmpty()) { + runGit(nativeGit, sandbox, listOf("add") + stageTargets) + } + } + + return inspectSandbox(level) + } + + fun execute(level: Level, currentRepo: RepoState, command: String): Pair> { + val nativeGit = nativeGitBinary() + if (nativeGit == null) { + return GitSandboxEngine.execute(currentRepo, command) + } + + val sandbox = sandboxDir(level) + if (!sandbox.exists()) { + prepareLevel(level) + } + + val tokens = GitSandboxEngine.tokenizeCommand(command) + if (tokens.isEmpty()) return inspectSandbox(level) to emptyList() + + val output = when (tokens.first()) { + "git" -> runGit(nativeGit, sandbox, tokens.drop(1)).outputLines + "help", "?" -> commandReferenceLines() + else -> executeHelperCommand(sandbox, tokens) + } + + return inspectSandbox(level) to output + } + + fun commandReferenceLines(): List { + return buildList { + addAll(GitSandboxEngine.commandReferenceLines()) + add("Native Git prototype:") + add(" bundled binary path: files/native-git/bin/git") + add(" helper commands: ls, pwd, cat, touch, mkdir, rm, echo") + } + } + + private fun executeHelperCommand(workingDir: File, tokens: List): List { + return when (tokens.first()) { + "ls" -> workingDir.listFiles() + ?.filterNot { it.name == ".git" } + ?.sortedBy { it.name } + ?.map { it.name } + .orEmpty() + "pwd" -> listOf("/sandbox/${workingDir.name}") + "cat" -> { + val target = tokens.getOrNull(1) ?: return listOf("usage: cat ") + val file = File(workingDir, target) + if (!file.exists() || file.isDirectory) listOf("cat: $target: No such file") + else file.readLines().ifEmpty { listOf("") } + } + "touch" -> { + val target = tokens.getOrNull(1) ?: return listOf("usage: touch ") + val file = File(workingDir, target) + if (file.exists()) { + listOf("$target already exists") + } else { + file.parentFile?.mkdirs() + file.writeText("") + emptyList() + } + } + "mkdir" -> { + val target = tokens.getOrNull(1) ?: return listOf("usage: mkdir ") + val dir = File(workingDir, target) + if (dir.exists()) listOf("mkdir: $target: File exists") else { + dir.mkdirs() + emptyList() + } + } + "rm" -> { + val target = tokens.getOrNull(1) ?: return listOf("usage: rm ") + val file = File(workingDir, target) + if (!file.exists()) listOf("rm: $target: No such file or directory") else { + file.deleteRecursively() + emptyList() + } + } + "echo" -> listOf(tokens.drop(1).joinToString(" ")) + else -> listOf("Command not supported in prototype runtime. Try a git command or helper command.") + } + } + + private fun inspectSandbox(level: Level): RepoState { + val sandbox = sandboxDir(level) + val nativeGit = nativeGitBinary() + val filesOnDisk = sandbox.listFiles() + ?.filter { it.isFile && it.name != ".git" } + .orEmpty() + + if (nativeGit == null || !File(sandbox, ".git").exists()) { + return RepoState( + initialized = File(sandbox, ".git").exists(), + files = filesOnDisk.map { GitFile(name = it.name, content = it.readText()) }, + ) + } + + val statusResult = runGit(nativeGit, sandbox, listOf("status", "--porcelain")) + val statusMap = mutableMapOf>() + statusResult.outputLines.forEach { line -> + if (line.length < 4) return@forEach + val x = line[0] + val y = line[1] + val path = line.substring(3).trim() + val staged = x != ' ' && x != '?' + val tracked = x != '?' || y != '?' + statusMap[path] = staged to tracked + } + + val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%s")) + val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list")) + val headResult = runGit(nativeGit, sandbox, listOf("branch", "--show-current")) + + return RepoState( + initialized = true, + files = filesOnDisk.map { file -> + val (staged, tracked) = statusMap[file.name] ?: (false to true) + GitFile( + name = file.name, + content = file.readText(), + staged = staged, + tracked = tracked, + ) + }, + commits = logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line -> + val parts = line.split('\t', limit = 2) + if (parts.isEmpty()) null else CommitNode(parts[0], parts.getOrElse(1) { "" }) + }, + headBranch = headResult.outputLines.firstOrNull()?.ifBlank { null } ?: "master", + branches = branchResult.outputLines.map { it.removePrefix("*").trim() }.filter { it.isNotBlank() }.associateWith { 0 }, + ) + } + + private fun nativeGitBinary(): File? { + val candidates = listOf( + File(context.filesDir, "native-git/bin/git"), + File(context.filesDir, "git/bin/git"), + ) + return candidates.firstOrNull { it.exists() && it.canExecute() } + } + + private fun sandboxDir(level: Level): File = File(sandboxesRoot, level.id) + + private fun runGit(binary: File, workingDir: File, arguments: List): ProcessExecutionResult { + return runProcess(binary, workingDir, arguments) + } + + private fun runProcess(binary: File, workingDir: File, arguments: List): ProcessExecutionResult { + return try { + val process = ProcessBuilder(listOf(binary.absolutePath) + arguments) + .directory(workingDir) + .redirectErrorStream(true) + .apply { + environment()["HOME"] = workingDir.absolutePath + environment()["GIT_CONFIG_NOSYSTEM"] = "1" + environment()["GIT_AUTHOR_NAME"] = "GitHug" + environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com" + environment()["GIT_COMMITTER_NAME"] = "GitHug" + environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com" + environment()["LC_ALL"] = "C" + } + .start() + + val output = process.inputStream.bufferedReader().readLines() + val exit = process.waitFor() + ProcessExecutionResult(exitCode = exit, outputLines = output.ifEmpty { if (exit == 0) emptyList() else listOf("Command failed") }) + } catch (error: Exception) { + ProcessExecutionResult(exitCode = -1, outputLines = listOf("Native Git execution failed: ${error.message ?: error::class.java.simpleName}")) + } + } +} + +private data class ProcessExecutionResult( + val exitCode: Int, + val outputLines: List, +) \ No newline at end of file