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

@@ -20,8 +20,8 @@ android {
applicationId = "solutions.tretter.githugandroid"
minSdk = 26
targetSdk = 35
versionCode = 183
versionName = "0.1.182"
versionCode = 184
versionName = "0.1.183"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

View File

@@ -8,12 +8,14 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#define LOG_TAG "GitHugNativeGit"
#define GIT_COMMAND_TIMEOUT_MS 30000
#define GIT_SESSION_MAX_READ_WAIT_MS 1000
typedef int (*git_main_fn)(int argc, const char **argv);
typedef int (*githug_git_main_fn)(int argc, const char **argv);
@@ -31,6 +33,16 @@ static git_main_fn git_main = NULL;
static githug_git_main_fn githug_git_main = NULL;
static git_init_fn git_init = NULL;
struct git_session {
int id;
pid_t child;
int pty_fd;
struct git_session *next;
};
static struct git_session *git_sessions = NULL;
static int next_git_session_id = 1;
static int append_output(struct output_buffer *buffer, const char *data, size_t length) {
if (length == 0) {
return 0;
@@ -100,6 +112,34 @@ static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output)
return result;
}
static jobjectArray make_session_result(JNIEnv *env, int session_id, int running, int exit_code, const char *output) {
jclass string_class = (*env)->FindClass(env, "java/lang/String");
jobjectArray result = (*env)->NewObjectArray(env, 4, string_class, NULL);
char session_text[32];
char running_text[8];
char exit_text[32];
snprintf(session_text, sizeof(session_text), "%d", session_id);
snprintf(running_text, sizeof(running_text), "%d", running ? 1 : 0);
if (exit_code >= 0) {
snprintf(exit_text, sizeof(exit_text), "%d", exit_code);
} else {
exit_text[0] = '\0';
}
jstring session_string = (*env)->NewStringUTF(env, session_text);
jstring running_string = (*env)->NewStringUTF(env, running_text);
jstring exit_string = (*env)->NewStringUTF(env, exit_text);
jstring output_string = (*env)->NewStringUTF(env, output != NULL ? output : "");
(*env)->SetObjectArrayElement(env, result, 0, session_string);
(*env)->SetObjectArrayElement(env, result, 1, running_string);
(*env)->SetObjectArrayElement(env, result, 2, exit_string);
(*env)->SetObjectArrayElement(env, result, 3, output_string);
(*env)->DeleteLocalRef(env, session_string);
(*env)->DeleteLocalRef(env, running_string);
(*env)->DeleteLocalRef(env, exit_string);
(*env)->DeleteLocalRef(env, output_string);
return result;
}
static jobjectArray make_error(JNIEnv *env, const char *message) {
return make_result(env, -1, message);
}
@@ -161,6 +201,45 @@ static void free_string_array(char **values, int count) {
free(values);
}
static struct git_session *find_session(int session_id, struct git_session ***link_out) {
struct git_session **link = &git_sessions;
while (*link != NULL) {
if ((*link)->id == session_id) {
if (link_out != NULL) {
*link_out = link;
}
return *link;
}
link = &((*link)->next);
}
return NULL;
}
static void remove_session(struct git_session **link) {
if (link == NULL) {
return;
}
struct git_session *session = *link;
if (session == NULL) {
return;
}
*link = session->next;
if (session->pty_fd >= 0) {
close(session->pty_fd);
}
free(session);
}
static int child_exit_code_from_status(int status) {
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
}
if (WIFSIGNALED(status)) {
return 128 + WTERMSIG(status);
}
return -1;
}
struct saved_env {
char *name;
char *previous;
@@ -353,3 +432,220 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return result;
}
JNIEXPORT jobjectArray JNICALL
Java_solutions_tretter_githugandroid_NativeGitBridge_startGitSessionNative(
JNIEnv *env,
jclass clazz,
jstring library_path,
jstring working_directory,
jobjectArray argv_array,
jobjectArray environment_array
) {
(void)clazz;
const char *library_path_chars = (*env)->GetStringUTFChars(env, library_path, NULL);
const char *working_directory_chars = (*env)->GetStringUTFChars(env, working_directory, NULL);
int argc = 0;
int envc = 0;
char **argv = copy_string_array(env, argv_array, &argc);
char **env_entries = copy_string_array(env, environment_array, &envc);
if (library_path_chars == NULL || working_directory_chars == NULL || argv == NULL || env_entries == NULL) {
return make_session_result(env, 0, 0, -1, "Native Git session failed: out of memory");
}
int master_fd = posix_openpt(O_RDWR | O_NOCTTY);
if (master_fd < 0 || grantpt(master_fd) != 0 || unlockpt(master_fd) != 0) {
if (master_fd >= 0) {
close(master_fd);
}
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not create PTY");
}
char *slave_name = ptsname(master_fd);
if (slave_name == NULL) {
close(master_fd);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not resolve PTY slave");
}
pthread_mutex_lock(&git_mutex);
if (load_git(library_path_chars) != 0) {
close(master_fd);
pthread_mutex_unlock(&git_mutex);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: init_git/cmd_main not found");
}
pid_t child = fork();
if (child < 0) {
close(master_fd);
pthread_mutex_unlock(&git_mutex);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not fork");
}
if (child == 0) {
setsid();
int slave_fd = open(slave_name, O_RDWR);
if (slave_fd < 0) {
_exit(127);
}
ioctl(slave_fd, TIOCSCTTY, 0);
dup2(slave_fd, STDIN_FILENO);
dup2(slave_fd, STDOUT_FILENO);
dup2(slave_fd, STDERR_FILENO);
if (slave_fd > STDERR_FILENO) {
close(slave_fd);
}
close(master_fd);
int applied_env = 0;
struct saved_env *saved_env = apply_environment(env_entries, envc, &applied_env);
(void)saved_env;
chdir(working_directory_chars);
int child_exit_code;
if (githug_git_main != NULL) {
child_exit_code = githug_git_main(argc, (const char **)argv);
} else {
git_init((const char **)argv);
child_exit_code = git_main(argc, (const char **)argv);
}
fflush(stdout);
fflush(stderr);
_exit(child_exit_code);
}
setpgid(child, child);
int flags = fcntl(master_fd, F_GETFL, 0);
if (flags >= 0) {
fcntl(master_fd, F_SETFL, flags | O_NONBLOCK);
}
struct git_session *session = calloc(1, sizeof(struct git_session));
if (session == NULL) {
kill(-child, SIGKILL);
close(master_fd);
pthread_mutex_unlock(&git_mutex);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: out of memory");
}
session->id = next_git_session_id++;
session->child = child;
session->pty_fd = master_fd;
session->next = git_sessions;
git_sessions = session;
struct output_buffer output = {0};
int saw_eof = 0;
long long started_at = monotonic_millis();
while (started_at > 0 && monotonic_millis() - started_at < GIT_SESSION_MAX_READ_WAIT_MS) {
drain_available_output(session->pty_fd, &output, &saw_eof);
if (output.length > 0) {
break;
}
usleep(10000);
}
int session_id = session->id;
int status = 0;
int running = 1;
int exit_code = -1;
pid_t wait_result = waitpid(child, &status, WNOHANG);
if (wait_result == child) {
running = 0;
exit_code = child_exit_code_from_status(status);
struct git_session **link = NULL;
find_session(session_id, &link);
remove_session(link);
}
jobjectArray result = make_session_result(env, session_id, running, exit_code, output.data);
free(output.data);
pthread_mutex_unlock(&git_mutex);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return result;
}
JNIEXPORT jobjectArray JNICALL
Java_solutions_tretter_githugandroid_NativeGitBridge_writeGitSessionNative(
JNIEnv *env,
jclass clazz,
jint session_id,
jstring input
) {
(void)clazz;
const char *input_chars = (*env)->GetStringUTFChars(env, input, NULL);
pthread_mutex_lock(&git_mutex);
struct git_session **link = NULL;
struct git_session *session = find_session((int)session_id, &link);
if (session == NULL) {
pthread_mutex_unlock(&git_mutex);
if (input_chars != NULL) {
(*env)->ReleaseStringUTFChars(env, input, input_chars);
}
return make_session_result(env, (int)session_id, 0, -1, "Native Git session is not running.");
}
if (input_chars != NULL) {
size_t input_length = strlen(input_chars);
size_t written = 0;
while (written < input_length) {
ssize_t count = write(session->pty_fd, input_chars + written, input_length - written);
if (count > 0) {
written += (size_t)count;
} else if (errno != EINTR) {
break;
}
}
(*env)->ReleaseStringUTFChars(env, input, input_chars);
}
struct output_buffer output = {0};
int saw_eof = 0;
long long started_at = monotonic_millis();
while (started_at > 0 && monotonic_millis() - started_at < GIT_SESSION_MAX_READ_WAIT_MS) {
drain_available_output(session->pty_fd, &output, &saw_eof);
if (output.length > 0 || saw_eof) {
break;
}
usleep(10000);
}
int status = 0;
int running = 1;
int exit_code = -1;
pid_t wait_result = waitpid(session->child, &status, WNOHANG);
if (wait_result == session->child || saw_eof) {
if (wait_result != session->child) {
while (waitpid(session->child, &status, 0) < 0 && errno == EINTR) {
}
}
running = 0;
exit_code = child_exit_code_from_status(status);
remove_session(link);
}
jobjectArray result = make_session_result(env, (int)session_id, running, exit_code, output.data);
free(output.data);
pthread_mutex_unlock(&git_mutex);
return result;
}

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

View File

@@ -535,6 +535,59 @@ class GitSandboxEngineTest {
}
}
@Test
fun nativeInteractiveAddKeepsCompiledGitSessionOpenForInput() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-native-add-i").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = addLevel()
var repo = runtime.prepareLevel(level)
val (menuRepo, menuOutput) = runtime.execute(level, repo, "git add -i")
repo = menuRepo
val (quitRepo, quitOutput) = runtime.execute(level, repo, "q")
assertNotNull(menuRepo.nativeGitSession)
assertTrue(menuOutput.joinToString("\n"), menuOutput.any { it.contains("What now") })
assertNull(quitRepo.nativeGitSession)
assertTrue(quitOutput.joinToString("\n"), quitOutput.any { it.contains("Bye") })
} finally {
root.deleteRecursively()
}
}
@Test
fun nativePatchAddUsesCompiledGitEditorAndStagesOnlyEditedStageLinesHunk() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-stage-lines-native-patch").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = stageLinesLevel()
var repo = runtime.prepareLevel(level)
val (patchRepo, patchOutput) = runtime.execute(
level,
repo,
"GIT_EDITOR=\"sed -i '/second feature/d'\" git add -p feature.rb",
)
repo = patchRepo
val (resultRepo, _) = runtime.execute(level, repo, "e")
val file = resultRepo.files.single { it.name == "feature.rb" }
assertNotNull(patchRepo.nativeGitSession)
assertTrue(patchOutput.joinToString("\n"), patchOutput.any { it.contains("Stage this hunk") })
assertNull(resultRepo.nativeGitSession)
assertTrue(resultRepo.diagnosticSnapshot(), level.validator(resultRepo, "e"))
assertTrue(file.stagedContent.orEmpty(), file.stagedContent.orEmpty().contains("This change belongs to the first feature"))
assertFalse(file.stagedContent.orEmpty(), file.stagedContent.orEmpty().contains("This change belongs to the second feature"))
} finally {
root.deleteRecursively()
}
}
private fun testGitBinary(): File {
System.getenv("GITHUG_TEST_GIT_BINARY")
?.takeIf { it.isNotBlank() }

View File

@@ -1,200 +0,0 @@
package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class InteractiveAddEngineTest {
@Test
fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(output.any { it.contains("What now>") })
assertFalse(output.any { it.contains("What now> update") })
assertFalse(output.any { it.contains("What now> quit") })
assertFalse(output.any { it.contains("GitHug Android") })
}
@Test
fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(updatedRepo.interactiveAddSession != null)
assertTrue(output.any { it.contains("What now>") })
assertFalse(output.any { it.contains("What now> update") })
assertFalse(output.any { it.contains("What now> quit") })
}
@Test
fun interactiveAddAcceptsUpdateSelectionFromNextInput() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i")
val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2")
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1")
val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7")
assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true)
assertTrue(updateOutput.any { it.contains("Update>>") })
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false)
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
assertTrue(quitRepo.interactiveAddSession == null)
assertTrue(quitOutput.any { it.contains("Bye.") })
}
@Test
fun interactiveAddHandlesEveryDisplayedMenuCommand() {
val menuCommands = listOf(
"1" to "What now> 1",
"2" to "Update>>",
"3" to "Revert>>",
"4" to "Add untracked>>",
"5" to "Patch update>>",
"6" to "Diff>>",
"7" to "Bye.",
"8" to "What now> 8",
)
menuCommands.forEach { (command, expectedOutput) ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
if (command in listOf("2", "3", "4", "5", "6")) {
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
}
}
}
@Test
fun interactiveAddHandlesMenuCommandAliases() {
val aliases = listOf(
"status" to "What now> status",
"update" to "Update>>",
"revert" to "Revert>>",
"add untracked" to "Add untracked>>",
"patch" to "Patch update>>",
"diff" to "Diff>>",
"quit" to "Bye.",
"help" to "What now> help",
)
aliases.forEach { (command, expectedOutput) ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
}
}
}
@Test
fun interactiveAddPatchSelectionStagesSelectedPath() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
val (hunkRepo, hunkOutput) = GitSandboxEngine.execute(patchRepo, "1")
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(hunkRepo, "y")
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
assertTrue(patchOutput.any { it.contains("Patch update>>") })
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
}
@Test
fun patchAddStartsPatchHunkDialogWithoutStagingImmediately() {
listOf("git add -p README", "git add --patch README").forEach { command ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (patchRepo, output) = GitSandboxEngine.execute(repo, command)
assertFalse("$command should not stage before a selection", patchRepo.files.single { it.name == "README" }.staged)
assertEquals("patch-hunk", patchRepo.interactiveAddSession?.selectionAction)
assertTrue(output.any { it.startsWith("diff --git a/README b/README") })
assertTrue(output.any { it.contains("Stage this hunk") })
}
}
@Test
fun patchAddSelectionStagesSelectedPath() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
val (selectedRepo, output) = GitSandboxEngine.execute(patchRepo, "y")
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(output.any { it.contains("Stage this hunk") && it.contains("y") })
assertTrue(selectedRepo.interactiveAddSession == null)
}
@Test
fun patchAddHunkEditOpensPatchEditorInvocation() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
assertEquals(GitEditorCommandKind.PATCH_HUNK, invocation?.kind)
assertEquals("Edit Patch Hunk", invocation?.title)
assertTrue(invocation?.initialContent.orEmpty().startsWith("# Manual hunk edit mode -- see bottom for a quick guide."))
assertFalse(invocation?.initialContent.orEmpty().contains("diff --git a/README b/README"))
assertFalse(invocation?.initialContent.orEmpty().contains("--- a/README"))
assertTrue(invocation?.initialContent.orEmpty().contains("# ---"))
assertTrue(invocation?.initialContent.orEmpty().contains("# To remove '+' lines, delete them."))
assertTrue(invocation?.initialContent.orEmpty().contains("+A"))
assertFalse(invocation?.initialContent.orEmpty().contains("Stage this hunk"))
}
@Test
fun editedPatchHunkStagesCurrentPatchTarget() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
?: error("Expected patch editor invocation")
val (selectedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(patchRepo, invocation.initialContent)
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectedRepo.interactiveAddSession == null)
assertTrue(output.any { it.contains("Applied edited hunk.") })
}
@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 == "e") {
assertTrue(output.any { it.contains("Opening patch editor") })
}
if (command in listOf("y", "a")) {
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(updatedRepo.interactiveAddSession == null)
}
}
}
}

View File

@@ -222,7 +222,10 @@ class LevelSolutionsTest {
appendLine(" <none>")
} else {
files.sortedBy { it.name }.forEach { file ->
appendLine(" ${file.name} staged=${file.staged} tracked=${file.tracked} content=${file.content.toEvidenceValue()}")
appendLine(
" ${file.name} staged=${file.staged} tracked=${file.tracked} " +
"content=${file.content.toEvidenceValue()} stagedContent=${file.stagedContent.toNullableEvidenceValue()}",
)
}
}
appendLine("commits=")
@@ -240,6 +243,10 @@ class LevelSolutionsTest {
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
}
fun String?.toNullableEvidenceValue(): String {
return this?.toEvidenceValue() ?: "<none>"
}
val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf(
"init",
"config",