From 943bf03ca0ac15c4b53a0ce45995bbebd7bcdd8a Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Sat, 27 Jun 2026 13:07:35 -0500 Subject: [PATCH] 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. --- app/build.gradle.kts | 4 +- app/src/main/cpp/githug_runtime.c | 296 ++++++++++++++++++ .../solutions/tretter/githugandroid/AppLog.kt | 2 + .../tretter/githugandroid/GameModels.kt | 11 +- .../githugandroid/GitEditorCommands.kt | 1 - .../githugandroid/GitEditorWorkflow.kt | 6 - .../tretter/githugandroid/GitHugApp.kt | 11 +- .../tretter/githugandroid/GitProcessRunner.kt | 120 +++++++ .../githugandroid/GitRepositoryInspector.kt | 16 +- .../tretter/githugandroid/GitRuntime.kt | 71 +++++ .../tretter/githugandroid/GitSandboxEngine.kt | 9 - .../githugandroid/InteractiveAddEngine.kt | 288 ----------------- .../tretter/githugandroid/NativeGitBridge.kt | 71 +++++ .../tretter/githugandroid/RepoStateSaver.kt | 39 +-- .../githugandroid/SandboxCommandEngine.kt | 4 +- .../githugandroid/levels/StageLinesLevel.kt | 22 +- .../githugandroid/GitSandboxEngineTest.kt | 53 ++++ .../githugandroid/InteractiveAddEngineTest.kt | 200 ------------ .../githugandroid/LevelSolutionsTest.kt | 9 +- 19 files changed, 684 insertions(+), 549 deletions(-) delete mode 100644 app/src/main/java/solutions/tretter/githugandroid/InteractiveAddEngine.kt delete mode 100644 app/src/test/java/solutions/tretter/githugandroid/InteractiveAddEngineTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e724f03..f664117 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/cpp/githug_runtime.c b/app/src/main/cpp/githug_runtime.c index 7691c85..b347211 100644 --- a/app/src/main/cpp/githug_runtime.c +++ b/app/src/main/cpp/githug_runtime.c @@ -8,12 +8,14 @@ #include #include #include +#include #include #include #include #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; +} diff --git a/app/src/main/java/solutions/tretter/githugandroid/AppLog.kt b/app/src/main/java/solutions/tretter/githugandroid/AppLog.kt index 66deea5..32eef7c 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/AppLog.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/AppLog.kt @@ -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=") diff --git a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt index 00340d8..0ba07b0 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GameModels.kt @@ -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 = emptySet(), val submodules: Map = emptyMap(), val maintenanceActions: Set = emptySet(), - val interactiveAddSession: InteractiveAddSession? = null, + val nativeGitSession: NativeGitSession? = null, ) data class Level( diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt b/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt index ade6024..61f6179 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitEditorCommands.kt @@ -4,7 +4,6 @@ enum class GitEditorCommandKind { COMMIT_MESSAGE, REBASE_TODO, TAG_MESSAGE, - PATCH_HUNK, } data class GitEditorInvocation( diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitEditorWorkflow.kt b/app/src/main/java/solutions/tretter/githugandroid/GitEditorWorkflow.kt index ac5426b..95c7bd4 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitEditorWorkflow.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitEditorWorkflow.kt @@ -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.")) - } } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt index 4a3afb6..7b11fcb 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt @@ -393,7 +393,7 @@ fun GitHugApp() { fun applyCommandResult(raw: String, newRepo: RepoState, lines: List, 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)}") diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitProcessRunner.kt b/app/src/main/java/solutions/tretter/githugandroid/GitProcessRunner.kt index 653722b..40735d1 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitProcessRunner.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitProcessRunner.kt @@ -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() + private companion object { val RequiredGitCommandAliases = listOf( "add", @@ -77,6 +83,29 @@ internal class GitProcessRunner( return result } + fun startGitSession( + binary: File, + workingDir: File, + arguments: List, + environment: Map = 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): 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, + environment: Map, + ): 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, ) + +internal data class GitSessionResult( + val sessionId: Int, + val running: Boolean, + val exitCode: Int?, + val outputLines: List, +) + +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 = + replace("\r\n", "\n") + .replace('\r', '\n') + .lineSequence() + .toList() + .dropLastWhile { it.isEmpty() } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRepositoryInspector.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRepositoryInspector.kt index 951873d..112a7ea 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRepositoryInspector.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRepositoryInspector.kt @@ -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 = 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 -> diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 99d13d8..776a6f9 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -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.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.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.isGitConfigCommand(): Boolean { return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config" } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt index c245b26..ba5d3e0 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitSandboxEngine.kt @@ -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> = - InteractiveAddEngine.applyPatchHunkEdit(repo, content) - fun commandReferenceLines(): List = listOf( "Available sandbox commands:", " git ", @@ -31,9 +25,6 @@ object GitSandboxEngine { fun execute(repo: RepoState, command: String): Pair> { val shellParts = tokenizeShellCommand(command) if (shellParts.isEmpty()) return repo to emptyList() - repo.interactiveAddSession?.let { - return InteractiveAddEngine.handleInput(repo, command) - } return SandboxCommandEngine.execute(repo, shellParts) } diff --git a/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddEngine.kt deleted file mode 100644 index 208f5ec..0000000 --- a/app/src/main/java/solutions/tretter/githugandroid/InteractiveAddEngine.kt +++ /dev/null @@ -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): Pair> { - 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): Pair> { - 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> { - 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> { - 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> { - 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> { - 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, action: String): List { - 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, - selectedNames: Set, - 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> { - 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> { - 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 { - 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 { - 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 { - 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): List { - 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 { - return repo.files.filter { file -> - !file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/")) - } - } - - private fun selectedInteractiveAddNames(candidates: List, answer: String): Set { - 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() - } -} diff --git a/app/src/main/java/solutions/tretter/githugandroid/NativeGitBridge.kt b/app/src/main/java/solutions/tretter/githugandroid/NativeGitBridge.kt index f1d6ac1..8e212d7 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/NativeGitBridge.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/NativeGitBridge.kt @@ -38,10 +38,81 @@ internal object NativeGitBridge { ) } + fun startGitSession( + library: File, + workingDir: File, + arguments: List, + environment: Map, + ): 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): 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, environment: Array, ): Array + + private external fun startGitSessionNative( + libraryPath: String, + workingDirectory: String, + argv: Array, + environment: Array, + ): Array + + private external fun writeGitSessionNative( + sessionId: Int, + input: String, + ): Array } + +private fun String.toTerminalOutputLines(): List = + replace("\r\n", "\n") + .replace('\r', '\n') + .lineSequence() + .toList() + .dropLastWhile { it.isEmpty() } diff --git a/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt b/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt index 2250bef..a9d509e 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/RepoStateSaver.kt @@ -7,7 +7,16 @@ val RepoStateSaver = listSaver( 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( 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(), + emptyList(), ) }, restore = { saved -> @@ -54,17 +57,23 @@ val RepoStateSaver = listSaver( val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList() val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList() val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0 - val interactiveAddSessionParts = saved.getOrNull(16) as? List<*> ?: emptyList() 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( pushedTags = pushedTags.filterIsInstance().toSet(), submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) }, maintenanceActions = maintenanceActions.filterIsInstance().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", - ) - }, ) } ) diff --git a/app/src/main/java/solutions/tretter/githugandroid/SandboxCommandEngine.kt b/app/src/main/java/solutions/tretter/githugandroid/SandboxCommandEngine.kt index 7e93a6e..d71f18a 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/SandboxCommandEngine.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/SandboxCommandEngine.kt @@ -157,10 +157,10 @@ internal object SandboxCommandEngine { private fun stagePaths(repo: RepoState, parts: List): Pair> { 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 ") diff --git a/app/src/main/java/solutions/tretter/githugandroid/levels/StageLinesLevel.kt b/app/src/main/java/solutions/tretter/githugandroid/levels/StageLinesLevel.kt index 64f72a6..8759964 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/levels/StageLinesLevel.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/levels/StageLinesLevel.kt @@ -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"), ), ) diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index 098703b..cb3eedd 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -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() } diff --git a/app/src/test/java/solutions/tretter/githugandroid/InteractiveAddEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/InteractiveAddEngineTest.kt deleted file mode 100644 index a7040e3..0000000 --- a/app/src/test/java/solutions/tretter/githugandroid/InteractiveAddEngineTest.kt +++ /dev/null @@ -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) - } - } - } - -} diff --git a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt index 3b0d4b7..4ca7c95 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/LevelSolutionsTest.kt @@ -222,7 +222,10 @@ class LevelSolutionsTest { appendLine(" ") } 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() ?: "" + } + val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf( "init", "config",