Route interactive Git through native terminal sessions
Remove Kotlin interactive-add emulation and run patch add via the native Git runtime with PTY-backed session IO.
This commit is contained in:
@@ -535,6 +535,59 @@ class GitSandboxEngineTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeInteractiveAddKeepsCompiledGitSessionOpenForInput() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-native-add-i").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = addLevel()
|
||||
var repo = runtime.prepareLevel(level)
|
||||
|
||||
val (menuRepo, menuOutput) = runtime.execute(level, repo, "git add -i")
|
||||
repo = menuRepo
|
||||
val (quitRepo, quitOutput) = runtime.execute(level, repo, "q")
|
||||
|
||||
assertNotNull(menuRepo.nativeGitSession)
|
||||
assertTrue(menuOutput.joinToString("\n"), menuOutput.any { it.contains("What now") })
|
||||
assertNull(quitRepo.nativeGitSession)
|
||||
assertTrue(quitOutput.joinToString("\n"), quitOutput.any { it.contains("Bye") })
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativePatchAddUsesCompiledGitEditorAndStagesOnlyEditedStageLinesHunk() {
|
||||
val git = testGitBinary()
|
||||
assumeTrue(git.exists() && git.canExecute())
|
||||
val root = Files.createTempDirectory("githug-stage-lines-native-patch").toFile()
|
||||
try {
|
||||
val runtime = GitRepositoryRuntime(root, git)
|
||||
val level = stageLinesLevel()
|
||||
var repo = runtime.prepareLevel(level)
|
||||
|
||||
val (patchRepo, patchOutput) = runtime.execute(
|
||||
level,
|
||||
repo,
|
||||
"GIT_EDITOR=\"sed -i '/second feature/d'\" git add -p feature.rb",
|
||||
)
|
||||
repo = patchRepo
|
||||
val (resultRepo, _) = runtime.execute(level, repo, "e")
|
||||
val file = resultRepo.files.single { it.name == "feature.rb" }
|
||||
|
||||
assertNotNull(patchRepo.nativeGitSession)
|
||||
assertTrue(patchOutput.joinToString("\n"), patchOutput.any { it.contains("Stage this hunk") })
|
||||
assertNull(resultRepo.nativeGitSession)
|
||||
assertTrue(resultRepo.diagnosticSnapshot(), level.validator(resultRepo, "e"))
|
||||
assertTrue(file.stagedContent.orEmpty(), file.stagedContent.orEmpty().contains("This change belongs to the first feature"))
|
||||
assertFalse(file.stagedContent.orEmpty(), file.stagedContent.orEmpty().contains("This change belongs to the second feature"))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun testGitBinary(): File {
|
||||
System.getenv("GITHUG_TEST_GIT_BINARY")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InteractiveAddEngineTest {
|
||||
@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 interactiveAddHandlesEveryDisplayedMenuCommand() {
|
||||
val menuCommands = listOf(
|
||||
"1" to "What now> 1",
|
||||
"2" to "Update>>",
|
||||
"3" to "Revert>>",
|
||||
"4" to "Add untracked>>",
|
||||
"5" to "Patch update>>",
|
||||
"6" to "Diff>>",
|
||||
"7" to "Bye.",
|
||||
"8" to "What now> 8",
|
||||
)
|
||||
|
||||
menuCommands.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("2", "3", "4", "5", "6")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesMenuCommandAliases() {
|
||||
val aliases = listOf(
|
||||
"status" to "What now> status",
|
||||
"update" to "Update>>",
|
||||
"revert" to "Revert>>",
|
||||
"add untracked" to "Add untracked>>",
|
||||
"patch" to "Patch update>>",
|
||||
"diff" to "Diff>>",
|
||||
"quit" to "Bye.",
|
||||
"help" to "What now> help",
|
||||
)
|
||||
|
||||
aliases.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddPatchSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
|
||||
val (hunkRepo, hunkOutput) = GitSandboxEngine.execute(patchRepo, "1")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(hunkRepo, "y")
|
||||
|
||||
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(patchOutput.any { it.contains("Patch update>>") })
|
||||
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddStartsPatchHunkDialogWithoutStagingImmediately() {
|
||||
listOf("git add -p README", "git add --patch README").forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, output) = GitSandboxEngine.execute(repo, command)
|
||||
|
||||
assertFalse("$command should not stage before a selection", patchRepo.files.single { it.name == "README" }.staged)
|
||||
assertEquals("patch-hunk", patchRepo.interactiveAddSession?.selectionAction)
|
||||
assertTrue(output.any { it.startsWith("diff --git a/README b/README") })
|
||||
assertTrue(output.any { it.contains("Stage this hunk") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (selectedRepo, output) = GitSandboxEngine.execute(patchRepo, "y")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkEditOpensPatchEditorInvocation() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
|
||||
assertEquals(GitEditorCommandKind.PATCH_HUNK, invocation?.kind)
|
||||
assertEquals("Edit Patch Hunk", invocation?.title)
|
||||
assertTrue(invocation?.initialContent.orEmpty().startsWith("# Manual hunk edit mode -- see bottom for a quick guide."))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("diff --git a/README b/README"))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("--- a/README"))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("# ---"))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("# To remove '+' lines, delete them."))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("+A"))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("Stage this hunk"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editedPatchHunkStagesCurrentPatchTarget() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
|
||||
val (selectedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(patchRepo, invocation.initialContent)
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkDialogHandlesAdvertisedCommands() {
|
||||
val commands = listOf("y", "n", "q", "a", "d", "s", "e", "p", "P", "?")
|
||||
|
||||
commands.forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(patchRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Unknown command '$command'") })
|
||||
assertTrue("$command should echo hunk prompt", output.any { it.contains("Stage this hunk") })
|
||||
if (command == "e") {
|
||||
assertTrue(output.any { it.contains("Opening patch editor") })
|
||||
}
|
||||
if (command in listOf("y", "a")) {
|
||||
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession == null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -222,7 +222,10 @@ class LevelSolutionsTest {
|
||||
appendLine(" <none>")
|
||||
} else {
|
||||
files.sortedBy { it.name }.forEach { file ->
|
||||
appendLine(" ${file.name} staged=${file.staged} tracked=${file.tracked} content=${file.content.toEvidenceValue()}")
|
||||
appendLine(
|
||||
" ${file.name} staged=${file.staged} tracked=${file.tracked} " +
|
||||
"content=${file.content.toEvidenceValue()} stagedContent=${file.stagedContent.toNullableEvidenceValue()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
appendLine("commits=")
|
||||
@@ -240,6 +243,10 @@ class LevelSolutionsTest {
|
||||
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
|
||||
}
|
||||
|
||||
fun String?.toNullableEvidenceValue(): String {
|
||||
return this?.toEvidenceValue() ?: "<none>"
|
||||
}
|
||||
|
||||
val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf(
|
||||
"init",
|
||||
"config",
|
||||
|
||||
Reference in New Issue
Block a user