Move native level setups into level files

- Delete NativeLevelFixtures.kt and make native repository setup an optional property of each Level.
- Move every existing native setup into its corresponding level file so setup, validation, and tests stay together.
- Add the missing Blame native setup with config.rb history and a Spider Man password commit.
- Add a native runtime regression that verifies `git blame config.rb` identifies Spider Man on the password line.
- Verify the full JVM test suite using the compiled host Git runtime.
This commit is contained in:
Joe Tretter
2026-05-07 18:53:18 -05:00
parent 7bfb761068
commit 64743ff4a9
30 changed files with 580 additions and 505 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid"
minSdk = 26
targetSdk = 35
versionCode = 127
versionName = "0.1.126"
versionCode = 128
versionName = "0.1.127"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@@ -1,6 +1,7 @@
package solutions.tretter.githugandroid
import androidx.compose.runtime.saveable.listSaver
import java.io.File
enum class PlayMode(val label: String) {
CLI_ONLY("CLI ONLY"),
@@ -46,6 +47,7 @@ data class Level(
val commandSuggestions: List<String>,
val validator: (RepoState, String) -> Boolean,
val setup: () -> RepoState,
val nativeSetup: (NativeLevelSetup.() -> Boolean)? = null,
val testCases: List<LevelTestCase> = emptyList(),
)
@@ -54,6 +56,91 @@ data class LevelTestCase(
val commands: List<String>,
)
class NativeLevelSetup internal constructor(
internal val sandbox: File,
private val runGit: (File, List<String>) -> Int,
) {
fun resetFiles() {
sandbox.listFiles()
?.filterNot { it.name == ".git" }
?.forEach { it.deleteRecursively() }
git("checkout", "-B", "master")
}
fun git(vararg arguments: String): Int = runGit(sandbox, arguments.toList())
fun git(directory: File, vararg arguments: String): Int = runGit(directory, arguments.toList())
fun initRepo(directory: File) {
directory.mkdirs()
val initResult = git(directory, "init", "-b", "master")
if (initResult != 0) {
git(directory, "init")
git(directory, "checkout", "-B", "master")
}
git(directory, "config", "receive.denyCurrentBranch", "ignore")
}
fun write(path: String, content: String = "") {
File(sandbox, path).apply {
parentFile?.mkdirs()
writeText(content)
}
}
fun append(path: String, content: String) {
File(sandbox, path).appendText(content)
}
fun add(vararg paths: String) {
git("add", *paths)
}
fun commit(message: String, author: String? = null) {
if (author == null) {
git("commit", "-m", message)
} else {
git("commit", "--author", author, "-m", message)
}
}
fun addCommit(message: String, vararg paths: String, author: String? = null) {
add(*paths)
commit(message, author)
}
fun writeIn(directory: File, path: String, content: String = "") {
File(directory, path).apply {
parentFile?.mkdirs()
writeText(content)
}
}
fun addCommitIn(directory: File, message: String, vararg paths: String) {
git(directory, "add", *paths)
git(directory, "commit", "-m", message)
}
fun siblingRepo(name: String): File {
val directory = File(sandbox.parentFile ?: sandbox, "${sandbox.name}-$name")
directory.deleteRecursively()
initRepo(directory)
return directory
}
fun checkoutNew(branch: String) {
git("checkout", "-b", branch)
}
fun checkout(branch: String) {
git("checkout", branch)
}
fun tag(name: String) {
git("tag", "-f", name)
}
}
fun sampleLevels(): List<Level> = allGithugLevels()
val RepoStateSaver = listSaver<RepoState, Any>(

View File

@@ -90,7 +90,7 @@ class GitRepositoryRuntime private constructor(
runGit(nativeGit, sandbox, listOf("checkout", "-B", desired.headBranch))
}
materializeNativeGitState(nativeGit, sandbox, desired, level.id)
materializeNativeGitState(nativeGit, sandbox, desired, level)
}
return inspectSandbox(level)
@@ -443,16 +443,14 @@ class GitRepositoryRuntime private constructor(
return body
}
private fun materializeNativeGitState(nativeGit: File, sandbox: File, desired: RepoState, levelId: String) {
if (levelId == "push") {
materializeNativePushLevel(nativeGit, sandbox)
return
}
if (materializeNativeLevelFixture(levelId, sandbox) { directory, arguments ->
private fun materializeNativeGitState(nativeGit: File, sandbox: File, desired: RepoState, level: Level) {
level.nativeSetup?.let { setup ->
val nativeSetup = NativeLevelSetup(sandbox) { directory, arguments ->
runGit(nativeGit, directory, arguments).exitCode
}
) {
return
if (nativeSetup.setup()) {
return
}
}
desired.config.forEach { (key, value) ->
@@ -506,62 +504,6 @@ class GitRepositoryRuntime private constructor(
}
}
private fun materializeNativePushLevel(nativeGit: File, sandbox: File) {
sandbox.listFiles()
?.filterNot { it.name == ".git" }
?.forEach { it.deleteRecursively() }
fun writeFile(name: String, content: String = "$name\n") {
File(sandbox, name).apply {
parentFile?.mkdirs()
writeText(content)
}
}
fun commitIn(directory: File, message: String, vararg paths: String) {
runGit(nativeGit, directory, listOf("add") + paths)
runGit(nativeGit, directory, listOf("commit", "-m", message))
}
writeFile("file1")
commitIn(sandbox, "First commit", "file1")
writeFile("file2")
commitIn(sandbox, "Second commit", "file2")
val parent = sandbox.parentFile ?: sandbox
val remoteDir = File(parent, "${sandbox.name}-origin.git")
val remoteWorkTree = File(parent, "${sandbox.name}-origin-work")
remoteDir.deleteRecursively()
remoteWorkTree.deleteRecursively()
remoteDir.mkdirs()
remoteWorkTree.mkdirs()
runGit(nativeGit, remoteDir, listOf("init", "--bare", "-b", "master"))
runGit(nativeGit, sandbox, listOf("remote", "add", "push-setup-origin", remoteDir.absolutePath))
runGit(nativeGit, sandbox, listOf("push", "push-setup-origin", "master"))
runGit(nativeGit, sandbox, listOf("remote", "remove", "push-setup-origin"))
fun runRemoteGit(arguments: List<String>): ProcessExecutionResult {
return runGit(
nativeGit,
sandbox,
listOf("--git-dir", remoteDir.absolutePath, "--work-tree", remoteWorkTree.absolutePath) + arguments,
)
}
runRemoteGit(listOf("checkout", "-f", "master"))
File(remoteWorkTree, "file4").writeText("file4\n")
runRemoteGit(listOf("add", "file4"))
runRemoteGit(listOf("commit", "-m", "Fourth commit"))
writeFile("file3")
commitIn(sandbox, "Third commit", "file3")
runGit(nativeGit, sandbox, listOf("remote", "add", "origin", remoteDir.absolutePath))
runGit(nativeGit, sandbox, listOf("fetch", "origin"))
runGit(nativeGit, sandbox, listOf("branch", "--set-upstream-to=origin/master", "master"))
runGit(nativeGit, sandbox, listOf("config", "rebase.autoStash", "true"))
File(sandbox, "file3").appendText("local worktree change\n")
}
private fun filesForSetupCommit(files: List<GitFile>, index: Int, commitCount: Int): List<GitFile> {
if (files.isEmpty()) return emptyList()
if (commitCount <= 1) return files

View File

@@ -1,434 +0,0 @@
package solutions.tretter.githugandroid
import java.io.File
internal fun materializeNativeLevelFixture(
levelId: String,
sandbox: File,
runGit: (File, List<String>) -> Int,
): Boolean {
val fixture = NativeLevelFixture(sandbox, runGit)
return when (levelId) {
"branch_at" -> fixture.branchAt()
"checkout_tag" -> fixture.checkoutTag()
"checkout_tag_over_branch" -> fixture.checkoutTagOverBranch()
"diff" -> fixture.diff()
"fetch" -> fixture.fetch()
"pull" -> fixture.pull()
"push_branch" -> fixture.pushBranch()
"push_tags" -> fixture.pushTags()
"merge" -> fixture.merge()
"rebase" -> fixture.rebase()
"rebase_onto" -> fixture.rebaseOnto()
"merge_squash" -> fixture.mergeSquash()
"reset" -> fixture.reset()
"reset_soft" -> fixture.resetSoft()
"restore" -> fixture.restore()
"squash" -> fixture.squash()
"reorder" -> fixture.reorder()
"rename_commit" -> fixture.renameCommit()
"revert" -> fixture.revert()
"stash" -> fixture.stash()
"checkout_file" -> fixture.checkoutFile()
else -> false
}
}
private class NativeLevelFixture(
private val sandbox: File,
private val runGit: (File, List<String>) -> Int,
) {
private fun resetFiles() {
sandbox.listFiles()
?.filterNot { it.name == ".git" }
?.forEach { it.deleteRecursively() }
git("checkout", "-B", "master")
}
private fun git(vararg arguments: String): Int = runGit(sandbox, arguments.toList())
private fun git(directory: File, vararg arguments: String): Int = runGit(directory, arguments.toList())
private fun initRepo(directory: File) {
directory.mkdirs()
val initResult = git(directory, "init", "-b", "master")
if (initResult != 0) {
git(directory, "init")
git(directory, "checkout", "-B", "master")
}
git(directory, "config", "receive.denyCurrentBranch", "ignore")
}
private fun write(path: String, content: String = "") {
File(sandbox, path).apply {
parentFile?.mkdirs()
writeText(content)
}
}
private fun append(path: String, content: String) {
File(sandbox, path).appendText(content)
}
private fun add(vararg paths: String) {
git("add", *paths)
}
private fun commit(message: String) {
git("commit", "-m", message)
}
private fun addCommit(message: String, vararg paths: String) {
add(*paths)
commit(message)
}
private fun writeIn(directory: File, path: String, content: String = "") {
File(directory, path).apply {
parentFile?.mkdirs()
writeText(content)
}
}
private fun addCommitIn(directory: File, message: String, vararg paths: String) {
git(directory, "add", *paths)
git(directory, "commit", "-m", message)
}
private fun siblingRepo(name: String): File {
val directory = File(sandbox.parentFile ?: sandbox, "${sandbox.name}-$name")
directory.deleteRecursively()
initRepo(directory)
return directory
}
private fun checkoutNew(branch: String) {
git("checkout", "-b", branch)
}
private fun checkout(branch: String) {
git("checkout", branch)
}
private fun tag(name: String) {
git("tag", "-f", name)
}
fun branchAt(): Boolean {
resetFiles()
write("file1")
addCommit("Adding file1", "file1")
write("file1", "content")
addCommit("Updating file1", "file1")
append("file1", "\nAdding some more text")
addCommit("Updating file1 again", "file1")
return true
}
fun checkoutFile(): Boolean {
resetFiles()
write("config.rb", "This is the initial config file")
addCommit("Added initial config file", "config.rb")
append("config.rb", "\nThese are changes you don't want to keep!")
return true
}
fun checkoutTag(): Boolean {
resetFiles()
write("app.rb")
addCommit("Initial commit", "app.rb")
append("app.rb", "some changes\n")
addCommit("Some changes", "app.rb")
tag("v1.0")
append("app.rb", "some more changes\n")
addCommit("Some more changes", "app.rb")
tag("v1.2")
append("app.rb", "yet more changes\n")
addCommit("Yet more changes", "app.rb")
append("app.rb", "changes galore\n")
addCommit("Changes galore", "app.rb")
tag("v1.5")
return true
}
fun checkoutTagOverBranch(): Boolean {
checkoutTag()
checkoutNew("v1.2")
write("file3", "some feature\n")
addCommit("Developing new features", "file3")
checkout("master")
return true
}
fun fetch(): Boolean {
resetFiles()
write("master_file")
addCommit("Commits master_file", "master_file")
val remote = siblingRepo("origin")
git("remote", "add", "fetch-setup-origin", File(remote, ".git").absolutePath)
git("push", "fetch-setup-origin", "master")
git("remote", "remove", "fetch-setup-origin")
git(remote, "checkout", "-f", "master")
git(remote, "checkout", "-b", "new_branch")
writeIn(remote, "file1")
addCommitIn(remote, "Commits file 1", "file1")
git("remote", "add", "origin", File(remote, ".git").absolutePath)
git("branch", "--set-upstream-to=origin/master", "master")
return true
}
fun diff(): Boolean {
resetFiles()
write("app.rb", diffLevelBaselineAppRb())
addCommit("Add app routes", "app.rb")
write("app.rb", diffLevelModifiedAppRb())
return true
}
fun pull(): Boolean {
resetFiles()
write("local_file")
addCommit("Initial local commit", "local_file")
val remote = siblingRepo("origin")
git("remote", "add", "pull-setup-origin", File(remote, ".git").absolutePath)
git("push", "pull-setup-origin", "master")
git("remote", "remove", "pull-setup-origin")
git(remote, "checkout", "-f", "master")
writeIn(remote, "remote_file", "pulled from origin\n")
addCommitIn(remote, "Pulled commit", "remote_file")
git("remote", "add", "origin", File(remote, ".git").absolutePath)
git("config", "branch.master.remote", "origin")
git("config", "branch.master.merge", "refs/heads/master")
return true
}
fun pushBranch(): Boolean {
resetFiles()
write("file1")
addCommit("committed changes on master", "file1")
val remote = siblingRepo("origin")
git("remote", "add", "push-branch-setup-origin", File(remote, ".git").absolutePath)
git("push", "push-branch-setup-origin", "master")
git("remote", "remove", "push-branch-setup-origin")
git(remote, "checkout", "-f", "master")
write("file2")
addCommit("If this commit gets pushed to repo, then you have lost the level :( ", "file2")
checkoutNew("other_branch")
write("file3")
addCommit("If this commit gets pushed to repo, then you have lost the level :( ", "file3")
checkoutNew("test_branch")
write("file4")
addCommit("committed change on test_branch", "file4")
git("remote", "add", "origin", File(remote, ".git").absolutePath)
git("branch", "--set-upstream-to=origin/master", "master")
checkout("master")
return true
}
fun pushTags(): Boolean {
resetFiles()
write("file1")
addCommit("First commit", "file1")
tag("tag_to_be_pushed")
write("file2")
addCommit("Second commit", "file2")
val remote = siblingRepo("origin")
git("remote", "add", "push-tags-setup-origin", File(remote, ".git").absolutePath)
git("push", "push-tags-setup-origin", "master")
git("remote", "remove", "push-tags-setup-origin")
git(remote, "checkout", "-f", "master")
git("remote", "add", "origin", File(remote, ".git").absolutePath)
return true
}
fun merge(): Boolean {
resetFiles()
write("file1")
addCommit("added file1", "file1")
checkoutNew("feature")
write("file2")
addCommit("added file2", "file2")
checkout("master")
return true
}
fun rebase(): Boolean {
resetFiles()
write("README", "readme\n")
addCommit("init commit", "README")
checkoutNew("feature")
write("feature", "feature\n")
addCommit("add feature", "feature")
checkout("master")
append("README", "content\n")
addCommit("add content", "README")
return true
}
fun rebaseOnto(): Boolean {
resetFiles()
write("authors.md", "https://github.com/janis-vitols\n")
addCommit("Create authors file", "authors.md")
checkoutNew("wrong_branch")
write("authors.md", "None\n")
addCommit("Wrong changes", "authors.md")
checkoutNew("readme-update")
write("README.md", "# SuperApp\n")
addCommit("Add app name in readme", "README.md")
append("README.md", "## About\n")
addCommit("Add `About` header in readme", "README.md")
append("README.md", "## Install\n")
addCommit("Add `Install` header in readme", "README.md")
return true
}
fun mergeSquash(): Boolean {
resetFiles()
write("file1")
addCommit("First commit", "file1")
checkoutNew("long-feature-branch")
write("file3", "some feature\n")
addCommit("Developing new features", "file3")
append("file3", "getting awesomer\n")
addCommit("Takes", "file3")
append("file3", "and awesomer!\n")
addCommit("Time", "file3")
checkout("master")
write("file2")
addCommit("Second commit", "file2")
return true
}
fun reset(): Boolean {
resetFiles()
write("README")
addCommit("Initial commit", "README")
write("to_commit_first.rb")
write("to_commit_second.rb")
add("to_commit_first.rb", "to_commit_second.rb")
return true
}
fun resetSoft(): Boolean {
resetFiles()
write("README")
addCommit("Initial commit", "README")
write("newfile.rb")
addCommit("Premature commit", "newfile.rb")
return true
}
fun restore(): Boolean {
resetFiles()
write("file1")
addCommit("Initial commit", "file1")
write("file2")
addCommit("First commit", "file2")
write("file3")
addCommit("Restore this commit", "file3")
git("reset", "--hard", "HEAD^")
return true
}
fun squash(): Boolean {
resetFiles()
write(".hidden")
addCommit("Initial Commit", ".hidden")
write("README")
addCommit("Adding README", "README")
write("README", "hey there")
addCommit("Updating README (squash this commit into Adding README)", "README")
append("README", "\nAdding some more text")
addCommit("Updating README (squash this commit into Adding README)", "README")
append("README", "\neven more text")
addCommit("Updating README (squash this commit into Adding README)", "README")
return true
}
fun reorder(): Boolean {
resetFiles()
write("README")
addCommit("Initial Setup", "README")
write("file1")
addCommit("First commit", "file1")
write("file3")
addCommit("Third commit", "file3")
write("file2")
addCommit("Second commit", "file2")
return true
}
fun renameCommit(): Boolean {
resetFiles()
write("README")
addCommit("Initial commit", "README")
write("file1")
addCommit("First coommit", "file1")
write("file2")
addCommit("Second commit", "file2")
return true
}
fun revert(): Boolean {
resetFiles()
write("file1")
addCommit("First commit", "file1")
write("file3")
addCommit("Bad commit", "file3")
write("file2")
addCommit("Second commit", "file2")
return true
}
fun stash(): Boolean {
resetFiles()
write(
"lyrics.txt",
"""
Down in Louisiana in that sunny clime,
They play a class of music that is super fine,
And it makes no difference if its rain or shine,
You can hear that that jazz band music playing all the time.
""".trimIndent() + "\n",
)
addCommit("Add some lyrics", "lyrics.txt")
append("lyrics.txt", "\nHey!\n")
return true
}
}
internal fun diffLevelBaselineAppRb(): String = buildString {
appendLine("require 'sinatra'")
appendLine("require 'json'")
appendLine()
appendLine("helpers do")
appendLine(" def get_response(source)")
appendLine(" JSON.parse(File.read(source))['message']")
appendLine(" end")
appendLine("end")
appendLine()
appendLine("get '/' do")
appendLine(" @message = 'hello'")
appendLine(" erb :index")
appendLine("end")
appendLine()
appendLine("get '/page' do")
appendLine(" @message = 'page'")
appendLine(" erb :page")
appendLine("end")
appendLine()
appendLine("get '/yet_another' do")
appendLine(" @message = 'another'")
appendLine(" erb :success")
appendLine("end")
appendLine()
appendLine("get '/another_page' do")
appendLine(" @message = get_response('data.json')")
appendLine(" erb :another")
appendLine("end")
appendLine()
appendLine("# end of application")
}
internal fun diffLevelModifiedAppRb(): String =
diffLevelBaselineAppRb().replace("get_response('data.json')", "get_response('server.json')")

View File

@@ -13,9 +13,57 @@ internal fun blameLevel(): Level = level(
description = "Identify who put a password inside the file `config.rb`.",
hints = listOf("You want to research the `git blame` command."),
commandSuggestions = listOf("git blame config.rb"),
setup = { RepoState(initialized = true, files = listOf(GitFile("config.rb", tracked = true)), branches = mapOf("master" to 1)) },
setup = {
RepoState(
initialized = true,
files = listOf(GitFile("config.rb", blameLevelFinalConfigRb(), tracked = true)),
commits = listOf(
CommitNode("0000001", "Add default config"),
CommitNode("0000002", "Add secret config"),
CommitNode("0000003", "Document timeout"),
),
branches = mapOf("master" to 3),
)
},
nativeSetup = {
resetFiles()
write(
"config.rb",
"""
Githug::Application.configure do
config.cache_classes = true
config.log_level = :info
end
""".trimIndent() + "\n",
)
addCommit("Add default config", "config.rb", author = "Peter Parker <peter@example.com>")
write(
"config.rb",
"""
Githug::Application.configure do
config.cache_classes = true
config.log_level = :info
config.password = "correct horse battery staple"
end
""".trimIndent() + "\n",
)
addCommit("Add secret config", "config.rb", author = "Spider Man <spider@example.com>")
append("config.rb", "config.timeout = 30\n")
addCommit("Document timeout", "config.rb", author = "Mary Jane <mary@example.com>")
true
},
validator = commandAnswer("Spider Man"),
testCases = listOf(
levelTestCase("answer author", "Spider Man"),
),
)
private fun blameLevelFinalConfigRb(): String =
"""
Githug::Application.configure do
config.cache_classes = true
config.log_level = :info
config.password = "correct horse battery staple"
end
config.timeout = 30
""".trimIndent() + "\n"

View File

@@ -14,6 +14,16 @@ internal fun branchAtLevel(): Level = level(
hints = listOf("Just like creating a branch, but you have to pass an extra argument."),
commandSuggestions = listOf("git branch test_branch HEAD~1"),
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Adding file1"), CommitNode("2", "Updating file1"), CommitNode("3", "Updating file1 again")), branches = mapOf("master" to 3)) },
nativeSetup = {
resetFiles()
write("file1")
addCommit("Adding file1", "file1")
write("file1", "content")
addCommit("Updating file1", "file1")
append("file1", "\nAdding some more text")
addCommit("Updating file1 again", "file1")
true
},
validator = repoPredicate { repo -> repo.branches["test_branch"] == 2 },
testCases = listOf(
levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"),

View File

@@ -14,6 +14,13 @@ internal fun checkoutFileLevel(): Level = level(
hints = listOf("You will need to do some research on the checkout command for this one."),
commandSuggestions = listOf("git checkout -- config.rb"),
setup = { RepoState(initialized = true, files = listOf(GitFile("config.rb", "This is the initial config file\nThese are changes you don't want to keep!", tracked = true)), commits = listOf(CommitNode("0000001", "Added initial config file")), branches = mapOf("master" to 1)) },
nativeSetup = {
resetFiles()
write("config.rb", "This is the initial config file")
addCommit("Added initial config file", "config.rb")
append("config.rb", "\nThese are changes you don't want to keep!")
true
},
validator = repoPredicate { repo -> repo.files.find { it.name == "config.rb" }?.content == "This is the initial config file" },
testCases = listOf(
levelTestCase("checkout file from head", "git checkout -- config.rb"),

View File

@@ -14,9 +14,30 @@ internal fun checkoutTagLevel(): Level = level(
hints = listOf("There's no big difference between checking out a branch and checking out a tag."),
commandSuggestions = listOf("git checkout v1.2"),
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "Some changes"), CommitNode("3", "Some more changes"), CommitNode("4", "Yet more changes"), CommitNode("5", "Changes galore")), tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5)) },
nativeSetup = {
checkoutTagHistory()
true
},
validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" || repo.branches.keys.any { "detached at v1.2" in it } },
testCases = listOf(
levelTestCase("checkout tag", "git checkout v1.2"),
levelTestCase("checkout explicit tag", "git checkout tags/v1.2"),
),
)
internal fun NativeLevelSetup.checkoutTagHistory() {
resetFiles()
write("app.rb")
addCommit("Initial commit", "app.rb")
append("app.rb", "some changes\n")
addCommit("Some changes", "app.rb")
tag("v1.0")
append("app.rb", "some more changes\n")
addCommit("Some more changes", "app.rb")
tag("v1.2")
append("app.rb", "yet more changes\n")
addCommit("Yet more changes", "app.rb")
append("app.rb", "changes galore\n")
addCommit("Changes galore", "app.rb")
tag("v1.5")
}

View File

@@ -14,6 +14,14 @@ internal fun checkoutTagOverBranchLevel(): Level = level(
hints = listOf("You should think about specifying you're after the tag named `v1.2` (think `tags/`)."),
commandSuggestions = listOf("git checkout tags/v1.2"),
setup = { RepoState(initialized = true, tags = listOf("v1.0", "v1.2", "v1.5"), branches = mapOf("master" to 5, "v1.2" to 6)) },
nativeSetup = {
checkoutTagHistory()
checkoutNew("v1.2")
write("file3", "some feature\n")
addCommit("Developing new features", "file3")
checkout("master")
true
},
validator = repoPredicate { repo -> repo.headBranch == "tags/v1.2" || repo.branches.keys.any { "detached at v1.2" in it } },
testCases = listOf(
levelTestCase("checkout tag namespace", "git checkout tags/v1.2"),

View File

@@ -21,8 +21,51 @@ internal fun diffLevel(): Level = level(
branches = mapOf("master" to 1),
)
},
nativeSetup = {
resetFiles()
write("app.rb", diffLevelBaselineAppRb())
addCommit("Add app routes", "app.rb")
write("app.rb", diffLevelModifiedAppRb())
true
},
validator = commandAnswer("26"),
testCases = listOf(
levelTestCase("answer changed line", "26"),
),
)
internal fun diffLevelBaselineAppRb(): String = buildString {
appendLine("require 'sinatra'")
appendLine("require 'json'")
appendLine()
appendLine("helpers do")
appendLine(" def get_response(source)")
appendLine(" JSON.parse(File.read(source))['message']")
appendLine(" end")
appendLine("end")
appendLine()
appendLine("get '/' do")
appendLine(" @message = 'hello'")
appendLine(" erb :index")
appendLine("end")
appendLine()
appendLine("get '/page' do")
appendLine(" @message = 'page'")
appendLine(" erb :page")
appendLine("end")
appendLine()
appendLine("get '/yet_another' do")
appendLine(" @message = 'another'")
appendLine(" erb :success")
appendLine("end")
appendLine()
appendLine("get '/another_page' do")
appendLine(" @message = get_response('data.json')")
appendLine(" erb :another")
appendLine("end")
appendLine()
appendLine("# end of application")
}
internal fun diffLevelModifiedAppRb(): String =
diffLevelBaselineAppRb().replace("get_response('data.json')", "get_response('server.json')")

View File

@@ -1,5 +1,7 @@
package solutions.tretter.githugandroid
import java.io.File
/**
* Port of the upstream ruby-githug `fetch` level.
*
@@ -14,6 +16,22 @@ internal fun fetchLevel(): Level = level(
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")) },
nativeSetup = {
resetFiles()
write("master_file")
addCommit("Commits master_file", "master_file")
val remote = siblingRepo("origin")
git("remote", "add", "fetch-setup-origin", File(remote, ".git").absolutePath)
git("push", "fetch-setup-origin", "master")
git("remote", "remove", "fetch-setup-origin")
git(remote, "checkout", "-f", "master")
git(remote, "checkout", "-b", "new_branch")
writeIn(remote, "file1")
addCommitIn(remote, "Commits file 1", "file1")
git("remote", "add", "origin", File(remote, ".git").absolutePath)
git("branch", "--set-upstream-to=origin/master", "master")
true
},
validator = repoPredicate { repo -> "origin/new_branch" in repo.fetchedBranches && repo.headBranch == "master" },
testCases = listOf(
levelTestCase("fetch origin", "git fetch origin"),

View File

@@ -68,6 +68,7 @@ internal fun level(
setup: () -> RepoState,
validator: (RepoState, String) -> Boolean,
testCases: List<LevelTestCase> = emptyList(),
nativeSetup: (NativeLevelSetup.() -> Boolean)? = null,
): Level = Level(
id = id,
title = title,
@@ -76,6 +77,7 @@ internal fun level(
commandSuggestions = commandSuggestions,
validator = loggingValidator(id, title, validator),
setup = setup,
nativeSetup = nativeSetup,
testCases = testCases,
)

View File

@@ -14,6 +14,16 @@ internal fun mergeLevel(): Level = level(
hints = listOf("You want to research the `git merge` command."),
commandSuggestions = listOf("git merge feature"),
setup = { RepoState(initialized = true, files = listOf(GitFile("file1", tracked = true)), branches = mapOf("master" to 1, "feature" to 2)) },
nativeSetup = {
resetFiles()
write("file1")
addCommit("added file1", "file1")
checkoutNew("feature")
write("file2")
addCommit("added file2", "file2")
checkout("master")
true
},
validator = repoPredicate { repo -> repo.files.any { it.name == "file2" && it.tracked } },
testCases = listOf(
levelTestCase("merge feature", "git merge feature"),

View File

@@ -14,6 +14,22 @@ internal fun mergeSquashLevel(): Level = level(
hints = listOf("Take a look at the `--squash` option of the merge command. Don't forget to commit the merge!"),
commandSuggestions = listOf("git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""),
setup = { RepoState(initialized = true, files = listOf(GitFile("file1", tracked = true)), branches = mapOf("master" to 2, "long-feature-branch" to 4)) },
nativeSetup = {
resetFiles()
write("file1")
addCommit("First commit", "file1")
checkoutNew("long-feature-branch")
write("file3", "some feature\n")
addCommit("Developing new features", "file3")
append("file3", "getting awesomer\n")
addCommit("Takes", "file3")
append("file3", "and awesomer!\n")
addCommit("Time", "file3")
checkout("master")
write("file2")
addCommit("Second commit", "file2")
true
},
validator = repoPredicate { repo -> "merge-squash" in repo.maintenanceActions && repo.commits.isNotEmpty() },
testCases = listOf(
levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""),

View File

@@ -1,5 +1,7 @@
package solutions.tretter.githugandroid
import java.io.File
/**
* Port of the upstream ruby-githug `pull` level.
*
@@ -14,6 +16,22 @@ internal fun pullLevel(): Level = level(
hints = listOf("Check out the remote repositories and research `git pull`."),
commandSuggestions = listOf("git pull origin master"),
setup = { RepoState(initialized = true, remotes = mapOf("origin" to "remote"), branches = mapOf("master" to 1)) },
nativeSetup = {
resetFiles()
write("local_file")
addCommit("Initial local commit", "local_file")
val remote = siblingRepo("origin")
git("remote", "add", "pull-setup-origin", File(remote, ".git").absolutePath)
git("push", "pull-setup-origin", "master")
git("remote", "remove", "pull-setup-origin")
git(remote, "checkout", "-f", "master")
writeIn(remote, "remote_file", "pulled from origin\n")
addCommitIn(remote, "Pulled commit", "remote_file")
git("remote", "add", "origin", File(remote, ".git").absolutePath)
git("config", "branch.master.remote", "origin")
git("config", "branch.master.merge", "refs/heads/master")
true
},
validator = repoPredicate { repo ->
"origin/master" in repo.fetchedBranches &&
repo.files.any { it.name == "remote_file" && it.tracked } &&

View File

@@ -1,5 +1,7 @@
package solutions.tretter.githugandroid
import java.io.File
/**
* Port of the upstream ruby-githug `push_branch` level.
*
@@ -14,6 +16,28 @@ internal fun pushBranchLevel(): Level = level(
hints = listOf("Investigate the options in `git push` using `git push --help`"),
commandSuggestions = listOf("git push origin test_branch"),
setup = { RepoState(initialized = true, branches = mapOf("master" to 2, "other_branch" to 3, "test_branch" to 4), remotes = mapOf("origin" to "remote"), headBranch = "master") },
nativeSetup = {
resetFiles()
write("file1")
addCommit("committed changes on master", "file1")
val remote = siblingRepo("origin")
git("remote", "add", "push-branch-setup-origin", File(remote, ".git").absolutePath)
git("push", "push-branch-setup-origin", "master")
git("remote", "remove", "push-branch-setup-origin")
git(remote, "checkout", "-f", "master")
write("file2")
addCommit("If this commit gets pushed to repo, then you have lost the level :( ", "file2")
checkoutNew("other_branch")
write("file3")
addCommit("If this commit gets pushed to repo, then you have lost the level :( ", "file3")
checkoutNew("test_branch")
write("file4")
addCommit("committed change on test_branch", "file4")
git("remote", "add", "origin", File(remote, ".git").absolutePath)
git("branch", "--set-upstream-to=origin/master", "master")
checkout("master")
true
},
validator = repoPredicate { repo -> "origin/test_branch" in repo.pushedBranches && "origin/master" !in repo.pushedBranches && "origin/other_branch" !in repo.pushedBranches },
testCases = listOf(
levelTestCase("push named branch", "git push origin test_branch"),

View File

@@ -1,5 +1,7 @@
package solutions.tretter.githugandroid
import java.io.File
/**
* Port of the upstream ruby-githug `push` level.
*
@@ -31,6 +33,49 @@ internal fun pushLevel(): Level = level(
fetchedBranches = setOf("origin/master"),
)
},
nativeSetup = {
resetFiles()
write("file1", "file1\n")
addCommit("First commit", "file1")
write("file2", "file2\n")
addCommit("Second commit", "file2")
val parent = sandbox.parentFile ?: sandbox
val remoteDir = File(parent, "${sandbox.name}-origin.git")
val remoteWorkTree = File(parent, "${sandbox.name}-origin-work")
remoteDir.deleteRecursively()
remoteWorkTree.deleteRecursively()
remoteDir.mkdirs()
remoteWorkTree.mkdirs()
git(remoteDir, "init", "--bare", "-b", "master")
git("remote", "add", "push-setup-origin", remoteDir.absolutePath)
git("push", "push-setup-origin", "master")
git("remote", "remove", "push-setup-origin")
fun remoteGit(vararg arguments: String) {
git(
"--git-dir",
remoteDir.absolutePath,
"--work-tree",
remoteWorkTree.absolutePath,
*arguments,
)
}
remoteGit("checkout", "-f", "master")
File(remoteWorkTree, "file4").writeText("file4\n")
remoteGit("add", "file4")
remoteGit("commit", "-m", "Fourth commit")
write("file3", "file3\n")
addCommit("Third commit", "file3")
git("remote", "add", "origin", remoteDir.absolutePath)
git("fetch", "origin")
git("branch", "--set-upstream-to=origin/master", "master")
git("config", "rebase.autoStash", "true")
append("file3", "local worktree change\n")
true
},
validator = repoPredicate { repo ->
"origin/master" in repo.pushedBranches &&
repo.commits.size >= 4 &&

View File

@@ -1,5 +1,7 @@
package solutions.tretter.githugandroid
import java.io.File
/**
* Port of the upstream ruby-githug `push_tags` level.
*
@@ -14,6 +16,21 @@ internal fun pushTagsLevel(): Level = level(
hints = listOf("Take a look at `--tags` flag of `git push`"),
commandSuggestions = listOf("git push --tags"),
setup = { RepoState(initialized = true, tags = listOf("tag_to_be_pushed"), branches = mapOf("master" to 2), remotes = mapOf("origin" to "remote")) },
nativeSetup = {
resetFiles()
write("file1")
addCommit("First commit", "file1")
tag("tag_to_be_pushed")
write("file2")
addCommit("Second commit", "file2")
val remote = siblingRepo("origin")
git("remote", "add", "push-tags-setup-origin", File(remote, ".git").absolutePath)
git("push", "push-tags-setup-origin", "master")
git("remote", "remove", "push-tags-setup-origin")
git(remote, "checkout", "-f", "master")
git("remote", "add", "origin", File(remote, ".git").absolutePath)
true
},
validator = repoPredicate { repo -> "tag_to_be_pushed" in repo.pushedTags },
testCases = listOf(
levelTestCase("push all tags", "git push --tags"),

View File

@@ -14,6 +14,18 @@ internal fun rebaseLevel(): Level = level(
hints = listOf("You want to research the `git rebase` command"),
commandSuggestions = listOf("git checkout feature", "git rebase master"),
setup = { RepoState(initialized = true, headBranch = "master", branches = mapOf("master" to 2, "feature" to 2)) },
nativeSetup = {
resetFiles()
write("README", "readme\n")
addCommit("init commit", "README")
checkoutNew("feature")
write("feature", "feature\n")
addCommit("add feature", "feature")
checkout("master")
append("README", "content\n")
addCommit("add content", "README")
true
},
validator = repoPredicate { repo ->
repo.headBranch == "feature" &&
repo.commits.take(3).map { it.message } == listOf("add feature", "add content", "init commit")

View File

@@ -14,6 +14,22 @@ internal fun rebaseOntoLevel(): Level = level(
hints = listOf("You want to research the `git rebase` commands `--onto` argument"),
commandSuggestions = listOf("git rebase --onto master wrong_branch readme-update"),
setup = { RepoState(initialized = true, headBranch = "readme-update", branches = mapOf("master" to 1, "wrong_branch" to 2, "readme-update" to 4)) },
nativeSetup = {
resetFiles()
write("authors.md", "https://github.com/janis-vitols\n")
addCommit("Create authors file", "authors.md")
checkoutNew("wrong_branch")
write("authors.md", "None\n")
addCommit("Wrong changes", "authors.md")
checkoutNew("readme-update")
write("README.md", "# SuperApp\n")
addCommit("Add app name in readme", "README.md")
append("README.md", "## About\n")
addCommit("Add `About` header in readme", "README.md")
append("README.md", "## Install\n")
addCommit("Add `Install` header in readme", "README.md")
true
},
validator = repoPredicate { repo -> repo.headBranch == "readme-update" && "rebase-onto" in repo.maintenanceActions },
testCases = listOf(
levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"),

View File

@@ -14,6 +14,16 @@ internal fun renameCommitLevel(): Level = level(
hints = listOf("Take a look the `-i` flag of the rebase command."),
commandSuggestions = listOf("git rebase -i HEAD~2"),
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "First coommit"), CommitNode("3", "Second commit")), branches = mapOf("master" to 3)) },
nativeSetup = {
resetFiles()
write("README")
addCommit("Initial commit", "README")
write("file1")
addCommit("First coommit", "file1")
write("file2")
addCommit("Second commit", "file2")
true
},
validator = repoPredicate { repo -> repo.commits.any { it.message == "First commit" } && repo.commits.none { it.message.contains("coommit") } },
testCases = listOf(
levelTestCase("interactive rebase rename", "git rebase -i HEAD~2"),

View File

@@ -14,6 +14,18 @@ internal fun reorderLevel(): Level = level(
hints = listOf("Take a look the `-i` flag of the rebase command."),
commandSuggestions = listOf("git rebase -i HEAD~3"),
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial Setup"), CommitNode("2", "First commit"), CommitNode("3", "Third commit"), CommitNode("4", "Second commit")), branches = mapOf("master" to 4)) },
nativeSetup = {
resetFiles()
write("README")
addCommit("Initial Setup", "README")
write("file1")
addCommit("First commit", "file1")
write("file3")
addCommit("Third commit", "file3")
write("file2")
addCommit("Second commit", "file2")
true
},
validator = repoPredicate { repo -> repo.commits.map { it.message }.filter { it.endsWith("commit") } == listOf("First commit", "Second commit", "Third commit") },
testCases = listOf(
levelTestCase("interactive reorder", "git rebase -i HEAD~3"),

View File

@@ -14,6 +14,15 @@ internal fun resetLevel(): Level = level(
hints = listOf("git status will tell you the command you need to run."),
commandSuggestions = listOf("git reset to_commit_second.rb"),
setup = { RepoState(initialized = true, files = listOf(GitFile("to_commit_first.rb", staged = true), GitFile("to_commit_second.rb", staged = true), GitFile("README", tracked = true)), commits = listOf(CommitNode("0000001", "Initial commit")), branches = mapOf("master" to 1)) },
nativeSetup = {
resetFiles()
write("README")
addCommit("Initial commit", "README")
write("to_commit_first.rb")
write("to_commit_second.rb")
add("to_commit_first.rb", "to_commit_second.rb")
true
},
validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "to_commit_first.rb" && it.staged } && repo.files.any { it.name == "to_commit_second.rb" && !it.staged } },
testCases = listOf(
levelTestCase("reset path", "git reset to_commit_second.rb"),

View File

@@ -14,6 +14,14 @@ internal fun resetSoftLevel(): Level = level(
hints = listOf("What are some options you can use with `git reset`?"),
commandSuggestions = listOf("git reset --soft HEAD^"),
setup = { RepoState(initialized = true, files = listOf(GitFile("README", tracked = true), GitFile("newfile.rb", tracked = true)), commits = listOf(CommitNode("0000001", "Initial commit"), CommitNode("0000002", "Premature commit")), branches = mapOf("master" to 2)) },
nativeSetup = {
resetFiles()
write("README")
addCommit("Initial commit", "README")
write("newfile.rb")
addCommit("Premature commit", "newfile.rb")
true
},
validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "newfile.rb" && it.staged } },
testCases = listOf(
levelTestCase("soft reset caret", "git reset --soft HEAD^"),

View File

@@ -14,6 +14,17 @@ internal fun restoreLevel(): Level = level(
hints = listOf("The commit is still floating around somewhere. Have you checked out `git reflog`?"),
commandSuggestions = listOf("git checkout HEAD@{1} -- file3"),
setup = { RepoState(initialized = true, files = listOf(GitFile("file1", tracked = true), GitFile("file2", tracked = true)), commits = listOf(CommitNode("1", "Initial commit"), CommitNode("2", "First commit")), branches = mapOf("master" to 2)) },
nativeSetup = {
resetFiles()
write("file1")
addCommit("Initial commit", "file1")
write("file2")
addCommit("First commit", "file2")
write("file3")
addCommit("Restore this commit", "file3")
git("reset", "--hard", "HEAD^")
true
},
validator = repoPredicate { repo -> repo.files.any { it.name == "file3" && it.tracked } },
testCases = listOf(
levelTestCase("checkout file from reflog commit", "git checkout HEAD@{1} -- file3"),

View File

@@ -14,6 +14,16 @@ internal fun revertLevel(): Level = level(
hints = listOf("Try the revert command."),
commandSuggestions = listOf("git revert HEAD~1"),
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "First commit"), CommitNode("2", "Bad commit"), CommitNode("3", "Second commit")), branches = mapOf("master" to 3), pushedBranches = setOf("origin/master")) },
nativeSetup = {
resetFiles()
write("file1")
addCommit("First commit", "file1")
write("file3")
addCommit("Bad commit", "file3")
write("file2")
addCommit("Second commit", "file2")
true
},
validator = repoPredicate { repo -> repo.commits.any { it.message.startsWith("Revert") } },
testCases = listOf(
levelTestCase("revert middle commit", "git revert HEAD~1"),

View File

@@ -14,6 +14,20 @@ internal fun squashLevel(): Level = level(
hints = listOf("Take a look at the `-i` flag of the rebase command."),
commandSuggestions = listOf("git rebase -i HEAD~4"),
setup = { RepoState(initialized = true, commits = listOf(CommitNode("1", "Initial Commit"), CommitNode("2", "Adding README"), CommitNode("3", "Updating README (squash this commit into Adding README)"), CommitNode("4", "Updating README (squash this commit into Adding README)"), CommitNode("5", "Updating README (squash this commit into Adding README)")), branches = mapOf("master" to 5)) },
nativeSetup = {
resetFiles()
write(".hidden")
addCommit("Initial Commit", ".hidden")
write("README")
addCommit("Adding README", "README")
write("README", "hey there")
addCommit("Updating README (squash this commit into Adding README)", "README")
append("README", "\nAdding some more text")
addCommit("Updating README (squash this commit into Adding README)", "README")
append("README", "\neven more text")
addCommit("Updating README (squash this commit into Adding README)", "README")
true
},
validator = repoPredicate { repo -> repo.commits.size <= 2 && repo.commits.any { it.message == "Adding README" } },
testCases = listOf(
levelTestCase("interactive squash", "git rebase -i HEAD~4"),

View File

@@ -14,6 +14,21 @@ internal fun stashLevel(): Level = level(
hints = listOf("It's like stashing. Try finding an appropriate git command."),
commandSuggestions = listOf("git stash", "git status"),
setup = { RepoState(initialized = true, files = listOf(GitFile("lyrics.txt", "modified lyrics", tracked = true)), commits = listOf(CommitNode("0000001", "Initial commit")), branches = mapOf("master" to 1)) },
nativeSetup = {
resetFiles()
write(
"lyrics.txt",
"""
Down in Louisiana in that sunny clime,
They play a class of music that is super fine,
And it makes no difference if its rain or shine,
You can hear that that jazz band music playing all the time.
""".trimIndent() + "\n",
)
addCommit("Add some lyrics", "lyrics.txt")
append("lyrics.txt", "\nHey!\n")
true
},
validator = repoPredicate { repo -> repo.stashes.isNotEmpty() && repo.files.none { it.staged } },
testCases = listOf(
levelTestCase("stash changes", "git stash"),

View File

@@ -232,6 +232,27 @@ class GitSandboxEngineTest {
}
}
@Test
fun nativeBlameLevelShowsPasswordAuthor() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-blame-level").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = blameLevel()
val repo = runtime.prepareLevel(level)
assertTrue(repo.files.single { it.name == "config.rb" }.content.contains("password"))
val (_, blameOutput) = runtime.execute(level, repo, "git blame config.rb")
assertTrue(blameOutput.any { it.contains("Spider Man") && it.contains("password") })
assertTrue(level.validator(repo, "Spider Man"))
} finally {
root.deleteRecursively()
}
}
@Test
fun cdDotDotShortcutMovesToParentDirectory() {
val repo = RepoState(initialized = true, currentDir = "src/main")