diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cb18f6b..ab8600e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 120 - versionName = "0.1.119" + versionCode = 121 + versionName = "0.1.120" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index 5e827e5..624d0af 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -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(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, diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 8ba42e3..6e6cf3a 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -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 { + 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 { 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(), ) } diff --git a/app/src/main/java/solutions/tretter/githugandroid/NativeLevelFixtures.kt b/app/src/main/java/solutions/tretter/githugandroid/NativeLevelFixtures.kt new file mode 100644 index 0000000..1e524df --- /dev/null +++ b/app/src/main/java/solutions/tretter/githugandroid/NativeLevelFixtures.kt @@ -0,0 +1,389 @@ +package solutions.tretter.githugandroid + +import java.io.File + +internal fun materializeNativeLevelFixture( + levelId: String, + sandbox: File, + runGit: (File, List) -> 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) -> 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 + } +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/Terminal.kt b/app/src/main/java/solutions/tretter/githugandroid/Terminal.kt index 9375da0..d88aa98 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/Terminal.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/Terminal.kt @@ -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, diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/BranchAtLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/BranchAtLevel.kt index 28ec3df..a43156a 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/BranchAtLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/BranchAtLevel.kt @@ -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^"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/FetchLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/FetchLevel.kt index 17b3876..87ea09f 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/FetchLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/FetchLevel.kt @@ -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"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/PullLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/PullLevel.kt index bbafdd9..d05d360 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/PullLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/PullLevel.kt @@ -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"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/PushBranchLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/PushBranchLevel.kt index 83d00e9..cf78048 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/PushBranchLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/PushBranchLevel.kt @@ -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"), diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/RebaseLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/RebaseLevel.kt index ffcbd71..7ab5364 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/RebaseLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/RebaseLevel.kt @@ -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"), ), ) diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index 6978e46..aa67dde 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -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")