- show Git editor file paths instead of the submitted git command - surface follow-up commit message editors during interactive rebase reword - add rename-commit UI tests for editor and amend-message solution paths - add broader embedded level solution scenarios for alternate Git workflows
289 lines
13 KiB
Kotlin
289 lines
13 KiB
Kotlin
package solutions.tretter.githugandroid
|
|
|
|
internal object InteractiveAddEngine {
|
|
private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?"
|
|
|
|
fun start(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
|
val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") }
|
|
val target = targets.lastOrNull()
|
|
val candidates = interactiveAddCandidates(repo, target)
|
|
return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates)
|
|
}
|
|
|
|
fun startPatch(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)
|
|
}
|
|
|
|
fun handleInput(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 {
|
|
when (answer.lowercase()) {
|
|
"1", "s", "status" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
|
"2", "u", "update" -> interactiveAddSelectionPrompt(repo, session, answer, "Update>>", "update")
|
|
"3", "r", "revert" -> interactiveAddSelectionPrompt(repo, session, answer, "Revert>>", "revert")
|
|
"4", "a", "add untracked", "add-untracked" -> interactiveAddSelectionPrompt(repo, session, answer, "Add untracked>>", "add-untracked")
|
|
"5", "p", "patch" -> interactiveAddSelectionPrompt(repo, session, answer, "Patch update>>", "patch")
|
|
"6", "d", "diff" -> interactiveAddSelectionPrompt(repo, session, answer, "Diff>>", "diff")
|
|
"7", "q", "quit" -> repo.copy(interactiveAddSession = null) to listOf("What now> $answer", "Bye.")
|
|
"8", "h", "help" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
|
else -> repo to listOf("What now> $answer", "Huh ($answer)?") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
|
}
|
|
}
|
|
}
|
|
|
|
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",
|
|
displayPath = target,
|
|
initialContent = editablePatchHunkContent(file),
|
|
)
|
|
}
|
|
|
|
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> {
|
|
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.",
|
|
)
|
|
}
|
|
|
|
private fun interactiveAddSelectionPrompt(
|
|
repo: RepoState,
|
|
session: InteractiveAddSession,
|
|
answer: String,
|
|
prompt: String,
|
|
action: String,
|
|
): Pair<RepoState, List<String>> {
|
|
return repo.copy(
|
|
interactiveAddSession = session.copy(
|
|
awaitingUpdateSelection = true,
|
|
selectionPrompt = prompt,
|
|
selectionAction = action,
|
|
),
|
|
) to listOf("What now> $answer", prompt)
|
|
}
|
|
|
|
private fun applyInteractiveAddUpdateSelection(
|
|
repo: RepoState,
|
|
session: InteractiveAddSession,
|
|
answer: String,
|
|
): Pair<RepoState, List<String>> {
|
|
val candidates = interactiveAddCandidates(repo, session.target)
|
|
val selectedNames = selectedInteractiveAddNames(candidates, answer)
|
|
val prompt = session.selectionPrompt
|
|
if (selectedNames.isEmpty()) {
|
|
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,
|
|
interactiveAddSession = session.copy(
|
|
awaitingUpdateSelection = false,
|
|
selectionPrompt = "Update>>",
|
|
selectionAction = "update",
|
|
),
|
|
)
|
|
val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction)
|
|
return updatedRepo to listOf(
|
|
"$prompt $answer",
|
|
summary,
|
|
) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target))
|
|
}
|
|
|
|
private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set<String>, action: String): List<GitFile> {
|
|
return when (action) {
|
|
"revert" -> repo.files.mapNotNull { file ->
|
|
if (file.name !in selectedNames || file.deleted) {
|
|
file
|
|
} else if (file.tracked) {
|
|
file.copy(content = "", staged = false, deleted = false)
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
"diff" -> repo.files
|
|
else -> repo.files.map { file ->
|
|
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun interactiveAddSelectionSummary(
|
|
repo: RepoState,
|
|
updatedFiles: List<GitFile>,
|
|
selectedNames: Set<String>,
|
|
action: String,
|
|
): String {
|
|
return when (action) {
|
|
"revert" -> "reverted ${selectedNames.size} path(s)"
|
|
"diff" -> selectedNames.joinToString("\n") { "diff -- $it" }
|
|
else -> {
|
|
val stagedCount = updatedFiles.count { updatedFile ->
|
|
val before = repo.files.firstOrNull { it.name == updatedFile.name }
|
|
updatedFile.staged && before?.staged != true
|
|
}
|
|
"updated $stagedCount path(s)"
|
|
}
|
|
}
|
|
}
|
|
|
|
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", "Opening patch editor")
|
|
else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt)
|
|
}
|
|
}
|
|
|
|
private fun patchHunkLines(file: GitFile): List<String> {
|
|
return patchDiffHeaderLines(file) + patchHunkBodyLines(file) + PatchHunkPrompt
|
|
}
|
|
|
|
private fun editablePatchHunkContent(file: GitFile): String {
|
|
return buildList {
|
|
add("# Manual hunk edit mode -- see bottom for a quick guide.")
|
|
addAll(patchHunkBodyLines(file))
|
|
add("# ---")
|
|
add("# To remove '-' lines, make them ' ' lines (context).")
|
|
add("# To remove '+' lines, delete them.")
|
|
add("# Lines starting with # will be removed.")
|
|
add("# If the patch applies cleanly, the edited hunk will immediately be marked for staging.")
|
|
add("# If it does not apply cleanly, you will be given an opportunity to")
|
|
add("# edit again. If all lines of the hunk are removed, then the edit is")
|
|
add("# aborted and the hunk is left unchanged.")
|
|
}.joinToString("\n")
|
|
}
|
|
|
|
private fun patchDiffHeaderLines(file: GitFile): List<String> {
|
|
return listOf(
|
|
"diff --git a/${file.name} b/${file.name}",
|
|
"index 0000000..0000001 100644",
|
|
"--- a/${file.name}",
|
|
"+++ b/${file.name}",
|
|
)
|
|
}
|
|
|
|
private fun patchHunkBodyLines(file: GitFile): List<String> {
|
|
val lines = file.content.lines()
|
|
val nonEmptyLines = lines.dropLastWhile { it.isEmpty() }
|
|
val addedCount = nonEmptyLines.size.coerceAtLeast(1)
|
|
return buildList {
|
|
add("@@ -1 +1,$addedCount @@")
|
|
if (nonEmptyLines.isEmpty()) {
|
|
add("+")
|
|
} else {
|
|
nonEmptyLines.forEach { line -> add("+$line") }
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
|
|
return buildList {
|
|
add(" staged unstaged path")
|
|
candidates.forEachIndexed { index, file ->
|
|
val staged = if (file.staged) "unchanged" else "+0/-0"
|
|
val unstaged = when {
|
|
file.tracked -> "+1/-0"
|
|
else -> "+0/-0"
|
|
}
|
|
add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}")
|
|
}
|
|
if (candidates.isEmpty()) {
|
|
add("No changes.")
|
|
}
|
|
add("*** Commands ***")
|
|
add(" 1: status 2: update 3: revert 4: add untracked")
|
|
add(" 5: patch 6: diff 7: quit 8: help")
|
|
add("What now>")
|
|
}
|
|
}
|
|
|
|
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('/') + "/"))
|
|
}
|
|
}
|
|
|
|
private fun selectedInteractiveAddNames(candidates: List<GitFile>, answer: String): Set<String> {
|
|
if (answer == "*") return candidates.map { it.name }.toSet()
|
|
return answer.split(Regex("[,\\s]+"))
|
|
.mapNotNull { token ->
|
|
token.toIntOrNull()
|
|
?.takeIf { it in 1..candidates.size }
|
|
?.let { candidates[it - 1].name }
|
|
}
|
|
.toSet()
|
|
}
|
|
}
|