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:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -101,3 +103,4 @@ fun InfoPaneCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user