Auto-commit after successful build (2026-04-21 21:22:08)

This commit is contained in:
Joe Tretter
2026-04-21 21:22:08 -05:00
commit 8303cfdea6
19 changed files with 1040 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
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<GitFile> = emptyList(),
val commits: List<CommitNode> = emptyList(),
val headBranch: String = "master",
val branches: Map<String, Int> = emptyMap(),
)
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()) },
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<RepoState, List<String>> {
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<String>): Pair<RepoState, List<String>> {
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<String> {
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)
}
}
}
}
}

View File

@@ -0,0 +1,325 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
private val AppBackground = Color(0xFF000000)
private val PanelPrimary = Color(0xFF121212)
private val PanelSecondary = Color(0xFF1E1E1E)
private val PanelTertiary = Color(0xFF262626)
private val TerminalBackground = Color(0xFF050505)
private val TextPrimary = Color(0xFFFFFFFF)
private val TextSecondary = Color(0xFFE6E6E6)
private val TextMuted = Color(0xFFBDBDBD)
private val Accent = Color(0xFF00E5FF)
private val Success = Color(0xFF00FF95)
private val GitHugColorScheme = darkColorScheme(
primary = Accent,
onPrimary = Color.Black,
secondary = TextPrimary,
onSecondary = Color.Black,
background = AppBackground,
onBackground = TextPrimary,
surface = PanelPrimary,
onSurface = TextPrimary,
surfaceVariant = PanelSecondary,
onSurfaceVariant = TextSecondary,
outline = TextMuted,
)
@Composable
fun GitHugApp() {
MaterialTheme(colorScheme = GitHugColorScheme) {
val levels = remember { sampleLevels() }
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 commandInput by remember { mutableStateOf("") }
var output by remember { mutableStateOf(listOf("Welcome to GitHug Android.")) }
var hintIndex by remember { mutableStateOf(0) }
var completedLevels by remember { mutableStateOf(setOf<String>()) }
val currentLevel = levels[currentLevelIndex]
val solved = currentLevel.validator(repo)
LaunchedEffect(currentLevelIndex) {
screenScrollState.scrollTo(0)
}
fun resetCurrentLevel(message: String = "Level reset.") {
repo = currentLevel.setup()
commandInput = ""
output = listOf(message)
hintIndex = 0
}
fun runCommand() {
val raw = commandInput.trim()
if (raw.isBlank()) return
val (newRepo, lines) = GitSandboxEngine.execute(repo, raw)
val solvedAfterCommand = currentLevel.validator(newRepo)
val wasAlreadyCompleted = currentLevel.id in completedLevels
val newOutput = buildList {
addAll(output)
add("$ $raw")
addAll(lines)
if (solvedAfterCommand && !wasAlreadyCompleted) {
add("✔ Level solved: ${currentLevel.title}")
}
}
if (solvedAfterCommand && !wasAlreadyCompleted) {
completedLevels = completedLevels + currentLevel.id
val hasNextLevel = currentLevelIndex < levels.lastIndex
if (hasNextLevel) {
val nextLevelIndex = currentLevelIndex + 1
val nextLevel = levels[nextLevelIndex]
currentLevelIndex = nextLevelIndex
repo = nextLevel.setup()
output = listOf("Loaded level: ${nextLevel.title}")
commandInput = ""
hintIndex = 0
} else {
repo = newRepo
output = listOf("🏁 All available MVP levels completed.")
commandInput = ""
}
} else {
repo = newRepo
output = newOutput
commandInput = ""
}
}
Scaffold { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(AppBackground)
.verticalScroll(screenScrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Header(levels, currentLevelIndex, completedLevels) { index ->
currentLevelIndex = index
repo = levels[index].setup()
output = listOf("Loaded level: ${levels[index].title}")
commandInput = ""
hintIndex = 0
}
ModeBar(mode = mode, onModeSelected = { mode = it })
LevelCard(
level = currentLevel,
onHint = {
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
output = output + "hint> $hint"
hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size)
},
onReset = { resetCurrentLevel() },
)
if (mode == PlayMode.VISUAL) {
VisualizationPanel(repo)
}
TerminalPanel(
output = output,
commandInput = commandInput,
onValueChange = { commandInput = it },
onRun = { runCommand() },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 260.dp)
)
}
}
}
}
}
@Composable
private fun Header(levels: List<Level>, currentLevelIndex: Int, completedLevels: Set<String>, onSelect: (Int) -> Unit) {
Card(colors = CardDefaults.cardColors(containerColor = PanelPrimary)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("GitHug Android", style = MaterialTheme.typography.headlineSmall, color = TextPrimary)
Text("CLI-first Git learning on Android, with optional repository visualization.", color = TextSecondary)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
levels.forEachIndexed { index, level ->
TextButton(
onClick = { onSelect(index) },
colors = ButtonDefaults.textButtonColors(
contentColor = if (index == currentLevelIndex) Accent else TextSecondary
)
) {
Text(
text = buildString {
append(if (level.id in completedLevels) "" else "")
append(level.title)
}
)
}
}
}
}
}
}
@Composable
private fun ModeBar(
mode: PlayMode,
onModeSelected: (PlayMode) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PlayMode.entries.forEach {
FilterChip(
selected = mode == it,
onClick = { onModeSelected(it) },
label = { Text(it.label) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Accent,
selectedLabelColor = Color.Black,
containerColor = PanelSecondary,
labelColor = TextPrimary
)
)
}
}
}
@Composable
private fun LevelCard(level: Level, onHint: () -> Unit, onReset: () -> Unit) {
Card(colors = CardDefaults.cardColors(containerColor = PanelSecondary)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary)
Text(level.description, color = TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onHint,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = Color.Black
)
) { Text("Hint") }
TextButton(
onClick = onReset,
colors = ButtonDefaults.textButtonColors(contentColor = Accent)
) { Text("Reset level") }
}
}
}
}
@Composable
private fun VisualizationPanel(repo: RepoState) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
InfoCard("Workspace", repo.files.joinToString("\n") {
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
}.ifBlank { "(empty)" }, Modifier.weight(1f))
InfoCard("Branches", buildString {
appendLine("HEAD -> ${repo.headBranch}")
repo.branches.forEach { (name, _) -> appendLine(name) }
}.trim(), Modifier.weight(1f))
InfoCard("Commits", repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" }, Modifier.weight(1f))
}
}
@Composable
private fun InfoCard(title: String, content: String, modifier: Modifier = Modifier) {
Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = PanelSecondary), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, color = TextPrimary, fontWeight = FontWeight.Bold)
Text(content, color = TextSecondary, fontFamily = FontFamily.Monospace)
}
}
}
@Composable
private fun TerminalPanel(
output: List<String>,
commandInput: String,
onValueChange: (String) -> Unit,
onRun: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = TerminalBackground)) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Terminal", color = TextPrimary, fontWeight = FontWeight.Bold)
output.forEach { line ->
Text(
text = line,
color = if (line.startsWith("") || line.startsWith("🏁")) Success else TextSecondary,
fontFamily = FontFamily.Monospace
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(PanelPrimary, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "$",
color = Accent,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
BasicTextField(
value = commandInput,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
textStyle = TextStyle(color = TextPrimary, fontFamily = FontFamily.Monospace),
cursorBrush = SolidColor(Accent),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onRun() })
)
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
package com.kawomi.githugandroid
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
GitHugApp()
}
}
}