Route interactive Git through native terminal sessions
Remove Kotlin interactive-add emulation and run patch add via the native Git runtime with PTY-backed session IO.
This commit is contained in:
@@ -44,6 +44,8 @@ internal fun RepoState.diagnosticSnapshot(): String = buildString {
|
||||
append(submodules.toSortedMap())
|
||||
append(", maintenanceActions=")
|
||||
append(maintenanceActions.sorted())
|
||||
append(", nativeGitSession=")
|
||||
append(nativeGitSession)
|
||||
append(", config=")
|
||||
append(config.toSortedMap())
|
||||
append(", files=")
|
||||
|
||||
@@ -12,6 +12,7 @@ data class GitFile(
|
||||
val staged: Boolean = false,
|
||||
val tracked: Boolean = false,
|
||||
val deleted: Boolean = false,
|
||||
val stagedContent: String? = null,
|
||||
)
|
||||
|
||||
data class CommitNode(
|
||||
@@ -21,11 +22,9 @@ data class CommitNode(
|
||||
val parentCount: Int = 0,
|
||||
)
|
||||
|
||||
data class InteractiveAddSession(
|
||||
val target: String? = null,
|
||||
val awaitingUpdateSelection: Boolean = false,
|
||||
val selectionPrompt: String = "Update>>",
|
||||
val selectionAction: String = "update",
|
||||
data class NativeGitSession(
|
||||
val id: Int,
|
||||
val command: String,
|
||||
)
|
||||
|
||||
data class RepoState(
|
||||
@@ -45,7 +44,7 @@ data class RepoState(
|
||||
val pushedTags: Set<String> = emptySet(),
|
||||
val submodules: Map<String, String> = emptyMap(),
|
||||
val maintenanceActions: Set<String> = emptySet(),
|
||||
val interactiveAddSession: InteractiveAddSession? = null,
|
||||
val nativeGitSession: NativeGitSession? = null,
|
||||
)
|
||||
|
||||
data class Level(
|
||||
|
||||
@@ -4,7 +4,6 @@ enum class GitEditorCommandKind {
|
||||
COMMIT_MESSAGE,
|
||||
REBASE_TODO,
|
||||
TAG_MESSAGE,
|
||||
PATCH_HUNK,
|
||||
}
|
||||
|
||||
data class GitEditorInvocation(
|
||||
|
||||
@@ -22,8 +22,6 @@ internal class GitEditorWorkflow(
|
||||
private val shellExecutable: () -> String,
|
||||
) {
|
||||
fun initialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
||||
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) return invocation.initialContent
|
||||
|
||||
val nativeGit = requireNativeGit()
|
||||
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
|
||||
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
||||
@@ -43,7 +41,6 @@ internal class GitEditorWorkflow(
|
||||
GitEditorCommandKind.REBASE_TODO -> "GIT_SEQUENCE_EDITOR"
|
||||
GitEditorCommandKind.COMMIT_MESSAGE,
|
||||
GitEditorCommandKind.TAG_MESSAGE -> "GIT_EDITOR"
|
||||
GitEditorCommandKind.PATCH_HUNK -> return invocation.initialContent
|
||||
}
|
||||
|
||||
runGit(
|
||||
@@ -93,9 +90,6 @@ internal class GitEditorWorkflow(
|
||||
message = message,
|
||||
)
|
||||
|
||||
GitEditorCommandKind.PATCH_HUNK -> {
|
||||
GitEditorExecutionResult(currentRepo, listOf("Patch hunk editing is handled by Git, not the Android runtime."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ fun GitHugApp() {
|
||||
fun applyCommandResult(raw: String, newRepo: RepoState, lines: List<String>, echoCommand: Boolean) {
|
||||
val startedAt = System.nanoTime()
|
||||
val levelForResult = currentLevel
|
||||
val validationBlockedByEditor = editorState != null || gitMessageEditorState != null
|
||||
val validationBlockedByEditor = editorState != null || gitMessageEditorState != null || newRepo.nativeGitSession != null
|
||||
val solvedAfterCommand = if (validationBlockedByEditor) {
|
||||
false
|
||||
} else {
|
||||
@@ -496,7 +496,6 @@ fun GitHugApp() {
|
||||
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"
|
||||
}
|
||||
val newOutput = buildList {
|
||||
@@ -551,7 +550,7 @@ fun GitHugApp() {
|
||||
return
|
||||
}
|
||||
val submittedLevelId = currentLevel.id
|
||||
val isInteractiveInput = repo.interactiveAddSession != null
|
||||
val isInteractiveInput = repo.nativeGitSession != null
|
||||
AppLog.d(
|
||||
"GitHugApp",
|
||||
"Command submitted level=$submittedLevelId raw='$raw' interactive=$isInteractiveInput repo=${repo.diagnosticSnapshot()}",
|
||||
@@ -587,12 +586,6 @@ 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)
|
||||
AppLog.d("GitHugApp", "Command handling finished level=$submittedLevelId raw='$raw' durationMs=${elapsedMillisSince(startedAt)}")
|
||||
|
||||
@@ -3,12 +3,18 @@ package solutions.tretter.githugandroid
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
internal class GitProcessRunner(
|
||||
private val context: Context?,
|
||||
private val sandboxesRoot: File,
|
||||
) {
|
||||
private val nextHostSessionId = AtomicInteger(1)
|
||||
private val hostSessions = ConcurrentHashMap<Int, HostGitSession>()
|
||||
|
||||
private companion object {
|
||||
val RequiredGitCommandAliases = listOf(
|
||||
"add",
|
||||
@@ -77,6 +83,29 @@ internal class GitProcessRunner(
|
||||
return result
|
||||
}
|
||||
|
||||
fun startGitSession(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): GitSessionResult {
|
||||
val gitExecPath = gitExecDirectory(binary)
|
||||
val fullEnvironment = gitEnvironment(binary, workingDir, environment, gitExecPath)
|
||||
return if (context != null) {
|
||||
NativeGitBridge.startGitSession(binary, workingDir, arguments, fullEnvironment)
|
||||
} else {
|
||||
startHostGitSession(binary, workingDir, arguments, fullEnvironment)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeGitSession(sessionId: Int, input: String): GitSessionResult {
|
||||
return if (context != null) {
|
||||
NativeGitBridge.writeGitSession(sessionId, input)
|
||||
} else {
|
||||
writeHostGitSession(sessionId, input)
|
||||
}
|
||||
}
|
||||
|
||||
fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
@@ -119,6 +148,67 @@ internal class GitProcessRunner(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHostGitSession(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String>,
|
||||
): GitSessionResult {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply { environment().putAll(environment) }
|
||||
.start()
|
||||
val sessionId = nextHostSessionId.getAndIncrement()
|
||||
hostSessions[sessionId] = HostGitSession(process)
|
||||
readHostGitSessionResult(sessionId, process)
|
||||
} catch (error: Exception) {
|
||||
GitSessionResult(
|
||||
sessionId = 0,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git session failed: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeHostGitSession(sessionId: Int, input: String): GitSessionResult {
|
||||
val session = hostSessions[sessionId]
|
||||
?: return GitSessionResult(sessionId, running = false, exitCode = -1, outputLines = listOf("Native Git session is not running."))
|
||||
return try {
|
||||
session.process.outputStream.write(input.toByteArray())
|
||||
session.process.outputStream.flush()
|
||||
readHostGitSessionResult(sessionId, session.process)
|
||||
} catch (error: Exception) {
|
||||
hostSessions.remove(sessionId)
|
||||
GitSessionResult(
|
||||
sessionId = sessionId,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git session failed: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readHostGitSessionResult(sessionId: Int, process: Process): GitSessionResult {
|
||||
val output = StringBuilder()
|
||||
val deadline = System.nanoTime() + 1_000_000_000L
|
||||
do {
|
||||
output.append(process.inputStream.readAvailableText())
|
||||
if (!process.isAlive || output.isNotEmpty()) break
|
||||
Thread.sleep(25)
|
||||
} while (System.nanoTime() < deadline)
|
||||
|
||||
output.append(process.inputStream.readAvailableText())
|
||||
return if (process.isAlive) {
|
||||
GitSessionResult(sessionId = sessionId, running = true, exitCode = null, outputLines = output.toString().toOutputLines())
|
||||
} else {
|
||||
hostSessions.remove(sessionId)
|
||||
GitSessionResult(sessionId = sessionId, running = false, exitCode = process.exitValue(), outputLines = output.toString().toOutputLines())
|
||||
}
|
||||
}
|
||||
|
||||
private fun gitExecDirectory(binary: File): File {
|
||||
val directory = if (context != null) {
|
||||
File(context.filesDir, "git-exec")
|
||||
@@ -166,6 +256,7 @@ internal class GitProcessRunner(
|
||||
put("GIT_COMMITTER_NAME", "GitHug")
|
||||
put("GIT_COMMITTER_EMAIL", "githug@example.com")
|
||||
put("LC_ALL", "C")
|
||||
put("TERM", "xterm-256color")
|
||||
putAll(extraEnvironment)
|
||||
}
|
||||
}
|
||||
@@ -232,3 +323,32 @@ internal data class ProcessExecutionResult(
|
||||
val exitCode: Int,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
|
||||
internal data class GitSessionResult(
|
||||
val sessionId: Int,
|
||||
val running: Boolean,
|
||||
val exitCode: Int?,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
|
||||
private data class HostGitSession(
|
||||
val process: Process,
|
||||
)
|
||||
|
||||
private fun InputStream.readAvailableText(): String {
|
||||
val output = StringBuilder()
|
||||
val buffer = ByteArray(4096)
|
||||
while (available() > 0) {
|
||||
val count = read(buffer)
|
||||
if (count <= 0) break
|
||||
output.append(String(buffer, 0, count))
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun String.toOutputLines(): List<String> =
|
||||
replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.lineSequence()
|
||||
.toList()
|
||||
.dropLastWhile { it.isEmpty() }
|
||||
|
||||
@@ -42,7 +42,8 @@ internal class GitRepositoryInspector(
|
||||
if (repositoryRoot == null) {
|
||||
val repo = RepoState(
|
||||
initialized = false,
|
||||
files = filesOnDisk.map {
|
||||
files = filesOnDisk.mapNotNull {
|
||||
if (!it.isFile) return@mapNotNull null
|
||||
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
|
||||
},
|
||||
)
|
||||
@@ -143,7 +144,8 @@ internal class GitRepositoryInspector(
|
||||
|
||||
val repo = RepoState(
|
||||
initialized = true,
|
||||
files = filesOnDisk.map { file ->
|
||||
files = filesOnDisk.mapNotNull { file ->
|
||||
if (!file.isFile) return@mapNotNull null
|
||||
val relativePath = file.relativeTo(inspectionRoot).path
|
||||
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
||||
GitFile(
|
||||
@@ -151,6 +153,7 @@ internal class GitRepositoryInspector(
|
||||
content = file.readText(),
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
stagedContent = stagedContent(git, inspectionRoot, relativePath, staged),
|
||||
)
|
||||
} + deletedStatusPaths
|
||||
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
|
||||
@@ -161,6 +164,7 @@ internal class GitRepositoryInspector(
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
deleted = true,
|
||||
stagedContent = stagedContent(git, inspectionRoot, deletedPath, staged),
|
||||
)
|
||||
},
|
||||
commits = commits,
|
||||
@@ -203,6 +207,14 @@ internal class GitRepositoryInspector(
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult = runGit(binary, workingDir, arguments, environment)
|
||||
|
||||
private fun stagedContent(git: File, inspectionRoot: File, path: String, staged: Boolean): String? {
|
||||
if (!staged) return null
|
||||
val result = run(git, inspectionRoot, listOf("show", ":$path"))
|
||||
return result.outputLines
|
||||
.takeIf { result.exitCode == 0 }
|
||||
?.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun repositoryRoot(sandbox: File, workingDir: File): File? {
|
||||
val sandboxPath = sandbox.canonicalPath
|
||||
return generateSequence(workingDir) { directory ->
|
||||
|
||||
@@ -142,6 +142,50 @@ class GitRepositoryRuntime private constructor(
|
||||
"Command parsed level=${level.id} raw='$command' tokens=$tokens expanded=$expandedTokens cwd=${workingDir.absolutePath}",
|
||||
)
|
||||
|
||||
currentRepo.nativeGitSession?.let { session ->
|
||||
val sessionResult = processRunner.writeGitSession(session.id, command + "\n")
|
||||
val sessionRepo = if (sessionResult.running) {
|
||||
currentRepo
|
||||
} else {
|
||||
inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir)
|
||||
}.copy(
|
||||
nativeGitSession = if (sessionResult.running) session else null,
|
||||
)
|
||||
AppLog.d(
|
||||
"GitRuntime",
|
||||
"Native Git session input level=${level.id} session=${session.id} running=${sessionResult.running} " +
|
||||
"exit=${sessionResult.exitCode} repo=${sessionRepo.diagnosticSnapshot()}",
|
||||
)
|
||||
return sessionRepo to sessionResult.outputLines
|
||||
}
|
||||
|
||||
if (expandedTokens.requiresGitTerminalSession()) {
|
||||
val sessionResult = processRunner.startGitSession(
|
||||
nativeGit,
|
||||
workingDir,
|
||||
normalizeGitArgumentsForAndroid(expandedTokens.drop(1)),
|
||||
invocation.environment,
|
||||
)
|
||||
val sessionRepo = currentRepo.copy(
|
||||
nativeGitSession = if (sessionResult.running) {
|
||||
NativeGitSession(sessionResult.sessionId, command)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
val refreshedRepo = if (sessionResult.running) {
|
||||
sessionRepo
|
||||
} else {
|
||||
refreshRepoAfterCommand(level, currentRepo, sessionRepo, expandedTokens)
|
||||
}
|
||||
AppLog.d(
|
||||
"GitRuntime",
|
||||
"Native Git session start level=${level.id} session=${sessionResult.sessionId} running=${sessionResult.running} " +
|
||||
"exit=${sessionResult.exitCode} repo=${refreshedRepo.diagnosticSnapshot()}",
|
||||
)
|
||||
return refreshedRepo to sessionResult.outputLines
|
||||
}
|
||||
|
||||
val result = when (expandedTokens.first()) {
|
||||
"git" -> {
|
||||
val gitResult = runGit(
|
||||
@@ -361,6 +405,33 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.requiresGitTerminalSession(): Boolean {
|
||||
if (firstOrNull() != "git") return false
|
||||
val commandIndex = gitSubcommandIndex() ?: return false
|
||||
val command = this[commandIndex]
|
||||
if (command == "rebase") return false
|
||||
return drop(commandIndex + 1).any { it == "-i" || it == "--interactive" || it == "-p" || it == "--patch" }
|
||||
}
|
||||
|
||||
private fun List<String>.gitSubcommandIndex(): Int? {
|
||||
var index = 1
|
||||
while (index < size) {
|
||||
val argument = this[index]
|
||||
if (argument == "--") return null
|
||||
if (!argument.startsWith("-")) return index
|
||||
index += when {
|
||||
argument in setOf("-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path") -> 2
|
||||
argument.startsWith("-C") && argument.length > 2 -> 1
|
||||
argument.startsWith("--git-dir=") ||
|
||||
argument.startsWith("--work-tree=") ||
|
||||
argument.startsWith("--namespace=") ||
|
||||
argument.startsWith("--exec-path=") -> 1
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun List<String>.isGitConfigCommand(): Boolean {
|
||||
return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,6 @@ object GitSandboxEngine {
|
||||
val quoted: Boolean = false,
|
||||
)
|
||||
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? =
|
||||
InteractiveAddEngine.parsePatchHunkEditorInvocation(repo, command)
|
||||
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> =
|
||||
InteractiveAddEngine.applyPatchHunkEdit(repo, content)
|
||||
|
||||
fun commandReferenceLines(): List<String> = listOf(
|
||||
"Available sandbox commands:",
|
||||
" git ",
|
||||
@@ -31,9 +25,6 @@ object GitSandboxEngine {
|
||||
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||
val shellParts = tokenizeShellCommand(command)
|
||||
if (shellParts.isEmpty()) return repo to emptyList()
|
||||
repo.interactiveAddSession?.let {
|
||||
return InteractiveAddEngine.handleInput(repo, command)
|
||||
}
|
||||
return SandboxCommandEngine.execute(repo, shellParts)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,81 @@ internal object NativeGitBridge {
|
||||
)
|
||||
}
|
||||
|
||||
fun startGitSession(
|
||||
library: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String>,
|
||||
): GitSessionResult {
|
||||
loadResult.getOrElse { error ->
|
||||
return GitSessionResult(
|
||||
sessionId = 0,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
|
||||
val argv = (listOf("git") + arguments).toTypedArray()
|
||||
val env = environment.entries.map { (key, value) -> "$key=$value" }.toTypedArray()
|
||||
return sessionResult(
|
||||
startGitSessionNative(
|
||||
library.absolutePath,
|
||||
workingDir.absolutePath,
|
||||
argv,
|
||||
env,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun writeGitSession(sessionId: Int, input: String): GitSessionResult {
|
||||
loadResult.getOrElse { error ->
|
||||
return GitSessionResult(
|
||||
sessionId = sessionId,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
return sessionResult(writeGitSessionNative(sessionId, input))
|
||||
}
|
||||
|
||||
private fun sessionResult(result: Array<String>): GitSessionResult {
|
||||
val sessionId = result.getOrNull(0)?.toIntOrNull() ?: 0
|
||||
val running = result.getOrNull(1) == "1"
|
||||
val exitCode = result.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull()
|
||||
val output = result.getOrNull(3).orEmpty()
|
||||
return GitSessionResult(
|
||||
sessionId = sessionId,
|
||||
running = running,
|
||||
exitCode = exitCode,
|
||||
outputLines = output.toTerminalOutputLines(),
|
||||
)
|
||||
}
|
||||
|
||||
private external fun runGitMainNative(
|
||||
libraryPath: String,
|
||||
workingDirectory: String,
|
||||
argv: Array<String>,
|
||||
environment: Array<String>,
|
||||
): Array<String>
|
||||
|
||||
private external fun startGitSessionNative(
|
||||
libraryPath: String,
|
||||
workingDirectory: String,
|
||||
argv: Array<String>,
|
||||
environment: Array<String>,
|
||||
): Array<String>
|
||||
|
||||
private external fun writeGitSessionNative(
|
||||
sessionId: Int,
|
||||
input: String,
|
||||
): Array<String>
|
||||
}
|
||||
|
||||
private fun String.toTerminalOutputLines(): List<String> =
|
||||
replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.lineSequence()
|
||||
.toList()
|
||||
.dropLastWhile { it.isEmpty() }
|
||||
|
||||
@@ -7,7 +7,16 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
listOf(
|
||||
state.initialized,
|
||||
state.headBranch,
|
||||
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
|
||||
state.files.flatMap {
|
||||
listOf(
|
||||
it.name,
|
||||
it.content,
|
||||
it.staged.toString(),
|
||||
it.tracked.toString(),
|
||||
it.deleted.toString(),
|
||||
it.stagedContent.orEmpty(),
|
||||
)
|
||||
},
|
||||
state.commits.flatMap {
|
||||
listOf(
|
||||
it.id,
|
||||
@@ -28,14 +37,8 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
state.submodules.flatMap { listOf(it.key, it.value) },
|
||||
state.maintenanceActions.toList(),
|
||||
state.fetchHeadCount,
|
||||
state.interactiveAddSession?.let {
|
||||
listOf(
|
||||
it.target.orEmpty(),
|
||||
it.awaitingUpdateSelection.toString(),
|
||||
it.selectionPrompt,
|
||||
it.selectionAction,
|
||||
)
|
||||
}.orEmpty(),
|
||||
emptyList<Any>(),
|
||||
emptyList<Any>(),
|
||||
)
|
||||
},
|
||||
restore = { saved ->
|
||||
@@ -54,17 +57,23 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
|
||||
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
|
||||
val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0
|
||||
val interactiveAddSessionParts = saved.getOrNull(16) as? List<*> ?: emptyList<Any>()
|
||||
RepoState(
|
||||
initialized = initialized,
|
||||
headBranch = headBranch,
|
||||
files = fileParts.chunked(if (fileParts.size % 5 == 0) 5 else 4).map {
|
||||
files = fileParts.chunked(
|
||||
when {
|
||||
fileParts.size % 6 == 0 -> 6
|
||||
fileParts.size % 5 == 0 -> 5
|
||||
else -> 4
|
||||
},
|
||||
).map {
|
||||
GitFile(
|
||||
name = it[0] as String,
|
||||
content = it[1] as String,
|
||||
staged = (it[2] as String).toBoolean(),
|
||||
tracked = (it[3] as String).toBoolean(),
|
||||
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
|
||||
stagedContent = (it.getOrNull(5) as? String)?.takeIf { value -> value.isNotEmpty() },
|
||||
)
|
||||
},
|
||||
commits = commitParts.restoreCommitNodes(),
|
||||
@@ -80,14 +89,6 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
pushedTags = pushedTags.filterIsInstance<String>().toSet(),
|
||||
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
|
||||
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(),
|
||||
interactiveAddSession = interactiveAddSessionParts.takeIf { it.size >= 2 }?.let {
|
||||
InteractiveAddSession(
|
||||
target = (it[0] as String).ifBlank { null },
|
||||
awaitingUpdateSelection = (it[1] as String).toBoolean(),
|
||||
selectionPrompt = it.getOrNull(2) as? String ?: "Update>>",
|
||||
selectionAction = it.getOrNull(3) as? String ?: "update",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -157,10 +157,10 @@ internal object SandboxCommandEngine {
|
||||
|
||||
private fun stagePaths(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
|
||||
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
|
||||
return InteractiveAddEngine.start(repo, parts.drop(2))
|
||||
return repo to listOf("Interactive Git commands require the native Git runtime.")
|
||||
}
|
||||
if (parts.drop(2).any { it == "-p" || it == "--patch" }) {
|
||||
return InteractiveAddEngine.startPatch(repo, parts.drop(2))
|
||||
return repo to listOf("Interactive Git commands require the native Git runtime.")
|
||||
}
|
||||
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git add <path>")
|
||||
|
||||
@@ -12,7 +12,7 @@ internal fun stageLinesLevel(): Level = level(
|
||||
title = "Stage Lines",
|
||||
description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.",
|
||||
hints = listOf("Read about the flags which can be passed to the `add` command."),
|
||||
commandSuggestions = listOf("git add 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)) },
|
||||
nativeSetup = {
|
||||
resetFiles()
|
||||
@@ -21,9 +21,23 @@ internal fun stageLinesLevel(): Level = level(
|
||||
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 ->
|
||||
val file = repo.files.firstOrNull { it.name == "feature.rb" && !it.deleted } ?: return@repoPredicate false
|
||||
val stagedContent = file.stagedContent ?: return@repoPredicate false
|
||||
"This change belongs to the first feature" in stagedContent &&
|
||||
"This change belongs to the second feature" !in stagedContent &&
|
||||
"This change belongs to the first feature" in file.content &&
|
||||
"This change belongs to the second feature" in file.content
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("stage feature file", "git add feature.rb"),
|
||||
levelTestCase("stage alias", "git stage feature.rb"),
|
||||
levelTestCase(
|
||||
"patch add with git editor",
|
||||
"GIT_EDITOR=\"sed -i '/second feature/d'\" git add -p feature.rb",
|
||||
"e",
|
||||
),
|
||||
),
|
||||
negativeTestCases = listOf(
|
||||
levelTestCase("full file add stages both feature lines", "git add feature.rb"),
|
||||
levelTestCase("full file stage alias stages both feature lines", "git stage feature.rb"),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user