Fix level setup, reword flow, and catalog scope

- create and enter the level's declared starting directory
- inspect Git state from the repository containing the active directory
- start the init level in /sandbox/git_hug and cover it with a runtime test
- apply edited reword subjects as commit messages in the in-app rebase editor
- cover rename_commit with a native interactive rebase regression test
- remove the upstream contribute call to action from the playable level catalog
- update level parity documentation
This commit is contained in:
Joe Tretter
2026-06-08 14:21:21 -05:00
parent 0d20c089ec
commit 02a23694c3
11 changed files with 194 additions and 68 deletions

View File

@@ -24,6 +24,6 @@ fun parseGitHelpInvocation(command: String): GitHelpInvocation? {
tokens[1] == "--help" -> tokens[2]
tokens.drop(2).any { it == "--help" } -> tokens[1]
else -> return null
}.takeIf { it.matches(Regex("[A-Za-z0-9_-]+")) } ?: return null
}.takeIf { !it.startsWith("-") && it.matches(Regex("[A-Za-z0-9_-]+")) } ?: return null
return GitHelpInvocation(topic = topic, command = command)
}

View File

@@ -68,6 +68,7 @@ class GitRepositoryRuntime private constructor(
writeText(file.content)
}
}
File(sandbox, desired.currentDir).mkdirs()
val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty()
if (needsGit) {
@@ -80,7 +81,7 @@ class GitRepositoryRuntime private constructor(
levelMaterializer.materialize(nativeGit, sandbox, desired, level)
}
return inspectSandbox(level)
return inspectSandbox(level, desired.currentDir).copy(currentDir = desired.currentDir)
}
fun execute(level: Level, currentRepo: RepoState, command: String): Pair<RepoState, List<String>> {
@@ -99,7 +100,7 @@ class GitRepositoryRuntime private constructor(
val shellTokens = GitSandboxEngine.tokenizeShellCommand(command)
val invocation = parseEnvironmentPrefixedCommand(shellTokens)
val tokens = invocation.command.map { it.value }
if (tokens.isEmpty()) return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to emptyList()
if (tokens.isEmpty()) return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to emptyList()
if (currentRepo.interactiveAddSession != null) {
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
@@ -108,7 +109,7 @@ class GitRepositoryRuntime private constructor(
if (newlyStagedPaths.isNotEmpty()) {
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
}
val inspectedRepo = inspectSandbox(level).copy(
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
currentDir = updatedRepo.currentDir,
interactiveAddSession = updatedRepo.interactiveAddSession,
)
@@ -131,7 +132,7 @@ class GitRepositoryRuntime private constructor(
?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens)
}
val inspectedRepo = inspectSandbox(level).copy(currentDir = result.first.currentDir)
val inspectedRepo = inspectSandbox(level, result.first.currentDir).copy(currentDir = result.first.currentDir)
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) to result.second
}
@@ -219,7 +220,7 @@ class GitRepositoryRuntime private constructor(
file.parentFile?.mkdirs()
file.writeText(content)
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path")
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to listOf("Saved $path")
}
fun gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
@@ -245,7 +246,7 @@ class GitRepositoryRuntime private constructor(
appendLine("#")
appendLine("# Commands:")
appendLine("# p, pick <commit> = use commit")
appendLine("# r, reword <commit> = use commit, but edit the commit message")
appendLine("# r, reword <commit> = use commit and replace its message with the text on this line")
appendLine("# e, edit <commit> = use commit, but stop for amending")
appendLine("# s, squash <commit> = use commit, but meld into previous commit")
appendLine("# f, fixup [-C | -c] <commit> = like squash but keep only the previous commit's log message")
@@ -282,7 +283,7 @@ class GitRepositoryRuntime private constructor(
if (newlyStagedPaths.isNotEmpty()) {
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
}
val inspectedRepo = inspectSandbox(level).copy(
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
currentDir = updatedRepo.currentDir,
interactiveAddSession = updatedRepo.interactiveAddSession,
)
@@ -300,7 +301,7 @@ class GitRepositoryRuntime private constructor(
val result = runGit(nativeGit, workingDir, arguments)
messageFile.delete()
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
}
private fun executeInteractiveRebaseEditorCommand(
@@ -316,6 +317,7 @@ class GitRepositoryRuntime private constructor(
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
writeText(todo)
}
val rewordMessages = todo.lineSequence().mapNotNull(::rewordMessageFromTodoLine).toList()
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
writeText(
"""
@@ -325,6 +327,31 @@ class GitRepositoryRuntime private constructor(
)
setReadable(true, true)
}
val rewordMessageFiles = rewordMessages.mapIndexed { index, message ->
File(gitDir, "GITHUG_ANDROID_REWORD_${index + 1}").apply {
writeText(message.trimEnd() + "\n")
}
}
val rewordCounterFile = File(gitDir, "GITHUG_ANDROID_REWORD_COUNTER")
val messageEditorScript = File(gitDir, "githug-android-message-editor.sh").apply {
writeText(
"""
|#!/bin/sh
|counter_file=${rewordCounterFile.absolutePath.toShellSingleQuoted()}
|index=0
|if [ -f "${'$'}counter_file" ]; then
| index=$(cat "${'$'}counter_file")
|fi
|index=$((index + 1))
|printf '%s\n' "${'$'}index" > "${'$'}counter_file"
|message_file=${File(gitDir, "GITHUG_ANDROID_REWORD_").absolutePath.toShellSingleQuoted()}"${'$'}index"
|if [ -f "${'$'}message_file" ]; then
| cat "${'$'}message_file" > "$1"
|fi
|""".trimMargin(),
)
setReadable(true, true)
}
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command).drop(1)
val result = runGit(
nativeGit,
@@ -332,13 +359,28 @@ class GitRepositoryRuntime private constructor(
arguments,
mapOf(
"GIT_SEQUENCE_EDITOR" to "${shellExecutable().toShellSingleQuoted()} ${editorScript.absolutePath.toShellSingleQuoted()}",
"GIT_EDITOR" to "true",
"GIT_EDITOR" to if (rewordMessages.isEmpty()) {
"true"
} else {
"${shellExecutable().toShellSingleQuoted()} ${messageEditorScript.absolutePath.toShellSingleQuoted()}"
},
),
)
todoFile.delete()
editorScript.delete()
rewordMessageFiles.forEach { it.delete() }
rewordCounterFile.delete()
messageEditorScript.delete()
return inspectSandbox(level).copy(currentDir = currentRepo.currentDir) to result.outputLines
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
}
private fun rewordMessageFromTodoLine(line: String): String? {
val trimmed = line.trim()
if (trimmed.isEmpty() || trimmed.startsWith("#")) return null
val parts = trimmed.split(Regex("\\s+"), limit = 3)
if (parts.size < 3 || parts[0] !in setOf("r", "reword")) return null
return parts[2].takeIf { it.isNotBlank() }
}
private fun executeSyntheticGitCommand(
@@ -394,22 +436,33 @@ class GitRepositoryRuntime private constructor(
}
}
private fun inspectSandbox(level: Level): RepoState {
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState {
val sandbox = sandboxDir(level)
val nativeGit = requireNativeGit()
val filesOnDisk = sandbox.walkTopDown()
.filter { it.isFile && !it.relativeTo(sandbox).path.startsWith(".git/") }
val workingDir = File(sandbox, currentDir).canonicalFile
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
?: sandbox
val repositoryRoot = generateSequence(workingDir) { directory ->
directory.parentFile?.takeIf {
it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator)
}
}.firstOrNull { File(it, ".git").exists() }
val inspectionRoot = repositoryRoot ?: sandbox
val filesOnDisk = inspectionRoot.walkTopDown()
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
.orEmpty()
.toList()
if (!File(sandbox, ".git").exists()) {
if (repositoryRoot == null) {
return RepoState(
initialized = File(sandbox, ".git").exists(),
files = filesOnDisk.map { GitFile(name = it.name, content = it.readText()) },
initialized = false,
files = filesOnDisk.map {
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
},
)
}
val statusResult = runGit(nativeGit, sandbox, listOf("status", "--porcelain"))
val statusResult = runGit(nativeGit, inspectionRoot, listOf("status", "--porcelain"))
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
val deletedStatusPaths = mutableSetOf<String>()
statusResult.outputLines.forEach { line ->
@@ -425,16 +478,16 @@ class GitRepositoryRuntime private constructor(
}
}
val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
val branchResult = runGit(nativeGit, sandbox, listOf("branch", "--list"))
val remoteBranchResult = runGit(nativeGit, sandbox, listOf("branch", "-r", "--list"))
val tagResult = runGit(nativeGit, sandbox, listOf("tag", "--list"))
val remoteResult = runGit(nativeGit, sandbox, listOf("remote", "-v"))
val headResult = runGit(nativeGit, sandbox, listOf("branch", "--show-current"))
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")
val logResult = runGit(nativeGit, inspectionRoot, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
val branchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--list"))
val remoteBranchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "-r", "--list"))
val tagResult = runGit(nativeGit, inspectionRoot, listOf("tag", "--list"))
val remoteResult = runGit(nativeGit, inspectionRoot, listOf("remote", "-v"))
val headResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--show-current"))
val exactTagResult = runGit(nativeGit, inspectionRoot, listOf("describe", "--tags", "--exact-match"))
val userNameResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.name"))
val userEmailResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.email"))
val fetchHeadCount = File(inspectionRoot, ".git/FETCH_HEAD")
.takeIf { it.isFile }
?.readLines()
?.count { it.isNotBlank() }
@@ -474,7 +527,7 @@ class GitRepositoryRuntime private constructor(
.map { it.removePrefix("*").trim() }
.filter { it.isNotBlank() && !it.startsWith("(") }
.associateWith { branch ->
runGit(nativeGit, sandbox, listOf("rev-list", "--count", branch))
runGit(nativeGit, inspectionRoot, listOf("rev-list", "--count", branch))
.outputLines
.firstOrNull()
?.toIntOrNull()
@@ -490,7 +543,7 @@ class GitRepositoryRuntime private constructor(
return RepoState(
initialized = true,
files = filesOnDisk.map { file ->
val relativePath = file.relativeTo(sandbox).path
val relativePath = file.relativeTo(inspectionRoot).path
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
GitFile(
name = relativePath,
@@ -499,7 +552,7 @@ class GitRepositoryRuntime private constructor(
tracked = tracked,
)
} + deletedStatusPaths
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(sandbox).path == deletedPath } }
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
.map { deletedPath ->
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
GitFile(

View File

@@ -1,21 +0,0 @@
package solutions.tretter.githugandroid
/**
* Port of the upstream ruby-githug `contribute` level.
*
* Setup documents the repository shape the learner explores. Evaluation is kept
* state-based whenever the exercise changes repository objects; answer-only
* levels intentionally validate the answer entered at the prompt.
*/
internal fun contributeLevel(): Level = level(
id = "contribute",
title = "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!"),
commandSuggestions = listOf("Open a pull request"),
setup = { RepoState() },
validator = { _, command -> command.isNotBlank() },
testCases = listOf(
levelTestCase("acknowledge contribution goal", "Open a pull request"),
),
)

View File

@@ -13,9 +13,15 @@ internal fun initLevel(): Level = level(
description = "A new directory, `git_hug`, has been created; initialize an empty repository in it.",
hints = listOf("You can type `git --help` or `git` in your shell to get a list of available git commands."),
commandSuggestions = listOf("git init", "git status"),
setup = { RepoState() },
setup = { RepoState(currentDir = "git_hug") },
validator = repoPredicate { it.initialized },
testCases = listOf(
levelTestCase("plain init", "git init"),
),
setupChecks = listOf(
levelSetupCheck(
name = "starts in git_hug",
failureMessage = "Expected the learner to start in the created git_hug directory.",
) { repo -> repo.currentDir == "git_hug" && !repo.initialized },
),
)

View File

@@ -56,7 +56,6 @@ fun allGithugLevels(): List<Level> = listOf(
restoreLevel(),
conflictLevel(),
submoduleLevel(),
contributeLevel(),
)
internal fun level(

View File

@@ -26,6 +26,14 @@ class GitHelpCommandsTest {
assertEquals("git --help tag", invocation?.command)
}
@Test
fun leavesGitHelpListingOptionsForNativeGit() {
assertNull(parseGitHelpInvocation("git help -a"))
assertNull(parseGitHelpInvocation("git help --all"))
assertNull(parseGitHelpInvocation("git help -g"))
assertNull(parseGitHelpInvocation("git help --guides"))
}
@Test
fun parsesManGitCommand() {
val invocation = parseGitHelpInvocation("man git-cherry-pick")

View File

@@ -9,6 +9,31 @@ import java.io.File
import java.nio.file.Files
class GitSandboxEngineTest {
@Test
fun initLevelStartsInsideCreatedGitHugDirectory() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-init-level").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = initLevel()
val repo = runtime.prepareLevel(level)
val (_, pwdOutput) = runtime.execute(level, repo, "pwd")
val (initializedRepo, _) = runtime.execute(level, repo, "git init")
assertEquals("git_hug", repo.currentDir)
assertFalse(repo.initialized)
assertTrue(File(root, "init/git_hug").isDirectory)
assertEquals(listOf("/sandbox/git_hug"), pwdOutput)
assertTrue(initializedRepo.initialized)
assertTrue(File(root, "init/git_hug/.git").isDirectory)
assertTrue(level.validator(initializedRepo, "git init"))
} finally {
root.deleteRecursively()
}
}
@Test
fun statusShowsDeletedTrackedFiles() {
val repo = RepoState(
@@ -203,6 +228,36 @@ class GitSandboxEngineTest {
}
}
@Test
fun nativeGitHelpListsCommandsAndGuides() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-help-listing").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = level(
id = "help-listing-test",
title = "Help Listing Test",
description = "",
hints = emptyList(),
commandSuggestions = emptyList(),
setup = { RepoState(initialized = true) },
validator = { _, _ -> false },
)
val repo = runtime.prepareLevel(level)
val (_, commandOutput) = runtime.execute(level, repo, "git help -a")
val (_, guideOutput) = runtime.execute(level, repo, "git help -g")
assertTrue(commandOutput.any { it.contains("Main Porcelain Commands") })
assertTrue(commandOutput.any { it.contains("commit") && it.contains("Record changes") })
assertTrue(guideOutput.any { it.contains("Git concept guides") })
assertTrue(guideOutput.any { it.contains("tutorial") })
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeGitExecAliasesRefreshWhenBinaryChanges() {
val git = testGitBinary()
@@ -354,6 +409,34 @@ class GitSandboxEngineTest {
}
}
@Test
fun nativeInteractiveRebaseRewordUsesEditedTodoMessage() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-reword-editor").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = renameCommitLevel()
val repo = runtime.prepareLevel(level)
val invocation = parseGitEditorInvocation("git rebase -i HEAD~2")
?: error("Expected interactive rebase editor invocation")
val todo = runtime.gitEditorInitialContent(level, repo, invocation)
val rewordedTodo = todo.replace(
Regex("(?m)^pick (\\S+) First coommit$"),
"reword $1 First commit",
)
val (updatedRepo, output) = runtime.executeGitEditorCommand(level, repo, invocation, rewordedTodo)
assertFalse(output.any { it.contains("error:", ignoreCase = true) || it.contains("fatal:", ignoreCase = true) })
assertTrue(updatedRepo.commits.any { it.message == "First commit" })
assertFalse(updatedRepo.commits.any { it.message == "First coommit" })
assertTrue(level.validator(updatedRepo, invocation.command))
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeExecutableShortcutRunsScriptThroughShell() {
val git = testGitBinary()

View File

@@ -24,8 +24,8 @@ class LevelSolutionsTest {
}
@Test
fun levelOrderMatchesUpstreamRubyGithug() {
assertEquals(UPSTREAM_LEVEL_ORDER, allGithugLevels().map { it.id })
fun implementedLevelOrderMatchesUpstreamExercises() {
assertEquals(IMPLEMENTED_UPSTREAM_LEVEL_ORDER, allGithugLevels().map { it.id })
}
@Test
@@ -240,7 +240,7 @@ class LevelSolutionsTest {
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
}
val UPSTREAM_LEVEL_ORDER = listOf(
val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf(
"init",
"config",
"add",
@@ -296,7 +296,6 @@ class LevelSolutionsTest {
"restore",
"conflict",
"submodule",
"contribute",
)
}