Improve upstream parity for level fixtures

- Add native per-level fixtures for complex histories, tags, branch graphs, rebases, resets, restores, stashes, and local remotes.
- Strengthen repository inspection so branch commit counts, remote-tracking branches, and detached tag checkouts reflect real Git state.
- Align Fetch, Pull, Push Branch, Branch At, and Rebase validators/tests with upstream-style repository state.
- Keep the prompt callout and native filesystem tab-completion fixes from the previous batch.
This commit is contained in:
Joe Tretter
2026-05-07 09:21:04 -05:00
parent 8e88b5e7c5
commit 91e2df3ffe
11 changed files with 526 additions and 17 deletions

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -39,15 +40,18 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -91,6 +95,7 @@ fun GitHugApp() {
var showHelpOverlay by remember { mutableStateOf(false) }
var showHelpOnStart by remember { mutableStateOf(true) }
var helpPreferenceInitialized by remember { mutableStateOf(false) }
var promptBounds by remember { mutableStateOf<Rect?>(null) }
val currentLevel = levels[currentLevelIndex]
LaunchedEffect(solvedCelebrationTitle) {
@@ -293,7 +298,7 @@ fun GitHugApp() {
val isCdCompletion = leadingCommand == "cd"
if (token.isBlank() && !isCdCompletion) return
val candidates = if (isCdCompletion) directoryCompletionCandidates(repo) else fileCompletionCandidates(repo)
val candidates = runtime.completionCandidates(currentLevel, repo, directoriesOnly = isCdCompletion)
val matches = candidates.sorted().filter { it.startsWith(token) }
if (matches.isEmpty()) return
@@ -572,6 +577,7 @@ fun GitHugApp() {
onCursorRight = { moveCursor(1) },
onHistoryUp = { historyUp() },
onHistoryDown = { historyDown() },
onPromptBoundsChanged = { promptBounds = it },
)
},
)
@@ -585,6 +591,7 @@ fun GitHugApp() {
scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) }
},
onClose = { showHelpOverlay = false },
promptBounds = promptBounds,
)
}
}
@@ -663,7 +670,9 @@ private fun HelpCalloutOverlay(
onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit,
onClose: () -> Unit,
promptBounds: Rect?,
) {
val density = LocalDensity.current
Box(
modifier = Modifier
.fillMaxSize()
@@ -674,9 +683,12 @@ private fun HelpCalloutOverlay(
modifier = Modifier.align(Alignment.TopStart),
)
HelpBubbleWithControls(
modifier = Modifier
modifier = promptBounds?.let { bounds ->
val calloutTop = with(density) { (bounds.top.toDp() - 148.dp).coerceAtLeast(12.dp) }
Modifier.offset { IntOffset(0, with(density) { calloutTop.roundToPx() }) }
} ?: Modifier
.align(Alignment.BottomStart)
.padding(bottom = 54.dp),
.padding(bottom = 96.dp),
text = "Tap the prompt to enter a command.",
showHelpOnStart = showHelpOnStart,
onShowHelpOnStartChange = onShowHelpOnStartChange,

View File

@@ -103,6 +103,34 @@ class GitRepositoryRuntime private constructor(
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens) to result.second
}
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
val nativeGit = nativeGitBinary()
val sandbox = sandboxDir(level)
if (nativeGit == null || !sandbox.exists()) {
return if (directoriesOnly) {
directoryCompletionCandidates(currentRepo)
} else {
fileCompletionCandidates(currentRepo)
}
}
val sandboxRoot = sandbox.canonicalFile
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path == sandboxRoot.path || it.path.startsWith(sandboxRoot.path + File.separator) }
?: sandboxRoot
if (!workingDir.exists() || !workingDir.isDirectory) return emptyList()
return workingDir.walkTopDown()
.drop(1)
.filter { file -> !directoriesOnly || file.isDirectory }
.map { file ->
val relativePath = file.relativeTo(workingDir).path
if (file.isDirectory) "$relativePath/" else relativePath
}
.filter { it.isNotBlank() }
.toList()
}
fun commandReferenceLines(): List<String> {
return buildList {
addAll(GitSandboxEngine.commandReferenceLines())
@@ -390,6 +418,12 @@ class GitRepositoryRuntime private constructor(
materializeNativePushLevel(nativeGit, sandbox)
return
}
if (materializeNativeLevelFixture(levelId, sandbox) { directory, arguments ->
runGit(nativeGit, directory, arguments).exitCode
}
) {
return
}
desired.config.forEach { (key, value) ->
runGit(nativeGit, sandbox, listOf("config", key, value))
@@ -466,8 +500,17 @@ class GitRepositoryRuntime private constructor(
val remoteWorkTree = File(sandbox.parentFile ?: sandbox, "${sandbox.name}-origin")
remoteWorkTree.deleteRecursively()
runGit(nativeGit, sandbox.parentFile ?: sandbox, listOf("clone", sandbox.absolutePath, remoteWorkTree.absolutePath))
remoteWorkTree.mkdirs()
val initResult = runGit(nativeGit, remoteWorkTree, listOf("init", "-b", "master"))
if (initResult.exitCode != 0) {
runGit(nativeGit, remoteWorkTree, listOf("init"))
runGit(nativeGit, remoteWorkTree, listOf("checkout", "-B", "master"))
}
runGit(nativeGit, remoteWorkTree, listOf("config", "receive.denyCurrentBranch", "ignore"))
runGit(nativeGit, sandbox, listOf("remote", "add", "push-setup-origin", File(remoteWorkTree, ".git").absolutePath))
runGit(nativeGit, sandbox, listOf("push", "push-setup-origin", "master"))
runGit(nativeGit, sandbox, listOf("remote", "remove", "push-setup-origin"))
runGit(nativeGit, remoteWorkTree, listOf("checkout", "-f", "master"))
File(remoteWorkTree, "file4").writeText("file4\n")
commitIn(remoteWorkTree, "Fourth commit", "file4")
@@ -580,9 +623,11 @@ class GitRepositoryRuntime private constructor(
val logResult = runGit(nativeGit, sandbox, listOf("log", "--pretty=format:%h\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 config = buildMap {
@@ -606,6 +651,23 @@ class GitRepositoryRuntime private constructor(
emptyList()
}
val branches = branchResult.outputLines
.map { it.removePrefix("*").trim() }
.filter { it.isNotBlank() && !it.startsWith("(") }
.associateWith { branch ->
runGit(nativeGit, sandbox, listOf("rev-list", "--count", branch))
.outputLines
.firstOrNull()
?.toIntOrNull()
?: 0
}
val headBranch = headResult.outputLines.firstOrNull()
?.ifBlank { null }
?: exactTagResult.outputLines.firstOrNull()
?.takeIf { exactTagResult.exitCode == 0 && it.isNotBlank() }
?.let { "tags/$it" }
?: "DETACHED"
return RepoState(
initialized = true,
files = filesOnDisk.map { file ->
@@ -627,16 +689,20 @@ class GitRepositoryRuntime private constructor(
tracked = tracked,
deleted = true,
)
},
},
commits = commits,
headBranch = headResult.outputLines.firstOrNull()?.ifBlank { null } ?: "master",
branches = branchResult.outputLines.map { it.removePrefix("*").trim() }.filter { it.isNotBlank() }.associateWith { 0 },
headBranch = headBranch,
branches = branches,
tags = tagResult.outputLines.filter { it.isNotBlank() },
remotes = remoteResult.outputLines.mapNotNull { line ->
val parts = line.trim().split(Regex("\\s+"))
if (parts.size >= 2) parts[0] to parts[1] else null
}.toMap(),
config = config,
fetchedBranches = remoteBranchResult.outputLines
.map { it.removePrefix("*").trim() }
.filter { it.isNotBlank() && " -> " !in it }
.toSet(),
)
}

View File

@@ -0,0 +1,389 @@
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()
"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 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
}
}

View File

@@ -37,9 +37,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -69,6 +72,7 @@ fun TerminalPane(
onCursorRight: () -> Unit,
onHistoryUp: () -> Unit,
onHistoryDown: () -> Unit,
onPromptBoundsChanged: (Rect) -> Unit = {},
) {
val configuration = LocalConfiguration.current
val focusRequester = remember { FocusRequester() }
@@ -161,6 +165,9 @@ fun TerminalPane(
modifier = Modifier
.fillMaxWidth()
.widthIn(min = terminalMinWidth)
.onGloballyPositioned { coordinates ->
onPromptBoundsChanged(coordinates.boundsInRoot())
}
.background(PanelPrimary, RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,

View File

@@ -14,7 +14,7 @@ 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)) },
validator = repoPredicate { repo -> "test_branch" in repo.branches },
validator = repoPredicate { repo -> repo.branches["test_branch"] == 2 },
testCases = listOf(
levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"),
levelTestCase("branch at caret", "git branch test_branch HEAD^"),

View File

@@ -14,7 +14,7 @@ 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")) },
validator = repoPredicate { repo -> "origin/feature_branch" in repo.fetchedBranches && repo.headBranch == "master" },
validator = repoPredicate { repo -> "origin/new_branch" in repo.fetchedBranches && repo.headBranch == "master" },
testCases = listOf(
levelTestCase("fetch origin", "git fetch origin"),
levelTestCase("fetch default", "git fetch"),

View File

@@ -14,7 +14,11 @@ 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)) },
validator = repoPredicate { repo -> "origin/master" in repo.fetchedBranches },
validator = repoPredicate { repo ->
"origin/master" in repo.fetchedBranches &&
repo.files.any { it.name == "remote_file" && it.tracked } &&
repo.commits.size >= 2
},
testCases = listOf(
levelTestCase("pull explicit remote branch", "git pull origin master"),
levelTestCase("pull default origin", "git pull"),

View File

@@ -13,7 +13,7 @@ internal fun pushBranchLevel(): Level = level(
description = "You've made some changes to a local branch and want to share it, but aren't yet ready to merge it with the 'master' branch. Push only 'test_branch' to the remote repository",
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 = "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") },
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

@@ -13,10 +13,13 @@ internal fun rebaseLevel(): Level = level(
description = "We are using a git rebase workflow and the feature branch is ready to go into master. Let's rebase the feature branch onto our master branch.",
hints = listOf("You want to research the `git rebase` command"),
commandSuggestions = listOf("git checkout feature", "git rebase master"),
setup = { RepoState(initialized = true, headBranch = "feature", branches = mapOf("master" to 2, "feature" to 3)) },
validator = repoPredicate { repo -> repo.headBranch == "feature" && (repo.branches["feature"] ?: 0) >= (repo.branches["master"] ?: 0) },
setup = { RepoState(initialized = true, headBranch = "master", branches = mapOf("master" to 2, "feature" to 2)) },
validator = repoPredicate { repo ->
repo.headBranch == "feature" &&
repo.commits.take(3).map { it.message } == listOf("add feature", "add content", "init commit")
},
testCases = listOf(
levelTestCase("rebase feature on master", "git rebase master"),
levelTestCase("checkout then rebase", "git checkout feature", "git rebase master"),
levelTestCase("checkout feature then rebase", "git checkout feature", "git rebase master"),
levelTestCase("rebase named branch", "git rebase master feature"),
),
)

View File

@@ -3,7 +3,10 @@ package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class GitSandboxEngineTest {
@Test
@@ -147,6 +150,31 @@ class GitSandboxEngineTest {
assertEquals(listOf("model/"), directoryCompletionCandidates(repo))
}
@Test
fun nativeCompletionUsesCurrentDirectoryIncludingGitMetadata() {
val git = File("/usr/bin/git")
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-completion").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = level(
id = "completion-test",
title = "Completion Test",
description = "",
hints = emptyList(),
commandSuggestions = emptyList(),
setup = { RepoState(initialized = true) },
validator = { _, _ -> false },
)
val repo = runtime.prepareLevel(level)
val (gitDirRepo, _) = runtime.execute(level, repo, "cd .git")
assertTrue("HEAD" in runtime.completionCandidates(level, gitDirRepo, directoriesOnly = false))
} finally {
root.deleteRecursively()
}
}
@Test
fun cdDotDotShortcutMovesToParentDirectory() {
val repo = RepoState(initialized = true, currentDir = "src/main")