Start patch dialog for git add -p
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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('/') + "/"))
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user