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" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 183 versionCode = 184
versionName = "0.1.182" versionName = "0.1.183"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -8,12 +8,14 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/ioctl.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
#define LOG_TAG "GitHugNativeGit" #define LOG_TAG "GitHugNativeGit"
#define GIT_COMMAND_TIMEOUT_MS 30000 #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 (*git_main_fn)(int argc, const char **argv);
typedef int (*githug_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 githug_git_main_fn githug_git_main = NULL;
static git_init_fn git_init = 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) { static int append_output(struct output_buffer *buffer, const char *data, size_t length) {
if (length == 0) { if (length == 0) {
return 0; return 0;
@@ -100,6 +112,34 @@ static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output)
return result; 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) { static jobjectArray make_error(JNIEnv *env, const char *message) {
return make_result(env, -1, message); return make_result(env, -1, message);
} }
@@ -161,6 +201,45 @@ static void free_string_array(char **values, int count) {
free(values); 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 { struct saved_env {
char *name; char *name;
char *previous; char *previous;
@@ -353,3 +432,220 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars); (*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return result; 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(submodules.toSortedMap())
append(", maintenanceActions=") append(", maintenanceActions=")
append(maintenanceActions.sorted()) append(maintenanceActions.sorted())
append(", nativeGitSession=")
append(nativeGitSession)
append(", config=") append(", config=")
append(config.toSortedMap()) append(config.toSortedMap())
append(", files=") append(", files=")

View File

@@ -12,6 +12,7 @@ data class GitFile(
val staged: Boolean = false, val staged: Boolean = false,
val tracked: Boolean = false, val tracked: Boolean = false,
val deleted: Boolean = false, val deleted: Boolean = false,
val stagedContent: String? = null,
) )
data class CommitNode( data class CommitNode(
@@ -21,11 +22,9 @@ data class CommitNode(
val parentCount: Int = 0, val parentCount: Int = 0,
) )
data class InteractiveAddSession( data class NativeGitSession(
val target: String? = null, val id: Int,
val awaitingUpdateSelection: Boolean = false, val command: String,
val selectionPrompt: String = "Update>>",
val selectionAction: String = "update",
) )
data class RepoState( data class RepoState(
@@ -45,7 +44,7 @@ data class RepoState(
val pushedTags: Set<String> = emptySet(), val pushedTags: Set<String> = emptySet(),
val submodules: Map<String, String> = emptyMap(), val submodules: Map<String, String> = emptyMap(),
val maintenanceActions: Set<String> = emptySet(), val maintenanceActions: Set<String> = emptySet(),
val interactiveAddSession: InteractiveAddSession? = null, val nativeGitSession: NativeGitSession? = null,
) )
data class Level( data class Level(

View File

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

View File

@@ -22,8 +22,6 @@ internal class GitEditorWorkflow(
private val shellExecutable: () -> String, private val shellExecutable: () -> String,
) { ) {
fun initialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String { fun initialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) return invocation.initialContent
val nativeGit = requireNativeGit() val nativeGit = requireNativeGit()
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo) val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() } val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
@@ -43,7 +41,6 @@ internal class GitEditorWorkflow(
GitEditorCommandKind.REBASE_TODO -> "GIT_SEQUENCE_EDITOR" GitEditorCommandKind.REBASE_TODO -> "GIT_SEQUENCE_EDITOR"
GitEditorCommandKind.COMMIT_MESSAGE, GitEditorCommandKind.COMMIT_MESSAGE,
GitEditorCommandKind.TAG_MESSAGE -> "GIT_EDITOR" GitEditorCommandKind.TAG_MESSAGE -> "GIT_EDITOR"
GitEditorCommandKind.PATCH_HUNK -> return invocation.initialContent
} }
runGit( runGit(
@@ -93,9 +90,6 @@ internal class GitEditorWorkflow(
message = message, 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) { fun applyCommandResult(raw: String, newRepo: RepoState, lines: List<String>, echoCommand: Boolean) {
val startedAt = System.nanoTime() val startedAt = System.nanoTime()
val levelForResult = currentLevel val levelForResult = currentLevel
val validationBlockedByEditor = editorState != null || gitMessageEditorState != null val validationBlockedByEditor = editorState != null || gitMessageEditorState != null || newRepo.nativeGitSession != null
val solvedAfterCommand = if (validationBlockedByEditor) { val solvedAfterCommand = if (validationBlockedByEditor) {
false false
} else { } else {
@@ -496,7 +496,6 @@ fun GitHugApp() {
val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation) val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation)
val openedMessage = when (invocation.kind) { val openedMessage = when (invocation.kind) {
GitEditorCommandKind.REBASE_TODO -> "Opened Git rebase editor" GitEditorCommandKind.REBASE_TODO -> "Opened Git rebase editor"
GitEditorCommandKind.PATCH_HUNK -> "Opened Git patch editor"
else -> "Opened Git message editor" else -> "Opened Git message editor"
} }
val newOutput = buildList { val newOutput = buildList {
@@ -551,7 +550,7 @@ fun GitHugApp() {
return return
} }
val submittedLevelId = currentLevel.id val submittedLevelId = currentLevel.id
val isInteractiveInput = repo.interactiveAddSession != null val isInteractiveInput = repo.nativeGitSession != null
AppLog.d( AppLog.d(
"GitHugApp", "GitHugApp",
"Command submitted level=$submittedLevelId raw='$raw' interactive=$isInteractiveInput repo=${repo.diagnosticSnapshot()}", "Command submitted level=$submittedLevelId raw='$raw' interactive=$isInteractiveInput repo=${repo.diagnosticSnapshot()}",
@@ -587,12 +586,6 @@ fun GitHugApp() {
return return
} }
val patchHunkEditorInvocation = GitSandboxEngine.parsePatchHunkEditorInvocation(repo, raw)
if (patchHunkEditorInvocation != null) {
openGitMessageEditor(patchHunkEditorInvocation)
return
}
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput) applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput)
AppLog.d("GitHugApp", "Command handling finished level=$submittedLevelId raw='$raw' durationMs=${elapsedMillisSince(startedAt)}") 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.content.Context
import android.system.Os import android.system.Os
import java.io.File import java.io.File
import java.io.InputStream
import java.nio.file.Files import java.nio.file.Files
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
internal class GitProcessRunner( internal class GitProcessRunner(
private val context: Context?, private val context: Context?,
private val sandboxesRoot: File, private val sandboxesRoot: File,
) { ) {
private val nextHostSessionId = AtomicInteger(1)
private val hostSessions = ConcurrentHashMap<Int, HostGitSession>()
private companion object { private companion object {
val RequiredGitCommandAliases = listOf( val RequiredGitCommandAliases = listOf(
"add", "add",
@@ -77,6 +83,29 @@ internal class GitProcessRunner(
return result 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 { fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
return try { return try {
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments) 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 { private fun gitExecDirectory(binary: File): File {
val directory = if (context != null) { val directory = if (context != null) {
File(context.filesDir, "git-exec") File(context.filesDir, "git-exec")
@@ -166,6 +256,7 @@ internal class GitProcessRunner(
put("GIT_COMMITTER_NAME", "GitHug") put("GIT_COMMITTER_NAME", "GitHug")
put("GIT_COMMITTER_EMAIL", "githug@example.com") put("GIT_COMMITTER_EMAIL", "githug@example.com")
put("LC_ALL", "C") put("LC_ALL", "C")
put("TERM", "xterm-256color")
putAll(extraEnvironment) putAll(extraEnvironment)
} }
} }
@@ -232,3 +323,32 @@ internal data class ProcessExecutionResult(
val exitCode: Int, val exitCode: Int,
val outputLines: List<String>, 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) { if (repositoryRoot == null) {
val repo = RepoState( val repo = RepoState(
initialized = false, initialized = false,
files = filesOnDisk.map { files = filesOnDisk.mapNotNull {
if (!it.isFile) return@mapNotNull null
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText()) GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
}, },
) )
@@ -143,7 +144,8 @@ internal class GitRepositoryInspector(
val repo = RepoState( val repo = RepoState(
initialized = true, initialized = true,
files = filesOnDisk.map { file -> files = filesOnDisk.mapNotNull { file ->
if (!file.isFile) return@mapNotNull null
val relativePath = file.relativeTo(inspectionRoot).path val relativePath = file.relativeTo(inspectionRoot).path
val (staged, tracked) = statusMap[relativePath] ?: (false to true) val (staged, tracked) = statusMap[relativePath] ?: (false to true)
GitFile( GitFile(
@@ -151,6 +153,7 @@ internal class GitRepositoryInspector(
content = file.readText(), content = file.readText(),
staged = staged, staged = staged,
tracked = tracked, tracked = tracked,
stagedContent = stagedContent(git, inspectionRoot, relativePath, staged),
) )
} + deletedStatusPaths } + deletedStatusPaths
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } } .filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
@@ -161,6 +164,7 @@ internal class GitRepositoryInspector(
staged = staged, staged = staged,
tracked = tracked, tracked = tracked,
deleted = true, deleted = true,
stagedContent = stagedContent(git, inspectionRoot, deletedPath, staged),
) )
}, },
commits = commits, commits = commits,
@@ -203,6 +207,14 @@ internal class GitRepositoryInspector(
environment: Map<String, String> = emptyMap(), environment: Map<String, String> = emptyMap(),
): ProcessExecutionResult = runGit(binary, workingDir, arguments, environment) ): 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? { private fun repositoryRoot(sandbox: File, workingDir: File): File? {
val sandboxPath = sandbox.canonicalPath val sandboxPath = sandbox.canonicalPath
return generateSequence(workingDir) { directory -> 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}", "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()) { val result = when (expandedTokens.first()) {
"git" -> { "git" -> {
val gitResult = runGit( 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 { private fun List<String>.isGitConfigCommand(): Boolean {
return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config" return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config"
} }

View File

@@ -6,12 +6,6 @@ object GitSandboxEngine {
val quoted: Boolean = false, 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( fun commandReferenceLines(): List<String> = listOf(
"Available sandbox commands:", "Available sandbox commands:",
" git ", " git ",
@@ -31,9 +25,6 @@ object GitSandboxEngine {
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> { fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
val shellParts = tokenizeShellCommand(command) val shellParts = tokenizeShellCommand(command)
if (shellParts.isEmpty()) return repo to emptyList() if (shellParts.isEmpty()) return repo to emptyList()
repo.interactiveAddSession?.let {
return InteractiveAddEngine.handleInput(repo, command)
}
return SandboxCommandEngine.execute(repo, shellParts) 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( private external fun runGitMainNative(
libraryPath: String, libraryPath: String,
workingDirectory: String, workingDirectory: String,
argv: Array<String>, argv: Array<String>,
environment: Array<String>, environment: Array<String>,
): 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( listOf(
state.initialized, state.initialized,
state.headBranch, 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 { state.commits.flatMap {
listOf( listOf(
it.id, it.id,
@@ -28,14 +37,8 @@ val RepoStateSaver = listSaver<RepoState, Any>(
state.submodules.flatMap { listOf(it.key, it.value) }, state.submodules.flatMap { listOf(it.key, it.value) },
state.maintenanceActions.toList(), state.maintenanceActions.toList(),
state.fetchHeadCount, state.fetchHeadCount,
state.interactiveAddSession?.let { emptyList<Any>(),
listOf( emptyList<Any>(),
it.target.orEmpty(),
it.awaitingUpdateSelection.toString(),
it.selectionPrompt,
it.selectionAction,
)
}.orEmpty(),
) )
}, },
restore = { saved -> restore = { saved ->
@@ -54,17 +57,23 @@ val RepoStateSaver = listSaver<RepoState, Any>(
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>() val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>() val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0 val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0
val interactiveAddSessionParts = saved.getOrNull(16) as? List<*> ?: emptyList<Any>()
RepoState( RepoState(
initialized = initialized, initialized = initialized,
headBranch = headBranch, 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( GitFile(
name = it[0] as String, name = it[0] as String,
content = it[1] as String, content = it[1] as String,
staged = (it[2] as String).toBoolean(), staged = (it[2] as String).toBoolean(),
tracked = (it[3] as String).toBoolean(), tracked = (it[3] as String).toBoolean(),
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false, deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
stagedContent = (it.getOrNull(5) as? String)?.takeIf { value -> value.isNotEmpty() },
) )
}, },
commits = commitParts.restoreCommitNodes(), commits = commitParts.restoreCommitNodes(),
@@ -80,14 +89,6 @@ val RepoStateSaver = listSaver<RepoState, Any>(
pushedTags = pushedTags.filterIsInstance<String>().toSet(), pushedTags = pushedTags.filterIsInstance<String>().toSet(),
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) }, submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(), 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>> { private fun stagePaths(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) { 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" }) { 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("-") } val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git add <path>") ?: return repo to listOf("usage: git add <path>")

View File

@@ -12,7 +12,7 @@ internal fun stageLinesLevel(): Level = level(
title = "Stage Lines", 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.", 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."), 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)) }, 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 = { nativeSetup = {
resetFiles() 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") 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 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( testCases = listOf(
levelTestCase("stage feature file", "git add feature.rb"), levelTestCase(
levelTestCase("stage alias", "git stage feature.rb"), "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 { private fun testGitBinary(): File {
System.getenv("GITHUG_TEST_GIT_BINARY") System.getenv("GITHUG_TEST_GIT_BINARY")
?.takeIf { it.isNotBlank() } ?.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>") appendLine(" <none>")
} else { } else {
files.sortedBy { it.name }.forEach { file -> 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=") appendLine("commits=")
@@ -240,6 +243,10 @@ class LevelSolutionsTest {
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"") return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
} }
fun String?.toNullableEvidenceValue(): String {
return this?.toEvidenceValue() ?: "<none>"
}
val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf( val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf(
"init", "init",
"config", "config",