From 4c5d68c1cbc58423161b25e7f3491f701a9c6176 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Mon, 18 May 2026 21:02:42 -0500 Subject: [PATCH] Start patch dialog for git add -p --- app/build.gradle.kts | 4 +- .../tretter/githugandroid/GitSandboxEngine.kt | 90 +++++++++++++++++++ .../githugandroid/levels/StageLinesLevel.kt | 11 ++- .../githugandroid/GitSandboxEngineTest.kt | 78 +++++++++++++++- 4 files changed, 176 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a84c6f2..542d2ae 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 = 155 - versionName = "0.1.154" + versionCode = 156 + versionName = "0.1.155" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt index 9e35629..e8748c8 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt @@ -1,6 +1,8 @@ package solutions.tretter.githugandroid object GitSandboxEngine { + private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?" + data class ShellToken( val value: String, val quoted: Boolean = false, @@ -108,6 +110,9 @@ object GitSandboxEngine { if (parts.drop(2).any { it == "-i" || it == "--interactive" }) { return interactiveAdd(repo, parts.drop(2)) } + if (parts.drop(2).any { it == "-p" || it == "--patch" }) { + return interactiveAddPatch(repo, parts.drop(2)) + } val target = parts.drop(2).lastOrNull { !it.startsWith("-") } ?: return repo to listOf("usage: git add ") if (target != "." && repo.files.none { it.name == target && !it.deleted }) { @@ -180,6 +185,14 @@ object GitSandboxEngine { return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates) } + private fun interactiveAddPatch(repo: RepoState, arguments: List): Pair> { + val targets = arguments.filterNot { it == "-p" || it == "--patch" || it.startsWith("--") } + val target = targets.lastOrNull() + val patchFile = interactiveAddCandidates(repo, target).firstOrNull() + ?: return repo to listOf("No changes.") + return startPatchHunkSession(repo, patchFile.name) + } + private fun interactiveAddConsoleLines(candidates: List): List { return buildList { add(" staged unstaged path") @@ -204,6 +217,9 @@ object GitSandboxEngine { private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair> { val session = repo.interactiveAddSession ?: return repo to emptyList() val answer = input.trim() + if (session.selectionAction == "patch-hunk") { + return handlePatchHunkInput(repo, session, answer) + } return if (session.awaitingUpdateSelection) { applyInteractiveAddUpdateSelection(repo, session, answer) } else { @@ -249,6 +265,10 @@ object GitSandboxEngine { return repo to listOf("$prompt $answer", "No files selected.", prompt) } + if (session.selectionAction == "patch" && selectedNames.size == 1) { + return startPatchHunkSession(repo, selectedNames.single(), "$prompt $answer") + } + val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction) val updatedRepo = repo.copy( files = updatedFiles, @@ -302,6 +322,76 @@ object GitSandboxEngine { } } + private fun startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair> { + val file = repo.files.firstOrNull { it.name == target && !it.deleted } + ?: return repo to listOfNotNull(prefixLine, "No changes.") + val session = InteractiveAddSession( + target = target, + awaitingUpdateSelection = false, + selectionPrompt = PatchHunkPrompt, + selectionAction = "patch-hunk", + ) + val output = listOfNotNull(prefixLine) + patchHunkLines(file) + return repo.copy(interactiveAddSession = session) to output + } + + private fun handlePatchHunkInput(repo: RepoState, session: InteractiveAddSession, answer: String): Pair> { + val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No changes.") + return when (answer.lowercase()) { + "y", "a" -> { + val updatedFiles = repo.files.map { file -> + if (file.name == target && !file.deleted) file.copy(staged = true) else file + } + repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer") + } + "n", "d" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer") + "q" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer", "Quit") + "?" -> repo to listOf( + "$PatchHunkPrompt $answer", + "y - stage this hunk", + "n - do not stage this hunk", + "q - quit; do not stage this hunk or any remaining ones", + "a - stage this hunk and all later hunks in the file", + "d - do not stage this hunk or any later hunks in the file", + "s - split the current hunk into smaller hunks", + "e - manually edit the current hunk", + "p - print the current hunk", + "? - print help", + PatchHunkPrompt, + ) + "p" -> { + val file = repo.files.firstOrNull { it.name == target && !it.deleted } + if (file == null) { + repo.copy(interactiveAddSession = null) to listOf("No changes.") + } else { + repo to listOf("$PatchHunkPrompt $answer") + patchHunkLines(file) + } + } + "s" -> repo to listOf("$PatchHunkPrompt $answer", "Sorry, cannot split this hunk", PatchHunkPrompt) + "e" -> repo to listOf("$PatchHunkPrompt $answer", "Manual hunk editing is not available in this mobile sandbox.", PatchHunkPrompt) + else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt) + } + } + + private fun patchHunkLines(file: GitFile): List { + val lines = file.content.lines() + val nonEmptyLines = lines.dropLastWhile { it.isEmpty() } + val addedCount = nonEmptyLines.size.coerceAtLeast(1) + return buildList { + add("diff --git a/${file.name} b/${file.name}") + add("index 0000000..0000001 100644") + add("--- a/${file.name}") + add("+++ b/${file.name}") + add("@@ -1 +1,$addedCount @@") + if (nonEmptyLines.isEmpty()) { + add("+") + } else { + nonEmptyLines.forEach { line -> add("+$line") } + } + add(PatchHunkPrompt) + } + } + private fun interactiveAddCandidates(repo: RepoState, target: String?): List { return repo.files.filter { file -> !file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/StageLinesLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/StageLinesLevel.kt index 518156c..9c549be 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/StageLinesLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/StageLinesLevel.kt @@ -14,9 +14,16 @@ internal fun stageLinesLevel(): Level = level( hints = listOf("Read about the flags which can be passed to the `add` command."), commandSuggestions = listOf("git add -p feature.rb"), setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) }, + nativeSetup = { + resetFiles() + write("feature.rb", "this is the class of my feature\n") + addCommit("Initial feature", "feature.rb") + write("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature") + true + }, validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } }, testCases = listOf( - levelTestCase("patch add feature file", "git add -p feature.rb"), - levelTestCase("patch long option", "git add --patch feature.rb"), + levelTestCase("patch add feature file", "git add -p feature.rb", "y"), + levelTestCase("patch long option", "git add --patch feature.rb", "y"), ), ) diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index c46e807..15ccd01 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -427,13 +427,58 @@ class GitSandboxEngineTest { val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i") val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch") - val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(patchRepo, "1") + 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("Patch update>> 1") }) - assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") }) + 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 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 in listOf("y", "a")) { + assertTrue(updatedRepo.files.single { it.name == "README" }.staged) + assertTrue(updatedRepo.interactiveAddSession == null) + } + } } @Test @@ -467,6 +512,33 @@ class GitSandboxEngineTest { } } + @Test + fun nativePatchAddSelectionUpdatesGitIndex() { + val git = testGitBinary() + assumeTrue(git.exists() && git.canExecute()) + val root = Files.createTempDirectory("githug-patch-add").toFile() + try { + val runtime = GitRepositoryRuntime(root, git) + val level = level( + id = "patch-add-test", + title = "Patch 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 (patchRepo, patchOutput) = runtime.execute(level, repo, "git add -p README") + val (selectedRepo, _) = runtime.execute(level, patchRepo, "y") + + assertTrue(patchOutput.any { it.contains("Stage this hunk") }) + assertTrue(selectedRepo.files.single { it.name == "README" }.staged) + } finally { + root.deleteRecursively() + } + } + @Test fun nativeInteractiveRebaseUsesAppSequenceEditorContent() { val git = testGitBinary()