Call Git cmd_main directly
This commit is contained in:
7
app/src/main/cpp/CMakeLists.txt
Normal file
7
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project(githugruntime C)
|
||||
|
||||
add_library(githugruntime SHARED githug_runtime.c)
|
||||
|
||||
target_link_libraries(githugruntime dl log)
|
||||
293
app/src/main/cpp/githug_runtime.c
Normal file
293
app/src/main/cpp/githug_runtime.c
Normal file
@@ -0,0 +1,293 @@
|
||||
#include <jni.h>
|
||||
#include <android/log.h>
|
||||
#include <dlfcn.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define LOG_TAG "GitHugNativeGit"
|
||||
|
||||
typedef int (*git_main_fn)(int argc, const char **argv);
|
||||
typedef void (*git_init_fn)(const char **argv);
|
||||
|
||||
struct output_buffer {
|
||||
char *data;
|
||||
size_t length;
|
||||
size_t capacity;
|
||||
};
|
||||
|
||||
struct reader_state {
|
||||
int fd;
|
||||
struct output_buffer output;
|
||||
};
|
||||
|
||||
static pthread_mutex_t git_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static void *git_handle = NULL;
|
||||
static git_main_fn git_main = NULL;
|
||||
static git_init_fn git_init = NULL;
|
||||
|
||||
static int append_output(struct output_buffer *buffer, const char *data, size_t length) {
|
||||
if (length == 0) {
|
||||
return 0;
|
||||
}
|
||||
size_t required = buffer->length + length + 1;
|
||||
if (required > buffer->capacity) {
|
||||
size_t next_capacity = buffer->capacity == 0 ? 4096 : buffer->capacity;
|
||||
while (next_capacity < required) {
|
||||
next_capacity *= 2;
|
||||
}
|
||||
char *next = realloc(buffer->data, next_capacity);
|
||||
if (next == NULL) {
|
||||
return -1;
|
||||
}
|
||||
buffer->data = next;
|
||||
buffer->capacity = next_capacity;
|
||||
}
|
||||
memcpy(buffer->data + buffer->length, data, length);
|
||||
buffer->length += length;
|
||||
buffer->data[buffer->length] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void *read_output(void *arg) {
|
||||
struct reader_state *state = (struct reader_state *)arg;
|
||||
char chunk[4096];
|
||||
for (;;) {
|
||||
ssize_t count = read(state->fd, chunk, sizeof(chunk));
|
||||
if (count > 0) {
|
||||
if (append_output(&state->output, chunk, (size_t)count) != 0) {
|
||||
break;
|
||||
}
|
||||
} else if (count == 0) {
|
||||
break;
|
||||
} else if (errno != EINTR) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
close(state->fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output) {
|
||||
jclass string_class = (*env)->FindClass(env, "java/lang/String");
|
||||
jobjectArray result = (*env)->NewObjectArray(env, 2, string_class, NULL);
|
||||
char exit_text[32];
|
||||
snprintf(exit_text, sizeof(exit_text), "%d", exit_code);
|
||||
jstring exit_string = (*env)->NewStringUTF(env, exit_text);
|
||||
jstring output_string = (*env)->NewStringUTF(env, output != NULL ? output : "");
|
||||
(*env)->SetObjectArrayElement(env, result, 0, exit_string);
|
||||
(*env)->SetObjectArrayElement(env, result, 1, output_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);
|
||||
}
|
||||
|
||||
static int load_git(const char *library_path) {
|
||||
if (git_init != NULL && git_main != NULL) {
|
||||
return 0;
|
||||
}
|
||||
git_handle = dlopen(library_path, RTLD_NOW | RTLD_GLOBAL);
|
||||
if (git_handle == NULL) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlopen failed: %s", dlerror());
|
||||
return -1;
|
||||
}
|
||||
git_init = (git_init_fn)dlsym(git_handle, "init_git");
|
||||
if (git_init == NULL) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlsym init_git failed: %s", dlerror());
|
||||
return -1;
|
||||
}
|
||||
git_main = (git_main_fn)dlsym(git_handle, "cmd_main");
|
||||
if (git_main == NULL) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlsym cmd_main failed: %s", dlerror());
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char **copy_string_array(JNIEnv *env, jobjectArray source, int *count_out) {
|
||||
int count = source == NULL ? 0 : (*env)->GetArrayLength(env, source);
|
||||
char **values = calloc((size_t)count + 1, sizeof(char *));
|
||||
if (values == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
jstring item = (jstring)(*env)->GetObjectArrayElement(env, source, i);
|
||||
const char *chars = (*env)->GetStringUTFChars(env, item, NULL);
|
||||
values[i] = strdup(chars != NULL ? chars : "");
|
||||
(*env)->ReleaseStringUTFChars(env, item, chars);
|
||||
(*env)->DeleteLocalRef(env, item);
|
||||
if (values[i] == NULL) {
|
||||
for (int j = 0; j < i; j++) {
|
||||
free(values[j]);
|
||||
}
|
||||
free(values);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
*count_out = count;
|
||||
return values;
|
||||
}
|
||||
|
||||
static void free_string_array(char **values, int count) {
|
||||
if (values == NULL) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(values[i]);
|
||||
}
|
||||
free(values);
|
||||
}
|
||||
|
||||
struct saved_env {
|
||||
char *name;
|
||||
char *previous;
|
||||
int had_previous;
|
||||
};
|
||||
|
||||
static void restore_environment(struct saved_env *saved, int count) {
|
||||
for (int i = count - 1; i >= 0; i--) {
|
||||
if (saved[i].had_previous) {
|
||||
setenv(saved[i].name, saved[i].previous, 1);
|
||||
} else {
|
||||
unsetenv(saved[i].name);
|
||||
}
|
||||
free(saved[i].name);
|
||||
free(saved[i].previous);
|
||||
}
|
||||
free(saved);
|
||||
}
|
||||
|
||||
static struct saved_env *apply_environment(char **entries, int count, int *applied_out) {
|
||||
struct saved_env *saved = calloc((size_t)count, sizeof(struct saved_env));
|
||||
if (saved == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
int applied = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
char *separator = strchr(entries[i], '=');
|
||||
if (separator == NULL || separator == entries[i]) {
|
||||
continue;
|
||||
}
|
||||
*separator = '\0';
|
||||
const char *name = entries[i];
|
||||
const char *value = separator + 1;
|
||||
const char *previous = getenv(name);
|
||||
saved[applied].name = strdup(name);
|
||||
saved[applied].previous = previous == NULL ? NULL : strdup(previous);
|
||||
saved[applied].had_previous = previous != NULL;
|
||||
if (saved[applied].name == NULL || (previous != NULL && saved[applied].previous == NULL)) {
|
||||
*separator = '=';
|
||||
restore_environment(saved, applied + 1);
|
||||
return NULL;
|
||||
}
|
||||
setenv(name, value, 1);
|
||||
applied++;
|
||||
*separator = '=';
|
||||
}
|
||||
*applied_out = applied;
|
||||
return saved;
|
||||
}
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
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_error(env, "Native Git execution failed: out of memory");
|
||||
}
|
||||
|
||||
int pipe_fds[2];
|
||||
if (pipe(pipe_fds) != 0) {
|
||||
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_error(env, "Native Git execution failed: could not create output pipe");
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&git_mutex);
|
||||
|
||||
pid_t child = fork();
|
||||
if (child < 0) {
|
||||
close(pipe_fds[0]);
|
||||
close(pipe_fds[1]);
|
||||
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_error(env, "Native Git execution failed: could not fork");
|
||||
}
|
||||
|
||||
if (child == 0) {
|
||||
close(pipe_fds[0]);
|
||||
dup2(pipe_fds[1], STDOUT_FILENO);
|
||||
dup2(pipe_fds[1], STDERR_FILENO);
|
||||
close(pipe_fds[1]);
|
||||
|
||||
int applied_env = 0;
|
||||
struct saved_env *saved_env = apply_environment(env_entries, envc, &applied_env);
|
||||
(void)saved_env;
|
||||
chdir(working_directory_chars);
|
||||
|
||||
if (load_git(library_path_chars) != 0) {
|
||||
const char *error = dlerror();
|
||||
fprintf(stderr, "%s\n", error != NULL ? error : "Native Git execution failed: init_git/cmd_main not found");
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
git_init((const char **)argv);
|
||||
int child_exit_code = git_main(argc, (const char **)argv);
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(child_exit_code);
|
||||
}
|
||||
|
||||
close(pipe_fds[1]);
|
||||
struct reader_state reader = { .fd = pipe_fds[0], .output = {0} };
|
||||
read_output(&reader);
|
||||
|
||||
int child_status = 0;
|
||||
while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
pthread_mutex_unlock(&git_mutex);
|
||||
|
||||
int exit_code = -1;
|
||||
if (WIFEXITED(child_status)) {
|
||||
exit_code = WEXITSTATUS(child_status);
|
||||
} else if (WIFSIGNALED(child_status)) {
|
||||
exit_code = 128 + WTERMSIG(child_status);
|
||||
}
|
||||
|
||||
jobjectArray result = make_result(env, exit_code, reader.output.data);
|
||||
|
||||
free(reader.output.data);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal class GitEditorWorkflow(
|
||||
private val requireNativeGit: () -> File,
|
||||
private val prepareLevel: (Level) -> RepoState,
|
||||
private val sandboxDir: (Level) -> File,
|
||||
private val inspectSandbox: (Level, String) -> RepoState,
|
||||
private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
|
||||
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() }
|
||||
val capturedFile = File(gitDir, "GITHUG_ANDROID_CAPTURED_EDITOR")
|
||||
val editorScript = File(gitDir, "githug-android-capture-editor.sh")
|
||||
capturedFile.delete()
|
||||
|
||||
editorScript.writeText(
|
||||
"""
|
||||
|#!/bin/sh
|
||||
|cat "$1" > ${capturedFile.absolutePath.toShellSingleQuoted()}
|
||||
|exit 1
|
||||
|""".trimMargin(),
|
||||
)
|
||||
|
||||
val editorEnvironmentName = when (invocation.kind) {
|
||||
GitEditorCommandKind.REBASE_TODO -> "GIT_SEQUENCE_EDITOR"
|
||||
GitEditorCommandKind.COMMIT_MESSAGE,
|
||||
GitEditorCommandKind.TAG_MESSAGE -> "GIT_EDITOR"
|
||||
GitEditorCommandKind.PATCH_HUNK -> return invocation.initialContent
|
||||
}
|
||||
|
||||
runGit(
|
||||
nativeGit,
|
||||
workingDir,
|
||||
GitSandboxEngine.tokenizeCommand(invocation.command).drop(1),
|
||||
mapOf(editorEnvironmentName to editorCommand(editorScript)),
|
||||
)
|
||||
|
||||
if (invocation.kind == GitEditorCommandKind.REBASE_TODO && rebaseStateExists(gitDir)) {
|
||||
runGit(nativeGit, workingDir, listOf("rebase", "--abort"), emptyMap())
|
||||
}
|
||||
|
||||
val content = capturedFile.takeIf { it.isFile }?.readText() ?: invocation.initialContent
|
||||
capturedFile.delete()
|
||||
editorScript.delete()
|
||||
return content
|
||||
}
|
||||
|
||||
fun execute(
|
||||
level: Level,
|
||||
currentRepo: RepoState,
|
||||
invocation: GitEditorInvocation,
|
||||
message: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val nativeGit = requireNativeGit()
|
||||
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
|
||||
return when (invocation.kind) {
|
||||
GitEditorCommandKind.REBASE_TODO -> executeInteractiveRebase(
|
||||
level = level,
|
||||
currentRepo = currentRepo,
|
||||
nativeGit = nativeGit,
|
||||
sandboxRoot = sandboxRoot,
|
||||
workingDir = workingDir,
|
||||
invocation = invocation,
|
||||
todo = message,
|
||||
)
|
||||
|
||||
GitEditorCommandKind.COMMIT_MESSAGE,
|
||||
GitEditorCommandKind.TAG_MESSAGE -> executeMessageCommand(
|
||||
level = level,
|
||||
currentRepo = currentRepo,
|
||||
nativeGit = nativeGit,
|
||||
sandboxRoot = sandboxRoot,
|
||||
workingDir = workingDir,
|
||||
invocation = invocation,
|
||||
message = message,
|
||||
)
|
||||
|
||||
GitEditorCommandKind.PATCH_HUNK -> {
|
||||
currentRepo to listOf("Patch hunk editing is handled by Git, not the Android runtime.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeMessageCommand(
|
||||
level: Level,
|
||||
currentRepo: RepoState,
|
||||
nativeGit: File,
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
invocation: GitEditorInvocation,
|
||||
message: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
|
||||
parentFile?.mkdirs()
|
||||
writeText(message)
|
||||
}
|
||||
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command)
|
||||
.drop(1)
|
||||
.toMutableList()
|
||||
.apply { addAll(listOf("-F", messageFile.absolutePath)) }
|
||||
val result = runGit(nativeGit, workingDir, arguments, emptyMap())
|
||||
messageFile.delete()
|
||||
|
||||
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||
}
|
||||
|
||||
private fun executeInteractiveRebase(
|
||||
level: Level,
|
||||
currentRepo: RepoState,
|
||||
nativeGit: File,
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
invocation: GitEditorInvocation,
|
||||
todo: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
||||
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
|
||||
writeText(todo)
|
||||
}
|
||||
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
|
||||
writeText(
|
||||
"""
|
||||
|#!/bin/sh
|
||||
|cat ${todoFile.absolutePath.toShellSingleQuoted()} > "$1"
|
||||
|""".trimMargin(),
|
||||
)
|
||||
}
|
||||
val result = runGit(
|
||||
nativeGit,
|
||||
workingDir,
|
||||
GitSandboxEngine.tokenizeCommand(invocation.command).drop(1),
|
||||
mapOf(
|
||||
"GIT_SEQUENCE_EDITOR" to editorCommand(editorScript),
|
||||
"GIT_EDITOR" to "true",
|
||||
),
|
||||
)
|
||||
todoFile.delete()
|
||||
editorScript.delete()
|
||||
|
||||
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||
}
|
||||
|
||||
private fun repositoryPaths(level: Level, currentRepo: RepoState): Pair<File, File> {
|
||||
val sandboxRoot = sandboxDir(level).canonicalFile
|
||||
if (!sandboxRoot.exists()) {
|
||||
prepareLevel(level)
|
||||
}
|
||||
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
||||
.takeIf { it.path == sandboxRoot.path || it.path.startsWith(sandboxRoot.path + File.separator) }
|
||||
?: sandboxRoot
|
||||
return sandboxRoot to workingDir
|
||||
}
|
||||
|
||||
private fun editorCommand(script: File): String {
|
||||
return "${shellExecutable().toShellSingleQuoted()} ${script.absolutePath.toShellSingleQuoted()}"
|
||||
}
|
||||
|
||||
private fun rebaseStateExists(gitDir: File): Boolean {
|
||||
return File(gitDir, "rebase-merge").exists() || File(gitDir, "rebase-apply").exists()
|
||||
}
|
||||
|
||||
private fun String.toShellSingleQuoted(): String {
|
||||
return "'" + replace("'", "'\"'\"'") + "'"
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ internal class GitProcessRunner(
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
if (context != null) {
|
||||
gitExecDirectory(binary)
|
||||
return NativeGitBridge.runGitMain(binary, workingDir, arguments, gitEnvironment(binary, workingDir, environment))
|
||||
}
|
||||
return runProcess(binary, workingDir, arguments, environment)
|
||||
}
|
||||
|
||||
@@ -88,19 +92,7 @@ internal class GitProcessRunner(
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply {
|
||||
environment()["HOME"] = workingDir.absolutePath
|
||||
environment()["GIT_EXEC_PATH"] = gitExecPath.absolutePath
|
||||
environment()["PATH"] = listOfNotNull(
|
||||
gitExecPath.absolutePath,
|
||||
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
||||
).joinToString(File.pathSeparator)
|
||||
environment()["GIT_CONFIG_NOSYSTEM"] = "1"
|
||||
environment()["GIT_AUTHOR_NAME"] = "GitHug"
|
||||
environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com"
|
||||
environment()["GIT_COMMITTER_NAME"] = "GitHug"
|
||||
environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com"
|
||||
environment()["LC_ALL"] = "C"
|
||||
environment().putAll(extraEnvironment)
|
||||
environment().putAll(gitEnvironment(binary, workingDir, extraEnvironment, gitExecPath))
|
||||
}
|
||||
.start()
|
||||
|
||||
@@ -137,6 +129,32 @@ internal class GitProcessRunner(
|
||||
return directory
|
||||
}
|
||||
|
||||
private fun gitEnvironment(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
extraEnvironment: Map<String, String>,
|
||||
gitExecPath: File = gitExecDirectory(binary),
|
||||
): Map<String, String> {
|
||||
return buildMap {
|
||||
put("HOME", workingDir.absolutePath)
|
||||
put("GIT_EXEC_PATH", gitExecPath.absolutePath)
|
||||
put(
|
||||
"PATH",
|
||||
listOfNotNull(
|
||||
gitExecPath.absolutePath,
|
||||
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
||||
).joinToString(File.pathSeparator),
|
||||
)
|
||||
put("GIT_CONFIG_NOSYSTEM", "1")
|
||||
put("GIT_AUTHOR_NAME", "GitHug")
|
||||
put("GIT_AUTHOR_EMAIL", "githug@example.com")
|
||||
put("GIT_COMMITTER_NAME", "GitHug")
|
||||
put("GIT_COMMITTER_EMAIL", "githug@example.com")
|
||||
put("LC_ALL", "C")
|
||||
putAll(extraEnvironment)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshGitExecDirectoryIfNeeded(directory: File, binary: File) {
|
||||
val fingerprint = buildString {
|
||||
append(binary.absolutePath)
|
||||
@@ -161,7 +179,6 @@ internal class GitProcessRunner(
|
||||
if (context != null) {
|
||||
try {
|
||||
Os.symlink(binary.absolutePath, alias.absolutePath)
|
||||
alias.setExecutable(true, false)
|
||||
return
|
||||
} catch (_: Exception) {
|
||||
// Fall through to the portable options below.
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal class GitRepositoryInspector(
|
||||
private val nativeGit: () -> File,
|
||||
private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
|
||||
) {
|
||||
fun inspect(sandbox: File, currentDir: String = "."): RepoState {
|
||||
val git = nativeGit()
|
||||
val workingDir = File(sandbox, currentDir).canonicalFile
|
||||
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
|
||||
?: sandbox
|
||||
val repositoryRoot = generateSequence(workingDir) { directory ->
|
||||
directory.parentFile?.takeIf {
|
||||
it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator)
|
||||
}
|
||||
}.firstOrNull { File(it, ".git").exists() }
|
||||
val inspectionRoot = repositoryRoot ?: sandbox
|
||||
val filesOnDisk = inspectionRoot.walkTopDown()
|
||||
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
|
||||
.orEmpty()
|
||||
.toList()
|
||||
|
||||
if (repositoryRoot == null) {
|
||||
return RepoState(
|
||||
initialized = false,
|
||||
files = filesOnDisk.map {
|
||||
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val statusResult = run(git, inspectionRoot, listOf("status", "--porcelain"))
|
||||
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
|
||||
val deletedStatusPaths = mutableSetOf<String>()
|
||||
statusResult.outputLines.forEach { line ->
|
||||
if (line.length < 4) return@forEach
|
||||
val x = line[0]
|
||||
val y = line[1]
|
||||
val path = line.substring(3).trim()
|
||||
val staged = x != ' ' && x != '?'
|
||||
val tracked = x != '?' || y != '?'
|
||||
statusMap[path] = staged to tracked
|
||||
if (x == 'D' || y == 'D') {
|
||||
deletedStatusPaths += path
|
||||
}
|
||||
}
|
||||
|
||||
val logResult = run(git, inspectionRoot, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
|
||||
val branchResult = run(git, inspectionRoot, listOf("branch", "--list"))
|
||||
val remoteBranchResult = run(git, inspectionRoot, listOf("branch", "-r", "--list"))
|
||||
val tagResult = run(git, inspectionRoot, listOf("tag", "--list"))
|
||||
val remoteResult = run(git, inspectionRoot, listOf("remote", "-v"))
|
||||
val headResult = run(git, inspectionRoot, listOf("branch", "--show-current"))
|
||||
val exactTagResult = run(git, inspectionRoot, listOf("describe", "--tags", "--exact-match"))
|
||||
val userNameResult = run(git, inspectionRoot, listOf("config", "--get", "user.name"))
|
||||
val userEmailResult = run(git, inspectionRoot, listOf("config", "--get", "user.email"))
|
||||
val stashResult = run(git, inspectionRoot, listOf("stash", "list", "--format=%gd"))
|
||||
val submodules = inspectSubmodules(git, inspectionRoot)
|
||||
val maintenanceActions = buildSet {
|
||||
if (File(inspectionRoot, ".git/objects/pack").listFiles()?.any { it.extension == "pack" } == true) {
|
||||
add("repack")
|
||||
}
|
||||
}
|
||||
val fetchHeadCount = File(inspectionRoot, ".git/FETCH_HEAD")
|
||||
.takeIf { it.isFile }
|
||||
?.readLines()
|
||||
?.count { it.isNotBlank() }
|
||||
?: 0
|
||||
val config = buildMap {
|
||||
userNameResult.outputLines.firstOrNull()
|
||||
?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() }
|
||||
?.let { put("user.name", it) }
|
||||
userEmailResult.outputLines.firstOrNull()
|
||||
?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() }
|
||||
?.let { put("user.email", it) }
|
||||
}
|
||||
|
||||
val commits = if (logResult.exitCode == 0) {
|
||||
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
|
||||
val parts = line.split('\t', limit = 4)
|
||||
if (parts.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val parentHashes = parts.getOrNull(2).orEmpty().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
CommitNode(
|
||||
id = parts[0],
|
||||
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
|
||||
parentCount = parentHashes.size,
|
||||
message = parts.getOrElse(3) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val branches = branchResult.outputLines
|
||||
.map { it.removePrefix("*").trim() }
|
||||
.filter { it.isNotBlank() && !it.startsWith("(") }
|
||||
.associateWith { branch ->
|
||||
run(git, inspectionRoot, listOf("rev-list", "--count", branch))
|
||||
.outputLines
|
||||
.firstOrNull()
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
}
|
||||
val localBranchHeads = branches.keys.associateWith { branch ->
|
||||
run(git, inspectionRoot, listOf("rev-parse", "--verify", "refs/heads/$branch"))
|
||||
.outputLines
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
}
|
||||
val localTagHeads = tagResult.outputLines
|
||||
.filter { it.isNotBlank() }
|
||||
.associateWith { tag ->
|
||||
run(git, inspectionRoot, listOf("rev-list", "-n", "1", tag))
|
||||
.outputLines
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
}
|
||||
val remoteRefs = inspectRemoteRefs(git, inspectionRoot, remoteResult.outputLines)
|
||||
val headBranch = headResult.outputLines.firstOrNull()
|
||||
?.ifBlank { null }
|
||||
?: exactTagResult.outputLines.firstOrNull()
|
||||
?.takeIf { exactTagResult.exitCode == 0 && it.isNotBlank() }
|
||||
?.let { "tags/$it" }
|
||||
?: "DETACHED"
|
||||
|
||||
return RepoState(
|
||||
initialized = true,
|
||||
files = filesOnDisk.map { file ->
|
||||
val relativePath = file.relativeTo(inspectionRoot).path
|
||||
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
||||
GitFile(
|
||||
name = relativePath,
|
||||
content = file.readText(),
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
)
|
||||
} + deletedStatusPaths
|
||||
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
|
||||
.map { deletedPath ->
|
||||
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
|
||||
GitFile(
|
||||
name = deletedPath,
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
deleted = true,
|
||||
)
|
||||
},
|
||||
commits = commits,
|
||||
headBranch = headBranch,
|
||||
branches = branches,
|
||||
tags = tagResult.outputLines.filter { it.isNotBlank() },
|
||||
remotes = remoteResult.outputLines.mapNotNull { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
if (parts.size >= 2) parts[0] to parts[1] else null
|
||||
}.toMap(),
|
||||
config = config,
|
||||
stashes = stashResult.outputLines.filter { it.isNotBlank() },
|
||||
fetchedBranches = remoteBranchResult.outputLines
|
||||
.map { it.removePrefix("*").trim() }
|
||||
.filter { it.isNotBlank() && " -> " !in it }
|
||||
.toSet(),
|
||||
fetchHeadCount = fetchHeadCount,
|
||||
pushedBranches = remoteRefs.heads
|
||||
.filter { remoteHead -> localBranchHeads[remoteHead.branch] == remoteHead.hash }
|
||||
.map { remoteHead -> "${remoteHead.remote}/${remoteHead.branch}" }
|
||||
.toSet(),
|
||||
pushedTags = remoteRefs.tags
|
||||
.filter { remoteTag -> localTagHeads[remoteTag.tag] == remoteTag.hash }
|
||||
.map { it.tag }
|
||||
.toSet(),
|
||||
submodules = submodules,
|
||||
maintenanceActions = maintenanceActions,
|
||||
)
|
||||
}
|
||||
|
||||
private fun run(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult = runGit(binary, workingDir, arguments, environment)
|
||||
|
||||
private fun inspectRemoteRefs(git: File, inspectionRoot: File, remoteLines: List<String>): RemoteRefs {
|
||||
val remotes = remoteLines.mapNotNull { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
if (parts.size >= 2) parts[0] to parts[1] else null
|
||||
}.toMap()
|
||||
val heads = mutableSetOf<RemoteHead>()
|
||||
val tags = mutableSetOf<RemoteTag>()
|
||||
|
||||
remotes.forEach remote@{ (remoteName, remoteUrl) ->
|
||||
val output = run(git, inspectionRoot, listOf("ls-remote", remoteUrl))
|
||||
if (output.exitCode != 0) return@remote
|
||||
output.outputLines.forEach { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
if (parts.size < 2) return@forEach
|
||||
val hash = parts[0]
|
||||
val ref = parts[1].removeSuffix("^{}")
|
||||
when {
|
||||
ref.startsWith("refs/heads/") -> {
|
||||
heads += RemoteHead(remoteName, ref.removePrefix("refs/heads/"), hash)
|
||||
}
|
||||
ref.startsWith("refs/tags/") -> {
|
||||
tags += RemoteTag(ref.removePrefix("refs/tags/"), hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RemoteRefs(heads = heads, tags = tags)
|
||||
}
|
||||
|
||||
private fun inspectSubmodules(git: File, inspectionRoot: File): Map<String, String> {
|
||||
val gitmodules = File(inspectionRoot, ".gitmodules")
|
||||
if (!gitmodules.isFile) return emptyMap()
|
||||
|
||||
val output = run(git, inspectionRoot, listOf("config", "--file", gitmodules.absolutePath, "--get-regexp", "submodule\\..*\\.(path|url)"))
|
||||
if (output.exitCode != 0) return emptyMap()
|
||||
|
||||
val entries = mutableMapOf<String, MutableMap<String, String>>()
|
||||
output.outputLines.forEach { line ->
|
||||
val key = line.substringBefore(' ')
|
||||
val value = line.substringAfter(' ', missingDelimiterValue = "").trim()
|
||||
val parts = key.split('.')
|
||||
if (parts.size != 3 || parts[0] != "submodule") return@forEach
|
||||
entries.getOrPut(parts[1]) { mutableMapOf() }[parts[2]] = value
|
||||
}
|
||||
|
||||
return entries.values.mapNotNull { values ->
|
||||
val path = values["path"]?.trimEnd('/')
|
||||
val url = values["url"]
|
||||
if (path != null && url != null) path to url else null
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private data class RemoteRefs(
|
||||
val heads: Set<RemoteHead> = emptySet(),
|
||||
val tags: Set<RemoteTag> = emptySet(),
|
||||
)
|
||||
|
||||
private data class RemoteHead(
|
||||
val remote: String,
|
||||
val branch: String,
|
||||
val hash: String,
|
||||
)
|
||||
|
||||
private data class RemoteTag(
|
||||
val tag: String,
|
||||
val hash: String,
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,15 @@ class GitRepositoryRuntime private constructor(
|
||||
private val processRunner = GitProcessRunner(context, sandboxesRoot)
|
||||
private val helperCommands = GitHelperCommands(processRunner)
|
||||
private val levelMaterializer = GitLevelMaterializer(::runGit)
|
||||
private val repositoryInspector = GitRepositoryInspector(::requireNativeGit, ::runGit)
|
||||
private val editorWorkflow = GitEditorWorkflow(
|
||||
requireNativeGit = ::requireNativeGit,
|
||||
prepareLevel = ::prepareLevel,
|
||||
sandboxDir = ::sandboxDir,
|
||||
inspectSandbox = ::inspectSandbox,
|
||||
runGit = ::runGit,
|
||||
shellExecutable = processRunner::shellExecutable,
|
||||
)
|
||||
|
||||
constructor(context: Context) : this(
|
||||
context = context.applicationContext,
|
||||
@@ -25,7 +34,9 @@ class GitRepositoryRuntime private constructor(
|
||||
sandboxesRoot = sandboxesRoot,
|
||||
)
|
||||
|
||||
fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null
|
||||
fun isNativeGitAvailable(): Boolean {
|
||||
return nativeGitBinary() != null && (context == null || NativeGitBridge.isAvailable())
|
||||
}
|
||||
|
||||
fun unavailableMessage(): String {
|
||||
val selectedAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
|
||||
@@ -43,6 +54,9 @@ class GitRepositoryRuntime private constructor(
|
||||
append("\nPlatform: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})")
|
||||
append("\nDevice: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}; ${Build.HARDWARE})")
|
||||
append("\nExpected binary: $nativeLibraryDir/libgit.so")
|
||||
if (context != null && !NativeGitBridge.isAvailable()) {
|
||||
append("\nNative bridge: unavailable")
|
||||
}
|
||||
append("\n\nPlease report this information to the developer so support can be added for this device/platform.")
|
||||
append("\nInstall a build that bundles the cross-compiled Git binary for this device.")
|
||||
}
|
||||
@@ -101,29 +115,11 @@ class GitRepositoryRuntime private constructor(
|
||||
val invocation = parseEnvironmentPrefixedCommand(shellTokens)
|
||||
val tokens = invocation.command.map { it.value }
|
||||
if (tokens.isEmpty()) return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to emptyList()
|
||||
if (currentRepo.interactiveAddSession != null) {
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
|
||||
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
|
||||
updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true
|
||||
}.map { it.name }
|
||||
if (newlyStagedPaths.isNotEmpty()) {
|
||||
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
|
||||
}
|
||||
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
|
||||
currentDir = updatedRepo.currentDir,
|
||||
interactiveAddSession = updatedRepo.interactiveAddSession,
|
||||
)
|
||||
return augmentObservedRepoFacts(updatedRepo, inspectedRepo, tokens) to output
|
||||
}
|
||||
val expandedTokens = if (tokens.firstOrNull() == "echo") {
|
||||
tokens
|
||||
} else {
|
||||
expandShellPathspecs(currentRepo, invocation.command)
|
||||
}
|
||||
.normalizeGitStageAlias()
|
||||
.normalizeGitBisectRunScriptShortcut()
|
||||
|
||||
executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it }
|
||||
|
||||
val result = when (expandedTokens.first()) {
|
||||
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
|
||||
@@ -133,7 +129,7 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
|
||||
val inspectedRepo = inspectSandbox(level, result.first.currentDir).copy(currentDir = result.first.currentDir)
|
||||
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) to result.second
|
||||
return inspectedRepo to result.second
|
||||
}
|
||||
|
||||
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
|
||||
@@ -165,6 +161,7 @@ class GitRepositoryRuntime private constructor(
|
||||
addAll(GitSandboxEngine.commandReferenceLines())
|
||||
add("Native Git runtime:")
|
||||
add(" binary path: nativeLibraryDir/libgit.so")
|
||||
add(" invocation: init_git(argv), then cmd_main(argc, argv)")
|
||||
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
|
||||
add(" helper commands: ls/dir, pwd, cat, sh <script>, ./<script>, touch, mkdir/md, cd.., rm/del, echo")
|
||||
add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad")
|
||||
@@ -224,36 +221,7 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
|
||||
fun gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
||||
if (invocation.kind != GitEditorCommandKind.REBASE_TODO) return invocation.initialContent
|
||||
|
||||
val nativeGit = requireNativeGit()
|
||||
val sandboxRoot = sandboxDir(level).canonicalFile
|
||||
if (!sandboxRoot.exists()) {
|
||||
prepareLevel(level)
|
||||
}
|
||||
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
||||
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
|
||||
val target = rebaseTarget(invocation.command) ?: return invocation.initialContent
|
||||
val result = runGit(nativeGit, workingDir, listOf("log", "--reverse", "--format=%h %s", "$target..HEAD"))
|
||||
if (result.exitCode != 0 || result.outputLines.isEmpty()) return invocation.initialContent
|
||||
|
||||
return buildString {
|
||||
result.outputLines
|
||||
.filter { it.isNotBlank() }
|
||||
.forEach { line -> appendLine("pick $line") }
|
||||
appendLine()
|
||||
appendLine("# Rebase $target..HEAD onto $target")
|
||||
appendLine("#")
|
||||
appendLine("# Commands:")
|
||||
appendLine("# p, pick <commit> = use commit")
|
||||
appendLine("# r, reword <commit> = use commit and replace its message with the text on this line")
|
||||
appendLine("# e, edit <commit> = use commit, but stop for amending")
|
||||
appendLine("# s, squash <commit> = use commit, but meld into previous commit")
|
||||
appendLine("# f, fixup [-C | -c] <commit> = like squash but keep only the previous commit's log message")
|
||||
appendLine("# d, drop <commit> = remove commit")
|
||||
appendLine("#")
|
||||
appendLine("# These lines can be re-ordered; they are executed from top to bottom.")
|
||||
}
|
||||
return editorWorkflow.initialContent(level, currentRepo, invocation)
|
||||
}
|
||||
|
||||
fun executeGitEditorCommand(
|
||||
@@ -262,322 +230,11 @@ class GitRepositoryRuntime private constructor(
|
||||
invocation: GitEditorInvocation,
|
||||
message: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val nativeGit = requireNativeGit()
|
||||
|
||||
val sandboxRoot = sandboxDir(level).canonicalFile
|
||||
if (!sandboxRoot.exists()) {
|
||||
prepareLevel(level)
|
||||
}
|
||||
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
||||
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
|
||||
|
||||
if (invocation.kind == GitEditorCommandKind.REBASE_TODO) {
|
||||
return executeInteractiveRebaseEditorCommand(level, currentRepo, nativeGit, sandboxRoot, workingDir, invocation, message)
|
||||
}
|
||||
|
||||
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) {
|
||||
val (updatedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(currentRepo, message)
|
||||
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
|
||||
updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true
|
||||
}.map { it.name }
|
||||
if (newlyStagedPaths.isNotEmpty()) {
|
||||
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
|
||||
}
|
||||
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
|
||||
currentDir = updatedRepo.currentDir,
|
||||
interactiveAddSession = updatedRepo.interactiveAddSession,
|
||||
)
|
||||
return inspectedRepo to output
|
||||
}
|
||||
|
||||
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
|
||||
parentFile?.mkdirs()
|
||||
writeText(message)
|
||||
}
|
||||
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command)
|
||||
.drop(1)
|
||||
.toMutableList()
|
||||
.apply { addAll(listOf("-F", messageFile.absolutePath)) }
|
||||
val result = runGit(nativeGit, workingDir, arguments)
|
||||
messageFile.delete()
|
||||
|
||||
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||
return editorWorkflow.execute(level, currentRepo, invocation, message)
|
||||
}
|
||||
|
||||
private fun executeInteractiveRebaseEditorCommand(
|
||||
level: Level,
|
||||
currentRepo: RepoState,
|
||||
nativeGit: File,
|
||||
sandboxRoot: File,
|
||||
workingDir: File,
|
||||
invocation: GitEditorInvocation,
|
||||
todo: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
||||
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
|
||||
writeText(todo)
|
||||
}
|
||||
val rewordMessages = todo.lineSequence().mapNotNull(::rewordMessageFromTodoLine).toList()
|
||||
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
|
||||
writeText(
|
||||
"""
|
||||
|#!/bin/sh
|
||||
|cat ${todoFile.absolutePath.toShellSingleQuoted()} > "$1"
|
||||
|""".trimMargin(),
|
||||
)
|
||||
setReadable(true, true)
|
||||
}
|
||||
val rewordMessageFiles = rewordMessages.mapIndexed { index, message ->
|
||||
File(gitDir, "GITHUG_ANDROID_REWORD_${index + 1}").apply {
|
||||
writeText(message.trimEnd() + "\n")
|
||||
}
|
||||
}
|
||||
val rewordCounterFile = File(gitDir, "GITHUG_ANDROID_REWORD_COUNTER")
|
||||
val messageEditorScript = File(gitDir, "githug-android-message-editor.sh").apply {
|
||||
writeText(
|
||||
"""
|
||||
|#!/bin/sh
|
||||
|counter_file=${rewordCounterFile.absolutePath.toShellSingleQuoted()}
|
||||
|index=0
|
||||
|if [ -f "${'$'}counter_file" ]; then
|
||||
| index=$(cat "${'$'}counter_file")
|
||||
|fi
|
||||
|index=$((index + 1))
|
||||
|printf '%s\n' "${'$'}index" > "${'$'}counter_file"
|
||||
|message_file=${File(gitDir, "GITHUG_ANDROID_REWORD_").absolutePath.toShellSingleQuoted()}"${'$'}index"
|
||||
|if [ -f "${'$'}message_file" ]; then
|
||||
| cat "${'$'}message_file" > "$1"
|
||||
|fi
|
||||
|""".trimMargin(),
|
||||
)
|
||||
setReadable(true, true)
|
||||
}
|
||||
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command).drop(1)
|
||||
val result = runGit(
|
||||
nativeGit,
|
||||
workingDir,
|
||||
arguments,
|
||||
mapOf(
|
||||
"GIT_SEQUENCE_EDITOR" to "${shellExecutable().toShellSingleQuoted()} ${editorScript.absolutePath.toShellSingleQuoted()}",
|
||||
"GIT_EDITOR" to if (rewordMessages.isEmpty()) {
|
||||
"true"
|
||||
} else {
|
||||
"${shellExecutable().toShellSingleQuoted()} ${messageEditorScript.absolutePath.toShellSingleQuoted()}"
|
||||
},
|
||||
),
|
||||
)
|
||||
todoFile.delete()
|
||||
editorScript.delete()
|
||||
rewordMessageFiles.forEach { it.delete() }
|
||||
rewordCounterFile.delete()
|
||||
messageEditorScript.delete()
|
||||
|
||||
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||
}
|
||||
|
||||
private fun rewordMessageFromTodoLine(line: String): String? {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) return null
|
||||
val parts = trimmed.split(Regex("\\s+"), limit = 3)
|
||||
if (parts.size < 3 || parts[0] !in setOf("r", "reword")) return null
|
||||
return parts[2].takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun executeSyntheticGitCommand(
|
||||
currentRepo: RepoState,
|
||||
command: String,
|
||||
tokens: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): Pair<RepoState, List<String>>? {
|
||||
if (environment.isNotEmpty()) return null
|
||||
if (tokens.firstOrNull() != "git") return null
|
||||
val gitCommand = tokens.getOrNull(1) ?: return null
|
||||
if (gitCommand == "clone" && tokens.getOrNull(2)?.startsWith("https://github.com/Gazler/cloneme") == true) {
|
||||
val target = tokens.getOrNull(3) ?: "cloneme"
|
||||
return currentRepo.copy(
|
||||
files = currentRepo.files + GitFile("$target/README", tracked = true),
|
||||
) to listOf("Cloned ${tokens[2]} into $target")
|
||||
}
|
||||
val shouldUseSandboxSemantics = when (gitCommand) {
|
||||
"add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" }
|
||||
"rebase" -> "--onto" in tokens
|
||||
"merge" -> "--squash" in tokens || tokens.lastOrNull() == "feature"
|
||||
"revert", "stash" -> true
|
||||
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
|
||||
"submodule" -> tokens.getOrNull(2) == "add"
|
||||
"commit" -> "merge-squash" in currentRepo.maintenanceActions
|
||||
else -> false
|
||||
}
|
||||
if (!shouldUseSandboxSemantics) return null
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
|
||||
return updatedRepo to output
|
||||
}
|
||||
|
||||
private fun List<String>.normalizeGitStageAlias(): List<String> {
|
||||
return if (size >= 2 && this[0] == "git" && this[1] == "stage") {
|
||||
toMutableList().also { it[1] = "add" }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<String>.normalizeGitBisectRunScriptShortcut(): List<String> {
|
||||
return if (
|
||||
size >= 4 &&
|
||||
this[0] == "git" &&
|
||||
this[1] == "bisect" &&
|
||||
this[2] == "run" &&
|
||||
this[3].startsWith("./")
|
||||
) {
|
||||
take(3) + listOf("sh") + drop(3)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState {
|
||||
val sandbox = sandboxDir(level)
|
||||
val nativeGit = requireNativeGit()
|
||||
val workingDir = File(sandbox, currentDir).canonicalFile
|
||||
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
|
||||
?: sandbox
|
||||
val repositoryRoot = generateSequence(workingDir) { directory ->
|
||||
directory.parentFile?.takeIf {
|
||||
it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator)
|
||||
}
|
||||
}.firstOrNull { File(it, ".git").exists() }
|
||||
val inspectionRoot = repositoryRoot ?: sandbox
|
||||
val filesOnDisk = inspectionRoot.walkTopDown()
|
||||
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
|
||||
.orEmpty()
|
||||
.toList()
|
||||
|
||||
if (repositoryRoot == null) {
|
||||
return RepoState(
|
||||
initialized = false,
|
||||
files = filesOnDisk.map {
|
||||
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val statusResult = runGit(nativeGit, inspectionRoot, listOf("status", "--porcelain"))
|
||||
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
|
||||
val deletedStatusPaths = mutableSetOf<String>()
|
||||
statusResult.outputLines.forEach { line ->
|
||||
if (line.length < 4) return@forEach
|
||||
val x = line[0]
|
||||
val y = line[1]
|
||||
val path = line.substring(3).trim()
|
||||
val staged = x != ' ' && x != '?'
|
||||
val tracked = x != '?' || y != '?'
|
||||
statusMap[path] = staged to tracked
|
||||
if (x == 'D' || y == 'D') {
|
||||
deletedStatusPaths += path
|
||||
}
|
||||
}
|
||||
|
||||
val logResult = runGit(nativeGit, inspectionRoot, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
|
||||
val branchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--list"))
|
||||
val remoteBranchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "-r", "--list"))
|
||||
val tagResult = runGit(nativeGit, inspectionRoot, listOf("tag", "--list"))
|
||||
val remoteResult = runGit(nativeGit, inspectionRoot, listOf("remote", "-v"))
|
||||
val headResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--show-current"))
|
||||
val exactTagResult = runGit(nativeGit, inspectionRoot, listOf("describe", "--tags", "--exact-match"))
|
||||
val userNameResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.name"))
|
||||
val userEmailResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.email"))
|
||||
val fetchHeadCount = File(inspectionRoot, ".git/FETCH_HEAD")
|
||||
.takeIf { it.isFile }
|
||||
?.readLines()
|
||||
?.count { it.isNotBlank() }
|
||||
?: 0
|
||||
val config = buildMap {
|
||||
userNameResult.outputLines.firstOrNull()
|
||||
?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() }
|
||||
?.let { put("user.name", it) }
|
||||
userEmailResult.outputLines.firstOrNull()
|
||||
?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() }
|
||||
?.let { put("user.email", it) }
|
||||
}
|
||||
AppLog.d(
|
||||
"GitRuntime",
|
||||
"inspectSandbox level=${level.id} config=$config user.name.exit=${userNameResult.exitCode} user.name.output=${userNameResult.outputLines} user.email.exit=${userEmailResult.exitCode} user.email.output=${userEmailResult.outputLines}",
|
||||
)
|
||||
val commits = if (logResult.exitCode == 0) {
|
||||
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
|
||||
val parts = line.split('\t', limit = 4)
|
||||
if (parts.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
val parentHashes = parts.getOrNull(2).orEmpty().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||
CommitNode(
|
||||
id = parts[0],
|
||||
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
|
||||
parentCount = parentHashes.size,
|
||||
message = parts.getOrElse(3) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val branches = branchResult.outputLines
|
||||
.map { it.removePrefix("*").trim() }
|
||||
.filter { it.isNotBlank() && !it.startsWith("(") }
|
||||
.associateWith { branch ->
|
||||
runGit(nativeGit, inspectionRoot, listOf("rev-list", "--count", branch))
|
||||
.outputLines
|
||||
.firstOrNull()
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
}
|
||||
val headBranch = headResult.outputLines.firstOrNull()
|
||||
?.ifBlank { null }
|
||||
?: exactTagResult.outputLines.firstOrNull()
|
||||
?.takeIf { exactTagResult.exitCode == 0 && it.isNotBlank() }
|
||||
?.let { "tags/$it" }
|
||||
?: "DETACHED"
|
||||
|
||||
return RepoState(
|
||||
initialized = true,
|
||||
files = filesOnDisk.map { file ->
|
||||
val relativePath = file.relativeTo(inspectionRoot).path
|
||||
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
||||
GitFile(
|
||||
name = relativePath,
|
||||
content = file.readText(),
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
)
|
||||
} + deletedStatusPaths
|
||||
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
|
||||
.map { deletedPath ->
|
||||
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
|
||||
GitFile(
|
||||
name = deletedPath,
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
deleted = true,
|
||||
)
|
||||
},
|
||||
commits = commits,
|
||||
headBranch = headBranch,
|
||||
branches = branches,
|
||||
tags = tagResult.outputLines.filter { it.isNotBlank() },
|
||||
remotes = remoteResult.outputLines.mapNotNull { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
if (parts.size >= 2) parts[0] to parts[1] else null
|
||||
}.toMap(),
|
||||
config = config,
|
||||
fetchedBranches = remoteBranchResult.outputLines
|
||||
.map { it.removePrefix("*").trim() }
|
||||
.filter { it.isNotBlank() && " -> " !in it }
|
||||
.toSet(),
|
||||
fetchHeadCount = fetchHeadCount,
|
||||
)
|
||||
}
|
||||
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState =
|
||||
repositoryInspector.inspect(sandboxDir(level), currentDir)
|
||||
|
||||
private fun nativeGitBinary(): File? {
|
||||
return nativeGitOverride
|
||||
@@ -586,7 +243,11 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
|
||||
private fun requireNativeGit(): File {
|
||||
return nativeGitBinary() ?: error(unavailableMessage())
|
||||
val binary = nativeGitBinary() ?: error(unavailableMessage())
|
||||
if (context != null && !NativeGitBridge.isAvailable()) {
|
||||
error(unavailableMessage())
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
private fun packagedNativeGitBinary(): File? {
|
||||
@@ -602,107 +263,6 @@ class GitRepositoryRuntime private constructor(
|
||||
return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1))
|
||||
}
|
||||
|
||||
private fun augmentObservedRepoFacts(
|
||||
previousRepo: RepoState,
|
||||
inspectedRepo: RepoState,
|
||||
tokens: List<String>,
|
||||
outputLines: List<String> = emptyList(),
|
||||
): RepoState {
|
||||
if (tokens.firstOrNull() != "git") {
|
||||
return inspectedRepo.copy(
|
||||
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes),
|
||||
fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches,
|
||||
fetchHeadCount = inspectedRepo.fetchHeadCount,
|
||||
pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches,
|
||||
pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags,
|
||||
submodules = previousRepo.submodules + inspectedRepo.submodules,
|
||||
maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions,
|
||||
)
|
||||
}
|
||||
|
||||
return when (tokens.getOrNull(1)) {
|
||||
"bisect" -> {
|
||||
if (tokens.getOrNull(2) == "run" && outputLines.any { it.contains("is the first bad commit") }) {
|
||||
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions + "bisect-found")
|
||||
} else {
|
||||
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions)
|
||||
}
|
||||
}
|
||||
"stash" -> inspectedRepo.copy(
|
||||
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes + "stash@{${previousRepo.stashes.size}}"),
|
||||
)
|
||||
"fetch" -> {
|
||||
inspectedRepo.copy(
|
||||
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches,
|
||||
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "fetch",
|
||||
)
|
||||
}
|
||||
"pull" -> {
|
||||
val remote = tokens.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
||||
val branch = tokens.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: inspectedRepo.headBranch
|
||||
inspectedRepo.copy(
|
||||
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches + "$remote/$branch",
|
||||
fetchHeadCount = inspectedRepo.fetchHeadCount,
|
||||
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "pull",
|
||||
)
|
||||
}
|
||||
"push" -> {
|
||||
val remote = tokens.drop(2).firstOrNull { !it.startsWith("-") } ?: "origin"
|
||||
val explicitBranches = tokens
|
||||
.drop(2)
|
||||
.dropWhile { it.startsWith("-") }
|
||||
.drop(1)
|
||||
.filter { !it.startsWith("-") }
|
||||
val pushedBranches = when {
|
||||
tokens.any { it == "--all" } -> inspectedRepo.branches.keys.map { "$remote/$it" }
|
||||
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
|
||||
else -> listOf("$remote/${inspectedRepo.headBranch}")
|
||||
}
|
||||
val pushedTags = if (tokens.any { it == "--tags" || it == "--follow-tags" }) inspectedRepo.tags.toSet() else emptySet()
|
||||
inspectedRepo.copy(
|
||||
pushedBranches = inspectedRepo.pushedBranches + previousRepo.pushedBranches + pushedBranches,
|
||||
pushedTags = inspectedRepo.pushedTags + previousRepo.pushedTags + pushedTags,
|
||||
)
|
||||
}
|
||||
"submodule" -> {
|
||||
if (tokens.getOrNull(2) == "add") {
|
||||
val url = tokens.getOrNull(3)
|
||||
val path = tokens.getOrNull(4)?.trimEnd('/')
|
||||
if (url != null && path != null) {
|
||||
inspectedRepo.copy(submodules = previousRepo.submodules + inspectedRepo.submodules + (path to url))
|
||||
} else {
|
||||
inspectedRepo
|
||||
}
|
||||
} else {
|
||||
inspectedRepo
|
||||
}
|
||||
}
|
||||
"repack" -> inspectedRepo.copy(
|
||||
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "repack",
|
||||
)
|
||||
"merge" -> inspectedRepo.copy(
|
||||
maintenanceActions = if ("--squash" in tokens) {
|
||||
inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "merge-squash"
|
||||
} else {
|
||||
inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "merge"
|
||||
},
|
||||
)
|
||||
else -> inspectedRepo.copy(
|
||||
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes),
|
||||
fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches,
|
||||
fetchHeadCount = inspectedRepo.fetchHeadCount,
|
||||
pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches,
|
||||
pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags,
|
||||
submodules = previousRepo.submodules + inspectedRepo.submodules,
|
||||
maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mergeDistinct(first: List<String>, second: List<String>): List<String> {
|
||||
return (first + second).distinct()
|
||||
}
|
||||
|
||||
private data class EnvironmentPrefixedCommand(
|
||||
val environment: Map<String, String>,
|
||||
val command: List<GitSandboxEngine.ShellToken>,
|
||||
@@ -723,24 +283,12 @@ class GitRepositoryRuntime private constructor(
|
||||
return EnvironmentPrefixedCommand(environment, tokens.drop(commandStart))
|
||||
}
|
||||
|
||||
private fun rebaseTarget(command: String): String? {
|
||||
val tokens = GitSandboxEngine.tokenizeCommand(command)
|
||||
if (tokens.size < 3 || tokens[0] != "git" || tokens[1] != "rebase") return null
|
||||
return tokens.drop(2).lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
|
||||
}
|
||||
|
||||
private fun String.isShellEnvironmentName(): Boolean {
|
||||
if (isEmpty()) return false
|
||||
if (first() != '_' && !first().isLetter()) return false
|
||||
return all { it == '_' || it.isLetterOrDigit() }
|
||||
}
|
||||
|
||||
private fun String.toShellSingleQuoted(): String {
|
||||
return "'" + replace("'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
private fun shellExecutable(): String = processRunner.shellExecutable()
|
||||
|
||||
private fun runGit(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import java.io.File
|
||||
|
||||
internal object NativeGitBridge {
|
||||
private val loadResult: Result<Unit> by lazy {
|
||||
runCatching { System.loadLibrary("githugruntime") }
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = loadResult.isSuccess
|
||||
|
||||
fun runGitMain(
|
||||
library: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String>,
|
||||
): ProcessExecutionResult {
|
||||
loadResult.getOrElse { error ->
|
||||
return ProcessExecutionResult(
|
||||
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()
|
||||
val result = runGitMainNative(
|
||||
library.absolutePath,
|
||||
workingDir.absolutePath,
|
||||
argv,
|
||||
env,
|
||||
)
|
||||
val exitCode = result.getOrNull(0)?.toIntOrNull() ?: -1
|
||||
val output = result.getOrNull(1).orEmpty()
|
||||
return ProcessExecutionResult(
|
||||
exitCode = exitCode,
|
||||
outputLines = output.lineSequence().toList().dropLastWhile { it.isEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
private external fun runGitMainNative(
|
||||
libraryPath: String,
|
||||
workingDirectory: String,
|
||||
argv: Array<String>,
|
||||
environment: Array<String>,
|
||||
): Array<String>
|
||||
}
|
||||
@@ -41,15 +41,13 @@ internal fun fetchLevel(): Level = level(
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo ->
|
||||
repo.branches.size == 1 &&
|
||||
repo.fetchHeadCount == 2 &&
|
||||
"pull" !in repo.maintenanceActions
|
||||
repo.branches.keys == setOf("master") &&
|
||||
repo.fetchHeadCount >= 2 &&
|
||||
"origin/new_branch" in repo.fetchedBranches &&
|
||||
repo.files.none { it.name == "file1" && it.tracked }
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("fetch origin", "git fetch origin"),
|
||||
levelTestCase("fetch default", "git fetch"),
|
||||
),
|
||||
negativeTestCases = listOf(
|
||||
levelTestCase("pulling does not solve fetch", "git pull"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ internal fun mergeLevel(): Level = level(
|
||||
},
|
||||
validator = repoPredicate { repo ->
|
||||
repo.headBranch == "master" &&
|
||||
"merge" in repo.maintenanceActions &&
|
||||
repo.files.any { it.name == "file2" && it.tracked }
|
||||
},
|
||||
testCases = listOf(
|
||||
|
||||
@@ -30,7 +30,11 @@ internal fun mergeSquashLevel(): Level = level(
|
||||
addCommit("Second commit", "file2")
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo -> "merge-squash" in repo.maintenanceActions && repo.commits.isNotEmpty() },
|
||||
validator = repoPredicate { repo ->
|
||||
repo.commits.firstOrNull()?.message == "Merge long feature" &&
|
||||
repo.commits.firstOrNull()?.parentCount == 1 &&
|
||||
repo.files.any { it.name == "file3" && it.tracked }
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""),
|
||||
),
|
||||
|
||||
@@ -38,7 +38,10 @@ internal fun pushBranchLevel(): Level = level(
|
||||
checkout("master")
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo -> "origin/test_branch" in repo.pushedBranches && "origin/master" !in repo.pushedBranches && "origin/other_branch" !in repo.pushedBranches },
|
||||
validator = repoPredicate { repo ->
|
||||
"origin/test_branch" in repo.pushedBranches &&
|
||||
"origin/other_branch" !in repo.pushedBranches
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("push named branch", "git push origin test_branch"),
|
||||
levelTestCase("push current branch refspec", "git push origin test_branch:test_branch"),
|
||||
|
||||
@@ -30,7 +30,17 @@ internal fun rebaseOntoLevel(): Level = level(
|
||||
addCommit("Add `Install` header in readme", "README.md")
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo -> repo.headBranch == "readme-update" && "rebase-onto" in repo.maintenanceActions },
|
||||
validator = repoPredicate { repo ->
|
||||
repo.headBranch == "readme-update" &&
|
||||
repo.files.find { it.name == "authors.md" }?.content == "https://github.com/janis-vitols\n" &&
|
||||
repo.commits.map { it.message }.containsAll(
|
||||
listOf(
|
||||
"Add app name in readme",
|
||||
"Add `About` header in readme",
|
||||
"Add `Install` header in readme",
|
||||
),
|
||||
)
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"),
|
||||
levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"),
|
||||
|
||||
@@ -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 -p feature.rb"),
|
||||
commandSuggestions = listOf("git add 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()
|
||||
@@ -23,7 +23,6 @@ internal fun stageLinesLevel(): Level = level(
|
||||
},
|
||||
validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } },
|
||||
testCases = listOf(
|
||||
levelTestCase("patch add feature file", "git add -p feature.rb", "y"),
|
||||
levelTestCase("patch long option", "git add --patch feature.rb", "y"),
|
||||
levelTestCase("stage feature file", "git add feature.rb"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Port of the upstream ruby-githug `submodule` level.
|
||||
*
|
||||
@@ -12,10 +14,31 @@ internal fun submoduleLevel(): Level = level(
|
||||
title = "Submodule",
|
||||
description = "You want to include the files from the following repo: `https://github.com/jackmaney/githug-include-me` into the folder `./githug-include-me`. Do this without manually cloning the repo or copying the files from the repo into this repo.",
|
||||
hints = listOf("Take a look at `git submodule`."),
|
||||
commandSuggestions = listOf("git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"),
|
||||
commandSuggestions = listOf(
|
||||
"git config -f .gitmodules submodule.githug-include-me.path githug-include-me",
|
||||
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
|
||||
"git add .gitmodules",
|
||||
),
|
||||
setup = { RepoState(initialized = true, branches = mapOf("master" to 0)) },
|
||||
validator = repoPredicate { repo -> repo.submodules["./githug-include-me"] == "https://github.com/jackmaney/githug-include-me" || repo.submodules["githug-include-me"] == "https://github.com/jackmaney/githug-include-me" },
|
||||
nativeSetup = {
|
||||
val source = File(sandbox.parentFile ?: sandbox, "submodule-source")
|
||||
source.deleteRecursively()
|
||||
initRepo(source)
|
||||
writeIn(source, "README.md", "Included by submodule.\n")
|
||||
addCommitIn(source, "Initial submodule content", "README.md")
|
||||
git("config", "protocol.file.allow", "always")
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo ->
|
||||
repo.submodules["githug-include-me"]?.isNotBlank() == true &&
|
||||
repo.files.any { it.name == ".gitmodules" && it.tracked && it.content.contains("githug-include-me") }
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("add submodule", "git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"),
|
||||
levelTestCase(
|
||||
"record submodule metadata",
|
||||
"git config -f .gitmodules submodule.githug-include-me.path githug-include-me",
|
||||
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
|
||||
"git add .gitmodules",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user