Auto-commit after successful build: update app gameplay/UI, improve build setup

Changed files:\napp/build.gradle.kts
app/src/main/java/solutions/tretter/githugandroid/GameModels.kt
app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt
app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt
app/src/main/java/solutions/tretter/githugandroid/Visual.kt
app/src/main/java/solutions/tretter/githugandroid/levels/AdvancedLevels.kt
app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt
app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt
app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt
This commit is contained in:
Joe Tretter
2026-05-05 19:27:33 -05:00
parent 6200fdc912
commit 16d7809150
10 changed files with 322 additions and 46 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 110 versionCode = 111
versionName = "0.1.109" versionName = "0.1.110"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -12,6 +12,7 @@ data class GitFile(
val content: String = "", val content: String = "",
val staged: Boolean = false, val staged: Boolean = false,
val tracked: Boolean = false, val tracked: Boolean = false,
val deleted: Boolean = false,
) )
data class CommitNode( data class CommitNode(
@@ -48,7 +49,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
listOf( listOf(
state.initialized, state.initialized,
state.headBranch, state.headBranch,
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString()) }, 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.commits.flatMap { listOf(it.id, it.message) },
state.branches.flatMap { listOf(it.key, it.value.toString()) }, state.branches.flatMap { listOf(it.key, it.value.toString()) },
state.currentDir, state.currentDir,
@@ -69,12 +70,13 @@ val RepoStateSaver = listSaver<RepoState, Any>(
RepoState( RepoState(
initialized = initialized, initialized = initialized,
headBranch = headBranch, headBranch = headBranch,
files = fileParts.chunked(4).map { files = fileParts.chunked(if (fileParts.size % 5 == 0) 5 else 4).map {
GitFile( GitFile(
name = it[0] as String, name = it[0] as String,
content = it[1] as String, content = it[1] as String,
staged = (it[2] as String).toBoolean(), staged = (it[2] as String).toBoolean(),
tracked = (it[3] as String).toBoolean(), tracked = (it[3] as String).toBoolean(),
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
) )
}, },
commits = commitParts.chunked(2).map { commits = commitParts.chunked(2).map {
@@ -112,16 +114,22 @@ object GitSandboxEngine {
parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines() parts[0] == "help" || parts[0] == "?" -> repo to commandReferenceLines()
parts[0] == "touch" && parts.size >= 2 -> { parts[0] == "touch" && parts.size >= 2 -> {
val name = parts[1] val name = parts[1]
if (repo.files.any { it.name == name }) repo to listOf("$name already exists") 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() else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
} }
parts[0] == "mkdir" && parts.size >= 2 -> repo to emptyList() parts[0] == "mkdir" && parts.size >= 2 -> repo to emptyList()
parts[0] == "rm" && parts.size >= 2 -> { parts[0] == "rm" && parts.size >= 2 -> {
val target = parts[1] val target = parts[1]
repo.copy(files = repo.files.filterNot { it.name == target }) to emptyList() 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] == "echo" -> writeEcho(repo, parts)
parts[0] == "ls" -> repo to repo.files.map { it.name }.ifEmpty { listOf() } parts[0] == "ls" -> repo to repo.files.filterNot { it.deleted }.map { it.name }.ifEmpty { listOf() }
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.") 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") 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") !repo.initialized -> repo to listOf("fatal: not a git repository")
@@ -139,15 +147,15 @@ object GitSandboxEngine {
} }
parts.size >= 3 && parts[1] == "add" -> { parts.size >= 3 && parts[1] == "add" -> {
val target = parts[2] val target = parts[2]
if (target != "." && repo.files.none { it.name == target }) { if (target != "." && repo.files.none { it.name == target && !it.deleted }) {
repo to listOf("fatal: pathspec '$target' did not match any files") repo to listOf("fatal: pathspec '$target' did not match any files")
} else { } else {
val updated = repo.files.map { if (target == "." || it.name == target) it.copy(staged = true) else it } 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}") 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 >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, parts[2], parts[3]) 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] == "commit" -> commit(repo, parts.drop(2))
parts.size >= 2 && parts[1] == "log" -> { 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") repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
@@ -244,12 +252,28 @@ object GitSandboxEngine {
} else { } else {
val current = updatedFiles[index] val current = updatedFiles[index]
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
updatedFiles[index] = current.copy(content = nextContent) updatedFiles[index] = current.copy(content = nextContent, deleted = false)
} }
return repo.copy(files = updatedFiles) to emptyList() return repo.copy(files = updatedFiles) to emptyList()
} }
fun expandPathspecs(repo: RepoState, arguments: List<String>): List<String> {
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<String>): Pair<RepoState, List<String>> { private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val cached = "--cached" in arguments val cached = "--cached" in arguments
val target = arguments.lastOrNull { !it.startsWith("-") } val target = arguments.lastOrNull { !it.startsWith("-") }
@@ -266,9 +290,22 @@ object GitSandboxEngine {
return repo.copy(files = updated) to emptyList() return repo.copy(files = updated) to emptyList()
} }
private fun moveGitPath(repo: RepoState, source: String, destination: String): Pair<RepoState, List<String>> { private fun moveGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
val destination = arguments.lastOrNull() ?: return repo to listOf("usage: git mv <source> <destination>")
val sources = arguments.dropLast(1)
if (sources.isEmpty()) return repo to listOf("usage: git mv <source> <destination>")
val destinationIsDirectory = sources.size > 1 || destination.endsWith("/")
val updated = repo.files.map { file -> val updated = repo.files.map { file ->
if (file.name == source) file.copy(name = destination, staged = true) else 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() return repo.copy(files = updated) to emptyList()
} }
@@ -439,18 +476,27 @@ object GitSandboxEngine {
private fun statusLines(repo: RepoState): List<String> { private fun statusLines(repo: RepoState): List<String> {
val staged = repo.files.filter { it.staged }.map { val staged = repo.files.filter { it.staged }.map {
if (it.tracked) "modified: ${it.name}" else "new file: ${it.name}" when {
it.deleted -> "deleted: ${it.name}"
it.tracked -> "modified: ${it.name}"
else -> "new file: ${it.name}"
} }
val unstaged = repo.files.filterNot { it.staged || it.tracked }.map { "untracked: ${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 { return buildList {
add("On branch ${repo.headBranch}") add("On branch ${repo.headBranch}")
if (staged.isEmpty() && unstaged.isEmpty()) { if (staged.isEmpty() && deleted.isEmpty() && unstaged.isEmpty()) {
add("nothing to commit, working tree clean") add("nothing to commit, working tree clean")
} else { } else {
if (staged.isNotEmpty()) { if (staged.isNotEmpty()) {
add("Changes to be committed:") add("Changes to be committed:")
addAll(staged) addAll(staged)
} }
if (deleted.isNotEmpty()) {
add("Changes not staged for commit:")
addAll(deleted)
}
if (unstaged.isNotEmpty()) { if (unstaged.isNotEmpty()) {
add("Untracked files:") add("Untracked files:")
addAll(unstaged) addAll(unstaged)
@@ -459,6 +505,27 @@ object GitSandboxEngine {
} }
} }
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( private data class ParsedCommitArguments(
val message: String? = null, val message: String? = null,
val stageAllTracked: Boolean = false, val stageAllTracked: Boolean = false,

View File

@@ -7,7 +7,12 @@ data class GitHelpInvocation(
fun parseGitHelpInvocation(command: String): GitHelpInvocation? { fun parseGitHelpInvocation(command: String): GitHelpInvocation? {
val tokens = GitSandboxEngine.tokenizeCommand(command) val tokens = GitSandboxEngine.tokenizeCommand(command)
if (tokens.size < 3 || tokens[0] != "git" || tokens[1] != "help") return null if (tokens.size < 3 || tokens[0] != "git") return null
val topic = tokens[2].takeIf { it.matches(Regex("[A-Za-z0-9_-]+")) } ?: return null val topic = when {
tokens[1] == "help" -> tokens[2]
tokens[1] == "-h" || tokens[1] == "--help" -> tokens[2]
tokens.drop(2).any { it == "-h" || it == "--help" } -> tokens[1]
else -> return null
}.takeIf { it.matches(Regex("[A-Za-z0-9_-]+")) } ?: return null
return GitHelpInvocation(topic = topic, command = command) return GitHelpInvocation(topic = topic, command = command)
} }

View File

@@ -1,7 +1,15 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -10,10 +18,12 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -23,11 +33,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@@ -63,8 +78,16 @@ fun GitHugApp() {
var editorState by remember { mutableStateOf<TextEditorState?>(null) } var editorState by remember { mutableStateOf<TextEditorState?>(null) }
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) } var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
var manPageState by remember { mutableStateOf<ManPageState?>(null) } var manPageState by remember { mutableStateOf<ManPageState?>(null) }
var solvedCelebrationTitle by remember { mutableStateOf<String?>(null) }
val currentLevel = levels[currentLevelIndex] val currentLevel = levels[currentLevelIndex]
LaunchedEffect(solvedCelebrationTitle) {
if (solvedCelebrationTitle != null) {
delay(3_000)
solvedCelebrationTitle = null
}
}
LaunchedEffect(persistedPaneLayout) { LaunchedEffect(persistedPaneLayout) {
paneLayout = persistedPaneLayout paneLayout = persistedPaneLayout
} }
@@ -179,11 +202,11 @@ fun GitHugApp() {
applyRecommendedPaneWeights(persist = false) applyRecommendedPaneWeights(persist = false)
} }
fun loadLevel(index: Int) { fun loadLevel(index: Int, message: List<String> = listOf("Loaded level: ${levels[index].title}")) {
AppLog.d("GitHugApp", "Loading level index=$index id=${levels[index].id} title=${levels[index].title}") AppLog.d("GitHugApp", "Loading level index=$index id=${levels[index].id} title=${levels[index].title}")
currentLevelIndex = index currentLevelIndex = index
repo = runtime.prepareLevel(levels[index]) repo = runtime.prepareLevel(levels[index])
output = listOf("Loaded level: ${levels[index].title}") output = message
clearCommandInput() clearCommandInput()
suppressedImeEcho = null suppressedImeEcho = null
hintIndex = 0 hintIndex = 0
@@ -275,12 +298,15 @@ fun GitHugApp() {
} }
addAll(lines) addAll(lines)
if (solvedAfterCommand && !wasAlreadyCompleted) { if (solvedAfterCommand && !wasAlreadyCompleted) {
add("✔ Level solved: ${levelForResult.title}") add("")
add("👍 Level solved: ${levelForResult.title}")
add("Nice. ${completedLevels.size + 1}/${levels.size} levels complete.")
} }
} }
if (solvedAfterCommand && !wasAlreadyCompleted) { if (solvedAfterCommand && !wasAlreadyCompleted) {
val updatedCompletedLevels = completedLevels + levelForResult.id val updatedCompletedLevels = completedLevels + levelForResult.id
completedLevels = updatedCompletedLevels completedLevels = updatedCompletedLevels
solvedCelebrationTitle = levelForResult.title
val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels } val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels }
if (nextLevelIndex >= 0) { if (nextLevelIndex >= 0) {
val nextLevelId = levels[nextLevelIndex].id val nextLevelId = levels[nextLevelIndex].id
@@ -289,12 +315,17 @@ fun GitHugApp() {
"Level solved ${levelForResult.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId", "Level solved ${levelForResult.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId",
) )
scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) } scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) }
loadLevel(nextLevelIndex) loadLevel(
nextLevelIndex,
message = newOutput + listOf(
"Next level loaded: ${levels[nextLevelIndex].title}",
),
)
} else { } else {
AppLog.d("GitHugApp", "All levels completed") AppLog.d("GitHugApp", "All levels completed")
scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) } scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) }
repo = newRepo repo = newRepo
output = listOf("🏁 All Githug levels completed.") output = newOutput + listOf("🏁 All Githug levels completed.")
clearCommandInput() clearCommandInput()
suppressedImeEcho = null suppressedImeEcho = null
} }
@@ -427,10 +458,10 @@ fun GitHugApp() {
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.background(AppBackground) .background(AppBackground)
.verticalScroll(screenScrollState)
.imePadding() .imePadding()
.verticalScroll(screenScrollState)
.padding(horizontal = 10.dp, vertical = 8.dp), .padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -537,5 +568,55 @@ fun GitHugApp() {
onClose = { manPageState = null }, onClose = { manPageState = null },
) )
} }
solvedCelebrationTitle?.let { title ->
SolvedCelebrationOverlay(title = title)
}
}
}
@Composable
private fun SolvedCelebrationOverlay(title: String) {
val transition = rememberInfiniteTransition(label = "solved-celebration")
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1_200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "confetti-progress",
)
val colors = listOf(Accent, Success, Color(0xFFFFD166), Color(0xFFFF6B6B), Color(0xFF9BF6FF))
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.fillMaxSize()) {
repeat(42) { index ->
val x = ((index * 73) % 100) / 100f * size.width
val baseY = ((index * 37) % 100) / 100f * size.height
val y = (baseY + progress * size.height * 0.6f) % size.height
val pieceSize = 6.dp.toPx() + (index % 4) * 2.dp.toPx()
drawCircle(
color = colors[index % colors.size],
radius = pieceSize / 2f,
center = Offset(x, y),
alpha = 0.85f,
)
}
}
Column(
modifier = Modifier
.background(PanelPrimary.copy(alpha = 0.94f), RoundedCornerShape(24.dp))
.padding(horizontal = 26.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "👍", fontSize = 56.sp)
Text(text = "Level solved", color = Success, fontSize = 22.sp)
Text(text = title, color = TextSecondary, fontSize = 14.sp)
}
} }
} }

View File

@@ -67,11 +67,12 @@ class GitRepositoryRuntime(private val context: Context) {
val tokens = GitSandboxEngine.tokenizeCommand(command) val tokens = GitSandboxEngine.tokenizeCommand(command)
if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList() if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
val expandedTokens = expandShellPathspecs(currentRepo, tokens)
val result = when (tokens.first()) { val result = when (expandedTokens.first()) {
"git" -> currentRepo to runGit(nativeGit, workingDir, tokens.drop(1)).outputLines "git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1)).outputLines
"help", "?" -> currentRepo to commandReferenceLines() "help", "?" -> currentRepo to commandReferenceLines()
else -> executeHelperCommand(sandboxRoot, workingDir, currentRepo, tokens) else -> executeHelperCommand(sandboxRoot, workingDir, currentRepo, expandedTokens)
} }
return inspectSandbox(level).copy(currentDir = result.first.currentDir) to result.second return inspectSandbox(level).copy(currentDir = result.first.currentDir) to result.second
@@ -239,13 +240,19 @@ class GitRepositoryRuntime(private val context: Context) {
} }
} }
"rm" -> { "rm" -> {
val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: rm <path>") val targets = tokens.drop(1)
if (targets.isEmpty()) return currentRepo to listOf("usage: rm <path>")
val errors = targets.mapNotNull { target ->
val file = File(workingDir, target) val file = File(workingDir, target)
if (!file.exists()) currentRepo to listOf("rm: $target: No such file or directory") else { if (!file.exists()) {
"rm: $target: No such file or directory"
} else {
file.deleteRecursively() file.deleteRecursively()
currentRepo to emptyList() null
} }
} }
currentRepo to errors
}
"echo" -> executeEcho(workingDir, currentRepo, tokens) "echo" -> executeEcho(workingDir, currentRepo, tokens)
else -> currentRepo to listOf("Command not supported in prototype runtime. Try a git command or helper command.") else -> currentRepo to listOf("Command not supported in prototype runtime. Try a git command or helper command.")
} }
@@ -389,6 +396,9 @@ class GitRepositoryRuntime(private val context: Context) {
if (stagedTargets.isNotEmpty()) { if (stagedTargets.isNotEmpty()) {
runGit(nativeGit, sandbox, listOf("add") + stagedTargets) runGit(nativeGit, sandbox, listOf("add") + stagedTargets)
} }
desired.files.filter { it.deleted }.forEach { file ->
File(sandbox, file.name).delete()
}
} }
private fun filesForSetupCommit(files: List<GitFile>, index: Int, commitCount: Int): List<GitFile> { private fun filesForSetupCommit(files: List<GitFile>, index: Int, commitCount: Int): List<GitFile> {
@@ -437,6 +447,7 @@ class GitRepositoryRuntime(private val context: Context) {
val statusResult = runGit(nativeGit, sandbox, listOf("status", "--porcelain")) val statusResult = runGit(nativeGit, sandbox, listOf("status", "--porcelain"))
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>() val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
val deletedStatusPaths = mutableSetOf<String>()
statusResult.outputLines.forEach { line -> statusResult.outputLines.forEach { line ->
if (line.length < 4) return@forEach if (line.length < 4) return@forEach
val x = line[0] val x = line[0]
@@ -445,6 +456,9 @@ class GitRepositoryRuntime(private val context: Context) {
val staged = x != ' ' && x != '?' val staged = x != ' ' && x != '?'
val tracked = x != '?' || y != '?' val tracked = x != '?' || y != '?'
statusMap[path] = staged to tracked statusMap[path] = staged to tracked
if (x == 'D' || y == 'D') {
deletedStatusPaths += path
}
} }
val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%s")) val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%s"))
@@ -486,6 +500,16 @@ class GitRepositoryRuntime(private val context: Context) {
staged = staged, staged = staged,
tracked = tracked, tracked = tracked,
) )
} + deletedStatusPaths
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(sandbox).path == deletedPath } }
.map { deletedPath ->
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
GitFile(
name = deletedPath,
staged = staged,
tracked = tracked,
deleted = true,
)
}, },
commits = commits, commits = commits,
headBranch = headResult.outputLines.firstOrNull()?.ifBlank { null } ?: "master", headBranch = headResult.outputLines.firstOrNull()?.ifBlank { null } ?: "master",
@@ -511,6 +535,11 @@ class GitRepositoryRuntime(private val context: Context) {
private fun sandboxDir(level: Level): File = File(sandboxesRoot, level.id) private fun sandboxDir(level: Level): File = File(sandboxesRoot, level.id)
private fun expandShellPathspecs(repo: RepoState, tokens: List<String>): List<String> {
if (tokens.size <= 1) return tokens
return listOf(tokens.first()) + GitSandboxEngine.expandPathspecs(repo, tokens.drop(1))
}
private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult { private fun runGit(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
return runProcess(binary, workingDir, arguments) return runProcess(binary, workingDir, arguments)
} }

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -93,6 +94,7 @@ fun InfoPaneCard(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text(title, color = TextPrimary, fontWeight = FontWeight.Bold) Text(title, color = TextPrimary, fontWeight = FontWeight.Bold)
SelectionContainer {
Text( Text(
content, content,
color = TextSecondary, color = TextSecondary,
@@ -100,4 +102,5 @@ fun InfoPaneCard(
) )
} }
} }
}
} }

View File

@@ -7,12 +7,12 @@ internal fun advancedLevels(): List<Level> = listOf(
level("include", description = "Notice a few files with the '.a' extension. We want git to ignore all the files except the 'lib.a' file.", hints = listOf("Using `git help ignore`, read about the optional prefix to negate a pattern."), setup = { RepoState(initialized = true, files = listOf(GitFile(".gitignore", tracked = true), GitFile("lib.a"), GitFile("main.a")), branches = mapOf("master" to 0)) }, validator = repoPredicate { it.files.find { f -> f.name == ".gitignore" }?.content?.let { c -> "*.a" in c && "!lib.a" in c } == true }), level("include", description = "Notice a few files with the '.a' extension. We want git to ignore all the files except the 'lib.a' file.", hints = listOf("Using `git help ignore`, read about the optional prefix to negate a pattern."), setup = { RepoState(initialized = true, files = listOf(GitFile(".gitignore", tracked = true), GitFile("lib.a"), GitFile("main.a")), branches = mapOf("master" to 0)) }, validator = repoPredicate { it.files.find { f -> f.name == ".gitignore" }?.content?.let { c -> "*.a" in c && "!lib.a" in c } == true }),
level("status", description = "Among the files in this repository, which of them is untracked? (Enter the file name on the prompt!)", hints = listOf("You are looking for a command to identify the status of the repository."), setup = { RepoState(initialized = true, files = listOf(GitFile("database.yml"), GitFile("README", tracked = true)), branches = mapOf("master" to 0)) }, validator = commandAnswer("database.yml")), level("status", description = "Among the files in this repository, which of them is untracked? (Enter the file name on the prompt!)", hints = listOf("You are looking for a command to identify the status of the repository."), setup = { RepoState(initialized = true, files = listOf(GitFile("database.yml"), GitFile("README", tracked = true)), branches = mapOf("master" to 0)) }, validator = commandAnswer("database.yml")),
level("number_of_files_committed", description = "There are some files in this repository; how many of them are staged for a commit?", hints = listOf("You are looking for a command to identify the status of the repository (resembles a Linux command)."), setup = { RepoState(initialized = true, files = listOf(GitFile("rubyfile1.rb", staged = true), GitFile("rubyfile4.rb", staged = true, tracked = true), GitFile("rubyfile5.rb", tracked = true), GitFile("rubyfile6.rb"), GitFile("rubyfile7.rb")), branches = mapOf("master" to 1)) }, validator = commandAnswer("2")), level("number_of_files_committed", description = "There are some files in this repository; how many of them are staged for a commit?", hints = listOf("You are looking for a command to identify the status of the repository (resembles a Linux command)."), setup = { RepoState(initialized = true, files = listOf(GitFile("rubyfile1.rb", staged = true), GitFile("rubyfile4.rb", staged = true, tracked = true), GitFile("rubyfile5.rb", tracked = true), GitFile("rubyfile6.rb"), GitFile("rubyfile7.rb")), branches = mapOf("master" to 1)) }, validator = commandAnswer("2")),
level("rm", description = "A file has been removed from the working tree, but not from the repository. Identify this file and remove it.", hints = emptyList(), setup = { RepoState(initialized = true, commits = listOf(CommitNode("0000001", "Added a temp file")), branches = mapOf("master" to 1)) }, validator = { _, command -> command.contains("deleteme.rb") }), level("rm", description = "A file has been removed from the working tree, but not from the repository. Identify this file and remove it.", hints = emptyList(), setup = { RepoState(initialized = true, files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)), commits = listOf(CommitNode("0000001", "Added a temp file")), branches = mapOf("master" to 1)) }, validator = { _, command -> command.contains("deleteme.rb") }),
level("rm_cached", description = "A file has accidentally been added to your staging area. Identify and remove it from the staging area. *NOTE* Do not remove the file from the file system, only from git.", hints = listOf("You may need to use more than one command to complete this."), setup = { RepoState(initialized = true, files = listOf(GitFile("deleteme.rb", staged = true), GitFile(".gitignore", staged = true)), branches = mapOf("master" to 0)) }, validator = { repo, _ -> repo.files.any { it.name == "deleteme.rb" && !it.staged } }), level("rm_cached", description = "A file has accidentally been added to your staging area. Identify and remove it from the staging area. *NOTE* Do not remove the file from the file system, only from git.", hints = listOf("You may need to use more than one command to complete this."), setup = { RepoState(initialized = true, files = listOf(GitFile("deleteme.rb", staged = true), GitFile(".gitignore", staged = true)), branches = mapOf("master" to 0)) }, validator = { repo, _ -> repo.files.any { it.name == "deleteme.rb" && !it.staged } }),
level("stash", description = "You've made some changes and want to work on them later. You should save them, but don't commit them.", hints = listOf("It's like stashing. Try finding an appropriate git command."), setup = { RepoState(initialized = true, files = listOf(GitFile("lyrics.txt", tracked = true)), branches = mapOf("master" to 1)) }, validator = { _, command -> command.startsWith("git stash") }), level("stash", description = "You've made some changes and want to work on them later. You should save them, but don't commit them.", hints = listOf("It's like stashing. Try finding an appropriate git command."), setup = { RepoState(initialized = true, files = listOf(GitFile("lyrics.txt", tracked = true)), branches = mapOf("master" to 1)) }, validator = { _, command -> command.startsWith("git stash") }),
level("rename", description = "We have a file called `oldfile.txt`. We want to rename it to `newfile.txt` and stage this change.", hints = listOf("Take a look at `git mv`."), setup = { RepoState(initialized = true, files = listOf(GitFile("oldfile.txt", tracked = true)), commits = listOf(CommitNode("0000001", "Commited oldfile.txt")), branches = mapOf("master" to 1)) }, validator = { repo, _ -> repo.files.any { it.name == "newfile.txt" } }), level("rename", description = "We have a file called `oldfile.txt`. We want to rename it to `newfile.txt` and stage this change.", hints = listOf("Take a look at `git mv`."), setup = { RepoState(initialized = true, files = listOf(GitFile("oldfile.txt", tracked = true)), commits = listOf(CommitNode("0000001", "Commited oldfile.txt")), branches = mapOf("master" to 1)) }, validator = { repo, _ -> repo.files.any { it.name == "newfile.txt" } }),
level("restructure", description = "You added some files to your repository, but now realize that your project needs to be restructured. Make a new folder named `src` and use Git move all of the .html files into this folder.", hints = listOf("You'll have to use mkdir, and `git mv`."), setup = { RepoState(initialized = true, files = listOf(GitFile("about.html", tracked = true), GitFile("contact.html", tracked = true), GitFile("index.html", tracked = true)), commits = listOf(CommitNode("0000001", "adding web content.")), branches = mapOf("master" to 1)) }, validator = repoPredicate { listOf("src/about.html", "src/contact.html", "src/index.html").all { target -> it.files.any { f -> f.name == target } } }), level("restructure", description = "You added some files to your repository, but now realize that your project needs to be restructured. Make a new folder named `src` and use Git move all of the .html files into this folder.", hints = listOf("You'll have to use mkdir, and `git mv`."), setup = { RepoState(initialized = true, files = listOf(GitFile("about.html", tracked = true), GitFile("contact.html", tracked = true), GitFile("index.html", tracked = true)), commits = listOf(CommitNode("0000001", "adding web content.")), branches = mapOf("master" to 1)) }, validator = repoPredicate { listOf("src/about.html", "src/contact.html", "src/index.html").all { target -> it.files.any { f -> f.name == target } } }),
level("log", description = "Identify the hash of the latest commit.", hints = listOf("You need to investigate the logs."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("0000001", "THIS IS THE COMMIT YOU ARE LOOKING FOR!")), branches = mapOf("master" to 1)) }, validator = commandAnswer("0000001")), level("log", description = "Identify the hash of the latest commit.", hints = listOf("You need to investigate the logs."), setup = { RepoState(initialized = true, commits = listOf(CommitNode("0000001", "THIS IS THE COMMIT YOU ARE LOOKING FOR!")), branches = mapOf("master" to 1)) }, validator = commitHashAnswer("0000001")),
level("push_tags", description = "A tag in the local repository isn't pushed into remote repository. Push it now.", hints = listOf("Take a look at `--tags` flag of `git push`"), setup = { RepoState(initialized = true, tags = listOf("tag_to_be_pushed"), branches = mapOf("master" to 2), remotes = mapOf("origin" to "remote")) }, validator = { _, command -> command.contains("push") && command.contains("--tags") }), level("push_tags", description = "A tag in the local repository isn't pushed into remote repository. Push it now.", hints = listOf("Take a look at `--tags` flag of `git push`"), setup = { RepoState(initialized = true, tags = listOf("tag_to_be_pushed"), branches = mapOf("master" to 2), remotes = mapOf("origin" to "remote")) }, validator = { _, command -> command.contains("push") && command.contains("--tags") }),
level("commit_amend", description = "The `README` file has been committed, but it looks like the file `forgotten_file.rb` was missing from the commit. Add the file and amend your previous commit to include it.", hints = listOf("Running `git commit --help` will display the man page and possible flags."), setup = { RepoState(initialized = true, files = listOf(GitFile("README", tracked = true), GitFile("forgotten_file.rb")), commits = listOf(CommitNode("0000001", "Initial commit")), branches = mapOf("master" to 1)) }, validator = { repo, command -> repo.files.any { it.name == "forgotten_file.rb" && it.tracked } && "--amend" in command }), level("commit_amend", description = "The `README` file has been committed, but it looks like the file `forgotten_file.rb` was missing from the commit. Add the file and amend your previous commit to include it.", hints = listOf("Running `git commit --help` will display the man page and possible flags."), setup = { RepoState(initialized = true, files = listOf(GitFile("README", tracked = true), GitFile("forgotten_file.rb")), commits = listOf(CommitNode("0000001", "Initial commit")), branches = mapOf("master" to 1)) }, validator = { repo, command -> repo.files.any { it.name == "forgotten_file.rb" && it.tracked } && "--amend" in command }),
level("commit_in_future", description = "Commit your changes with the future date (e.g. tomorrow).", hints = listOf("Build a time format, and commit your code using --date parameter."), setup = { RepoState(initialized = true, files = listOf(GitFile("README", staged = true)), branches = mapOf("master" to 0)) }, validator = { repo, command -> repo.commits.isNotEmpty() && "--date" in command }), level("commit_in_future", description = "Commit your changes with the future date (e.g. tomorrow).", hints = listOf("Build a time format, and commit your code using --date parameter."), setup = { RepoState(initialized = true, files = listOf(GitFile("README", staged = true)), branches = mapOf("master" to 0)) }, validator = { repo, command -> repo.commits.isNotEmpty() && "--date" in command }),

View File

@@ -30,6 +30,19 @@ internal fun commandAnswer(vararg answers: String): (RepoState, String) -> Boole
matched != null matched != null
} }
internal fun commitHashAnswer(fallbackHash: String): (RepoState, String) -> Boolean = { repo, command ->
val normalized = command.trim()
val latestHash = repo.commits.firstOrNull()?.id ?: fallbackHash
val matched = normalized.equals(fallbackHash, ignoreCase = true) ||
normalized.startsWith(latestHash, ignoreCase = true) ||
latestHash.startsWith(normalized, ignoreCase = true)
AppLog.d(
"Validation",
"commitHashAnswer normalized='$normalized' fallback=$fallbackHash latest=$latestHash matched=$matched",
)
normalized.isNotBlank() && matched
}
internal fun repoPredicate(block: (RepoState) -> Boolean): (RepoState, String) -> Boolean = { repo, _ -> internal fun repoPredicate(block: (RepoState) -> Boolean): (RepoState, String) -> Boolean = { repo, _ ->
block(repo) block(repo)
} }

View File

@@ -14,8 +14,19 @@ class GitHelpCommandsTest {
} }
@Test @Test
fun ignoresNonHelpCommands() { fun parsesGitSubcommandShortHelpCommand() {
assertNull(parseGitHelpInvocation("git tag -h")) val invocation = parseGitHelpInvocation("git tag -h")
assertEquals("tag", invocation?.topic)
assertEquals("git tag -h", invocation?.command)
}
@Test
fun parsesGitGlobalShortHelpCommand() {
val invocation = parseGitHelpInvocation("git -h tag")
assertEquals("tag", invocation?.topic)
assertEquals("git -h tag", invocation?.command)
} }
@Test @Test

View File

@@ -0,0 +1,67 @@
package solutions.tretter.githugandroid
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GitSandboxEngineTest {
@Test
fun statusShowsDeletedTrackedFiles() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)),
commits = listOf(CommitNode("0000001", "Added a temp file")),
branches = mapOf("master" to 1),
)
val (_, output) = GitSandboxEngine.execute(repo, "git status")
assertTrue(output.any { it.contains("deleted:") && it.contains("deleteme.rb") })
}
@Test
fun gitRmRemovesDeletedTrackedFileFromIndex() {
val repo = RepoState(
initialized = true,
files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)),
commits = listOf(CommitNode("0000001", "Added a temp file")),
branches = mapOf("master" to 1),
)
val (updatedRepo, _) = GitSandboxEngine.execute(repo, "git rm deleteme.rb")
assertFalse(updatedRepo.files.any { it.name == "deleteme.rb" })
}
@Test
fun gitMvExpandsWildcardSourcesIntoDestinationDirectory() {
val repo = RepoState(
initialized = true,
files = listOf(
GitFile("about.html", tracked = true),
GitFile("contact.html", tracked = true),
GitFile("index.html", tracked = true),
GitFile("README", tracked = true),
),
branches = mapOf("master" to 1),
)
val (updatedRepo, _) = GitSandboxEngine.execute(repo, "git mv *.html src")
assertTrue(updatedRepo.files.any { it.name == "src/about.html" })
assertTrue(updatedRepo.files.any { it.name == "src/contact.html" })
assertTrue(updatedRepo.files.any { it.name == "src/index.html" })
assertTrue(updatedRepo.files.any { it.name == "README" })
}
@Test
fun commitHashAnswerAcceptsFullHashThatStartsWithDisplayedShortHash() {
val repo = RepoState(
initialized = true,
commits = listOf(CommitNode("abc1234", "Latest commit")),
branches = mapOf("master" to 1),
)
assertTrue(commitHashAnswer("0000001")(repo, "abc1234fedcba9876543210fedcba9876543210"))
}
}