diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48bc58c..9507a80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 110 - versionName = "0.1.109" + versionCode = 111 + versionName = "0.1.110" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index 938027c..2ecdb38 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -12,6 +12,7 @@ data class GitFile( val content: String = "", val staged: Boolean = false, val tracked: Boolean = false, + val deleted: Boolean = false, ) data class CommitNode( @@ -48,7 +49,7 @@ val RepoStateSaver = listSaver( listOf( state.initialized, 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.branches.flatMap { listOf(it.key, it.value.toString()) }, state.currentDir, @@ -69,12 +70,13 @@ val RepoStateSaver = listSaver( RepoState( initialized = initialized, headBranch = headBranch, - files = fileParts.chunked(4).map { + 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 { @@ -112,16 +114,22 @@ object GitSandboxEngine { 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") + 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.size >= 2 -> repo to emptyList() parts[0] == "rm" && parts.size >= 2 -> { 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] == "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.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") @@ -139,15 +147,15 @@ object GitSandboxEngine { } parts.size >= 3 && parts[1] == "add" -> { 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") } 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}") } } 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] == "log" -> { 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 { val current = updatedFiles[index] 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() } + 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("-") } @@ -266,9 +290,22 @@ object GitSandboxEngine { return repo.copy(files = updated) to emptyList() } - private fun moveGitPath(repo: RepoState, source: String, destination: String): Pair> { + 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 == 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() } @@ -439,18 +476,27 @@ object GitSandboxEngine { private fun statusLines(repo: RepoState): List { 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 { add("On branch ${repo.headBranch}") - if (staged.isEmpty() && unstaged.isEmpty()) { + 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) @@ -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( val message: String? = null, val stageAllTracked: Boolean = false, diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt index 5c14c48..7f91fce 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHelpCommands.kt @@ -7,7 +7,12 @@ data class GitHelpInvocation( fun parseGitHelpInvocation(command: String): GitHelpInvocation? { val tokens = GitSandboxEngine.tokenizeCommand(command) - if (tokens.size < 3 || tokens[0] != "git" || tokens[1] != "help") return null - val topic = tokens[2].takeIf { it.matches(Regex("[A-Za-z0-9_-]+")) } ?: return null + if (tokens.size < 3 || tokens[0] != "git") 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) } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index bb11740..9c1a341 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -1,7 +1,15 @@ 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.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -23,11 +33,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.TextRange import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable @@ -63,8 +78,16 @@ fun GitHugApp() { var editorState by remember { mutableStateOf(null) } var gitMessageEditorState by remember { mutableStateOf(null) } var manPageState by remember { mutableStateOf(null) } + var solvedCelebrationTitle by remember { mutableStateOf(null) } val currentLevel = levels[currentLevelIndex] + LaunchedEffect(solvedCelebrationTitle) { + if (solvedCelebrationTitle != null) { + delay(3_000) + solvedCelebrationTitle = null + } + } + LaunchedEffect(persistedPaneLayout) { paneLayout = persistedPaneLayout } @@ -179,11 +202,11 @@ fun GitHugApp() { applyRecommendedPaneWeights(persist = false) } - fun loadLevel(index: Int) { + fun loadLevel(index: Int, message: List = listOf("Loaded level: ${levels[index].title}")) { AppLog.d("GitHugApp", "Loading level index=$index id=${levels[index].id} title=${levels[index].title}") currentLevelIndex = index repo = runtime.prepareLevel(levels[index]) - output = listOf("Loaded level: ${levels[index].title}") + output = message clearCommandInput() suppressedImeEcho = null hintIndex = 0 @@ -275,12 +298,15 @@ fun GitHugApp() { } addAll(lines) 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) { val updatedCompletedLevels = completedLevels + levelForResult.id completedLevels = updatedCompletedLevels + solvedCelebrationTitle = levelForResult.title val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels } if (nextLevelIndex >= 0) { val nextLevelId = levels[nextLevelIndex].id @@ -289,12 +315,17 @@ fun GitHugApp() { "Level solved ${levelForResult.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId", ) scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) } - loadLevel(nextLevelIndex) + loadLevel( + nextLevelIndex, + message = newOutput + listOf( + "Next level loaded: ${levels[nextLevelIndex].title}", + ), + ) } else { AppLog.d("GitHugApp", "All levels completed") scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) } repo = newRepo - output = listOf("🏁 All Githug levels completed.") + output = newOutput + listOf("🏁 All Githug levels completed.") clearCommandInput() suppressedImeEcho = null } @@ -427,10 +458,10 @@ fun GitHugApp() { ) { Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .background(AppBackground) - .verticalScroll(screenScrollState) .imePadding() + .verticalScroll(screenScrollState) .padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -537,5 +568,55 @@ fun GitHugApp() { 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) + } } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 9623db3..c02e21a 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -67,11 +67,12 @@ class GitRepositoryRuntime(private val context: Context) { val tokens = GitSandboxEngine.tokenizeCommand(command) if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList() + val expandedTokens = expandShellPathspecs(currentRepo, tokens) - val result = when (tokens.first()) { - "git" -> currentRepo to runGit(nativeGit, workingDir, tokens.drop(1)).outputLines + val result = when (expandedTokens.first()) { + "git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1)).outputLines "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 @@ -239,12 +240,18 @@ class GitRepositoryRuntime(private val context: Context) { } } "rm" -> { - val target = tokens.getOrNull(1) ?: return currentRepo to listOf("usage: rm ") - val file = File(workingDir, target) - if (!file.exists()) currentRepo to listOf("rm: $target: No such file or directory") else { - file.deleteRecursively() - currentRepo to emptyList() + val targets = tokens.drop(1) + if (targets.isEmpty()) return currentRepo to listOf("usage: rm ") + val errors = targets.mapNotNull { target -> + val file = File(workingDir, target) + if (!file.exists()) { + "rm: $target: No such file or directory" + } else { + file.deleteRecursively() + null + } } + currentRepo to errors } "echo" -> executeEcho(workingDir, currentRepo, tokens) 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()) { runGit(nativeGit, sandbox, listOf("add") + stagedTargets) } + desired.files.filter { it.deleted }.forEach { file -> + File(sandbox, file.name).delete() + } } private fun filesForSetupCommit(files: List, index: Int, commitCount: Int): List { @@ -437,6 +447,7 @@ class GitRepositoryRuntime(private val context: Context) { val statusResult = runGit(nativeGit, sandbox, listOf("status", "--porcelain")) val statusMap = mutableMapOf>() + val deletedStatusPaths = mutableSetOf() statusResult.outputLines.forEach { line -> if (line.length < 4) return@forEach val x = line[0] @@ -445,6 +456,9 @@ class GitRepositoryRuntime(private val context: Context) { val staged = x != ' ' && x != '?' val tracked = x != '?' || y != '?' statusMap[path] = staged to tracked + if (x == 'D' || y == 'D') { + deletedStatusPaths += path + } } val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%s")) @@ -486,7 +500,17 @@ class GitRepositoryRuntime(private val context: Context) { staged = staged, 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, headBranch = headResult.outputLines.firstOrNull()?.ifBlank { null } ?: "master", branches = branchResult.outputLines.map { it.removePrefix("*").trim() }.filter { it.isNotBlank() }.associateWith { 0 }, @@ -511,6 +535,11 @@ class GitRepositoryRuntime(private val context: Context) { private fun sandboxDir(level: Level): File = File(sandboxesRoot, level.id) + private fun expandShellPathspecs(repo: RepoState, tokens: List): List { + 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): ProcessExecutionResult { return runProcess(binary, workingDir, arguments) } diff --git a/app/src/main/java/solutions/tretter/githugandroid/Visual.kt b/app/src/main/java/solutions/tretter/githugandroid/Visual.kt index 53f4b9b..e462263 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/Visual.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/Visual.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Text @@ -93,11 +94,13 @@ fun InfoPaneCard( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text(title, color = TextPrimary, fontWeight = FontWeight.Bold) - Text( - content, - color = TextSecondary, - fontFamily = FontFamily.Monospace, - ) + SelectionContainer { + Text( + content, + color = TextSecondary, + fontFamily = FontFamily.Monospace, + ) + } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/AdvancedLevels.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/AdvancedLevels.kt index 8986283..e7e2de9 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/AdvancedLevels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/AdvancedLevels.kt @@ -7,12 +7,12 @@ internal fun advancedLevels(): List = 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("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("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("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("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("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 }), @@ -50,4 +50,4 @@ internal fun advancedLevels(): List = listOf( level("conflict", description = "You need to merge mybranch into the current branch (master). But there may be some incorrect changes in mybranch which may cause conflicts. Solve any merge-conflicts you come across and finish the merge.", hints = emptyList(), setup = { RepoState(initialized = true, files = listOf(GitFile("poem.txt", tracked = true)), branches = mapOf("master" to 2, "mybranch" to 2)) }, validator = { _, command -> command.startsWith("git merge") || command.startsWith("git commit") }), level("submodule", description = "You want to include the files from the following repo: `https://github.com/jackmaney/githug-include-me` into the folder `./githug-include-me`. Do this without manually cloning the repo or copying the files from the repo into this repo.", hints = listOf("Take a look at `git submodule`."), setup = { RepoState(initialized = true, branches = mapOf("master" to 0)) }, validator = { _, command -> command.startsWith("git submodule add") }), level("contribute", description = "This is the final level, the goal is to contribute to this repository by making a pull request on GitHub. Please note that this level is designed to encourage you to add a valid contribution to Githug, not testing your ability to create a pull request. Contributions that are likely to be accepted are levels, bug fixes and improved documentation.", hints = listOf("Forking the repository would be a good start!"), setup = { RepoState() }, validator = { _, command -> command.isNotBlank() }), -) \ No newline at end of file +) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt index 82dd591..e50270d 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt @@ -30,6 +30,19 @@ internal fun commandAnswer(vararg answers: String): (RepoState, String) -> Boole 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, _ -> block(repo) } @@ -74,4 +87,4 @@ private fun RepoState.validationSnapshot(): String = buildString { append(files.map { file -> "${file.name}(staged=${file.staged},tracked=${file.tracked})" }.sorted()) append(", commits=") append(commits.map { it.id to it.message }) -} \ No newline at end of file +} diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt index 64a048e..4c8d6ec 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitHelpCommandsTest.kt @@ -14,8 +14,19 @@ class GitHelpCommandsTest { } @Test - fun ignoresNonHelpCommands() { - assertNull(parseGitHelpInvocation("git tag -h")) + fun parsesGitSubcommandShortHelpCommand() { + 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 diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt new file mode 100644 index 0000000..a28bd7f --- /dev/null +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -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")) + } +}