Start patch dialog for git add -p

This commit is contained in:
Joe Tretter
2026-05-18 21:02:42 -05:00
parent eb24866a7a
commit 4c5d68c1cb
4 changed files with 176 additions and 7 deletions

View File

@@ -19,8 +19,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 155 versionCode = 156
versionName = "0.1.154" versionName = "0.1.155"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -1,6 +1,8 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
object GitSandboxEngine { object GitSandboxEngine {
private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?"
data class ShellToken( data class ShellToken(
val value: String, val value: String,
val quoted: Boolean = false, val quoted: Boolean = false,
@@ -108,6 +110,9 @@ object GitSandboxEngine {
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) { if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
return interactiveAdd(repo, parts.drop(2)) 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("-") } val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git add <path>") ?: return repo to listOf("usage: git add <path>")
if (target != "." && repo.files.none { it.name == target && !it.deleted }) { 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) return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates)
} }
private fun interactiveAddPatch(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
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<GitFile>): List<String> { private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
return buildList { return buildList {
add(" staged unstaged path") add(" staged unstaged path")
@@ -204,6 +217,9 @@ object GitSandboxEngine {
private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair<RepoState, List<String>> { private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair<RepoState, List<String>> {
val session = repo.interactiveAddSession ?: return repo to emptyList() val session = repo.interactiveAddSession ?: return repo to emptyList()
val answer = input.trim() val answer = input.trim()
if (session.selectionAction == "patch-hunk") {
return handlePatchHunkInput(repo, session, answer)
}
return if (session.awaitingUpdateSelection) { return if (session.awaitingUpdateSelection) {
applyInteractiveAddUpdateSelection(repo, session, answer) applyInteractiveAddUpdateSelection(repo, session, answer)
} else { } else {
@@ -249,6 +265,10 @@ object GitSandboxEngine {
return repo to listOf("$prompt $answer", "No files selected.", prompt) 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 updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction)
val updatedRepo = repo.copy( val updatedRepo = repo.copy(
files = updatedFiles, files = updatedFiles,
@@ -302,6 +322,76 @@ object GitSandboxEngine {
} }
} }
private fun startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair<RepoState, List<String>> {
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<RepoState, List<String>> {
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<String> {
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<GitFile> { private fun interactiveAddCandidates(repo: RepoState, target: String?): List<GitFile> {
return repo.files.filter { file -> return repo.files.filter { file ->
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) !file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))

View File

@@ -14,9 +14,16 @@ internal fun stageLinesLevel(): Level = level(
hints = listOf("Read about the flags which can be passed to the `add` command."), hints = listOf("Read about the flags which can be passed to the `add` command."),
commandSuggestions = listOf("git add -p feature.rb"), 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)) }, 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 } }, validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("patch add feature file", "git add -p feature.rb"), levelTestCase("patch add feature file", "git add -p feature.rb", "y"),
levelTestCase("patch long option", "git add --patch feature.rb"), levelTestCase("patch long option", "git add --patch feature.rb", "y"),
), ),
) )

View File

@@ -427,13 +427,58 @@ class GitSandboxEngineTest {
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i") val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch") 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(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
assertTrue(patchOutput.any { it.contains("Patch update>>") }) assertTrue(patchOutput.any { it.contains("Patch update>>") })
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
assertTrue(selectedRepo.files.single { it.name == "README" }.staged) assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectionOutput.any { it.contains("Patch update>> 1") }) assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") }) }
@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 @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 @Test
fun nativeInteractiveRebaseUsesAppSequenceEditorContent() { fun nativeInteractiveRebaseUsesAppSequenceEditorContent() {
val git = testGitBinary() val git = testGitBinary()