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:
Joe Tretter
2026-06-27 13:07:35 -05:00
parent d703f539d3
commit 943bf03ca0
19 changed files with 684 additions and 549 deletions

View File

@@ -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=")

View File

@@ -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(

View File

@@ -4,7 +4,6 @@ enum class GitEditorCommandKind {
COMMIT_MESSAGE,
REBASE_TODO,
TAG_MESSAGE,
PATCH_HUNK,
}
data class GitEditorInvocation(

View File

@@ -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."))
}
}
}

View File

@@ -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)}")

View File

@@ -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() }

View File

@@ -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 ->

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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() }

View File

@@ -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",
)
},
)
}
)

View File

@@ -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>")

View File

@@ -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"),
),
)