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:
@@ -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
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define LOG_TAG "GitHugNativeGit"
|
||||
#define GIT_COMMAND_TIMEOUT_MS 30000
|
||||
#define GIT_SESSION_MAX_READ_WAIT_MS 1000
|
||||
|
||||
typedef int (*git_main_fn)(int argc, const char **argv);
|
||||
typedef int (*githug_git_main_fn)(int argc, const char **argv);
|
||||
@@ -31,6 +33,16 @@ static git_main_fn git_main = NULL;
|
||||
static githug_git_main_fn githug_git_main = NULL;
|
||||
static git_init_fn git_init = NULL;
|
||||
|
||||
struct git_session {
|
||||
int id;
|
||||
pid_t child;
|
||||
int pty_fd;
|
||||
struct git_session *next;
|
||||
};
|
||||
|
||||
static struct git_session *git_sessions = NULL;
|
||||
static int next_git_session_id = 1;
|
||||
|
||||
static int append_output(struct output_buffer *buffer, const char *data, size_t length) {
|
||||
if (length == 0) {
|
||||
return 0;
|
||||
@@ -100,6 +112,34 @@ static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output)
|
||||
return result;
|
||||
}
|
||||
|
||||
static jobjectArray make_session_result(JNIEnv *env, int session_id, int running, int exit_code, const char *output) {
|
||||
jclass string_class = (*env)->FindClass(env, "java/lang/String");
|
||||
jobjectArray result = (*env)->NewObjectArray(env, 4, string_class, NULL);
|
||||
char session_text[32];
|
||||
char running_text[8];
|
||||
char exit_text[32];
|
||||
snprintf(session_text, sizeof(session_text), "%d", session_id);
|
||||
snprintf(running_text, sizeof(running_text), "%d", running ? 1 : 0);
|
||||
if (exit_code >= 0) {
|
||||
snprintf(exit_text, sizeof(exit_text), "%d", exit_code);
|
||||
} else {
|
||||
exit_text[0] = '\0';
|
||||
}
|
||||
jstring session_string = (*env)->NewStringUTF(env, session_text);
|
||||
jstring running_string = (*env)->NewStringUTF(env, running_text);
|
||||
jstring exit_string = (*env)->NewStringUTF(env, exit_text);
|
||||
jstring output_string = (*env)->NewStringUTF(env, output != NULL ? output : "");
|
||||
(*env)->SetObjectArrayElement(env, result, 0, session_string);
|
||||
(*env)->SetObjectArrayElement(env, result, 1, running_string);
|
||||
(*env)->SetObjectArrayElement(env, result, 2, exit_string);
|
||||
(*env)->SetObjectArrayElement(env, result, 3, output_string);
|
||||
(*env)->DeleteLocalRef(env, session_string);
|
||||
(*env)->DeleteLocalRef(env, running_string);
|
||||
(*env)->DeleteLocalRef(env, exit_string);
|
||||
(*env)->DeleteLocalRef(env, output_string);
|
||||
return result;
|
||||
}
|
||||
|
||||
static jobjectArray make_error(JNIEnv *env, const char *message) {
|
||||
return make_result(env, -1, message);
|
||||
}
|
||||
@@ -161,6 +201,45 @@ static void free_string_array(char **values, int count) {
|
||||
free(values);
|
||||
}
|
||||
|
||||
static struct git_session *find_session(int session_id, struct git_session ***link_out) {
|
||||
struct git_session **link = &git_sessions;
|
||||
while (*link != NULL) {
|
||||
if ((*link)->id == session_id) {
|
||||
if (link_out != NULL) {
|
||||
*link_out = link;
|
||||
}
|
||||
return *link;
|
||||
}
|
||||
link = &((*link)->next);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void remove_session(struct git_session **link) {
|
||||
if (link == NULL) {
|
||||
return;
|
||||
}
|
||||
struct git_session *session = *link;
|
||||
if (session == NULL) {
|
||||
return;
|
||||
}
|
||||
*link = session->next;
|
||||
if (session->pty_fd >= 0) {
|
||||
close(session->pty_fd);
|
||||
}
|
||||
free(session);
|
||||
}
|
||||
|
||||
static int child_exit_code_from_status(int status) {
|
||||
if (WIFEXITED(status)) {
|
||||
return WEXITSTATUS(status);
|
||||
}
|
||||
if (WIFSIGNALED(status)) {
|
||||
return 128 + WTERMSIG(status);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct saved_env {
|
||||
char *name;
|
||||
char *previous;
|
||||
@@ -353,3 +432,220 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_solutions_tretter_githugandroid_NativeGitBridge_startGitSessionNative(
|
||||
JNIEnv *env,
|
||||
jclass clazz,
|
||||
jstring library_path,
|
||||
jstring working_directory,
|
||||
jobjectArray argv_array,
|
||||
jobjectArray environment_array
|
||||
) {
|
||||
(void)clazz;
|
||||
const char *library_path_chars = (*env)->GetStringUTFChars(env, library_path, NULL);
|
||||
const char *working_directory_chars = (*env)->GetStringUTFChars(env, working_directory, NULL);
|
||||
int argc = 0;
|
||||
int envc = 0;
|
||||
char **argv = copy_string_array(env, argv_array, &argc);
|
||||
char **env_entries = copy_string_array(env, environment_array, &envc);
|
||||
if (library_path_chars == NULL || working_directory_chars == NULL || argv == NULL || env_entries == NULL) {
|
||||
return make_session_result(env, 0, 0, -1, "Native Git session failed: out of memory");
|
||||
}
|
||||
|
||||
int master_fd = posix_openpt(O_RDWR | O_NOCTTY);
|
||||
if (master_fd < 0 || grantpt(master_fd) != 0 || unlockpt(master_fd) != 0) {
|
||||
if (master_fd >= 0) {
|
||||
close(master_fd);
|
||||
}
|
||||
free_string_array(argv, argc);
|
||||
free_string_array(env_entries, envc);
|
||||
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not create PTY");
|
||||
}
|
||||
char *slave_name = ptsname(master_fd);
|
||||
if (slave_name == NULL) {
|
||||
close(master_fd);
|
||||
free_string_array(argv, argc);
|
||||
free_string_array(env_entries, envc);
|
||||
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not resolve PTY slave");
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&git_mutex);
|
||||
if (load_git(library_path_chars) != 0) {
|
||||
close(master_fd);
|
||||
pthread_mutex_unlock(&git_mutex);
|
||||
free_string_array(argv, argc);
|
||||
free_string_array(env_entries, envc);
|
||||
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||
return make_session_result(env, 0, 0, -1, "Native Git session failed: init_git/cmd_main not found");
|
||||
}
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
close(master_fd);
|
||||
pthread_mutex_unlock(&git_mutex);
|
||||
free_string_array(argv, argc);
|
||||
free_string_array(env_entries, envc);
|
||||
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not fork");
|
||||
}
|
||||
|
||||
if (child == 0) {
|
||||
setsid();
|
||||
int slave_fd = open(slave_name, O_RDWR);
|
||||
if (slave_fd < 0) {
|
||||
_exit(127);
|
||||
}
|
||||
ioctl(slave_fd, TIOCSCTTY, 0);
|
||||
dup2(slave_fd, STDIN_FILENO);
|
||||
dup2(slave_fd, STDOUT_FILENO);
|
||||
dup2(slave_fd, STDERR_FILENO);
|
||||
if (slave_fd > STDERR_FILENO) {
|
||||
close(slave_fd);
|
||||
}
|
||||
close(master_fd);
|
||||
|
||||
int applied_env = 0;
|
||||
struct saved_env *saved_env = apply_environment(env_entries, envc, &applied_env);
|
||||
(void)saved_env;
|
||||
chdir(working_directory_chars);
|
||||
|
||||
int child_exit_code;
|
||||
if (githug_git_main != NULL) {
|
||||
child_exit_code = githug_git_main(argc, (const char **)argv);
|
||||
} else {
|
||||
git_init((const char **)argv);
|
||||
child_exit_code = git_main(argc, (const char **)argv);
|
||||
}
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(child_exit_code);
|
||||
}
|
||||
|
||||
setpgid(child, child);
|
||||
int flags = fcntl(master_fd, F_GETFL, 0);
|
||||
if (flags >= 0) {
|
||||
fcntl(master_fd, F_SETFL, flags | O_NONBLOCK);
|
||||
}
|
||||
|
||||
struct git_session *session = calloc(1, sizeof(struct git_session));
|
||||
if (session == NULL) {
|
||||
kill(-child, SIGKILL);
|
||||
close(master_fd);
|
||||
pthread_mutex_unlock(&git_mutex);
|
||||
free_string_array(argv, argc);
|
||||
free_string_array(env_entries, envc);
|
||||
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||
return make_session_result(env, 0, 0, -1, "Native Git session failed: out of memory");
|
||||
}
|
||||
session->id = next_git_session_id++;
|
||||
session->child = child;
|
||||
session->pty_fd = master_fd;
|
||||
session->next = git_sessions;
|
||||
git_sessions = session;
|
||||
|
||||
struct output_buffer output = {0};
|
||||
int saw_eof = 0;
|
||||
long long started_at = monotonic_millis();
|
||||
while (started_at > 0 && monotonic_millis() - started_at < GIT_SESSION_MAX_READ_WAIT_MS) {
|
||||
drain_available_output(session->pty_fd, &output, &saw_eof);
|
||||
if (output.length > 0) {
|
||||
break;
|
||||
}
|
||||
usleep(10000);
|
||||
}
|
||||
|
||||
int session_id = session->id;
|
||||
int status = 0;
|
||||
int running = 1;
|
||||
int exit_code = -1;
|
||||
pid_t wait_result = waitpid(child, &status, WNOHANG);
|
||||
if (wait_result == child) {
|
||||
running = 0;
|
||||
exit_code = child_exit_code_from_status(status);
|
||||
struct git_session **link = NULL;
|
||||
find_session(session_id, &link);
|
||||
remove_session(link);
|
||||
}
|
||||
|
||||
jobjectArray result = make_session_result(env, session_id, running, exit_code, output.data);
|
||||
free(output.data);
|
||||
pthread_mutex_unlock(&git_mutex);
|
||||
free_string_array(argv, argc);
|
||||
free_string_array(env_entries, envc);
|
||||
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_solutions_tretter_githugandroid_NativeGitBridge_writeGitSessionNative(
|
||||
JNIEnv *env,
|
||||
jclass clazz,
|
||||
jint session_id,
|
||||
jstring input
|
||||
) {
|
||||
(void)clazz;
|
||||
const char *input_chars = (*env)->GetStringUTFChars(env, input, NULL);
|
||||
pthread_mutex_lock(&git_mutex);
|
||||
struct git_session **link = NULL;
|
||||
struct git_session *session = find_session((int)session_id, &link);
|
||||
if (session == NULL) {
|
||||
pthread_mutex_unlock(&git_mutex);
|
||||
if (input_chars != NULL) {
|
||||
(*env)->ReleaseStringUTFChars(env, input, input_chars);
|
||||
}
|
||||
return make_session_result(env, (int)session_id, 0, -1, "Native Git session is not running.");
|
||||
}
|
||||
|
||||
if (input_chars != NULL) {
|
||||
size_t input_length = strlen(input_chars);
|
||||
size_t written = 0;
|
||||
while (written < input_length) {
|
||||
ssize_t count = write(session->pty_fd, input_chars + written, input_length - written);
|
||||
if (count > 0) {
|
||||
written += (size_t)count;
|
||||
} else if (errno != EINTR) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(*env)->ReleaseStringUTFChars(env, input, input_chars);
|
||||
}
|
||||
|
||||
struct output_buffer output = {0};
|
||||
int saw_eof = 0;
|
||||
long long started_at = monotonic_millis();
|
||||
while (started_at > 0 && monotonic_millis() - started_at < GIT_SESSION_MAX_READ_WAIT_MS) {
|
||||
drain_available_output(session->pty_fd, &output, &saw_eof);
|
||||
if (output.length > 0 || saw_eof) {
|
||||
break;
|
||||
}
|
||||
usleep(10000);
|
||||
}
|
||||
|
||||
int status = 0;
|
||||
int running = 1;
|
||||
int exit_code = -1;
|
||||
pid_t wait_result = waitpid(session->child, &status, WNOHANG);
|
||||
if (wait_result == session->child || saw_eof) {
|
||||
if (wait_result != session->child) {
|
||||
while (waitpid(session->child, &status, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
}
|
||||
running = 0;
|
||||
exit_code = child_exit_code_from_status(status);
|
||||
remove_session(link);
|
||||
}
|
||||
|
||||
jobjectArray result = make_session_result(env, (int)session_id, running, exit_code, output.data);
|
||||
free(output.data);
|
||||
pthread_mutex_unlock(&git_mutex);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ internal fun RepoState.diagnosticSnapshot(): String = buildString {
|
||||
append(submodules.toSortedMap())
|
||||
append(", maintenanceActions=")
|
||||
append(maintenanceActions.sorted())
|
||||
append(", nativeGitSession=")
|
||||
append(nativeGitSession)
|
||||
append(", config=")
|
||||
append(config.toSortedMap())
|
||||
append(", files=")
|
||||
|
||||
@@ -12,6 +12,7 @@ data class GitFile(
|
||||
val staged: Boolean = false,
|
||||
val tracked: Boolean = false,
|
||||
val deleted: Boolean = false,
|
||||
val stagedContent: String? = null,
|
||||
)
|
||||
|
||||
data class CommitNode(
|
||||
@@ -21,11 +22,9 @@ data class CommitNode(
|
||||
val parentCount: Int = 0,
|
||||
)
|
||||
|
||||
data class InteractiveAddSession(
|
||||
val target: String? = null,
|
||||
val awaitingUpdateSelection: Boolean = false,
|
||||
val selectionPrompt: String = "Update>>",
|
||||
val selectionAction: String = "update",
|
||||
data class NativeGitSession(
|
||||
val id: Int,
|
||||
val command: String,
|
||||
)
|
||||
|
||||
data class RepoState(
|
||||
@@ -45,7 +44,7 @@ data class RepoState(
|
||||
val pushedTags: Set<String> = emptySet(),
|
||||
val submodules: Map<String, String> = emptyMap(),
|
||||
val maintenanceActions: Set<String> = emptySet(),
|
||||
val interactiveAddSession: InteractiveAddSession? = null,
|
||||
val nativeGitSession: NativeGitSession? = null,
|
||||
)
|
||||
|
||||
data class Level(
|
||||
|
||||
@@ -4,7 +4,6 @@ enum class GitEditorCommandKind {
|
||||
COMMIT_MESSAGE,
|
||||
REBASE_TODO,
|
||||
TAG_MESSAGE,
|
||||
PATCH_HUNK,
|
||||
}
|
||||
|
||||
data class GitEditorInvocation(
|
||||
|
||||
@@ -22,8 +22,6 @@ internal class GitEditorWorkflow(
|
||||
private val shellExecutable: () -> String,
|
||||
) {
|
||||
fun initialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
||||
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) return invocation.initialContent
|
||||
|
||||
val nativeGit = requireNativeGit()
|
||||
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
|
||||
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
||||
@@ -43,7 +41,6 @@ internal class GitEditorWorkflow(
|
||||
GitEditorCommandKind.REBASE_TODO -> "GIT_SEQUENCE_EDITOR"
|
||||
GitEditorCommandKind.COMMIT_MESSAGE,
|
||||
GitEditorCommandKind.TAG_MESSAGE -> "GIT_EDITOR"
|
||||
GitEditorCommandKind.PATCH_HUNK -> return invocation.initialContent
|
||||
}
|
||||
|
||||
runGit(
|
||||
@@ -93,9 +90,6 @@ internal class GitEditorWorkflow(
|
||||
message = message,
|
||||
)
|
||||
|
||||
GitEditorCommandKind.PATCH_HUNK -> {
|
||||
GitEditorExecutionResult(currentRepo, listOf("Patch hunk editing is handled by Git, not the Android runtime."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ fun GitHugApp() {
|
||||
fun applyCommandResult(raw: String, newRepo: RepoState, lines: List<String>, echoCommand: Boolean) {
|
||||
val startedAt = System.nanoTime()
|
||||
val levelForResult = currentLevel
|
||||
val validationBlockedByEditor = editorState != null || gitMessageEditorState != null
|
||||
val validationBlockedByEditor = editorState != null || gitMessageEditorState != null || newRepo.nativeGitSession != null
|
||||
val solvedAfterCommand = if (validationBlockedByEditor) {
|
||||
false
|
||||
} else {
|
||||
@@ -496,7 +496,6 @@ fun GitHugApp() {
|
||||
val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation)
|
||||
val openedMessage = when (invocation.kind) {
|
||||
GitEditorCommandKind.REBASE_TODO -> "Opened Git rebase editor"
|
||||
GitEditorCommandKind.PATCH_HUNK -> "Opened Git patch editor"
|
||||
else -> "Opened Git message editor"
|
||||
}
|
||||
val newOutput = buildList {
|
||||
@@ -551,7 +550,7 @@ fun GitHugApp() {
|
||||
return
|
||||
}
|
||||
val submittedLevelId = currentLevel.id
|
||||
val isInteractiveInput = repo.interactiveAddSession != null
|
||||
val isInteractiveInput = repo.nativeGitSession != null
|
||||
AppLog.d(
|
||||
"GitHugApp",
|
||||
"Command submitted level=$submittedLevelId raw='$raw' interactive=$isInteractiveInput repo=${repo.diagnosticSnapshot()}",
|
||||
@@ -587,12 +586,6 @@ fun GitHugApp() {
|
||||
return
|
||||
}
|
||||
|
||||
val patchHunkEditorInvocation = GitSandboxEngine.parsePatchHunkEditorInvocation(repo, raw)
|
||||
if (patchHunkEditorInvocation != null) {
|
||||
openGitMessageEditor(patchHunkEditorInvocation)
|
||||
return
|
||||
}
|
||||
|
||||
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
|
||||
applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput)
|
||||
AppLog.d("GitHugApp", "Command handling finished level=$submittedLevelId raw='$raw' durationMs=${elapsedMillisSince(startedAt)}")
|
||||
|
||||
@@ -3,12 +3,18 @@ package solutions.tretter.githugandroid
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
internal class GitProcessRunner(
|
||||
private val context: Context?,
|
||||
private val sandboxesRoot: File,
|
||||
) {
|
||||
private val nextHostSessionId = AtomicInteger(1)
|
||||
private val hostSessions = ConcurrentHashMap<Int, HostGitSession>()
|
||||
|
||||
private companion object {
|
||||
val RequiredGitCommandAliases = listOf(
|
||||
"add",
|
||||
@@ -77,6 +83,29 @@ internal class GitProcessRunner(
|
||||
return result
|
||||
}
|
||||
|
||||
fun startGitSession(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): GitSessionResult {
|
||||
val gitExecPath = gitExecDirectory(binary)
|
||||
val fullEnvironment = gitEnvironment(binary, workingDir, environment, gitExecPath)
|
||||
return if (context != null) {
|
||||
NativeGitBridge.startGitSession(binary, workingDir, arguments, fullEnvironment)
|
||||
} else {
|
||||
startHostGitSession(binary, workingDir, arguments, fullEnvironment)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeGitSession(sessionId: Int, input: String): GitSessionResult {
|
||||
return if (context != null) {
|
||||
NativeGitBridge.writeGitSession(sessionId, input)
|
||||
} else {
|
||||
writeHostGitSession(sessionId, input)
|
||||
}
|
||||
}
|
||||
|
||||
fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
@@ -119,6 +148,67 @@ internal class GitProcessRunner(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHostGitSession(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String>,
|
||||
): GitSessionResult {
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply { environment().putAll(environment) }
|
||||
.start()
|
||||
val sessionId = nextHostSessionId.getAndIncrement()
|
||||
hostSessions[sessionId] = HostGitSession(process)
|
||||
readHostGitSessionResult(sessionId, process)
|
||||
} catch (error: Exception) {
|
||||
GitSessionResult(
|
||||
sessionId = 0,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git session failed: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeHostGitSession(sessionId: Int, input: String): GitSessionResult {
|
||||
val session = hostSessions[sessionId]
|
||||
?: return GitSessionResult(sessionId, running = false, exitCode = -1, outputLines = listOf("Native Git session is not running."))
|
||||
return try {
|
||||
session.process.outputStream.write(input.toByteArray())
|
||||
session.process.outputStream.flush()
|
||||
readHostGitSessionResult(sessionId, session.process)
|
||||
} catch (error: Exception) {
|
||||
hostSessions.remove(sessionId)
|
||||
GitSessionResult(
|
||||
sessionId = sessionId,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git session failed: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readHostGitSessionResult(sessionId: Int, process: Process): GitSessionResult {
|
||||
val output = StringBuilder()
|
||||
val deadline = System.nanoTime() + 1_000_000_000L
|
||||
do {
|
||||
output.append(process.inputStream.readAvailableText())
|
||||
if (!process.isAlive || output.isNotEmpty()) break
|
||||
Thread.sleep(25)
|
||||
} while (System.nanoTime() < deadline)
|
||||
|
||||
output.append(process.inputStream.readAvailableText())
|
||||
return if (process.isAlive) {
|
||||
GitSessionResult(sessionId = sessionId, running = true, exitCode = null, outputLines = output.toString().toOutputLines())
|
||||
} else {
|
||||
hostSessions.remove(sessionId)
|
||||
GitSessionResult(sessionId = sessionId, running = false, exitCode = process.exitValue(), outputLines = output.toString().toOutputLines())
|
||||
}
|
||||
}
|
||||
|
||||
private fun gitExecDirectory(binary: File): File {
|
||||
val directory = if (context != null) {
|
||||
File(context.filesDir, "git-exec")
|
||||
@@ -166,6 +256,7 @@ internal class GitProcessRunner(
|
||||
put("GIT_COMMITTER_NAME", "GitHug")
|
||||
put("GIT_COMMITTER_EMAIL", "githug@example.com")
|
||||
put("LC_ALL", "C")
|
||||
put("TERM", "xterm-256color")
|
||||
putAll(extraEnvironment)
|
||||
}
|
||||
}
|
||||
@@ -232,3 +323,32 @@ internal data class ProcessExecutionResult(
|
||||
val exitCode: Int,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
|
||||
internal data class GitSessionResult(
|
||||
val sessionId: Int,
|
||||
val running: Boolean,
|
||||
val exitCode: Int?,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
|
||||
private data class HostGitSession(
|
||||
val process: Process,
|
||||
)
|
||||
|
||||
private fun InputStream.readAvailableText(): String {
|
||||
val output = StringBuilder()
|
||||
val buffer = ByteArray(4096)
|
||||
while (available() > 0) {
|
||||
val count = read(buffer)
|
||||
if (count <= 0) break
|
||||
output.append(String(buffer, 0, count))
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun String.toOutputLines(): List<String> =
|
||||
replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.lineSequence()
|
||||
.toList()
|
||||
.dropLastWhile { it.isEmpty() }
|
||||
|
||||
@@ -42,7 +42,8 @@ internal class GitRepositoryInspector(
|
||||
if (repositoryRoot == null) {
|
||||
val repo = RepoState(
|
||||
initialized = false,
|
||||
files = filesOnDisk.map {
|
||||
files = filesOnDisk.mapNotNull {
|
||||
if (!it.isFile) return@mapNotNull null
|
||||
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
|
||||
},
|
||||
)
|
||||
@@ -143,7 +144,8 @@ internal class GitRepositoryInspector(
|
||||
|
||||
val repo = RepoState(
|
||||
initialized = true,
|
||||
files = filesOnDisk.map { file ->
|
||||
files = filesOnDisk.mapNotNull { file ->
|
||||
if (!file.isFile) return@mapNotNull null
|
||||
val relativePath = file.relativeTo(inspectionRoot).path
|
||||
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
||||
GitFile(
|
||||
@@ -151,6 +153,7 @@ internal class GitRepositoryInspector(
|
||||
content = file.readText(),
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
stagedContent = stagedContent(git, inspectionRoot, relativePath, staged),
|
||||
)
|
||||
} + deletedStatusPaths
|
||||
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
|
||||
@@ -161,6 +164,7 @@ internal class GitRepositoryInspector(
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
deleted = true,
|
||||
stagedContent = stagedContent(git, inspectionRoot, deletedPath, staged),
|
||||
)
|
||||
},
|
||||
commits = commits,
|
||||
@@ -203,6 +207,14 @@ internal class GitRepositoryInspector(
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult = runGit(binary, workingDir, arguments, environment)
|
||||
|
||||
private fun stagedContent(git: File, inspectionRoot: File, path: String, staged: Boolean): String? {
|
||||
if (!staged) return null
|
||||
val result = run(git, inspectionRoot, listOf("show", ":$path"))
|
||||
return result.outputLines
|
||||
.takeIf { result.exitCode == 0 }
|
||||
?.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun repositoryRoot(sandbox: File, workingDir: File): File? {
|
||||
val sandboxPath = sandbox.canonicalPath
|
||||
return generateSequence(workingDir) { directory ->
|
||||
|
||||
@@ -142,6 +142,50 @@ class GitRepositoryRuntime private constructor(
|
||||
"Command parsed level=${level.id} raw='$command' tokens=$tokens expanded=$expandedTokens cwd=${workingDir.absolutePath}",
|
||||
)
|
||||
|
||||
currentRepo.nativeGitSession?.let { session ->
|
||||
val sessionResult = processRunner.writeGitSession(session.id, command + "\n")
|
||||
val sessionRepo = if (sessionResult.running) {
|
||||
currentRepo
|
||||
} else {
|
||||
inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir)
|
||||
}.copy(
|
||||
nativeGitSession = if (sessionResult.running) session else null,
|
||||
)
|
||||
AppLog.d(
|
||||
"GitRuntime",
|
||||
"Native Git session input level=${level.id} session=${session.id} running=${sessionResult.running} " +
|
||||
"exit=${sessionResult.exitCode} repo=${sessionRepo.diagnosticSnapshot()}",
|
||||
)
|
||||
return sessionRepo to sessionResult.outputLines
|
||||
}
|
||||
|
||||
if (expandedTokens.requiresGitTerminalSession()) {
|
||||
val sessionResult = processRunner.startGitSession(
|
||||
nativeGit,
|
||||
workingDir,
|
||||
normalizeGitArgumentsForAndroid(expandedTokens.drop(1)),
|
||||
invocation.environment,
|
||||
)
|
||||
val sessionRepo = currentRepo.copy(
|
||||
nativeGitSession = if (sessionResult.running) {
|
||||
NativeGitSession(sessionResult.sessionId, command)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
val refreshedRepo = if (sessionResult.running) {
|
||||
sessionRepo
|
||||
} else {
|
||||
refreshRepoAfterCommand(level, currentRepo, sessionRepo, expandedTokens)
|
||||
}
|
||||
AppLog.d(
|
||||
"GitRuntime",
|
||||
"Native Git session start level=${level.id} session=${sessionResult.sessionId} running=${sessionResult.running} " +
|
||||
"exit=${sessionResult.exitCode} repo=${refreshedRepo.diagnosticSnapshot()}",
|
||||
)
|
||||
return refreshedRepo to sessionResult.outputLines
|
||||
}
|
||||
|
||||
val result = when (expandedTokens.first()) {
|
||||
"git" -> {
|
||||
val gitResult = runGit(
|
||||
@@ -361,6 +405,33 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.requiresGitTerminalSession(): Boolean {
|
||||
if (firstOrNull() != "git") return false
|
||||
val commandIndex = gitSubcommandIndex() ?: return false
|
||||
val command = this[commandIndex]
|
||||
if (command == "rebase") return false
|
||||
return drop(commandIndex + 1).any { it == "-i" || it == "--interactive" || it == "-p" || it == "--patch" }
|
||||
}
|
||||
|
||||
private fun List<String>.gitSubcommandIndex(): Int? {
|
||||
var index = 1
|
||||
while (index < size) {
|
||||
val argument = this[index]
|
||||
if (argument == "--") return null
|
||||
if (!argument.startsWith("-")) return index
|
||||
index += when {
|
||||
argument in setOf("-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path") -> 2
|
||||
argument.startsWith("-C") && argument.length > 2 -> 1
|
||||
argument.startsWith("--git-dir=") ||
|
||||
argument.startsWith("--work-tree=") ||
|
||||
argument.startsWith("--namespace=") ||
|
||||
argument.startsWith("--exec-path=") -> 1
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun List<String>.isGitConfigCommand(): Boolean {
|
||||
return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,6 @@ object GitSandboxEngine {
|
||||
val quoted: Boolean = false,
|
||||
)
|
||||
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? =
|
||||
InteractiveAddEngine.parsePatchHunkEditorInvocation(repo, command)
|
||||
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> =
|
||||
InteractiveAddEngine.applyPatchHunkEdit(repo, content)
|
||||
|
||||
fun commandReferenceLines(): List<String> = listOf(
|
||||
"Available sandbox commands:",
|
||||
" git ",
|
||||
@@ -31,9 +25,6 @@ object GitSandboxEngine {
|
||||
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||
val shellParts = tokenizeShellCommand(command)
|
||||
if (shellParts.isEmpty()) return repo to emptyList()
|
||||
repo.interactiveAddSession?.let {
|
||||
return InteractiveAddEngine.handleInput(repo, command)
|
||||
}
|
||||
return SandboxCommandEngine.execute(repo, shellParts)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
internal object InteractiveAddEngine {
|
||||
private const val PatchHunkPrompt = "(1/1) Stage this hunk [y,n,q,a,d,s,e,p,P,?]?"
|
||||
|
||||
fun start(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-i" || it == "--interactive" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val candidates = interactiveAddCandidates(repo, target)
|
||||
return repo.copy(interactiveAddSession = InteractiveAddSession(target = target)) to interactiveAddConsoleLines(candidates)
|
||||
}
|
||||
|
||||
fun startPatch(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||
val targets = arguments.filterNot { it == "-p" || it == "--patch" || it.startsWith("--") }
|
||||
val target = targets.lastOrNull()
|
||||
val patchFile = interactiveAddCandidates(repo, target).firstOrNull()
|
||||
?: return repo to listOf("No changes.")
|
||||
return startPatchHunkSession(repo, patchFile.name)
|
||||
}
|
||||
|
||||
fun handleInput(repo: RepoState, input: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to emptyList()
|
||||
val answer = input.trim()
|
||||
if (session.selectionAction == "patch-hunk") {
|
||||
return handlePatchHunkInput(repo, session, answer)
|
||||
}
|
||||
return if (session.awaitingUpdateSelection) {
|
||||
applyInteractiveAddUpdateSelection(repo, session, answer)
|
||||
} else {
|
||||
when (answer.lowercase()) {
|
||||
"1", "s", "status" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
"2", "u", "update" -> interactiveAddSelectionPrompt(repo, session, answer, "Update>>", "update")
|
||||
"3", "r", "revert" -> interactiveAddSelectionPrompt(repo, session, answer, "Revert>>", "revert")
|
||||
"4", "a", "add untracked", "add-untracked" -> interactiveAddSelectionPrompt(repo, session, answer, "Add untracked>>", "add-untracked")
|
||||
"5", "p", "patch" -> interactiveAddSelectionPrompt(repo, session, answer, "Patch update>>", "patch")
|
||||
"6", "d", "diff" -> interactiveAddSelectionPrompt(repo, session, answer, "Diff>>", "diff")
|
||||
"7", "q", "quit" -> repo.copy(interactiveAddSession = null) to listOf("What now> $answer", "Bye.")
|
||||
"8", "h", "help" -> repo to listOf("What now> $answer") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
else -> repo to listOf("What now> $answer", "Huh ($answer)?") + interactiveAddConsoleLines(interactiveAddCandidates(repo, session.target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? {
|
||||
val session = repo.interactiveAddSession ?: return null
|
||||
if (session.selectionAction != "patch-hunk") return null
|
||||
if (command.trim().lowercase() != "e") return null
|
||||
val target = session.target ?: return null
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted } ?: return null
|
||||
return GitEditorInvocation(
|
||||
command = command,
|
||||
kind = GitEditorCommandKind.PATCH_HUNK,
|
||||
title = "Edit Patch Hunk",
|
||||
displayPath = target,
|
||||
initialContent = editablePatchHunkContent(file),
|
||||
)
|
||||
}
|
||||
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> {
|
||||
val session = repo.interactiveAddSession ?: return repo to listOf("No patch hunk is active.")
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No patch hunk is active.")
|
||||
if (session.selectionAction != "patch-hunk") return repo to listOf("No patch hunk is active.")
|
||||
if (content.isBlank()) return repo to listOf("Edited hunk was empty; patch was not applied.", PatchHunkPrompt)
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
return repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf(
|
||||
"$PatchHunkPrompt e",
|
||||
"Applied edited hunk.",
|
||||
)
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionPrompt(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
prompt: String,
|
||||
action: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
return repo.copy(
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = true,
|
||||
selectionPrompt = prompt,
|
||||
selectionAction = action,
|
||||
),
|
||||
) to listOf("What now> $answer", prompt)
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddUpdateSelection(
|
||||
repo: RepoState,
|
||||
session: InteractiveAddSession,
|
||||
answer: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val candidates = interactiveAddCandidates(repo, session.target)
|
||||
val selectedNames = selectedInteractiveAddNames(candidates, answer)
|
||||
val prompt = session.selectionPrompt
|
||||
if (selectedNames.isEmpty()) {
|
||||
return repo to listOf("$prompt $answer", "No files selected.", prompt)
|
||||
}
|
||||
|
||||
if (session.selectionAction == "patch" && selectedNames.size == 1) {
|
||||
return startPatchHunkSession(repo, selectedNames.single(), "$prompt $answer")
|
||||
}
|
||||
|
||||
val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction)
|
||||
val updatedRepo = repo.copy(
|
||||
files = updatedFiles,
|
||||
interactiveAddSession = session.copy(
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = "Update>>",
|
||||
selectionAction = "update",
|
||||
),
|
||||
)
|
||||
val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction)
|
||||
return updatedRepo to listOf(
|
||||
"$prompt $answer",
|
||||
summary,
|
||||
) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target))
|
||||
}
|
||||
|
||||
private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set<String>, action: String): List<GitFile> {
|
||||
return when (action) {
|
||||
"revert" -> repo.files.mapNotNull { file ->
|
||||
if (file.name !in selectedNames || file.deleted) {
|
||||
file
|
||||
} else if (file.tracked) {
|
||||
file.copy(content = "", staged = false, deleted = false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
"diff" -> repo.files
|
||||
else -> repo.files.map { file ->
|
||||
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddSelectionSummary(
|
||||
repo: RepoState,
|
||||
updatedFiles: List<GitFile>,
|
||||
selectedNames: Set<String>,
|
||||
action: String,
|
||||
): String {
|
||||
return when (action) {
|
||||
"revert" -> "reverted ${selectedNames.size} path(s)"
|
||||
"diff" -> selectedNames.joinToString("\n") { "diff -- $it" }
|
||||
else -> {
|
||||
val stagedCount = updatedFiles.count { updatedFile ->
|
||||
val before = repo.files.firstOrNull { it.name == updatedFile.name }
|
||||
updatedFile.staged && before?.staged != true
|
||||
}
|
||||
"updated $stagedCount path(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair<RepoState, List<String>> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
?: return repo to listOfNotNull(prefixLine, "No changes.")
|
||||
val session = InteractiveAddSession(
|
||||
target = target,
|
||||
awaitingUpdateSelection = false,
|
||||
selectionPrompt = PatchHunkPrompt,
|
||||
selectionAction = "patch-hunk",
|
||||
)
|
||||
val output = listOfNotNull(prefixLine) + patchHunkLines(file)
|
||||
return repo.copy(interactiveAddSession = session) to output
|
||||
}
|
||||
|
||||
private fun handlePatchHunkInput(repo: RepoState, session: InteractiveAddSession, answer: String): Pair<RepoState, List<String>> {
|
||||
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
return when (answer.lowercase()) {
|
||||
"y", "a" -> {
|
||||
val updatedFiles = repo.files.map { file ->
|
||||
if (file.name == target && !file.deleted) file.copy(staged = true) else file
|
||||
}
|
||||
repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
}
|
||||
"n", "d" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
|
||||
"q" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer", "Quit")
|
||||
"?" -> repo to listOf(
|
||||
"$PatchHunkPrompt $answer",
|
||||
"y - stage this hunk",
|
||||
"n - do not stage this hunk",
|
||||
"q - quit; do not stage this hunk or any remaining ones",
|
||||
"a - stage this hunk and all later hunks in the file",
|
||||
"d - do not stage this hunk or any later hunks in the file",
|
||||
"s - split the current hunk into smaller hunks",
|
||||
"e - manually edit the current hunk",
|
||||
"p - print the current hunk",
|
||||
"? - print help",
|
||||
PatchHunkPrompt,
|
||||
)
|
||||
"p" -> {
|
||||
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
|
||||
if (file == null) {
|
||||
repo.copy(interactiveAddSession = null) to listOf("No changes.")
|
||||
} else {
|
||||
repo to listOf("$PatchHunkPrompt $answer") + patchHunkLines(file)
|
||||
}
|
||||
}
|
||||
"s" -> repo to listOf("$PatchHunkPrompt $answer", "Sorry, cannot split this hunk", PatchHunkPrompt)
|
||||
"e" -> repo to listOf("$PatchHunkPrompt $answer", "Opening patch editor")
|
||||
else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchHunkLines(file: GitFile): List<String> {
|
||||
return patchDiffHeaderLines(file) + patchHunkBodyLines(file) + PatchHunkPrompt
|
||||
}
|
||||
|
||||
private fun editablePatchHunkContent(file: GitFile): String {
|
||||
return buildList {
|
||||
add("# Manual hunk edit mode -- see bottom for a quick guide.")
|
||||
addAll(patchHunkBodyLines(file))
|
||||
add("# ---")
|
||||
add("# To remove '-' lines, make them ' ' lines (context).")
|
||||
add("# To remove '+' lines, delete them.")
|
||||
add("# Lines starting with # will be removed.")
|
||||
add("# If the patch applies cleanly, the edited hunk will immediately be marked for staging.")
|
||||
add("# If it does not apply cleanly, you will be given an opportunity to")
|
||||
add("# edit again. If all lines of the hunk are removed, then the edit is")
|
||||
add("# aborted and the hunk is left unchanged.")
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun patchDiffHeaderLines(file: GitFile): List<String> {
|
||||
return listOf(
|
||||
"diff --git a/${file.name} b/${file.name}",
|
||||
"index 0000000..0000001 100644",
|
||||
"--- a/${file.name}",
|
||||
"+++ b/${file.name}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun patchHunkBodyLines(file: GitFile): List<String> {
|
||||
val lines = file.content.lines()
|
||||
val nonEmptyLines = lines.dropLastWhile { it.isEmpty() }
|
||||
val addedCount = nonEmptyLines.size.coerceAtLeast(1)
|
||||
return buildList {
|
||||
add("@@ -1 +1,$addedCount @@")
|
||||
if (nonEmptyLines.isEmpty()) {
|
||||
add("+")
|
||||
} else {
|
||||
nonEmptyLines.forEach { line -> add("+$line") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
|
||||
return buildList {
|
||||
add(" staged unstaged path")
|
||||
candidates.forEachIndexed { index, file ->
|
||||
val staged = if (file.staged) "unchanged" else "+0/-0"
|
||||
val unstaged = when {
|
||||
file.tracked -> "+1/-0"
|
||||
else -> "+0/-0"
|
||||
}
|
||||
add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}")
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
add("No changes.")
|
||||
}
|
||||
add("*** Commands ***")
|
||||
add(" 1: status 2: update 3: revert 4: add untracked")
|
||||
add(" 5: patch 6: diff 7: quit 8: help")
|
||||
add("What now>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun interactiveAddCandidates(repo: RepoState, target: String?): List<GitFile> {
|
||||
return repo.files.filter { file ->
|
||||
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedInteractiveAddNames(candidates: List<GitFile>, answer: String): Set<String> {
|
||||
if (answer == "*") return candidates.map { it.name }.toSet()
|
||||
return answer.split(Regex("[,\\s]+"))
|
||||
.mapNotNull { token ->
|
||||
token.toIntOrNull()
|
||||
?.takeIf { it in 1..candidates.size }
|
||||
?.let { candidates[it - 1].name }
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,81 @@ internal object NativeGitBridge {
|
||||
)
|
||||
}
|
||||
|
||||
fun startGitSession(
|
||||
library: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String>,
|
||||
): GitSessionResult {
|
||||
loadResult.getOrElse { error ->
|
||||
return GitSessionResult(
|
||||
sessionId = 0,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
|
||||
val argv = (listOf("git") + arguments).toTypedArray()
|
||||
val env = environment.entries.map { (key, value) -> "$key=$value" }.toTypedArray()
|
||||
return sessionResult(
|
||||
startGitSessionNative(
|
||||
library.absolutePath,
|
||||
workingDir.absolutePath,
|
||||
argv,
|
||||
env,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun writeGitSession(sessionId: Int, input: String): GitSessionResult {
|
||||
loadResult.getOrElse { error ->
|
||||
return GitSessionResult(
|
||||
sessionId = sessionId,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
return sessionResult(writeGitSessionNative(sessionId, input))
|
||||
}
|
||||
|
||||
private fun sessionResult(result: Array<String>): GitSessionResult {
|
||||
val sessionId = result.getOrNull(0)?.toIntOrNull() ?: 0
|
||||
val running = result.getOrNull(1) == "1"
|
||||
val exitCode = result.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull()
|
||||
val output = result.getOrNull(3).orEmpty()
|
||||
return GitSessionResult(
|
||||
sessionId = sessionId,
|
||||
running = running,
|
||||
exitCode = exitCode,
|
||||
outputLines = output.toTerminalOutputLines(),
|
||||
)
|
||||
}
|
||||
|
||||
private external fun runGitMainNative(
|
||||
libraryPath: String,
|
||||
workingDirectory: String,
|
||||
argv: Array<String>,
|
||||
environment: Array<String>,
|
||||
): Array<String>
|
||||
|
||||
private external fun startGitSessionNative(
|
||||
libraryPath: String,
|
||||
workingDirectory: String,
|
||||
argv: Array<String>,
|
||||
environment: Array<String>,
|
||||
): Array<String>
|
||||
|
||||
private external fun writeGitSessionNative(
|
||||
sessionId: Int,
|
||||
input: String,
|
||||
): Array<String>
|
||||
}
|
||||
|
||||
private fun String.toTerminalOutputLines(): List<String> =
|
||||
replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.lineSequence()
|
||||
.toList()
|
||||
.dropLastWhile { it.isEmpty() }
|
||||
|
||||
@@ -7,7 +7,16 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
listOf(
|
||||
state.initialized,
|
||||
state.headBranch,
|
||||
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
|
||||
state.files.flatMap {
|
||||
listOf(
|
||||
it.name,
|
||||
it.content,
|
||||
it.staged.toString(),
|
||||
it.tracked.toString(),
|
||||
it.deleted.toString(),
|
||||
it.stagedContent.orEmpty(),
|
||||
)
|
||||
},
|
||||
state.commits.flatMap {
|
||||
listOf(
|
||||
it.id,
|
||||
@@ -28,14 +37,8 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
state.submodules.flatMap { listOf(it.key, it.value) },
|
||||
state.maintenanceActions.toList(),
|
||||
state.fetchHeadCount,
|
||||
state.interactiveAddSession?.let {
|
||||
listOf(
|
||||
it.target.orEmpty(),
|
||||
it.awaitingUpdateSelection.toString(),
|
||||
it.selectionPrompt,
|
||||
it.selectionAction,
|
||||
)
|
||||
}.orEmpty(),
|
||||
emptyList<Any>(),
|
||||
emptyList<Any>(),
|
||||
)
|
||||
},
|
||||
restore = { saved ->
|
||||
@@ -54,17 +57,23 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
|
||||
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
|
||||
val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0
|
||||
val interactiveAddSessionParts = saved.getOrNull(16) as? List<*> ?: emptyList<Any>()
|
||||
RepoState(
|
||||
initialized = initialized,
|
||||
headBranch = headBranch,
|
||||
files = fileParts.chunked(if (fileParts.size % 5 == 0) 5 else 4).map {
|
||||
files = fileParts.chunked(
|
||||
when {
|
||||
fileParts.size % 6 == 0 -> 6
|
||||
fileParts.size % 5 == 0 -> 5
|
||||
else -> 4
|
||||
},
|
||||
).map {
|
||||
GitFile(
|
||||
name = it[0] as String,
|
||||
content = it[1] as String,
|
||||
staged = (it[2] as String).toBoolean(),
|
||||
tracked = (it[3] as String).toBoolean(),
|
||||
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
|
||||
stagedContent = (it.getOrNull(5) as? String)?.takeIf { value -> value.isNotEmpty() },
|
||||
)
|
||||
},
|
||||
commits = commitParts.restoreCommitNodes(),
|
||||
@@ -80,14 +89,6 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
pushedTags = pushedTags.filterIsInstance<String>().toSet(),
|
||||
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
|
||||
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(),
|
||||
interactiveAddSession = interactiveAddSessionParts.takeIf { it.size >= 2 }?.let {
|
||||
InteractiveAddSession(
|
||||
target = (it[0] as String).ifBlank { null },
|
||||
awaitingUpdateSelection = (it[1] as String).toBoolean(),
|
||||
selectionPrompt = it.getOrNull(2) as? String ?: "Update>>",
|
||||
selectionAction = it.getOrNull(3) as? String ?: "update",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -157,10 +157,10 @@ internal object SandboxCommandEngine {
|
||||
|
||||
private fun stagePaths(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
|
||||
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
|
||||
return InteractiveAddEngine.start(repo, parts.drop(2))
|
||||
return repo to listOf("Interactive Git commands require the native Git runtime.")
|
||||
}
|
||||
if (parts.drop(2).any { it == "-p" || it == "--patch" }) {
|
||||
return InteractiveAddEngine.startPatch(repo, parts.drop(2))
|
||||
return repo to listOf("Interactive Git commands require the native Git runtime.")
|
||||
}
|
||||
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git add <path>")
|
||||
|
||||
@@ -12,7 +12,7 @@ internal fun stageLinesLevel(): Level = level(
|
||||
title = "Stage Lines",
|
||||
description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.",
|
||||
hints = listOf("Read about the flags which can be passed to the `add` command."),
|
||||
commandSuggestions = listOf("git add feature.rb"),
|
||||
commandSuggestions = listOf("git add -p feature.rb"),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) },
|
||||
nativeSetup = {
|
||||
resetFiles()
|
||||
@@ -21,9 +21,23 @@ internal fun stageLinesLevel(): Level = level(
|
||||
write("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature")
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } },
|
||||
validator = repoPredicate { repo ->
|
||||
val file = repo.files.firstOrNull { it.name == "feature.rb" && !it.deleted } ?: return@repoPredicate false
|
||||
val stagedContent = file.stagedContent ?: return@repoPredicate false
|
||||
"This change belongs to the first feature" in stagedContent &&
|
||||
"This change belongs to the second feature" !in stagedContent &&
|
||||
"This change belongs to the first feature" in file.content &&
|
||||
"This change belongs to the second feature" in file.content
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("stage feature file", "git add feature.rb"),
|
||||
levelTestCase("stage alias", "git stage feature.rb"),
|
||||
levelTestCase(
|
||||
"patch add with git editor",
|
||||
"GIT_EDITOR=\"sed -i '/second feature/d'\" git add -p feature.rb",
|
||||
"e",
|
||||
),
|
||||
),
|
||||
negativeTestCases = listOf(
|
||||
levelTestCase("full file add stages both feature lines", "git add feature.rb"),
|
||||
levelTestCase("full file stage alias stages both feature lines", "git stage feature.rb"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -222,7 +222,10 @@ class LevelSolutionsTest {
|
||||
appendLine(" <none>")
|
||||
} else {
|
||||
files.sortedBy { it.name }.forEach { file ->
|
||||
appendLine(" ${file.name} staged=${file.staged} tracked=${file.tracked} content=${file.content.toEvidenceValue()}")
|
||||
appendLine(
|
||||
" ${file.name} staged=${file.staged} tracked=${file.tracked} " +
|
||||
"content=${file.content.toEvidenceValue()} stagedContent=${file.stagedContent.toNullableEvidenceValue()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
appendLine("commits=")
|
||||
@@ -240,6 +243,10 @@ class LevelSolutionsTest {
|
||||
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
|
||||
}
|
||||
|
||||
fun String?.toNullableEvidenceValue(): String {
|
||||
return this?.toEvidenceValue() ?: "<none>"
|
||||
}
|
||||
|
||||
val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf(
|
||||
"init",
|
||||
"config",
|
||||
|
||||
Reference in New Issue
Block a user