From ed0b81e5876e7ec181419a07edae03e09c05b712 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Mon, 18 May 2026 21:10:17 -0500 Subject: [PATCH] Open patch hunk editor for git add -p edit command --- app/build.gradle.kts | 4 +- .../githugandroid/GitEditorCommands.kt | 1 + .../tretter/githugandroid/GitHugApp.kt | 13 +++- .../tretter/githugandroid/GitRuntime.kt | 15 +++++ .../tretter/githugandroid/GitSandboxEngine.kt | 32 +++++++++- .../githugandroid/GitSandboxEngineTest.kt | 60 +++++++++++++++++++ 6 files changed, 121 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 542d2ae..774b500 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 = 156 - versionName = "0.1.155" + versionCode = 157 + versionName = "0.1.156" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt index 3826833..9155add 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt @@ -4,6 +4,7 @@ enum class GitEditorCommandKind { COMMIT_MESSAGE, REBASE_TODO, TAG_MESSAGE, + PATCH_HUNK, } data class GitEditorInvocation( diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index c6e3775..0e3cecc 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -405,10 +405,15 @@ fun GitHugApp() { fun openGitMessageEditor(invocation: GitEditorInvocation) { val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation) + val openedMessage = when (invocation.kind) { + GitEditorCommandKind.REBASE_TODO -> "Opened Git rebase editor" + GitEditorCommandKind.PATCH_HUNK -> "Opened Git patch editor" + else -> "Opened Git message editor" + } output = buildList { addAll(output) add("$ ${invocation.command}") - add(if (invocation.kind == GitEditorCommandKind.REBASE_TODO) "Opened Git rebase editor" else "Opened Git message editor") + add(openedMessage) } gitMessageEditorState = GitMessageEditorState( invocation = invocation.copy(initialContent = initialContent), @@ -474,6 +479,12 @@ fun GitHugApp() { return } + val patchHunkEditorInvocation = GitSandboxEngine.parsePatchHunkEditorInvocation(repo, raw) + if (patchHunkEditorInvocation != null) { + openGitMessageEditor(patchHunkEditorInvocation) + return + } + val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput) } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 466ce3b..e997cf8 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -315,6 +315,21 @@ class GitRepositoryRuntime private constructor( return executeInteractiveRebaseEditorCommand(level, currentRepo, nativeGit, sandboxRoot, workingDir, invocation, message) } + if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) { + val (updatedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(currentRepo, message) + val newlyStagedPaths = updatedRepo.files.filter { updatedFile -> + updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true + }.map { it.name } + if (newlyStagedPaths.isNotEmpty()) { + runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths) + } + val inspectedRepo = inspectSandbox(level).copy( + currentDir = updatedRepo.currentDir, + interactiveAddSession = updatedRepo.interactiveAddSession, + ) + return inspectedRepo to output + } + val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply { parentFile?.mkdirs() writeText(message) diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt index e8748c8..25f8086 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt @@ -8,6 +8,36 @@ object GitSandboxEngine { val quoted: Boolean = false, ) + fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? { + val session = repo.interactiveAddSession ?: return null + if (session.selectionAction != "patch-hunk") return null + if (command.trim().lowercase() != "e") return null + val target = session.target ?: return null + val file = repo.files.firstOrNull { it.name == target && !it.deleted } ?: return null + return GitEditorInvocation( + command = command, + kind = GitEditorCommandKind.PATCH_HUNK, + title = "Edit Patch Hunk", + initialContent = patchHunkLines(file) + .dropLastWhile { it == PatchHunkPrompt } + .joinToString("\n"), + ) + } + + fun applyPatchHunkEdit(repo: RepoState, content: String): Pair> { + val session = repo.interactiveAddSession ?: return repo to listOf("No patch hunk is active.") + val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No patch hunk is active.") + if (session.selectionAction != "patch-hunk") return repo to listOf("No patch hunk is active.") + if (content.isBlank()) return repo to listOf("Edited hunk was empty; patch was not applied.", PatchHunkPrompt) + val updatedFiles = repo.files.map { file -> + if (file.name == target && !file.deleted) file.copy(staged = true) else file + } + return repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf( + "$PatchHunkPrompt e", + "Applied edited hunk.", + ) + } + fun commandReferenceLines(): List = listOf( "Available sandbox commands:", " git ", @@ -368,7 +398,7 @@ object GitSandboxEngine { } } "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) + "e" -> repo to listOf("$PatchHunkPrompt $answer", "Opening patch editor") else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt) } } diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index 15ccd01..1aa0a71 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -463,6 +463,34 @@ class GitSandboxEngineTest { 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().contains("diff --git a/README b/README")) + 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", "?") @@ -474,6 +502,9 @@ class GitSandboxEngineTest { 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) @@ -539,6 +570,35 @@ class GitSandboxEngineTest { } } + @Test + fun nativePatchHunkEditorSaveUpdatesGitIndex() { + val git = testGitBinary() + assumeTrue(git.exists() && git.canExecute()) + val root = Files.createTempDirectory("githug-patch-edit").toFile() + try { + val runtime = GitRepositoryRuntime(root, git) + val level = level( + id = "patch-edit-test", + title = "Patch Edit 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, _) = runtime.execute(level, repo, "git add -p README") + val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e") + ?: error("Expected patch editor invocation") + val (selectedRepo, output) = runtime.executeGitEditorCommand(level, patchRepo, invocation, invocation.initialContent) + + assertTrue(output.any { it.contains("Applied edited hunk.") }) + assertTrue(selectedRepo.files.single { it.name == "README" }.staged) + } finally { + root.deleteRecursively() + } + } + @Test fun nativeInteractiveRebaseUsesAppSequenceEditorContent() { val git = testGitBinary()