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"
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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=")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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."))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
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() }
|
||||||
|
|||||||
@@ -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",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>")
|
||||||
|
|||||||
@@ -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"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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>")
|
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user