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 fun statusShowsDeletedTrackedFiles() { val repo = RepoState( initialized = true, files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)), commits = listOf(CommitNode("0000001", "Added a temp file")), branches = mapOf("master" to 1), ) val (_, output) = GitSandboxEngine.execute(repo, "git status") assertTrue(output.any { it.contains("deleted:") && it.contains("deleteme.rb") }) } @Test fun gitRmRemovesDeletedTrackedFileFromIndex() { val repo = RepoState( initialized = true, files = listOf(GitFile("deleteme.rb", tracked = true, deleted = true)), commits = listOf(CommitNode("0000001", "Added a temp file")), branches = mapOf("master" to 1), ) val (updatedRepo, _) = GitSandboxEngine.execute(repo, "git rm deleteme.rb") assertFalse(updatedRepo.files.any { it.name == "deleteme.rb" }) } @Test fun rmLevelIsNotSolvedByReadOnlyCommands() { val level = rmLevel() val repo = level.setup() val (updatedRepo, _) = GitSandboxEngine.execute(repo, "ls") assertFalse(level.validator(updatedRepo, "ls")) } @Test fun gitMvExpandsWildcardSourcesIntoDestinationDirectory() { val repo = RepoState( initialized = true, files = listOf( GitFile("about.html", tracked = true), GitFile("contact.html", tracked = true), GitFile("index.html", tracked = true), GitFile("README", tracked = true), ), branches = mapOf("master" to 1), ) val (updatedRepo, _) = GitSandboxEngine.execute(repo, "git mv *.html src") assertTrue(updatedRepo.files.any { it.name == "src/about.html" }) assertTrue(updatedRepo.files.any { it.name == "src/contact.html" }) assertTrue(updatedRepo.files.any { it.name == "src/index.html" }) assertTrue(updatedRepo.files.any { it.name == "README" }) } @Test fun singleQuotedWildcardIsNotExpanded() { val repo = RepoState( initialized = true, files = listOf(GitFile("README"), GitFile("main.kt")), ) val (_, output) = GitSandboxEngine.execute(repo, "echo '*'") assertEquals(listOf("*"), output) } @Test fun echoRedirectAcceptsNoSurroundingSpaces() { val repo = RepoState(initialized = true) val (updatedRepo, output) = GitSandboxEngine.execute(repo, "echo '*.swp'>.gitignore") assertTrue(output.isEmpty()) assertEquals("*.swp", updatedRepo.files.single { it.name == ".gitignore" }.content) } @Test fun commitHashAnswerAcceptsFullHashThatStartsWithDisplayedShortHash() { val repo = RepoState( initialized = true, commits = listOf(CommitNode("abc1234", "Latest commit")), branches = mapOf("master" to 1), ) assertTrue(commitHashAnswer("0000001")(repo, "abc1234fedcba9876543210fedcba9876543210")) } @Test fun mdIsAcceptedAsMkdirAlias() { val repo = RepoState(initialized = true) val (_, output) = GitSandboxEngine.execute(repo, "md src") assertTrue(output.isEmpty()) } @Test fun dirIsAcceptedAsLsAlias() { val repo = RepoState( initialized = true, files = listOf(GitFile("README"), GitFile("src/main.kt")), ) val (_, output) = GitSandboxEngine.execute(repo, "dir") assertTrue("README" in output) assertTrue("src/main.kt" in output) } @Test fun lsShowsDotfiles() { val repo = RepoState( initialized = true, files = listOf(GitFile(".gitignore"), GitFile("README")), ) val (_, output) = GitSandboxEngine.execute(repo, "ls") assertTrue(".gitignore" in output) } @Test fun lsShowsGitDirectoryEntry() { val repo = RepoState(initialized = true) val (_, output) = GitSandboxEngine.execute(repo, "ls") assertTrue(".git" in output) } @Test fun fileCompletionUsesCurrentDirectory() { val repo = RepoState( initialized = true, currentDir = "src", files = listOf( GitFile("README"), GitFile("src/main.kt"), GitFile("src/model/User.kt"), ), ) assertEquals(listOf("main.kt", "model/User.kt"), fileCompletionCandidates(repo).sorted()) assertEquals(listOf("model/"), directoryCompletionCandidates(repo)) } @Test fun nativeCompletionUsesCurrentDirectoryIncludingGitMetadata() { val git = testGitBinary() 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 nativeCompletionIncludesExecutableShortcutPaths() { val git = testGitBinary() assumeTrue(git.exists() && git.canExecute()) val root = Files.createTempDirectory("githug-script-completion").toFile() try { val runtime = GitRepositoryRuntime(root, git) val level = bisectLevel() val repo = runtime.prepareLevel(level) val candidates = runtime.completionCandidates(level, repo, directoriesOnly = false) assertTrue("./test-balance.sh" in candidates) } finally { root.deleteRecursively() } } @Test fun nativeGitExecAliasesRefreshWhenBinaryChanges() { val git = testGitBinary() assumeTrue(git.exists() && git.canExecute()) val root = Files.createTempDirectory("githug-exec-refresh").toFile() try { val firstGit = File(root, "first-git").also { git.copyTo(it); it.setExecutable(true) } val secondGit = File(root, "second-git").also { git.copyTo(it); it.setExecutable(true) } val level = level( id = "exec-refresh-test", title = "Exec Refresh Test", description = "", hints = emptyList(), commandSuggestions = emptyList(), setup = { RepoState(initialized = true) }, validator = { _, _ -> false }, ) GitRepositoryRuntime(root, firstGit).prepareLevel(level) val alias = File(root, "git-exec/git") assertTrue(alias.exists()) assertTrue(File(root, "git-exec/.binary-fingerprint").readText().contains(firstGit.absolutePath)) GitRepositoryRuntime(root, secondGit).prepareLevel(level) assertTrue(alias.exists()) assertTrue(File(root, "git-exec/.binary-fingerprint").readText().contains(secondGit.absolutePath)) } finally { root.deleteRecursively() } } @Test fun nativePushLevelHasDivergedStatusAndRebasesCleanly() { val git = testGitBinary() assumeTrue(git.exists() && git.canExecute()) val root = Files.createTempDirectory("githug-push-level").toFile() try { val runtime = GitRepositoryRuntime(root, git) val level = pushLevel() val repo = runtime.prepareLevel(level) val (_, statusOutput) = runtime.execute(level, repo, "git status") assertTrue(statusOutput.any { it.contains("diverged", ignoreCase = true) }) val (_, diffOutput) = runtime.execute(level, repo, "git diff") assertTrue(diffOutput.isNotEmpty()) val (rebasedRepo, pullOutput) = runtime.execute(level, repo, "git pull --rebase origin master") assertFalse(pullOutput.any { it.contains("fatal:", ignoreCase = true) || it.contains("error:", ignoreCase = true) }) assertTrue(rebasedRepo.files.any { it.name == "file4" && it.tracked }) } finally { root.deleteRecursively() } } @Test fun nativeDiffLevelShowsLine26Change() { val git = testGitBinary() assumeTrue(git.exists() && git.canExecute()) val root = Files.createTempDirectory("githug-diff-level").toFile() try { val runtime = GitRepositoryRuntime(root, git) val level = diffLevel() val repo = runtime.prepareLevel(level) val appFile = repo.files.single { it.name == "app.rb" } assertTrue(appFile.content.contains("server.json")) val (_, diffOutput) = runtime.execute(level, repo, "git diff") assertTrue(diffOutput.any { it.contains("- @message = get_response('data.json')") }) assertTrue(diffOutput.any { it.contains("+ @message = get_response('server.json')") }) assertTrue(diffOutput.any { it.contains("@@ -23,7 +23,7 @@") }) } finally { root.deleteRecursively() } } @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") val (updatedRepo, output) = GitSandboxEngine.execute(repo, "cd..") assertTrue(output.isEmpty()) assertEquals("src", updatedRepo.currentDir) } @Test fun delIsAcceptedAsRmAlias() { val repo = RepoState( initialized = true, files = listOf(GitFile("deleteme.txt")), ) val (updatedRepo, output) = GitSandboxEngine.execute(repo, "del deleteme.txt") assertTrue(output.isEmpty()) assertFalse(updatedRepo.files.any { it.name == "deleteme.txt" }) } @Test fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() { val repo = RepoState(initialized = true, files = listOf(GitFile("README"))) val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i") assertFalse(updatedRepo.files.single { it.name == "README" }.staged) assertTrue(output.any { it.contains("What now>") }) assertFalse(output.any { it.contains("What now> update") }) assertFalse(output.any { it.contains("What now> quit") }) assertFalse(output.any { it.contains("GitHug Android") }) } @Test fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() { val repo = RepoState(initialized = true, files = listOf(GitFile("README"))) val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i") assertFalse(updatedRepo.files.single { it.name == "README" }.staged) assertTrue(updatedRepo.interactiveAddSession != null) assertTrue(output.any { it.contains("What now>") }) assertFalse(output.any { it.contains("What now> update") }) assertFalse(output.any { it.contains("What now> quit") }) } @Test fun interactiveAddAcceptsUpdateSelectionFromNextInput() { val repo = RepoState(initialized = true, files = listOf(GitFile("README"))) val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i") val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2") val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1") val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7") assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true) assertTrue(updateOutput.any { it.contains("Update>>") }) assertTrue(selectedRepo.files.single { it.name == "README" }.staged) assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false) assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") }) assertTrue(quitRepo.interactiveAddSession == null) assertTrue(quitOutput.any { it.contains("Bye.") }) } @Test fun nativeInteractiveAddSelectionUpdatesGitIndex() { val git = testGitBinary() assumeTrue(git.exists() && git.canExecute()) val root = Files.createTempDirectory("githug-interactive-add").toFile() try { val runtime = GitRepositoryRuntime(root, git) val level = level( id = "interactive-add-test", title = "Interactive Add Test", description = "", hints = emptyList(), commandSuggestions = emptyList(), setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) }, validator = { _, _ -> false }, ) val repo = runtime.prepareLevel(level) val (menuRepo, _) = runtime.execute(level, repo, "git stage -i") val (updateRepo, _) = runtime.execute(level, menuRepo, "2") val (selectedRepo, _) = runtime.execute(level, updateRepo, "1") val (quitRepo, _) = runtime.execute(level, selectedRepo, "7") val (_, statusOutput) = runtime.execute(level, quitRepo, "git status") assertTrue(selectedRepo.files.single { it.name == "README" }.staged) assertTrue(quitRepo.interactiveAddSession == null) assertTrue(statusOutput.any { it.contains("new file:") && it.contains("README") }) } finally { root.deleteRecursively() } } @Test fun nativeInteractiveRebaseUsesAppSequenceEditorContent() { val git = testGitBinary() assumeTrue(git.exists() && git.canExecute()) val root = Files.createTempDirectory("githug-interactive-rebase-editor").toFile() try { val runtime = GitRepositoryRuntime(root, git) val level = reorderLevel() val repo = runtime.prepareLevel(level) val invocation = parseGitEditorInvocation("git rebase -i HEAD~3") ?: error("Expected interactive rebase editor invocation") val todo = runtime.gitEditorInitialContent(level, repo, invocation) val reorderedTodo = todo.lines().toMutableList().also { lines -> val secondPick = lines.indexOfFirst { it.contains("Third commit") } val thirdPick = lines.indexOfFirst { it.contains("Second commit") } val thirdLine = lines[thirdPick] lines[thirdPick] = lines[secondPick] lines[secondPick] = thirdLine }.joinToString("\n") val (updatedRepo, output) = runtime.executeGitEditorCommand(level, repo, invocation, reorderedTodo) assertFalse(output.any { it.contains("Terminal is dumb", ignoreCase = true) }) assertTrue(level.validator(updatedRepo, invocation.command)) } finally { root.deleteRecursively() } } @Test fun nativeExecutableShortcutRunsScriptThroughShell() { val git = testGitBinary() assumeTrue(git.exists() && git.canExecute()) val root = Files.createTempDirectory("githug-script-shortcut").toFile() try { val runtime = GitRepositoryRuntime(root, git) val level = bisectLevel() val repo = runtime.prepareLevel(level) val (_, output) = runtime.execute(level, repo, "./test-balance.sh") assertTrue(output.joinToString("\n"), output.any { it.contains("balance broken") }) } finally { root.deleteRecursively() } } private fun testGitBinary(): File { System.getenv("GITHUG_TEST_GIT_BINARY") ?.takeIf { it.isNotBlank() } ?.let { File(it) } ?.takeIf { it.exists() && it.canExecute() } ?.let { return it } val repoHostGit = File(repoRoot(), "build/host-git/libgit.so") if (repoHostGit.exists() && repoHostGit.canExecute()) return repoHostGit return File("/usr/bin/git") } private fun repoRoot(): File { val userDir = File(System.getProperty("user.dir") ?: ".") return if (File(userDir, "app/build.gradle.kts").exists()) userDir else userDir.parentFile ?: userDir } }