Align level parity docs and fetch/status validation

- Add LevelsCompare.md with setup and validation comparisons against Gazler/githug.
- Document that LevelsCompare.md must be updated when level behavior changes.
- Track FETCH_HEAD count in RepoState so the Fetch level can match upstream validation.
- Mirror upstream Status setup with staged files plus untracked database.yml.
- Keep Fetch from being solved by pull despite native Git FETCH_HEAD behavior.
- Bump app version and build debug APK.
This commit is contained in:
Joe Tretter
2026-05-11 21:34:28 -05:00
parent 316323a00c
commit 4bac818571
11 changed files with 228 additions and 12 deletions

View File

@@ -31,6 +31,7 @@ data class RepoState(
val config: Map<String, String> = emptyMap(),
val stashes: List<String> = emptyList(),
val fetchedBranches: Set<String> = emptySet(),
val fetchHeadCount: Int = 0,
val pushedBranches: Set<String> = emptySet(),
val pushedTags: Set<String> = emptySet(),
val submodules: Map<String, String> = emptyMap(),
@@ -55,4 +56,3 @@ data class LevelTestCase(
)
fun sampleLevels(): List<Level> = allGithugLevels()

View File

@@ -631,6 +631,11 @@ class GitRepositoryRuntime private constructor(
val exactTagResult = runGit(nativeGit, sandbox, listOf("describe", "--tags", "--exact-match"))
val userNameResult = runGit(nativeGit, sandbox, listOf("config", "--get", "user.name"))
val userEmailResult = runGit(nativeGit, sandbox, listOf("config", "--get", "user.email"))
val fetchHeadCount = File(sandbox, ".git/FETCH_HEAD")
.takeIf { it.isFile }
?.readLines()
?.count { it.isNotBlank() }
?: 0
val config = buildMap {
userNameResult.outputLines.firstOrNull()
?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() }
@@ -704,6 +709,7 @@ class GitRepositoryRuntime private constructor(
.map { it.removePrefix("*").trim() }
.filter { it.isNotBlank() && " -> " !in it }
.toSet(),
fetchHeadCount = fetchHeadCount,
)
}
@@ -738,9 +744,8 @@ class GitRepositoryRuntime private constructor(
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes + "stash@{${previousRepo.stashes.size}}"),
)
"fetch" -> {
val remote = tokens.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
inspectedRepo.copy(
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches + "$remote/master" + "$remote/feature_branch" + "$remote/new_branch",
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches,
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "fetch",
)
}
@@ -749,6 +754,8 @@ class GitRepositoryRuntime private constructor(
val branch = tokens.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: inspectedRepo.headBranch
inspectedRepo.copy(
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches + "$remote/$branch",
fetchHeadCount = inspectedRepo.fetchHeadCount,
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "pull",
)
}
"push" -> {
@@ -795,6 +802,7 @@ class GitRepositoryRuntime private constructor(
else -> inspectedRepo.copy(
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes),
fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches,
fetchHeadCount = inspectedRepo.fetchHeadCount,
pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches,
pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags,
submodules = previousRepo.submodules + inspectedRepo.submodules,

View File

@@ -63,7 +63,8 @@ object GitSandboxEngine {
parts.size >= 2 && parts[1] == "fetch" -> {
val remote = parts.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
repo.copy(
fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/feature_branch", "$remote/new_branch"),
fetchedBranches = repo.fetchedBranches + listOf("$remote/master", "$remote/new_branch"),
fetchHeadCount = 2,
maintenanceActions = repo.maintenanceActions + "fetch",
) to emptyList()
}
@@ -72,7 +73,9 @@ object GitSandboxEngine {
val branch = parts.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: repo.headBranch
repo.copy(
fetchedBranches = repo.fetchedBranches + "$remote/$branch",
fetchHeadCount = 1,
branches = repo.branches + (repo.headBranch to maxOf(repo.branches[repo.headBranch] ?: 0, 2)),
maintenanceActions = repo.maintenanceActions + "pull",
) to emptyList()
}
parts.size >= 2 && parts[1] == "push" -> pushRefs(repo, parts.drop(2))

View File

@@ -20,6 +20,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
state.pushedTags.toList(),
state.submodules.flatMap { listOf(it.key, it.value) },
state.maintenanceActions.toList(),
state.fetchHeadCount,
)
},
restore = { saved ->
@@ -37,6 +38,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
val pushedTags = saved.getOrNull(12) as? List<*> ?: emptyList<Any>()
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0
RepoState(
initialized = initialized,
headBranch = headBranch,
@@ -59,6 +61,7 @@ val RepoStateSaver = listSaver<RepoState, Any>(
config = configParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
stashes = stashes.filterIsInstance<String>(),
fetchedBranches = fetchedBranches.filterIsInstance<String>().toSet(),
fetchHeadCount = fetchHeadCount,
pushedBranches = pushedBranches.filterIsInstance<String>().toSet(),
pushedTags = pushedTags.filterIsInstance<String>().toSet(),
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },

View File

@@ -15,7 +15,15 @@ internal fun fetchLevel(): Level = level(
description = "Looks like a new branch was pushed into our remote repository. Get the changes without merging them with the local repository",
hints = listOf("Look up the 'git fetch' command"),
commandSuggestions = listOf("git fetch origin"),
setup = { RepoState(initialized = true, branches = mapOf("master" to 1), remotes = mapOf("origin" to "remote")) },
setup = {
RepoState(
initialized = true,
branches = mapOf("master" to 1),
remotes = mapOf("origin" to "remote"),
fetchedBranches = setOf("origin/master"),
fetchHeadCount = 1,
)
},
nativeSetup = {
resetFiles()
write("master_file")
@@ -33,9 +41,9 @@ internal fun fetchLevel(): Level = level(
true
},
validator = repoPredicate { repo ->
"fetch" in repo.maintenanceActions &&
"origin/new_branch" in repo.fetchedBranches &&
repo.headBranch == "master"
repo.branches.size == 1 &&
repo.fetchHeadCount == 2 &&
"pull" !in repo.maintenanceActions
},
testCases = listOf(
levelTestCase("fetch origin", "git fetch origin"),

View File

@@ -149,6 +149,8 @@ private fun RepoState.validationSnapshot(): String = buildString {
append(remotes.toSortedMap())
append(", fetchedBranches=")
append(fetchedBranches.sorted())
append(", fetchHeadCount=")
append(fetchHeadCount)
append(", pushedBranches=")
append(pushedBranches.sorted())
append(", pushedTags=")

View File

@@ -13,7 +13,29 @@ internal fun statusLevel(): Level = level(
description = "Among the files in this repository, which of them is untracked?",
hints = listOf("You are looking for a command to identify the status of the repository."),
commandSuggestions = listOf("git status"),
setup = { RepoState(initialized = true, files = listOf(GitFile("database.yml")), branches = mapOf("master" to 0)) },
setup = {
RepoState(
initialized = true,
files = listOf(
GitFile("config.rb", staged = true),
GitFile("README", staged = true),
GitFile("setup.rb", staged = true),
GitFile("deploy.rb", staged = true),
GitFile("Guardfile", staged = true),
GitFile("database.yml"),
),
branches = mapOf("master" to 0),
)
},
nativeSetup = {
resetFiles()
listOf("config.rb", "README", "setup.rb", "deploy.rb", "Guardfile").forEach { file ->
write(file)
git("add", file)
}
write("database.yml")
true
},
validator = commandAnswer("database.yml"),
testCases = listOf(
levelTestCase("answer untracked file", "database.yml"),