Start patch dialog for git add -p
This commit is contained in:
@@ -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 <path>")
|
||||
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<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> {
|
||||
return buildList {
|
||||
add(" staged unstaged path")
|
||||
@@ -204,6 +217,9 @@ object GitSandboxEngine {
|
||||
private fun handleInteractiveAddInput(repo: RepoState, input: String): Pair<RepoState, List<String>> {
|
||||
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<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> {
|
||||
return repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user