Compare commits

...

10 Commits

Author SHA1 Message Date
Joe Tretter
943bf03ca0 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.
2026-06-27 13:07:35 -05:00
Joe Tretter
d703f539d3 Fix bisect script run and level validators 2026-06-26 19:00:51 -05:00
Joe Tretter
caaf26a541 Remove user message dependency from level validators 2026-06-26 18:10:48 -05:00
Joe Tretter
4b94173ae5 Add low-output tooling mode 2026-06-26 16:24:45 -05:00
Joe Tretter
91913a9de8 Defer level validation while editors are open 2026-06-26 13:26:56 -05:00
Joe Tretter
781b5090f7 Fix Git editor reword workflow and UI coverage
- show Git editor file paths instead of the submitted git command
- surface follow-up commit message editors during interactive rebase reword
- add rename-commit UI tests for editor and amend-message solution paths
- add broader embedded level solution scenarios for alternate Git workflows
2026-06-26 11:23:16 -05:00
Joe Tretter
2db8fdd328 Fix Android native Git child exit handling so committed levels validate and Git calls avoid SIGSEGV status 139 2026-06-25 14:56:35 -05:00
Joe Tretter
60dd3c82f7 Add runtime diagnostic logging for level setup, native Git calls, inspection, validation, and UI command flow 2026-06-25 13:53:51 -05:00
Joe Tretter
035851ac05 Make manual level selection responsive 2026-06-25 10:02:06 -05:00
Joe Tretter
82e38de3bb Fix config level completion on device 2026-06-24 20:32:23 -05:00
65 changed files with 1809 additions and 828 deletions

View File

@@ -37,11 +37,66 @@ ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1"
ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin" ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin"
STATE_TTL_SECONDS=86400 STATE_TTL_SECONDS=86400
HIDE_EMULATOR_WINDOW="false" HIDE_EMULATOR_WINDOW="false"
QUIET_LOG_DIR="$PROJECT_DIR/build/reports/android-project-tooling"
log() { log() {
printf '\n[%s] %s\n' "tooling" "$1" printf '\n[%s] %s\n' "tooling" "$1"
} }
quiet_log_name() {
local mode="setup"
local arg
for arg in "$@"; do
case "$arg" in
--build|--build-release-aab|--test|--clean-test|--test-emulator|--compile-git)
mode="${arg#--}"
;;
esac
done
printf '%s-%s.log\n' "$(date +%Y%m%d-%H%M%S)" "$mode"
}
run_quiet_if_requested() {
if [ "${ANDROID_PROJECT_TOOLING_QUIET_CHILD:-}" = "1" ]; then
return
fi
local quiet="false"
local filtered_args=()
local arg
for arg in "$@"; do
if [ "$arg" = "--quiet" ]; then
quiet="true"
else
filtered_args+=("$arg")
fi
done
if [ "$quiet" != "true" ]; then
return
fi
mkdir -p "$QUIET_LOG_DIR"
local log_file="$QUIET_LOG_DIR/$(quiet_log_name "${filtered_args[@]}")"
printf '[tooling] Running quietly; full log: %s\n' "${log_file#$PROJECT_DIR/}"
set +e
ANDROID_PROJECT_TOOLING_QUIET_CHILD=1 bash "$0" "${filtered_args[@]}" >"$log_file" 2>&1 &
local child_pid=$!
wait "$child_pid"
local status=$?
set -e
if [ "$status" -eq 0 ]; then
printf '[tooling] Completed successfully; full log: %s\n' "${log_file#$PROJECT_DIR/}"
else
printf '[tooling] Failed with exit code %s; full log: %s\n' "$status" "${log_file#$PROJECT_DIR/}" >&2
printf '[tooling] Last 120 log lines:\n' >&2
tail -n 120 "$log_file" >&2 || true
fi
exit "$status"
}
require_tool() { require_tool() {
if ! command -v "$1" >/dev/null 2>&1; then if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required tool: $1" >&2 echo "Missing required tool: $1" >&2
@@ -450,7 +505,7 @@ build_android_git_for_abi() {
local output_dir="$ANDROID_JNI_DIR/$abi" local output_dir="$ANDROID_JNI_DIR/$abi"
local output_binary="$output_dir/libgit.so" local output_binary="$output_dir/libgit.so"
local stamp="$output_dir/.source-fingerprint" local stamp="$output_dir/.source-fingerprint"
local fingerprint="$3:android:$abi:$cc:$ANDROID_API" local fingerprint="$3:android:$abi:$cc:$ANDROID_API:githug-embedded-main"
require_path "$ANDROID_TOOLBIN/$cc" require_path "$ANDROID_TOOLBIN/$cc"
@@ -470,6 +525,7 @@ build_android_git_for_abi() {
NO_PTHREADS=YesPlease \ NO_PTHREADS=YesPlease \
NO_LIBGEN_H=YesPlease \ NO_LIBGEN_H=YesPlease \
HAVE_DEV_TTY=YesPlease \ HAVE_DEV_TTY=YesPlease \
CFLAGS_APPEND=-DGITHUG_EMBEDDED_MAIN \
CC="$cc" \ CC="$cc" \
AR=llvm-ar \ AR=llvm-ar \
RANLIB=llvm-ranlib \ RANLIB=llvm-ranlib \
@@ -794,7 +850,7 @@ maybe_run_operation() {
print_usage() { print_usage() {
cat <<EOF_USAGE cat <<EOF_USAGE
Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test | --clean-test | --test-emulator [--hide-emulator-window] | --compile-git] Usage: bash ./AndroidProjectTooling.sh [--quiet] [--build | --build-release-aab | --test | --clean-test | --test-emulator [--hide-emulator-window] | --compile-git]
--build Set up the environment and build the debug APK --build Set up the environment and build the debug APK
--build-release-aab Set up the environment and build a release Android App Bundle (AAB) --build-release-aab Set up the environment and build a release Android App Bundle (AAB)
@@ -803,11 +859,16 @@ Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test |
--test-emulator Set up a visible Android emulator and run debug instrumentation tests on it --test-emulator Set up a visible Android emulator and run debug instrumentation tests on it
--hide-emulator-window Run --test-emulator with the emulator window hidden --hide-emulator-window Run --test-emulator with the emulator window hidden
--compile-git Compile Git for the development host and all Android target ABIs --compile-git Compile Git for the development host and all Android target ABIs
--quiet Write full output to build/reports/android-project-tooling/ and print only a concise result
EOF_USAGE EOF_USAGE
} }
validate_args() { validate_args() {
local mode="${1:-}" local mode="${1:-}"
if [ "$mode" = "--quiet" ]; then
mode="${2:-}"
shift || true
fi
shift || true shift || true
case "$mode" in case "$mode" in
@@ -822,6 +883,8 @@ validate_args() {
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
--quiet)
;;
--hide-emulator-window) --hide-emulator-window)
if [ "$mode" != "--test-emulator" ]; then if [ "$mode" != "--test-emulator" ]; then
echo "--hide-emulator-window can only be used with --test-emulator" >&2 echo "--hide-emulator-window can only be used with --test-emulator" >&2
@@ -841,6 +904,7 @@ validate_args() {
} }
main() { main() {
run_quiet_if_requested "$@"
validate_args "$@" validate_args "$@"
require_tool curl require_tool curl

View File

@@ -54,6 +54,14 @@ Available commands:
| `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` | | `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` |
| `bash ./AndroidProjectTooling.sh --compile-git` | Compile Git for the development host and all Android target ABIs. | Host and Android `libgit.so` binaries | | `bash ./AndroidProjectTooling.sh --compile-git` | Compile Git for the development host and all Android target ABIs. | Host and Android `libgit.so` binaries |
Add `--quiet` before any tooling command when running through an AI or other log-sensitive automation:
```bash
bash ./AndroidProjectTooling.sh --quiet --test
```
Quiet mode runs the same command in a child process, writes the full output to `build/reports/android-project-tooling/`, waits for completion, and prints only a concise success/failure result. On failure it prints the last 120 log lines so the immediate error is visible without repeatedly streaming the full Gradle or emulator log.
To run the JVM unit test suite after ensuring the local toolchain is ready: To run the JVM unit test suite after ensuring the local toolchain is ready:
```bash ```bash
@@ -81,6 +89,15 @@ Test outputs and logs are written under project-local build directories:
- Per-test emulator logcat files: `app/build/outputs/androidTest-results/connected/debug/<device-name>/logcat-*.txt` - Per-test emulator logcat files: `app/build/outputs/androidTest-results/connected/debug/<device-name>/logcat-*.txt`
- Emulator startup log: `build/reports/android-emulator.log` - Emulator startup log: `build/reports/android-emulator.log`
Runtime diagnostics are emitted to Android logcat with the `GitHugAndroid` tag. To capture a focused trace from a device or emulator:
```bash
android-sdk/platform-tools/adb logcat -c
android-sdk/platform-tools/adb logcat -v time -s GitHugAndroid:D '*:S'
```
The trace includes level loading and preparation timings, native Git argv/cwd/exit/output previews, repository inspection timings, validation snapshots, and the final app decision for each submitted command. For level-resolution issues, compare the `GitRuntime`, `GitProcess`, `GitInspector`, `Validation`, and `GitHugApp` lines around the submitted command.
To build installable/debuggable artifacts: To build installable/debuggable artifacts:
```bash ```bash
@@ -146,13 +163,13 @@ Git build outputs are stamped with a Git source fingerprint. Re-running `--test`
Git manpage assets are also refreshed from the checked-out Git source whenever Git is compiled or an app artifact is built. Git manpage assets are also refreshed from the checked-out Git source whenever Git is compiled or an app artifact is built.
Git source patches can live under `patches/git/` and are applied by `AndroidProjectTooling.sh` after the Git source checkout is cloned or reused. These patches are part of the source fingerprint, so changing a patch forces the host and Android Git binaries to rebuild. The current runtime does not require a Git source patch: it resolves Git's existing exported `init_git` and `cmd_main` symbols through JNI and calls them with Git-style `argc`/`argv`. Git source patches live under `patches/git/` and are applied by `AndroidProjectTooling.sh` after the Git source checkout is cloned or reused. These patches are part of the source fingerprint, so changing a patch forces Git binaries to rebuild. The Android Git build enables a small embedded-main patch that exports `githug_git_main(argc, argv)`. That wrapper still delegates command dispatch and parsing to Git, but routes Git's process-exit path through `_exit()` in the forked child so Android does not run libc/JVM inherited exit handlers after native Git completes.
## Runtime Architecture ## Runtime Architecture
The command engine has one app-facing runtime: The command engine has one app-facing runtime:
- **Native Git path**: the packaged Git binary for the device ABI is loaded by the JNI bridge and every `git ...` command invokes Git's existing `init_git(argv)` and `cmd_main(argc, argv)` path in a real per-level repository sandbox in app-private storage. - **Native Git path**: the packaged Git binary for the device ABI is loaded by the JNI bridge and every `git ...` command invokes Git's patched embedded entry point, `githug_git_main(argc, argv)`, in a forked child process inside a real per-level repository sandbox in app-private storage.
The runtime exposes a `RepoState` surface to validators. In addition to files, commits, branches, tags, remotes, and config, the model tracks learning-relevant effects such as stashes, fetched remote refs, pushed branches/tags, submodules, and repository maintenance actions. The runtime exposes a `RepoState` surface to validators. In addition to files, commits, branches, tags, remotes, and config, the model tracks learning-relevant effects such as stashes, fetched remote refs, pushed branches/tags, submodules, and repository maintenance actions.

View File

@@ -20,8 +20,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 174 versionCode = 184
versionName = "0.1.173" versionName = "0.1.183"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -3,15 +3,23 @@ package solutions.tretter.githugandroid
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.AnnotatedString
import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import java.io.File import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -42,16 +50,105 @@ class GitRepositoryRuntimeInstrumentedTest {
launchFreshApp().use { launchFreshApp().use {
allGithugLevels().forEachIndexed { index, level -> allGithugLevels().forEachIndexed { index, level ->
waitForExercise(level) waitForExercise(level)
if (level.id == renameCommitLevel().id) {
completeRenameCommitWithEditor()
} else {
level.testCases.first().commands.forEach(::submitTerminalCommand) level.testCases.first().commands.forEach(::submitTerminalCommand)
}
waitForLevelCompletion(nextLevel = allGithugLevels().getOrNull(index + 1)) waitForLevelCompletion(nextLevel = allGithugLevels().getOrNull(index + 1))
} }
} }
} }
@Test
fun renameCommitLevelCompletesThroughUiEditor() {
launchFreshApp().use {
selectLevel(renameCommitLevel())
completeRenameCommitWithEditor()
waitForLevelCompletion(nextLevel = initLevel())
}
}
@Test
fun renameCommitLevelCompletesThroughMessageOptionAfterReword() {
launchFreshApp().use {
selectLevel(renameCommitLevel())
startRenameCommitReword()
composeRule.onNodeWithTag("git-message-editor-dismiss").performClick()
submitTerminalCommand("git commit --amend -m \"First commit\"")
submitTerminalCommand("git rebase --continue")
waitForLevelCompletion(nextLevel = initLevel())
}
}
@Test
fun squashLevelWaitsForFinalEditorBeforeCompleting() {
launchFreshApp().use {
selectLevel(squashLevel())
submitTerminalCommand("git rebase -i HEAD~4")
waitForGitEditorPath(".git/rebase-merge/git-rebase-todo")
replaceGitEditorContent(squashRebaseTodo(currentGitEditorContent()))
composeRule.onNodeWithTag("git-message-editor-save").performClick()
waitForGitMessageEditor()
composeRule.onNodeWithTag("exercise-title-${squashLevel().id}").assertIsDisplayed()
composeRule.onNodeWithTag("git-message-editor-save").performClick()
waitForLevelCompletion(nextLevel = initLevel())
}
}
@Test
fun configLevelCompletesThroughUiWithArbitraryValues() {
launchFreshApp().use {
submitTerminalCommand("git init")
waitForExercise(configLevel())
submitTerminalCommand("git config user.name githug")
submitTerminalCommand("git config user.email xxx@yy.com")
waitForLevelCompletion(nextLevel = addLevel())
}
}
@Test
fun visualFileEditorStartsWithActionsVisible() {
launchFreshApp().use {
submitTerminalCommand("edit notes.txt")
waitForTextEditorActions()
}
}
@Test
fun selectingLevelFromLevelsPaneUpdatesExerciseImmediately() {
launchFreshApp().use {
composeRule.onNodeWithTag("level-submodule")
.performScrollTo()
.performClick()
composeRule.waitUntil(timeoutMillis = 1_500) {
composeRule.onAllNodes(hasText(submoduleLevel().title, substring = true))
.fetchSemanticsNodes()
.isNotEmpty()
}
}
}
private fun launchFreshApp(): ActivityScenario<MainActivity> { private fun launchFreshApp(): ActivityScenario<MainActivity> {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
File(context.applicationInfo.dataDir, "files").deleteRecursively() File(context.filesDir, "githug-sandboxes").deleteRecursively()
File(context.applicationInfo.dataDir, "cache").deleteRecursively() File(context.applicationInfo.dataDir, "cache").deleteRecursively()
runBlocking {
GameProgressStore(context.applicationContext).saveProgress(emptySet(), activeLevelId = null)
GameProgressStore(context.applicationContext).saveShowHelpOnStart(showHelpOnStart = true)
}
val scenario = ActivityScenario.launch(MainActivity::class.java) val scenario = ActivityScenario.launch(MainActivity::class.java)
waitForExercise(initLevel()) waitForExercise(initLevel())
return scenario return scenario
@@ -77,6 +174,87 @@ class GitRepositoryRuntimeInstrumentedTest {
composeRule.onNodeWithTag("exercise-title-${nextLevel.id}").assertIsDisplayed() composeRule.onNodeWithTag("exercise-title-${nextLevel.id}").assertIsDisplayed()
} }
private fun selectLevel(level: Level) {
composeRule.onNodeWithTag("level-${level.id}")
.performScrollTo()
.performClick()
waitForExercise(level)
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText("Loaded level: ${level.title}", substring = true))
.fetchSemanticsNodes()
.isNotEmpty()
}
}
private fun completeRenameCommitWithEditor() {
startRenameCommitReword()
waitForGitEditorPath(".git/COMMIT_EDITMSG")
replaceGitEditorContent(
currentGitEditorContent().replaceFirst("First coommit", "First commit"),
)
composeRule.onNodeWithTag("git-message-editor-save").performClick()
}
private fun startRenameCommitReword() {
submitTerminalCommand("git rebase -i HEAD~2")
waitForGitEditorPath(".git/rebase-merge/git-rebase-todo")
replaceGitEditorContent(
currentGitEditorContent().replaceFirst(Regex("(?m)^pick "), "reword "),
)
composeRule.onNodeWithTag("git-message-editor-save").performClick()
}
private fun waitForGitEditorPath(path: String) {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText(path, substring = true)).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("git-message-editor-path").assertIsDisplayed()
waitForGitEditorActions()
}
private fun waitForGitMessageEditor() {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodesWithTag("git-message-editor-content").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("git-message-editor-content").assertIsDisplayed()
waitForGitEditorActions()
}
private fun waitForGitEditorActions() {
composeRule.onNodeWithTag("git-message-editor-save").assertIsDisplayed()
composeRule.onNodeWithTag("git-message-editor-dismiss").assertIsDisplayed()
}
private fun waitForTextEditorActions() {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodesWithTag("text-editor-content").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("text-editor-save").assertIsDisplayed()
composeRule.onNodeWithTag("text-editor-dismiss").assertIsDisplayed()
}
private fun currentGitEditorContent(): String {
val node = composeRule.onNodeWithTag("git-message-editor-content").fetchSemanticsNode()
return node.config.getOrNull(SemanticsProperties.EditableText)?.text
?: error("Expected Git editor content semantics")
}
private fun squashRebaseTodo(content: String): String {
return content
.lineSequence()
.mapIndexed { index, line ->
if (index > 0 && line.startsWith("pick ")) line.replaceFirst("pick ", "squash ") else line
}
.joinToString("\n")
}
private fun replaceGitEditorContent(content: String) {
composeRule.onNodeWithTag("git-message-editor-content")
.performSemanticsAction(SemanticsActions.SetText) { setText ->
setText(AnnotatedString(content))
}
}
private fun submitTerminalCommand(command: String) { private fun submitTerminalCommand(command: String) {
val input = composeRule.onNodeWithTag("terminal-command-input") val input = composeRule.onNodeWithTag("terminal-command-input")
input.performClick() input.performClick()

View File

@@ -4,15 +4,21 @@
#include <errno.h> #include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <pthread.h> #include <pthread.h>
#include <signal.h>
#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 <unistd.h> #include <unistd.h>
#define LOG_TAG "GitHugNativeGit" #define LOG_TAG "GitHugNativeGit"
#define GIT_COMMAND_TIMEOUT_MS 30000
#define GIT_SESSION_MAX_READ_WAIT_MS 1000
typedef int (*git_main_fn)(int argc, const char **argv); typedef int (*git_main_fn)(int argc, const char **argv);
typedef int (*githug_git_main_fn)(int argc, const char **argv);
typedef void (*git_init_fn)(const char **argv); typedef void (*git_init_fn)(const char **argv);
struct output_buffer { struct output_buffer {
@@ -24,8 +30,19 @@ struct output_buffer {
static pthread_mutex_t git_mutex = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t git_mutex = PTHREAD_MUTEX_INITIALIZER;
static void *git_handle = NULL; static void *git_handle = NULL;
static git_main_fn git_main = NULL; static git_main_fn 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;
@@ -73,6 +90,14 @@ static int drain_available_output(int fd, struct output_buffer *output, int *saw
} }
} }
static long long monotonic_millis(void) {
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) {
return 0;
}
return ((long long)now.tv_sec * 1000LL) + ((long long)now.tv_nsec / 1000000LL);
}
static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output) { static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output) {
jclass string_class = (*env)->FindClass(env, "java/lang/String"); jclass string_class = (*env)->FindClass(env, "java/lang/String");
jobjectArray result = (*env)->NewObjectArray(env, 2, string_class, NULL); jobjectArray result = (*env)->NewObjectArray(env, 2, string_class, NULL);
@@ -87,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);
} }
@@ -105,6 +158,7 @@ static int load_git(const char *library_path) {
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlsym init_git failed: %s", dlerror()); __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlsym init_git failed: %s", dlerror());
return -1; return -1;
} }
githug_git_main = (githug_git_main_fn)dlsym(git_handle, "githug_git_main");
git_main = (git_main_fn)dlsym(git_handle, "cmd_main"); git_main = (git_main_fn)dlsym(git_handle, "cmd_main");
if (git_main == NULL) { if (git_main == NULL) {
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlsym cmd_main failed: %s", dlerror()); __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlsym cmd_main failed: %s", dlerror());
@@ -147,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;
@@ -227,6 +320,17 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
} }
pthread_mutex_lock(&git_mutex); pthread_mutex_lock(&git_mutex);
if (load_git(library_path_chars) != 0) {
const char *error = dlerror();
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, error != NULL ? error : "Native Git execution failed: init_git/cmd_main not found");
}
pid_t child = fork(); pid_t child = fork();
if (child < 0) { if (child < 0) {
@@ -241,6 +345,7 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
} }
if (child == 0) { if (child == 0) {
setpgid(0, 0);
close(pipe_fds[0]); close(pipe_fds[0]);
dup2(pipe_fds[1], STDOUT_FILENO); dup2(pipe_fds[1], STDOUT_FILENO);
dup2(pipe_fds[1], STDERR_FILENO); dup2(pipe_fds[1], STDERR_FILENO);
@@ -251,20 +356,18 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
(void)saved_env; (void)saved_env;
chdir(working_directory_chars); chdir(working_directory_chars);
if (load_git(library_path_chars) != 0) { int child_exit_code;
const char *error = dlerror(); if (githug_git_main != NULL) {
fprintf(stderr, "%s\n", error != NULL ? error : "Native Git execution failed: init_git/cmd_main not found"); child_exit_code = githug_git_main(argc, (const char **)argv);
fflush(stdout); } else {
fflush(stderr);
_exit(127);
}
git_init((const char **)argv); git_init((const char **)argv);
int child_exit_code = git_main(argc, (const char **)argv); child_exit_code = git_main(argc, (const char **)argv);
}
fflush(stdout); fflush(stdout);
fflush(stderr); fflush(stderr);
_exit(child_exit_code); _exit(child_exit_code);
} }
setpgid(child, child);
close(pipe_fds[1]); close(pipe_fds[1]);
struct output_buffer output = {0}; struct output_buffer output = {0};
@@ -276,6 +379,8 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
int child_status = 0; int child_status = 0;
int child_done = 0; int child_done = 0;
int saw_eof = 0; int saw_eof = 0;
int timed_out = 0;
long long started_at = monotonic_millis();
while (!child_done || !saw_eof) { while (!child_done || !saw_eof) {
if (drain_available_output(pipe_fds[0], &output, &saw_eof) != 0) { if (drain_available_output(pipe_fds[0], &output, &saw_eof) != 0) {
break; break;
@@ -288,6 +393,16 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
child_done = 1; child_done = 1;
} }
} }
if (!child_done && started_at > 0 && monotonic_millis() - started_at > GIT_COMMAND_TIMEOUT_MS) {
timed_out = 1;
kill(-child, SIGKILL);
kill(child, SIGKILL);
while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {
}
child_done = 1;
const char *timeout_message = "\nNative Git execution timed out.\n";
append_output(&output, timeout_message, strlen(timeout_message));
}
if (child_done) { if (child_done) {
if (!saw_eof) { if (!saw_eof) {
drain_available_output(pipe_fds[0], &output, &saw_eof); drain_available_output(pipe_fds[0], &output, &saw_eof);
@@ -300,7 +415,9 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
pthread_mutex_unlock(&git_mutex); pthread_mutex_unlock(&git_mutex);
int exit_code = -1; int exit_code = -1;
if (WIFEXITED(child_status)) { if (timed_out) {
exit_code = 124;
} else if (WIFEXITED(child_status)) {
exit_code = WEXITSTATUS(child_status); exit_code = WEXITSTATUS(child_status);
} else if (WIFSIGNALED(child_status)) { } else if (WIFSIGNALED(child_status)) {
exit_code = 128 + WTERMSIG(child_status); exit_code = 128 + WTERMSIG(child_status);
@@ -315,3 +432,220 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars); (*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return result; return result;
} }
JNIEXPORT jobjectArray JNICALL
Java_solutions_tretter_githugandroid_NativeGitBridge_startGitSessionNative(
JNIEnv *env,
jclass clazz,
jstring library_path,
jstring working_directory,
jobjectArray argv_array,
jobjectArray environment_array
) {
(void)clazz;
const char *library_path_chars = (*env)->GetStringUTFChars(env, library_path, NULL);
const char *working_directory_chars = (*env)->GetStringUTFChars(env, working_directory, NULL);
int argc = 0;
int envc = 0;
char **argv = copy_string_array(env, argv_array, &argc);
char **env_entries = copy_string_array(env, environment_array, &envc);
if (library_path_chars == NULL || working_directory_chars == NULL || argv == NULL || env_entries == NULL) {
return make_session_result(env, 0, 0, -1, "Native Git session failed: out of memory");
}
int master_fd = posix_openpt(O_RDWR | O_NOCTTY);
if (master_fd < 0 || grantpt(master_fd) != 0 || unlockpt(master_fd) != 0) {
if (master_fd >= 0) {
close(master_fd);
}
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not create PTY");
}
char *slave_name = ptsname(master_fd);
if (slave_name == NULL) {
close(master_fd);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not resolve PTY slave");
}
pthread_mutex_lock(&git_mutex);
if (load_git(library_path_chars) != 0) {
close(master_fd);
pthread_mutex_unlock(&git_mutex);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: init_git/cmd_main not found");
}
pid_t child = fork();
if (child < 0) {
close(master_fd);
pthread_mutex_unlock(&git_mutex);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: could not fork");
}
if (child == 0) {
setsid();
int slave_fd = open(slave_name, O_RDWR);
if (slave_fd < 0) {
_exit(127);
}
ioctl(slave_fd, TIOCSCTTY, 0);
dup2(slave_fd, STDIN_FILENO);
dup2(slave_fd, STDOUT_FILENO);
dup2(slave_fd, STDERR_FILENO);
if (slave_fd > STDERR_FILENO) {
close(slave_fd);
}
close(master_fd);
int applied_env = 0;
struct saved_env *saved_env = apply_environment(env_entries, envc, &applied_env);
(void)saved_env;
chdir(working_directory_chars);
int child_exit_code;
if (githug_git_main != NULL) {
child_exit_code = githug_git_main(argc, (const char **)argv);
} else {
git_init((const char **)argv);
child_exit_code = git_main(argc, (const char **)argv);
}
fflush(stdout);
fflush(stderr);
_exit(child_exit_code);
}
setpgid(child, child);
int flags = fcntl(master_fd, F_GETFL, 0);
if (flags >= 0) {
fcntl(master_fd, F_SETFL, flags | O_NONBLOCK);
}
struct git_session *session = calloc(1, sizeof(struct git_session));
if (session == NULL) {
kill(-child, SIGKILL);
close(master_fd);
pthread_mutex_unlock(&git_mutex);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return make_session_result(env, 0, 0, -1, "Native Git session failed: out of memory");
}
session->id = next_git_session_id++;
session->child = child;
session->pty_fd = master_fd;
session->next = git_sessions;
git_sessions = session;
struct output_buffer output = {0};
int saw_eof = 0;
long long started_at = monotonic_millis();
while (started_at > 0 && monotonic_millis() - started_at < GIT_SESSION_MAX_READ_WAIT_MS) {
drain_available_output(session->pty_fd, &output, &saw_eof);
if (output.length > 0) {
break;
}
usleep(10000);
}
int session_id = session->id;
int status = 0;
int running = 1;
int exit_code = -1;
pid_t wait_result = waitpid(child, &status, WNOHANG);
if (wait_result == child) {
running = 0;
exit_code = child_exit_code_from_status(status);
struct git_session **link = NULL;
find_session(session_id, &link);
remove_session(link);
}
jobjectArray result = make_session_result(env, session_id, running, exit_code, output.data);
free(output.data);
pthread_mutex_unlock(&git_mutex);
free_string_array(argv, argc);
free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
return result;
}
JNIEXPORT jobjectArray JNICALL
Java_solutions_tretter_githugandroid_NativeGitBridge_writeGitSessionNative(
JNIEnv *env,
jclass clazz,
jint session_id,
jstring input
) {
(void)clazz;
const char *input_chars = (*env)->GetStringUTFChars(env, input, NULL);
pthread_mutex_lock(&git_mutex);
struct git_session **link = NULL;
struct git_session *session = find_session((int)session_id, &link);
if (session == NULL) {
pthread_mutex_unlock(&git_mutex);
if (input_chars != NULL) {
(*env)->ReleaseStringUTFChars(env, input, input_chars);
}
return make_session_result(env, (int)session_id, 0, -1, "Native Git session is not running.");
}
if (input_chars != NULL) {
size_t input_length = strlen(input_chars);
size_t written = 0;
while (written < input_length) {
ssize_t count = write(session->pty_fd, input_chars + written, input_length - written);
if (count > 0) {
written += (size_t)count;
} else if (errno != EINTR) {
break;
}
}
(*env)->ReleaseStringUTFChars(env, input, input_chars);
}
struct output_buffer output = {0};
int saw_eof = 0;
long long started_at = monotonic_millis();
while (started_at > 0 && monotonic_millis() - started_at < GIT_SESSION_MAX_READ_WAIT_MS) {
drain_available_output(session->pty_fd, &output, &saw_eof);
if (output.length > 0 || saw_eof) {
break;
}
usleep(10000);
}
int status = 0;
int running = 1;
int exit_code = -1;
pid_t wait_result = waitpid(session->child, &status, WNOHANG);
if (wait_result == session->child || saw_eof) {
if (wait_result != session->child) {
while (waitpid(session->child, &status, 0) < 0 && errno == EINTR) {
}
}
running = 0;
exit_code = child_exit_code_from_status(status);
remove_session(link);
}
jobjectArray result = make_session_result(env, (int)session_id, running, exit_code, output.data);
free(output.data);
pthread_mutex_unlock(&git_mutex);
return result;
}

View File

@@ -13,3 +13,47 @@ object AppLog {
Log.e(TAG, "[$area] $message", error) Log.e(TAG, "[$area] $message", error)
} }
} }
internal fun elapsedMillisSince(startNanos: Long): Long =
(System.nanoTime() - startNanos) / 1_000_000L
internal fun RepoState.diagnosticSnapshot(): String = buildString {
append("initialized=")
append(initialized)
append(", headBranch=")
append(headBranch)
append(", currentDir=")
append(currentDir)
append(", branches=")
append(branches.keys.sorted())
append(", tags=")
append(tags.sorted())
append(", remotes=")
append(remotes.toSortedMap())
append(", fetchedBranches=")
append(fetchedBranches.sorted())
append(", fetchHeadCount=")
append(fetchHeadCount)
append(", pushedBranches=")
append(pushedBranches.sorted())
append(", pushedTags=")
append(pushedTags.sorted())
append(", stashes=")
append(stashes)
append(", submodules=")
append(submodules.toSortedMap())
append(", maintenanceActions=")
append(maintenanceActions.sorted())
append(", nativeGitSession=")
append(nativeGitSession)
append(", config=")
append(config.toSortedMap())
append(", files=")
append(files.map { file ->
"${file.name}(staged=${file.staged},tracked=${file.tracked},deleted=${file.deleted})"
}.sorted())
append(", commits=")
append(commits.map { commit ->
"(${commit.id}, parents=${commit.parentCount}, message=${commit.message})"
})
}

View File

@@ -24,6 +24,27 @@ internal fun directoryCompletionCandidates(repo: RepoState): List<String> {
.distinct() .distinct()
} }
internal fun contextualCompletionCandidates(
candidates: List<String>,
commandBeforeToken: String,
token: String,
directoriesOnly: Boolean,
): List<String> {
val matches = candidates.sorted().filter { it.startsWith(token) }
if (directoriesOnly) return matches
val commandTokens = GitSandboxEngine.tokenizeCommand(commandBeforeToken.trim())
if (commandTokens == listOf("git", "bisect", "run")) {
val scriptMatches = matches.filter { candidate ->
val normalized = candidate.removePrefix("./")
normalized.endsWith(".sh") && '/' !in normalized && !normalized.startsWith(".")
}
if (scriptMatches.isNotEmpty()) return scriptMatches
}
return matches
}
private fun RepoState.currentDirPrefix(): String { private fun RepoState.currentDirPrefix(): String {
return if (currentDir == ".") "" else currentDir.trimEnd('/') + "/" return if (currentDir == ".") "" else currentDir.trimEnd('/') + "/"
} }

View File

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

View File

@@ -4,13 +4,13 @@ enum class GitEditorCommandKind {
COMMIT_MESSAGE, COMMIT_MESSAGE,
REBASE_TODO, REBASE_TODO,
TAG_MESSAGE, TAG_MESSAGE,
PATCH_HUNK,
} }
data class GitEditorInvocation( data class GitEditorInvocation(
val command: String, val command: String,
val kind: GitEditorCommandKind, val kind: GitEditorCommandKind,
val title: String, val title: String,
val displayPath: String,
val initialContent: String = "", val initialContent: String = "",
) )
@@ -35,6 +35,7 @@ private fun parseGitCommitEditor(command: String, arguments: List<String>): GitE
command = command, command = command,
kind = GitEditorCommandKind.COMMIT_MESSAGE, kind = GitEditorCommandKind.COMMIT_MESSAGE,
title = "Edit Commit Message", title = "Edit Commit Message",
displayPath = ".git/COMMIT_EDITMSG",
) )
} }
@@ -48,6 +49,7 @@ private fun parseGitRebaseEditor(command: String, arguments: List<String>): GitE
command = command, command = command,
kind = GitEditorCommandKind.REBASE_TODO, kind = GitEditorCommandKind.REBASE_TODO,
title = "Edit Rebase Todo", title = "Edit Rebase Todo",
displayPath = ".git/rebase-merge/git-rebase-todo",
) )
} }
@@ -61,6 +63,7 @@ private fun parseGitTagEditor(command: String, arguments: List<String>): GitEdit
command = command, command = command,
kind = GitEditorCommandKind.TAG_MESSAGE, kind = GitEditorCommandKind.TAG_MESSAGE,
title = "Edit Tag Message", title = "Edit Tag Message",
displayPath = ".git/TAG_EDITMSG",
) )
} }

View File

@@ -2,6 +2,17 @@ package solutions.tretter.githugandroid
import java.io.File import java.io.File
data class GitEditorContinuation(
val invocation: GitEditorInvocation,
val content: String,
)
data class GitEditorExecutionResult(
val repo: RepoState,
val outputLines: List<String>,
val nextEditor: GitEditorContinuation? = null,
)
internal class GitEditorWorkflow( internal class GitEditorWorkflow(
private val requireNativeGit: () -> File, private val requireNativeGit: () -> File,
private val prepareLevel: (Level) -> RepoState, private val prepareLevel: (Level) -> RepoState,
@@ -11,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() }
@@ -32,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(
@@ -57,7 +65,7 @@ internal class GitEditorWorkflow(
currentRepo: RepoState, currentRepo: RepoState,
invocation: GitEditorInvocation, invocation: GitEditorInvocation,
message: String, message: String,
): Pair<RepoState, List<String>> { ): GitEditorExecutionResult {
val nativeGit = requireNativeGit() val nativeGit = requireNativeGit()
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo) val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
return when (invocation.kind) { return when (invocation.kind) {
@@ -82,9 +90,6 @@ internal class GitEditorWorkflow(
message = message, message = message,
) )
GitEditorCommandKind.PATCH_HUNK -> {
currentRepo to listOf("Patch hunk editing is handled by Git, not the Android runtime.")
}
} }
} }
@@ -96,19 +101,39 @@ internal class GitEditorWorkflow(
workingDir: File, workingDir: File,
invocation: GitEditorInvocation, invocation: GitEditorInvocation,
message: String, message: String,
): Pair<RepoState, List<String>> { ): GitEditorExecutionResult {
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply { val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
parentFile?.mkdirs() parentFile?.mkdirs()
writeText(message) writeText(message)
} }
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command) val commandTokens = GitSandboxEngine.tokenizeCommand(invocation.command)
val arguments = commandTokens
.drop(1) .drop(1)
.toMutableList() .toMutableList()
.apply { addAll(listOf("-F", messageFile.absolutePath)) } .apply { addAll(listOf("-F", messageFile.absolutePath)) }
val result = runGit(nativeGit, workingDir, arguments, emptyMap()) val result = runGit(nativeGit, workingDir, arguments, emptyMap())
messageFile.delete() messageFile.delete()
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines if (
result.exitCode == 0 &&
invocation.kind == GitEditorCommandKind.COMMIT_MESSAGE &&
commandTokens.drop(1).isCommitAmendCommand() &&
rebaseStateExists(File(sandboxRoot, ".git"))
) {
return runRebaseContinueCapturingEditor(
level = level,
currentRepo = currentRepo,
nativeGit = nativeGit,
sandboxRoot = sandboxRoot,
workingDir = workingDir,
leadingOutput = result.outputLines,
)
}
return GitEditorExecutionResult(
repo = inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir),
outputLines = result.outputLines,
)
} }
private fun executeInteractiveRebase( private fun executeInteractiveRebase(
@@ -119,7 +144,7 @@ internal class GitEditorWorkflow(
workingDir: File, workingDir: File,
invocation: GitEditorInvocation, invocation: GitEditorInvocation,
todo: String, todo: String,
): Pair<RepoState, List<String>> { ): GitEditorExecutionResult {
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() } val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply { val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
writeText(todo) writeText(todo)
@@ -132,19 +157,106 @@ internal class GitEditorWorkflow(
|""".trimMargin(), |""".trimMargin(),
) )
} }
val capture = editorCaptureFiles(gitDir)
capture.content.delete()
capture.path.delete()
val messageEditorScript = createCaptureEditorScript(gitDir, capture)
val result = runGit( val result = runGit(
nativeGit, nativeGit,
workingDir, workingDir,
GitSandboxEngine.tokenizeCommand(invocation.command).drop(1), GitSandboxEngine.tokenizeCommand(invocation.command).drop(1),
mapOf( mapOf(
"GIT_SEQUENCE_EDITOR" to editorCommand(editorScript), "GIT_SEQUENCE_EDITOR" to editorCommand(editorScript),
"GIT_EDITOR" to "true", "GIT_EDITOR" to editorCommand(messageEditorScript),
), ),
) )
todoFile.delete() todoFile.delete()
editorScript.delete() editorScript.delete()
messageEditorScript.delete()
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines val nextEditor = capturedCommitMessageEditor(sandboxRoot, capture)
capture.content.delete()
capture.path.delete()
return GitEditorExecutionResult(
repo = inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir),
outputLines = result.outputLines,
nextEditor = nextEditor,
)
}
private fun runRebaseContinueCapturingEditor(
level: Level,
currentRepo: RepoState,
nativeGit: File,
sandboxRoot: File,
workingDir: File,
leadingOutput: List<String>,
): GitEditorExecutionResult {
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
val capture = editorCaptureFiles(gitDir)
capture.content.delete()
capture.path.delete()
val messageEditorScript = createCaptureEditorScript(gitDir, capture)
val result = runGit(
nativeGit,
workingDir,
listOf("rebase", "--continue"),
mapOf("GIT_EDITOR" to editorCommand(messageEditorScript)),
)
messageEditorScript.delete()
val nextEditor = capturedCommitMessageEditor(sandboxRoot, capture)
capture.content.delete()
capture.path.delete()
return GitEditorExecutionResult(
repo = inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir),
outputLines = leadingOutput + result.outputLines,
nextEditor = nextEditor,
)
}
private data class EditorCaptureFiles(
val content: File,
val path: File,
)
private fun editorCaptureFiles(gitDir: File): EditorCaptureFiles = EditorCaptureFiles(
content = File(gitDir, "GITHUG_ANDROID_CAPTURED_EDITOR"),
path = File(gitDir, "GITHUG_ANDROID_CAPTURED_EDITOR_PATH"),
)
private fun createCaptureEditorScript(gitDir: File, capture: EditorCaptureFiles): File {
return File(gitDir, "githug-android-capture-message-editor.sh").apply {
writeText(
"""
|#!/bin/sh
|cat "$1" > ${capture.content.absolutePath.toShellSingleQuoted()}
|printf '%s\n' "$1" > ${capture.path.absolutePath.toShellSingleQuoted()}
|exit 1
|""".trimMargin(),
)
}
}
private fun capturedCommitMessageEditor(sandboxRoot: File, capture: EditorCaptureFiles): GitEditorContinuation? {
val content = capture.content.takeIf { it.isFile }?.readText() ?: return null
val capturedPath = capture.path.takeIf { it.isFile }?.readText()?.trim().orEmpty()
val displayPath = capturedPath
.takeIf { it.isNotBlank() }
?.let { File(it).relativeToOrSelf(sandboxRoot).path }
?: ".git/COMMIT_EDITMSG"
return GitEditorContinuation(
invocation = GitEditorInvocation(
command = "git commit --amend",
kind = GitEditorCommandKind.COMMIT_MESSAGE,
title = "Edit Commit Message",
displayPath = displayPath,
initialContent = content,
),
content = content,
)
} }
private fun repositoryPaths(level: Level, currentRepo: RepoState): Pair<File, File> { private fun repositoryPaths(level: Level, currentRepo: RepoState): Pair<File, File> {
@@ -166,6 +278,10 @@ internal class GitEditorWorkflow(
return File(gitDir, "rebase-merge").exists() || File(gitDir, "rebase-apply").exists() return File(gitDir, "rebase-merge").exists() || File(gitDir, "rebase-apply").exists()
} }
private fun List<String>.isCommitAmendCommand(): Boolean {
return firstOrNull() == "commit" && any { it == "--amend" }
}
private fun String.toShellSingleQuoted(): String { private fun String.toShellSingleQuoted(): String {
return "'" + replace("'", "'\"'\"'") + "'" return "'" + replace("'", "'\"'\"'") + "'"
} }

View File

@@ -34,8 +34,10 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable @Composable
fun GitHugApp() { fun GitHugApp() {
@@ -73,6 +75,8 @@ fun GitHugApp() {
var historyIndex by remember { mutableStateOf(-1) } var historyIndex by remember { mutableStateOf(-1) }
var historyDraft by remember { mutableStateOf("") } var historyDraft by remember { mutableStateOf("") }
var hasRestoredProgress by remember { mutableStateOf(false) } var hasRestoredProgress by remember { mutableStateOf(false) }
var isPreparingLevel by remember { mutableStateOf(false) }
var levelLoadRequestId by remember { mutableStateOf(0) }
var editorState by remember { mutableStateOf<TextEditorState?>(null) } var editorState by remember { mutableStateOf<TextEditorState?>(null) }
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) } var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
var manPageState by remember { mutableStateOf<ManPageState?>(null) } var manPageState by remember { mutableStateOf<ManPageState?>(null) }
@@ -107,8 +111,10 @@ fun GitHugApp() {
} }
LaunchedEffect(currentLevelIndex) { LaunchedEffect(currentLevelIndex) {
AppLog.d("GitHugApp", "Current level index changed to $currentLevelIndex id=${levels[currentLevelIndex].id}")
delay(100) delay(100)
screenScrollState.animateScrollTo(0) screenScrollState.animateScrollTo(0)
AppLog.d("GitHugApp", "Scrolled to top for level index=$currentLevelIndex id=${levels[currentLevelIndex].id}")
} }
fun updatePaneLayout(transform: (PaneLayout) -> PaneLayout, persist: Boolean = true) { fun updatePaneLayout(transform: (PaneLayout) -> PaneLayout, persist: Boolean = true) {
@@ -210,7 +216,9 @@ fun GitHugApp() {
} }
fun resetCurrentLevel(message: String = "Level reset.") { fun resetCurrentLevel(message: String = "Level reset.") {
val startedAt = System.nanoTime()
val resetLevelId = currentLevel.id val resetLevelId = currentLevel.id
AppLog.d("GitHugApp", "Reset requested level=$resetLevelId")
val updatedCompletedLevels = completedLevels - resetLevelId val updatedCompletedLevels = completedLevels - resetLevelId
completedLevels = updatedCompletedLevels completedLevels = updatedCompletedLevels
scope.launch { persistProgress(updatedCompletedLevels, resetLevelId) } scope.launch { persistProgress(updatedCompletedLevels, resetLevelId) }
@@ -227,17 +235,25 @@ fun GitHugApp() {
gitMessageEditorState = null gitMessageEditorState = null
manPageState = null manPageState = null
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(listOf(message))) applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(listOf(message)))
AppLog.d(
"GitHugApp",
"Reset finished level=$resetLevelId durationMs=${elapsedMillisSince(startedAt)} repo=${repo.diagnosticSnapshot()}",
)
} }
fun loadLevel( fun loadLevel(
index: Int, index: Int,
message: List<String> = listOf("Loaded level: ${levels[index].title}"), message: List<String> = listOf("Loaded level: ${levels[index].title}"),
keepSuppressedImeEcho: Boolean = false, keepSuppressedImeEcho: Boolean = false,
prepareInBackground: Boolean = false,
) { ) {
AppLog.d("GitHugApp", "Loading level index=$index id=${levels[index].id} title=${levels[index].title}") val startedAt = System.nanoTime()
val level = levels[index]
val requestId = levelLoadRequestId + 1
levelLoadRequestId = requestId
AppLog.d("GitHugApp", "Loading level index=$index id=${level.id} title=${level.title} background=$prepareInBackground")
currentLevelIndex = index currentLevelIndex = index
repo = runtime.prepareLevel(levels[index]) output = if (prepareInBackground) listOf("Preparing level: ${level.title}") else message
output = message
clearCommandInput(recreateField = true) clearCommandInput(recreateField = true)
if (!keepSuppressedImeEcho) { if (!keepSuppressedImeEcho) {
suppressedImeEcho = null suppressedImeEcho = null
@@ -250,10 +266,54 @@ fun GitHugApp() {
editorState = null editorState = null
gitMessageEditorState = null gitMessageEditorState = null
manPageState = null manPageState = null
if (prepareInBackground) {
isPreparingLevel = true
repo = RepoState()
AppLog.d(
"GitHugApp",
"Level load state updated index=$index id=${level.id} requestId=$requestId durationMs=${elapsedMillisSince(startedAt)} preparing=true",
)
scope.launch {
val prepareStartedAt = System.nanoTime()
AppLog.d("GitHugApp", "Background prepare started index=$index id=${level.id} requestId=$requestId")
val preparedRepo = withContext(Dispatchers.Default) {
runtime.prepareLevel(level)
}
val prepareMs = elapsedMillisSince(prepareStartedAt)
if (levelLoadRequestId == requestId) {
AppLog.d(
"GitHugApp",
"Background prepare applying index=$index id=${level.id} requestId=$requestId prepareMs=$prepareMs " +
"repo=${preparedRepo.diagnosticSnapshot()}",
)
repo = preparedRepo
output = message
isPreparingLevel = false
clearCommandInput(recreateField = true)
AppLog.d(
"GitHugApp",
"Background prepare applied index=$index id=${level.id} requestId=$requestId totalMs=${elapsedMillisSince(startedAt)}",
)
} else {
AppLog.d(
"GitHugApp",
"Background prepare discarded index=$index id=${level.id} requestId=$requestId activeRequestId=$levelLoadRequestId prepareMs=$prepareMs",
)
}
}
} else {
isPreparingLevel = false
val prepareStartedAt = System.nanoTime()
repo = runtime.prepareLevel(level)
AppLog.d(
"GitHugApp",
"Synchronous prepare applied index=$index id=${level.id} prepareMs=${elapsedMillisSince(prepareStartedAt)}",
)
}
paneLayout = paneLayout.copy( paneLayout = paneLayout.copy(
weights = paneLayout.weights + recommendedPaneWeights( weights = paneLayout.weights + recommendedPaneWeights(
heights = recommendedPaneHeights( heights = recommendedPaneHeights(
level = levels[index], level = level,
levelCount = levels.size, levelCount = levels.size,
outputLineCount = 1, outputLineCount = 1,
screenHeightDp = screenHeightDp, screenHeightDp = screenHeightDp,
@@ -262,6 +322,10 @@ fun GitHugApp() {
), ),
) )
) )
AppLog.d(
"GitHugApp",
"Level load returned index=$index id=${level.id} requestId=$requestId durationMs=${elapsedMillisSince(startedAt)} preparing=$isPreparingLevel",
)
} }
fun setCommandText(text: String) { fun setCommandText(text: String) {
@@ -305,7 +369,12 @@ fun GitHugApp() {
if (token.isBlank() && !isCdCompletion) return if (token.isBlank() && !isCdCompletion) return
val candidates = runtime.completionCandidates(currentLevel, repo, directoriesOnly = isCdCompletion) val candidates = runtime.completionCandidates(currentLevel, repo, directoriesOnly = isCdCompletion)
val matches = candidates.sorted().filter { it.startsWith(token) } val matches = contextualCompletionCandidates(
candidates = candidates,
commandBeforeToken = beforeCursor.substring(0, tokenStart),
token = token,
directoriesOnly = isCdCompletion,
)
if (matches.isEmpty()) return if (matches.isEmpty()) return
val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches) val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches)
@@ -322,12 +391,19 @@ 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 levelForResult = currentLevel val levelForResult = currentLevel
val solvedAfterCommand = levelForResult.validator(newRepo, raw) val validationBlockedByEditor = editorState != null || gitMessageEditorState != null || newRepo.nativeGitSession != null
val solvedAfterCommand = if (validationBlockedByEditor) {
false
} else {
levelForResult.validator(newRepo, raw)
}
val wasAlreadyCompleted = currentLevel.id in completedLevels val wasAlreadyCompleted = currentLevel.id in completedLevels
AppLog.d( AppLog.d(
"GitHugApp", "GitHugApp",
"Command='$raw' level=${levelForResult.id} solved=$solvedAfterCommand alreadyCompleted=$wasAlreadyCompleted completedBefore=${completedLevels.sorted()}", "Command='$raw' level=${levelForResult.id} solved=$solvedAfterCommand validationBlockedByEditor=$validationBlockedByEditor alreadyCompleted=$wasAlreadyCompleted " +
"completedBefore=${completedLevels.sorted()} outputLineCount=${lines.size} repo=${newRepo.diagnosticSnapshot()}",
) )
val newOutput = buildList { val newOutput = buildList {
addAll(output) addAll(output)
@@ -370,6 +446,10 @@ fun GitHugApp() {
output = newOutput output = newOutput
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput)) applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(newOutput))
} }
AppLog.d(
"GitHugApp",
"Command result applied level=${levelForResult.id} solved=$solvedAfterCommand durationMs=${elapsedMillisSince(startedAt)}",
)
} }
fun openEditor(invocation: VisualEditorInvocation) { fun openEditor(invocation: VisualEditorInvocation) {
@@ -416,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 {
@@ -443,21 +522,39 @@ fun GitHugApp() {
fun saveGitMessageEditor() { fun saveGitMessageEditor() {
val state = gitMessageEditorState ?: return val state = gitMessageEditorState ?: return
val (newRepo, lines) = runtime.executeGitEditorCommand( val result = runtime.executeGitEditorCommandWithResult(
level = currentLevel, level = currentLevel,
currentRepo = repo, currentRepo = repo,
invocation = state.invocation, invocation = state.invocation,
message = state.content, message = state.content,
) )
gitMessageEditorState = null gitMessageEditorState = result.nextEditor?.let { nextEditor ->
applyCommandResult(state.invocation.command, newRepo, lines, echoCommand = false) GitMessageEditorState(
invocation = nextEditor.invocation,
content = nextEditor.content,
)
}
applyCommandResult(state.invocation.command, result.repo, result.outputLines, echoCommand = false)
} }
fun runCommand() { fun runCommand() {
val startedAt = System.nanoTime()
val submittedText = commandInput.text val submittedText = commandInput.text
val raw = submittedText.trim() val raw = submittedText.trim()
if (raw.isBlank()) return if (raw.isBlank()) return
val isInteractiveInput = repo.interactiveAddSession != null if (isPreparingLevel) {
AppLog.d("GitHugApp", "Command blocked while preparing level=${currentLevel.id} raw='$raw'")
output = output + "Still preparing ${currentLevel.title}. Try again in a moment."
clearCommandInput(recreateField = true)
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(output))
return
}
val submittedLevelId = currentLevel.id
val isInteractiveInput = repo.nativeGitSession != null
AppLog.d(
"GitHugApp",
"Command submitted level=$submittedLevelId raw='$raw' interactive=$isInteractiveInput repo=${repo.diagnosticSnapshot()}",
)
showHelpOverlay = false showHelpOverlay = false
showTerminalInputHint = false showTerminalInputHint = false
@@ -489,14 +586,9 @@ 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)}")
} }
fun showCommandHelp() { fun showCommandHelp() {
@@ -560,7 +652,9 @@ fun GitHugApp() {
}) })
}, },
levelsContent = { levelsContent = {
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) } LevelsPane(levels, currentLevelIndex, completedLevels) { index ->
loadLevel(index, prepareInBackground = true)
}
}, },
visualContent = { VisualPane(repo) }, visualContent = { VisualPane(repo) },
exerciseContent = { exerciseContent = {

View File

@@ -1,158 +0,0 @@
package solutions.tretter.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
data class GitMessageEditorState(
val invocation: GitEditorInvocation,
val content: String,
)
@Composable
fun GitMessageEditorDialog(
state: GitMessageEditorState,
onContentChange: (String) -> Unit,
onClose: () -> Unit,
onSave: () -> Unit,
) {
val horizontalScroll = rememberScrollState()
val verticalScroll = rememberScrollState()
val dialogScroll = rememberScrollState()
val contentFocusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(state.invocation.command) {
contentFocusRequester.requestFocus()
keyboardController?.show()
}
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.verticalScroll(dialogScroll)
.padding(12.dp),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 360.dp, max = 720.dp),
color = PanelPrimary,
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = state.invocation.title,
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
EditorButton(label = "Save", enabled = state.content.isNotBlank(), onClick = onSave)
EditorButton(label = "Dismiss", onClick = onClose)
}
Text(
text = state.invocation.command,
color = TextSecondary,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.heightIn(min = 260.dp)
.background(TerminalBackground, RoundedCornerShape(6.dp))
.padding(10.dp)
.horizontalScroll(horizontalScroll)
.verticalScroll(verticalScroll),
) {
BasicTextField(
value = state.content,
onValueChange = onContentChange,
modifier = Modifier
.sizeIn(minWidth = 1200.dp, minHeight = 1200.dp)
.focusRequester(contentFocusRequester),
textStyle = TextStyle(
color = TextPrimary,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
),
cursorBrush = SolidColor(Accent),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
),
)
}
}
}
}
}
}
@Composable
private fun EditorButton(
label: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = AppBackground,
disabledContainerColor = PanelTertiary,
disabledContentColor = TextMuted,
),
) {
Text(label)
}
}

View File

@@ -3,12 +3,18 @@ package solutions.tretter.githugandroid
import android.content.Context import android.content.Context
import android.system.Os import android.system.Os
import java.io.File import java.io.File
import java.io.InputStream
import java.nio.file.Files import java.nio.file.Files
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
internal class GitProcessRunner( internal class GitProcessRunner(
private val context: Context?, private val context: Context?,
private val sandboxesRoot: File, private val sandboxesRoot: File,
) { ) {
private val nextHostSessionId = AtomicInteger(1)
private val hostSessions = ConcurrentHashMap<Int, HostGitSession>()
private companion object { private companion object {
val RequiredGitCommandAliases = listOf( val RequiredGitCommandAliases = listOf(
"add", "add",
@@ -54,11 +60,50 @@ internal class GitProcessRunner(
arguments: List<String>, arguments: List<String>,
environment: Map<String, String> = emptyMap(), environment: Map<String, String> = emptyMap(),
): ProcessExecutionResult { ): ProcessExecutionResult {
if (context != null) { val startedAt = System.nanoTime()
gitExecDirectory(binary) val execStartedAt = System.nanoTime()
return NativeGitBridge.runGitMain(binary, workingDir, arguments, gitEnvironment(binary, workingDir, environment)) val gitExecPath = gitExecDirectory(binary)
val execDirectoryMs = elapsedMillisSince(execStartedAt)
val fullEnvironment = gitEnvironment(binary, workingDir, environment, gitExecPath)
AppLog.d(
"GitProcess",
"runGit start cwd=${workingDir.absolutePath} argv=${formatGitArgv(arguments)} " +
"extraEnvKeys=${environment.keys.sorted()} execDirectoryMs=$execDirectoryMs",
)
val result = if (context != null) {
NativeGitBridge.runGitMain(binary, workingDir, arguments, fullEnvironment)
} else {
runProcess(binary, workingDir, arguments, fullEnvironment)
}
AppLog.d(
"GitProcess",
"runGit finish exit=${result.exitCode} durationMs=${elapsedMillisSince(startedAt)} " +
"output=${formatOutputPreview(result.outputLines)}",
)
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)
} }
return runProcess(binary, workingDir, arguments, environment)
} }
fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult { fun runShellProcess(binary: File, workingDir: File, arguments: List<String>): ProcessExecutionResult {
@@ -84,15 +129,14 @@ internal class GitProcessRunner(
binary: File, binary: File,
workingDir: File, workingDir: File,
arguments: List<String>, arguments: List<String>,
extraEnvironment: Map<String, String> = emptyMap(), environment: Map<String, String>,
): ProcessExecutionResult { ): ProcessExecutionResult {
return try { return try {
val gitExecPath = gitExecDirectory(binary)
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments) val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
.directory(workingDir) .directory(workingDir)
.redirectErrorStream(true) .redirectErrorStream(true)
.apply { .apply {
environment().putAll(gitEnvironment(binary, workingDir, extraEnvironment, gitExecPath)) environment().putAll(environment)
} }
.start() .start()
@@ -104,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")
@@ -151,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)
} }
} }
@@ -192,9 +298,57 @@ internal class GitProcessRunner(
} }
alias.setExecutable(true, false) alias.setExecutable(true, false)
} }
private fun formatGitArgv(arguments: List<String>): String =
(listOf("git") + arguments).joinToString(" ") { argument ->
if (argument.any { it.isWhitespace() || it == '"' || it == '\'' }) {
"'" + argument.replace("'", "'\\''") + "'"
} else {
argument
}
}
private fun formatOutputPreview(lines: List<String>): String {
if (lines.isEmpty()) return "[]"
val preview = lines
.take(8)
.joinToString(" | ")
.take(1_000)
val suffix = if (lines.size > 8) " ... (${lines.size} lines)" else " (${lines.size} lines)"
return "[$preview]$suffix"
}
} }
internal data class ProcessExecutionResult( internal data class ProcessExecutionResult(
val exitCode: Int, val exitCode: Int,
val outputLines: List<String>, val outputLines: List<String>,
) )
internal data class GitSessionResult(
val sessionId: Int,
val running: Boolean,
val exitCode: Int?,
val outputLines: List<String>,
)
private data class HostGitSession(
val process: Process,
)
private fun InputStream.readAvailableText(): String {
val output = StringBuilder()
val buffer = ByteArray(4096)
while (available() > 0) {
val count = read(buffer)
if (count <= 0) break
output.append(String(buffer, 0, count))
}
return output.toString()
}
private fun String.toOutputLines(): List<String> =
replace("\r\n", "\n")
.replace('\r', '\n')
.lineSequence()
.toList()
.dropLastWhile { it.isEmpty() }

View File

@@ -7,33 +7,51 @@ internal class GitRepositoryInspector(
private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult, private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
) { ) {
fun inspectConfig(sandbox: File, currentDir: String = "."): Map<String, String> { fun inspectConfig(sandbox: File, currentDir: String = "."): Map<String, String> {
val startedAt = System.nanoTime()
val git = nativeGit() val git = nativeGit()
val workingDir = File(sandbox, currentDir).canonicalFile val workingDir = File(sandbox, currentDir).canonicalFile
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) } .takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
?: sandbox ?: sandbox
val repositoryRoot = repositoryRoot(sandbox, workingDir) ?: sandbox val repositoryRoot = repositoryRoot(sandbox, workingDir) ?: sandbox
return readConfig(git, repositoryRoot) val config = readConfig(git, repositoryRoot)
AppLog.d(
"GitInspector",
"inspectConfig sandbox=${sandbox.name} currentDir=$currentDir durationMs=${elapsedMillisSince(startedAt)} config=${config.toSortedMap()}",
)
return config
} }
fun inspect(sandbox: File, currentDir: String = "."): RepoState { fun inspect(sandbox: File, currentDir: String = "."): RepoState {
val startedAt = System.nanoTime()
val git = nativeGit() val git = nativeGit()
val workingDir = File(sandbox, currentDir).canonicalFile val workingDir = File(sandbox, currentDir).canonicalFile
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) } .takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
?: sandbox ?: sandbox
val repositoryRoot = repositoryRoot(sandbox, workingDir) val repositoryRoot = repositoryRoot(sandbox, workingDir)
val inspectionRoot = repositoryRoot ?: sandbox val inspectionRoot = repositoryRoot ?: sandbox
AppLog.d(
"GitInspector",
"inspect start sandbox=${sandbox.name} currentDir=$currentDir workingDir=${workingDir.absolutePath} " +
"repositoryRoot=${repositoryRoot?.absolutePath ?: "<none>"}",
)
val filesOnDisk = inspectionRoot.walkTopDown() val filesOnDisk = inspectionRoot.walkTopDown()
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") } .filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
.orEmpty() .orEmpty()
.toList() .toList()
if (repositoryRoot == null) { if (repositoryRoot == null) {
return 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())
}, },
) )
AppLog.d(
"GitInspector",
"inspect finish sandbox=${sandbox.name} durationMs=${elapsedMillisSince(startedAt)} repo=${repo.diagnosticSnapshot()}",
)
return repo
} }
val statusResult = run(git, inspectionRoot, listOf("status", "--porcelain")) val statusResult = run(git, inspectionRoot, listOf("status", "--porcelain"))
@@ -124,9 +142,10 @@ internal class GitRepositoryInspector(
?.let { "tags/$it" } ?.let { "tags/$it" }
?: "DETACHED" ?: "DETACHED"
return 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(
@@ -134,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 } }
@@ -144,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,
@@ -172,6 +193,11 @@ internal class GitRepositoryInspector(
submodules = submodules, submodules = submodules,
maintenanceActions = maintenanceActions, maintenanceActions = maintenanceActions,
) )
AppLog.d(
"GitInspector",
"inspect finish sandbox=${sandbox.name} durationMs=${elapsedMillisSince(startedAt)} repo=${repo.diagnosticSnapshot()}",
)
return repo
} }
private fun run( private fun run(
@@ -181,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 ->
@@ -200,9 +234,33 @@ internal class GitRepositoryInspector(
userEmailResult.outputLines.firstOrNull() userEmailResult.outputLines.firstOrNull()
?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() } ?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() }
?.let { put("user.email", it) } ?.let { put("user.email", it) }
putAll(readGitConfigFile(inspectionRoot).filterKeys { it !in keys })
} }
} }
private fun readGitConfigFile(inspectionRoot: File): Map<String, String> {
val configFile = File(inspectionRoot, ".git/config")
if (!configFile.isFile) return emptyMap()
val entries = linkedMapOf<String, String>()
var section = ""
configFile.forEachLine { rawLine ->
val line = rawLine.trim()
when {
line.isBlank() || line.startsWith("#") || line.startsWith(";") -> return@forEachLine
line.startsWith("[") && line.endsWith("]") -> {
section = line.removePrefix("[").removeSuffix("]").trim().substringBefore(' ')
}
"=" in line && section.isNotBlank() -> {
val key = line.substringBefore('=').trim()
val value = line.substringAfter('=').trim()
entries["$section.$key"] = value
}
}
}
return entries
}
private fun inspectRemoteRefs(git: File, inspectionRoot: File, remoteLines: List<String>): RemoteRefs { private fun inspectRemoteRefs(git: File, inspectionRoot: File, remoteLines: List<String>): RemoteRefs {
val remotes = remoteLines.mapNotNull { line -> val remotes = remoteLines.mapNotNull { line ->
val parts = line.trim().split(Regex("\\s+")) val parts = line.trim().split(Regex("\\s+"))

View File

@@ -68,14 +68,19 @@ class GitRepositoryRuntime private constructor(
} }
fun prepareLevel(level: Level): RepoState { fun prepareLevel(level: Level): RepoState {
val startedAt = System.nanoTime()
AppLog.d("GitRuntime", "Preparing level id=${level.id} title=${level.title}") AppLog.d("GitRuntime", "Preparing level id=${level.id} title=${level.title}")
val nativeGit = requireNativeGit() val nativeGit = requireNativeGit()
val sandbox = sandboxDir(level) val sandbox = sandboxDir(level)
val resetStartedAt = System.nanoTime()
sandbox.deleteRecursively() sandbox.deleteRecursively()
sandbox.mkdirs() sandbox.mkdirs()
val resetMs = elapsedMillisSince(resetStartedAt)
val desired = level.setup() val desired = level.setup()
AppLog.d("GitRuntime", "Level setup desired id=${level.id} repo=${desired.diagnosticSnapshot()}")
val filesStartedAt = System.nanoTime()
desired.files.forEach { file -> desired.files.forEach { file ->
File(sandbox, file.name).apply { File(sandbox, file.name).apply {
parentFile?.mkdirs() parentFile?.mkdirs()
@@ -83,8 +88,10 @@ class GitRepositoryRuntime private constructor(
} }
} }
File(sandbox, desired.currentDir).mkdirs() File(sandbox, desired.currentDir).mkdirs()
val filesMs = elapsedMillisSince(filesStartedAt)
val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty() val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty()
val materializeStartedAt = System.nanoTime()
if (needsGit) { if (needsGit) {
val initResult = runGit(nativeGit, sandbox, listOf("init", "-b", desired.headBranch)) val initResult = runGit(nativeGit, sandbox, listOf("init", "-b", desired.headBranch))
if (initResult.exitCode != 0) { if (initResult.exitCode != 0) {
@@ -94,12 +101,22 @@ class GitRepositoryRuntime private constructor(
levelMaterializer.materialize(nativeGit, sandbox, desired, level) levelMaterializer.materialize(nativeGit, sandbox, desired, level)
} }
val materializeMs = elapsedMillisSince(materializeStartedAt)
return inspectSandbox(level, desired.currentDir).copy(currentDir = desired.currentDir) val inspectStartedAt = System.nanoTime()
val preparedRepo = inspectSandbox(level, desired.currentDir).copy(currentDir = desired.currentDir)
val inspectMs = elapsedMillisSince(inspectStartedAt)
AppLog.d(
"GitRuntime",
"Prepared level id=${level.id} durationMs=${elapsedMillisSince(startedAt)} resetMs=$resetMs " +
"filesMs=$filesMs materializeMs=$materializeMs inspectMs=$inspectMs repo=${preparedRepo.diagnosticSnapshot()}",
)
return preparedRepo
} }
fun execute(level: Level, currentRepo: RepoState, command: String): Pair<RepoState, List<String>> { fun execute(level: Level, currentRepo: RepoState, command: String): Pair<RepoState, List<String>> {
AppLog.d("GitRuntime", "Executing command for level=${level.id}: $command") val startedAt = System.nanoTime()
AppLog.d("GitRuntime", "Executing command for level=${level.id}: $command currentRepo=${currentRepo.diagnosticSnapshot()}")
val nativeGit = requireNativeGit() val nativeGit = requireNativeGit()
val sandbox = sandboxDir(level) val sandbox = sandboxDir(level)
@@ -120,18 +137,98 @@ class GitRepositoryRuntime private constructor(
} else { } else {
expandShellPathspecs(currentRepo, invocation.command) expandShellPathspecs(currentRepo, invocation.command)
} }
AppLog.d(
"GitRuntime",
"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" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines "git" -> {
val gitResult = runGit(
nativeGit,
workingDir,
normalizeGitArgumentsForAndroid(expandedTokens.drop(1)),
invocation.environment,
)
AppLog.d(
"GitRuntime",
"Git command result level=${level.id} exit=${gitResult.exitCode} output=${gitResult.outputLines}",
)
currentRepo to gitResult.outputLines
}
"help", "?" -> currentRepo to commandReferenceLines() "help", "?" -> currentRepo to commandReferenceLines()
else -> helperCommands.executeExecutableShortcut(sandboxRoot, workingDir, currentRepo, expandedTokens) else -> helperCommands.executeExecutableShortcut(sandboxRoot, workingDir, currentRepo, expandedTokens)
?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens) ?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens)
} }
val refreshStartedAt = System.nanoTime()
val refreshedRepo = refreshRepoAfterCommand(level, currentRepo, result.first, expandedTokens) val refreshedRepo = refreshRepoAfterCommand(level, currentRepo, result.first, expandedTokens)
val refreshMs = elapsedMillisSince(refreshStartedAt)
AppLog.d(
"GitRuntime",
"Command finished level=${level.id} durationMs=${elapsedMillisSince(startedAt)} refreshMs=$refreshMs " +
"repo=${refreshedRepo.diagnosticSnapshot()}",
)
return refreshedRepo to result.second return refreshedRepo to result.second
} }
private fun normalizeGitArgumentsForAndroid(arguments: List<String>): List<String> {
if (
arguments.size >= 3 &&
arguments[0] == "bisect" &&
arguments[1] == "run" &&
arguments[2].startsWith("./") &&
arguments[2].endsWith(".sh")
) {
return arguments.take(2) + listOf("sh", arguments[2]) + arguments.drop(3)
}
return arguments
}
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> { fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
requireNativeGit() requireNativeGit()
val sandbox = sandboxDir(level) val sandbox = sandboxDir(level)
@@ -161,7 +258,7 @@ class GitRepositoryRuntime private constructor(
addAll(GitSandboxEngine.commandReferenceLines()) addAll(GitSandboxEngine.commandReferenceLines())
add("Native Git runtime:") add("Native Git runtime:")
add(" binary path: nativeLibraryDir/libgit.so") add(" binary path: nativeLibraryDir/libgit.so")
add(" invocation: init_git(argv), then cmd_main(argc, argv)") add(" invocation: githug_git_main(argc, argv)")
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}") 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(" 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") add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad")
@@ -230,6 +327,16 @@ class GitRepositoryRuntime private constructor(
invocation: GitEditorInvocation, invocation: GitEditorInvocation,
message: String, message: String,
): Pair<RepoState, List<String>> { ): Pair<RepoState, List<String>> {
val result = executeGitEditorCommandWithResult(level, currentRepo, invocation, message)
return result.repo to result.outputLines
}
fun executeGitEditorCommandWithResult(
level: Level,
currentRepo: RepoState,
invocation: GitEditorInvocation,
message: String,
): GitEditorExecutionResult {
return editorWorkflow.execute(level, currentRepo, invocation, message) return editorWorkflow.execute(level, currentRepo, invocation, message)
} }
@@ -298,6 +405,33 @@ class GitRepositoryRuntime private constructor(
} }
} }
private fun List<String>.requiresGitTerminalSession(): Boolean {
if (firstOrNull() != "git") return false
val commandIndex = gitSubcommandIndex() ?: return false
val command = this[commandIndex]
if (command == "rebase") return false
return drop(commandIndex + 1).any { it == "-i" || it == "--interactive" || it == "-p" || it == "--patch" }
}
private fun List<String>.gitSubcommandIndex(): Int? {
var index = 1
while (index < size) {
val argument = this[index]
if (argument == "--") return null
if (!argument.startsWith("-")) return index
index += when {
argument in setOf("-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path") -> 2
argument.startsWith("-C") && argument.length > 2 -> 1
argument.startsWith("--git-dir=") ||
argument.startsWith("--work-tree=") ||
argument.startsWith("--namespace=") ||
argument.startsWith("--exec-path=") -> 1
else -> 1
}
}
return null
}
private fun List<String>.isGitConfigCommand(): Boolean { private fun List<String>.isGitConfigCommand(): Boolean {
return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config" return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config"
} }

View File

@@ -6,12 +6,6 @@ object GitSandboxEngine {
val quoted: Boolean = false, val quoted: Boolean = false,
) )
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? =
InteractiveAddEngine.parsePatchHunkEditorInvocation(repo, command)
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> =
InteractiveAddEngine.applyPatchHunkEdit(repo, content)
fun commandReferenceLines(): List<String> = listOf( fun commandReferenceLines(): List<String> = listOf(
"Available sandbox commands:", "Available sandbox commands:",
" git ", " git ",
@@ -31,9 +25,6 @@ object GitSandboxEngine {
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> { fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
val shellParts = tokenizeShellCommand(command) val shellParts = tokenizeShellCommand(command)
if (shellParts.isEmpty()) return repo to emptyList() if (shellParts.isEmpty()) return repo to emptyList()
repo.interactiveAddSession?.let {
return InteractiveAddEngine.handleInput(repo, command)
}
return SandboxCommandEngine.execute(repo, shellParts) return SandboxCommandEngine.execute(repo, shellParts)
} }

View File

@@ -1,287 +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",
initialContent = editablePatchHunkContent(file),
)
}
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> {
val session = repo.interactiveAddSession ?: return repo to listOf("No patch hunk is active.")
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No patch hunk is active.")
if (session.selectionAction != "patch-hunk") return repo to listOf("No patch hunk is active.")
if (content.isBlank()) return repo to listOf("Edited hunk was empty; patch was not applied.", PatchHunkPrompt)
val updatedFiles = repo.files.map { file ->
if (file.name == target && !file.deleted) file.copy(staged = true) else file
}
return repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf(
"$PatchHunkPrompt e",
"Applied edited hunk.",
)
}
private fun interactiveAddSelectionPrompt(
repo: RepoState,
session: InteractiveAddSession,
answer: String,
prompt: String,
action: String,
): Pair<RepoState, List<String>> {
return repo.copy(
interactiveAddSession = session.copy(
awaitingUpdateSelection = true,
selectionPrompt = prompt,
selectionAction = action,
),
) to listOf("What now> $answer", prompt)
}
private fun applyInteractiveAddUpdateSelection(
repo: RepoState,
session: InteractiveAddSession,
answer: String,
): Pair<RepoState, List<String>> {
val candidates = interactiveAddCandidates(repo, session.target)
val selectedNames = selectedInteractiveAddNames(candidates, answer)
val prompt = session.selectionPrompt
if (selectedNames.isEmpty()) {
return repo to listOf("$prompt $answer", "No files selected.", prompt)
}
if (session.selectionAction == "patch" && selectedNames.size == 1) {
return startPatchHunkSession(repo, selectedNames.single(), "$prompt $answer")
}
val updatedFiles = applyInteractiveAddSelectionAction(repo, selectedNames, session.selectionAction)
val updatedRepo = repo.copy(
files = updatedFiles,
interactiveAddSession = session.copy(
awaitingUpdateSelection = false,
selectionPrompt = "Update>>",
selectionAction = "update",
),
)
val summary = interactiveAddSelectionSummary(repo, updatedFiles, selectedNames, session.selectionAction)
return updatedRepo to listOf(
"$prompt $answer",
summary,
) + interactiveAddConsoleLines(interactiveAddCandidates(updatedRepo, session.target))
}
private fun applyInteractiveAddSelectionAction(repo: RepoState, selectedNames: Set<String>, action: String): List<GitFile> {
return when (action) {
"revert" -> repo.files.mapNotNull { file ->
if (file.name !in selectedNames || file.deleted) {
file
} else if (file.tracked) {
file.copy(content = "", staged = false, deleted = false)
} else {
null
}
}
"diff" -> repo.files
else -> repo.files.map { file ->
if (file.name in selectedNames && !file.deleted) file.copy(staged = true) else file
}
}
}
private fun interactiveAddSelectionSummary(
repo: RepoState,
updatedFiles: List<GitFile>,
selectedNames: Set<String>,
action: String,
): String {
return when (action) {
"revert" -> "reverted ${selectedNames.size} path(s)"
"diff" -> selectedNames.joinToString("\n") { "diff -- $it" }
else -> {
val stagedCount = updatedFiles.count { updatedFile ->
val before = repo.files.firstOrNull { it.name == updatedFile.name }
updatedFile.staged && before?.staged != true
}
"updated $stagedCount path(s)"
}
}
}
private fun startPatchHunkSession(repo: RepoState, target: String, prefixLine: String? = null): Pair<RepoState, List<String>> {
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
?: return repo to listOfNotNull(prefixLine, "No changes.")
val session = InteractiveAddSession(
target = target,
awaitingUpdateSelection = false,
selectionPrompt = PatchHunkPrompt,
selectionAction = "patch-hunk",
)
val output = listOfNotNull(prefixLine) + patchHunkLines(file)
return repo.copy(interactiveAddSession = session) to output
}
private fun handlePatchHunkInput(repo: RepoState, session: InteractiveAddSession, answer: String): Pair<RepoState, List<String>> {
val target = session.target ?: return repo.copy(interactiveAddSession = null) to listOf("No changes.")
return when (answer.lowercase()) {
"y", "a" -> {
val updatedFiles = repo.files.map { file ->
if (file.name == target && !file.deleted) file.copy(staged = true) else file
}
repo.copy(files = updatedFiles, interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
}
"n", "d" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer")
"q" -> repo.copy(interactiveAddSession = null) to listOf("$PatchHunkPrompt $answer", "Quit")
"?" -> repo to listOf(
"$PatchHunkPrompt $answer",
"y - stage this hunk",
"n - do not stage this hunk",
"q - quit; do not stage this hunk or any remaining ones",
"a - stage this hunk and all later hunks in the file",
"d - do not stage this hunk or any later hunks in the file",
"s - split the current hunk into smaller hunks",
"e - manually edit the current hunk",
"p - print the current hunk",
"? - print help",
PatchHunkPrompt,
)
"p" -> {
val file = repo.files.firstOrNull { it.name == target && !it.deleted }
if (file == null) {
repo.copy(interactiveAddSession = null) to listOf("No changes.")
} else {
repo to listOf("$PatchHunkPrompt $answer") + patchHunkLines(file)
}
}
"s" -> repo to listOf("$PatchHunkPrompt $answer", "Sorry, cannot split this hunk", PatchHunkPrompt)
"e" -> repo to listOf("$PatchHunkPrompt $answer", "Opening patch editor")
else -> repo to listOf("$PatchHunkPrompt $answer", "Unknown command '$answer'.", PatchHunkPrompt)
}
}
private fun patchHunkLines(file: GitFile): List<String> {
return patchDiffHeaderLines(file) + patchHunkBodyLines(file) + PatchHunkPrompt
}
private fun editablePatchHunkContent(file: GitFile): String {
return buildList {
add("# Manual hunk edit mode -- see bottom for a quick guide.")
addAll(patchHunkBodyLines(file))
add("# ---")
add("# To remove '-' lines, make them ' ' lines (context).")
add("# To remove '+' lines, delete them.")
add("# Lines starting with # will be removed.")
add("# If the patch applies cleanly, the edited hunk will immediately be marked for staging.")
add("# If it does not apply cleanly, you will be given an opportunity to")
add("# edit again. If all lines of the hunk are removed, then the edit is")
add("# aborted and the hunk is left unchanged.")
}.joinToString("\n")
}
private fun patchDiffHeaderLines(file: GitFile): List<String> {
return listOf(
"diff --git a/${file.name} b/${file.name}",
"index 0000000..0000001 100644",
"--- a/${file.name}",
"+++ b/${file.name}",
)
}
private fun patchHunkBodyLines(file: GitFile): List<String> {
val lines = file.content.lines()
val nonEmptyLines = lines.dropLastWhile { it.isEmpty() }
val addedCount = nonEmptyLines.size.coerceAtLeast(1)
return buildList {
add("@@ -1 +1,$addedCount @@")
if (nonEmptyLines.isEmpty()) {
add("+")
} else {
nonEmptyLines.forEach { line -> add("+$line") }
}
}
}
private fun interactiveAddConsoleLines(candidates: List<GitFile>): List<String> {
return buildList {
add(" staged unstaged path")
candidates.forEachIndexed { index, file ->
val staged = if (file.staged) "unchanged" else "+0/-0"
val unstaged = when {
file.tracked -> "+1/-0"
else -> "+0/-0"
}
add("${index + 1}: ${staged.padEnd(10)} ${unstaged.padEnd(9)} ${file.name}")
}
if (candidates.isEmpty()) {
add("No changes.")
}
add("*** Commands ***")
add(" 1: status 2: update 3: revert 4: add untracked")
add(" 5: patch 6: diff 7: quit 8: help")
add("What now>")
}
}
private fun interactiveAddCandidates(repo: RepoState, target: String?): List<GitFile> {
return repo.files.filter { file ->
!file.deleted && (target == null || target == "." || file.name == target || file.name.startsWith(target.trimEnd('/') + "/"))
}
}
private fun selectedInteractiveAddNames(candidates: List<GitFile>, answer: String): Set<String> {
if (answer == "*") return candidates.map { it.name }.toSet()
return answer.split(Regex("[,\\s]+"))
.mapNotNull { token ->
token.toIntOrNull()
?.takeIf { it in 1..candidates.size }
?.let { candidates[it - 1].name }
}
.toSet()
}
}

View File

@@ -38,10 +38,81 @@ internal object NativeGitBridge {
) )
} }
fun startGitSession(
library: File,
workingDir: File,
arguments: List<String>,
environment: Map<String, String>,
): GitSessionResult {
loadResult.getOrElse { error ->
return GitSessionResult(
sessionId = 0,
running = false,
exitCode = -1,
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
)
}
val argv = (listOf("git") + arguments).toTypedArray()
val env = environment.entries.map { (key, value) -> "$key=$value" }.toTypedArray()
return sessionResult(
startGitSessionNative(
library.absolutePath,
workingDir.absolutePath,
argv,
env,
),
)
}
fun writeGitSession(sessionId: Int, input: String): GitSessionResult {
loadResult.getOrElse { error ->
return GitSessionResult(
sessionId = sessionId,
running = false,
exitCode = -1,
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
)
}
return sessionResult(writeGitSessionNative(sessionId, input))
}
private fun sessionResult(result: Array<String>): GitSessionResult {
val sessionId = result.getOrNull(0)?.toIntOrNull() ?: 0
val running = result.getOrNull(1) == "1"
val exitCode = result.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull()
val output = result.getOrNull(3).orEmpty()
return GitSessionResult(
sessionId = sessionId,
running = running,
exitCode = exitCode,
outputLines = output.toTerminalOutputLines(),
)
}
private external fun runGitMainNative( private external fun runGitMainNative(
libraryPath: String, libraryPath: String,
workingDirectory: String, workingDirectory: String,
argv: Array<String>, argv: Array<String>,
environment: Array<String>, environment: Array<String>,
): Array<String> ): Array<String>
private external fun startGitSessionNative(
libraryPath: String,
workingDirectory: String,
argv: Array<String>,
environment: Array<String>,
): Array<String>
private external fun writeGitSessionNative(
sessionId: Int,
input: String,
): Array<String>
} }
private fun String.toTerminalOutputLines(): List<String> =
replace("\r\n", "\n")
.replace('\r', '\n')
.lineSequence()
.toList()
.dropLastWhile { it.isEmpty() }

View File

@@ -7,7 +7,16 @@ val RepoStateSaver = listSaver<RepoState, Any>(
listOf( listOf(
state.initialized, state.initialized,
state.headBranch, state.headBranch,
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) }, state.files.flatMap {
listOf(
it.name,
it.content,
it.staged.toString(),
it.tracked.toString(),
it.deleted.toString(),
it.stagedContent.orEmpty(),
)
},
state.commits.flatMap { state.commits.flatMap {
listOf( listOf(
it.id, it.id,
@@ -28,14 +37,8 @@ val RepoStateSaver = listSaver<RepoState, Any>(
state.submodules.flatMap { listOf(it.key, it.value) }, state.submodules.flatMap { listOf(it.key, it.value) },
state.maintenanceActions.toList(), state.maintenanceActions.toList(),
state.fetchHeadCount, state.fetchHeadCount,
state.interactiveAddSession?.let { emptyList<Any>(),
listOf( emptyList<Any>(),
it.target.orEmpty(),
it.awaitingUpdateSelection.toString(),
it.selectionPrompt,
it.selectionAction,
)
}.orEmpty(),
) )
}, },
restore = { saved -> restore = { saved ->
@@ -54,17 +57,23 @@ val RepoStateSaver = listSaver<RepoState, Any>(
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>() val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>() val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0 val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0
val interactiveAddSessionParts = saved.getOrNull(16) as? List<*> ?: emptyList<Any>()
RepoState( RepoState(
initialized = initialized, initialized = initialized,
headBranch = headBranch, headBranch = headBranch,
files = fileParts.chunked(if (fileParts.size % 5 == 0) 5 else 4).map { files = fileParts.chunked(
when {
fileParts.size % 6 == 0 -> 6
fileParts.size % 5 == 0 -> 5
else -> 4
},
).map {
GitFile( GitFile(
name = it[0] as String, name = it[0] as String,
content = it[1] as String, content = it[1] as String,
staged = (it[2] as String).toBoolean(), staged = (it[2] as String).toBoolean(),
tracked = (it[3] as String).toBoolean(), tracked = (it[3] as String).toBoolean(),
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false, deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
stagedContent = (it.getOrNull(5) as? String)?.takeIf { value -> value.isNotEmpty() },
) )
}, },
commits = commitParts.restoreCommitNodes(), commits = commitParts.restoreCommitNodes(),
@@ -80,14 +89,6 @@ val RepoStateSaver = listSaver<RepoState, Any>(
pushedTags = pushedTags.filterIsInstance<String>().toSet(), pushedTags = pushedTags.filterIsInstance<String>().toSet(),
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) }, submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(), maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(),
interactiveAddSession = interactiveAddSessionParts.takeIf { it.size >= 2 }?.let {
InteractiveAddSession(
target = (it[0] as String).ifBlank { null },
awaitingUpdateSelection = (it[1] as String).toBoolean(),
selectionPrompt = it.getOrNull(2) as? String ?: "Update>>",
selectionAction = it.getOrNull(3) as? String ?: "update",
)
},
) )
} }
) )

View File

@@ -157,10 +157,10 @@ internal object SandboxCommandEngine {
private fun stagePaths(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> { private fun stagePaths(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) { if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
return InteractiveAddEngine.start(repo, parts.drop(2)) return repo to listOf("Interactive Git commands require the native Git runtime.")
} }
if (parts.drop(2).any { it == "-p" || it == "--patch" }) { if (parts.drop(2).any { it == "-p" || it == "--patch" }) {
return InteractiveAddEngine.startPatch(repo, parts.drop(2)) return repo to listOf("Interactive Git commands require the native Git runtime.")
} }
val target = parts.drop(2).lastOrNull { !it.startsWith("-") } val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
?: return repo to listOf("usage: git add <path>") ?: return repo to listOf("usage: git add <path>")

View File

@@ -7,13 +7,11 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
@@ -26,17 +24,16 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@@ -50,6 +47,11 @@ data class TextEditorState(
val saveAsPath: String, val saveAsPath: String,
) )
data class GitMessageEditorState(
val invocation: GitEditorInvocation,
val content: String,
)
@Composable @Composable
fun TextEditorDialog( fun TextEditorDialog(
state: TextEditorState, state: TextEditorState,
@@ -57,16 +59,75 @@ fun TextEditorDialog(
onSaveAsPathChange: (String) -> Unit, onSaveAsPathChange: (String) -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
) {
ParameterizedTextEditorDialog(
title = "Edit File",
titleTag = null,
metadata = "${state.editor} ${state.path.ifBlank { "<new file>" }}",
metadataTag = null,
metadataStyle = EditorMetadataStyle.Prominent,
content = state.content,
saveAsPath = state.saveAsPath,
saveEnabled = state.saveAsPath.isNotBlank(),
testTagPrefix = "text-editor",
resetKey = "${state.originalCommand}\n${state.path}",
onContentChange = onContentChange,
onSaveAsPathChange = onSaveAsPathChange,
onClose = onClose,
onSave = onSave,
)
}
@Composable
fun GitMessageEditorDialog(
state: GitMessageEditorState,
onContentChange: (String) -> Unit,
onClose: () -> Unit,
onSave: () -> Unit,
) {
ParameterizedTextEditorDialog(
title = state.invocation.title,
titleTag = "git-message-editor-title",
metadata = state.invocation.displayPath,
metadataTag = "git-message-editor-path",
metadataStyle = EditorMetadataStyle.SecondaryMonospace,
content = state.content,
saveAsPath = null,
saveEnabled = state.content.isNotBlank(),
testTagPrefix = "git-message-editor",
resetKey = "${state.invocation.command}\n${state.invocation.displayPath}",
onContentChange = onContentChange,
onSaveAsPathChange = {},
onClose = onClose,
onSave = onSave,
)
}
@Composable
private fun ParameterizedTextEditorDialog(
title: String,
titleTag: String?,
metadata: String,
metadataTag: String?,
metadataStyle: EditorMetadataStyle,
content: String,
saveAsPath: String?,
saveEnabled: Boolean,
testTagPrefix: String,
resetKey: String,
onContentChange: (String) -> Unit,
onSaveAsPathChange: (String) -> Unit,
onClose: () -> Unit,
onSave: () -> Unit,
) { ) {
val editorHorizontalScroll = rememberScrollState() val editorHorizontalScroll = rememberScrollState()
val editorVerticalScroll = rememberScrollState() val editorVerticalScroll = rememberScrollState()
val dialogScroll = rememberScrollState() val dialogScroll = rememberScrollState()
val contentFocusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(state.originalCommand, state.path) { LaunchedEffect(resetKey) {
contentFocusRequester.requestFocus() dialogScroll.scrollTo(0)
keyboardController?.show() editorVerticalScroll.scrollTo(0)
editorHorizontalScroll.scrollTo(0)
} }
Dialog( Dialog(
@@ -92,7 +153,8 @@ fun TextEditorDialog(
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Text(
text = "Edit File", text = title,
modifier = titleTag?.let { Modifier.testTag(it) } ?: Modifier,
color = TextPrimary, color = TextPrimary,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 20.sp, fontSize = 20.sp,
@@ -101,16 +163,29 @@ fun TextEditorDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
EditorButton(label = "Save", enabled = state.saveAsPath.isNotBlank(), onClick = onSave) EditorButton(
EditorButton(label = "Dismiss", onClick = onClose) label = "Save",
enabled = saveEnabled,
modifier = Modifier.testTag("$testTagPrefix-save"),
onClick = onSave,
)
EditorButton(
label = "Dismiss",
modifier = Modifier.testTag("$testTagPrefix-dismiss"),
onClick = onClose,
)
} }
Text( Text(
text = "${state.editor} ${state.path.ifBlank { "<new file>" }}", text = metadata,
color = TextPrimary, modifier = metadataTag?.let { Modifier.testTag(it) } ?: Modifier,
fontWeight = FontWeight.Bold, color = metadataStyle.color,
fontFamily = metadataStyle.fontFamily,
fontWeight = metadataStyle.fontWeight,
fontSize = metadataStyle.fontSize,
) )
saveAsPath?.let { path ->
OutlinedTextField( OutlinedTextField(
value = state.saveAsPath, value = path,
onValueChange = onSaveAsPathChange, onValueChange = onSaveAsPathChange,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
@@ -121,6 +196,7 @@ fun TextEditorDialog(
), ),
label = { Text("File name") }, label = { Text("File name") },
) )
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -132,11 +208,11 @@ fun TextEditorDialog(
.verticalScroll(editorVerticalScroll), .verticalScroll(editorVerticalScroll),
) { ) {
BasicTextField( BasicTextField(
value = state.content, value = content,
onValueChange = onContentChange, onValueChange = onContentChange,
modifier = Modifier modifier = Modifier
.sizeIn(minWidth = 1200.dp, minHeight = 1200.dp) .sizeIn(minWidth = 1200.dp, minHeight = 1200.dp)
.focusRequester(contentFocusRequester), .testTag("$testTagPrefix-content"),
textStyle = TextStyle( textStyle = TextStyle(
color = TextPrimary, color = TextPrimary,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -155,15 +231,34 @@ fun TextEditorDialog(
} }
} }
private enum class EditorMetadataStyle(
val color: Color,
val fontFamily: FontFamily? = null,
val fontWeight: FontWeight? = null,
val fontSize: TextUnit = 14.sp,
) {
Prominent(
color = TextPrimary,
fontWeight = FontWeight.Bold,
),
SecondaryMonospace(
color = TextSecondary,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
),
}
@Composable @Composable
private fun EditorButton( private fun EditorButton(
label: String, label: String,
enabled: Boolean = true, enabled: Boolean = true,
modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
modifier = modifier,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Accent, containerColor = Accent,
contentColor = AppBackground, contentColor = AppBackground,

View File

@@ -18,5 +18,7 @@ internal fun addLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("add exact file", "git add README"), levelTestCase("add exact file", "git add README"),
levelTestCase("add all", "git add ."), levelTestCase("add all", "git add ."),
levelTestCase("stage exact file", "git stage README"),
levelTestCase("add with path separator", "git add -- README"),
), ),
) )

View File

@@ -91,6 +91,14 @@ internal fun bisectLevel(): Level = level(
"git bisect run ./test-balance.sh", "git bisect run ./test-balance.sh",
"c8c7c00", "c8c7c00",
), ),
levelTestCase(
"answer last good commit after shell run",
"git bisect start",
"git bisect bad HEAD",
"git bisect good known-good",
"git bisect run sh test-balance.sh",
"c8c7",
),
), ),
negativeTestCases = listOf( negativeTestCases = listOf(
levelTestCase( levelTestCase(

View File

@@ -28,5 +28,6 @@ internal fun branchAtLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"), levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"),
levelTestCase("branch at caret", "git branch test_branch HEAD^"), levelTestCase("branch at caret", "git branch test_branch HEAD^"),
levelTestCase("branch at explicit master parent", "git branch test_branch master^"),
), ),
) )

View File

@@ -17,5 +17,6 @@ internal fun branchLevel(): Level = level(
validator = repoPredicate { repo -> "test_code" in repo.branches && repo.headBranch == "master" }, validator = repoPredicate { repo -> "test_code" in repo.branches && repo.headBranch == "master" },
testCases = listOf( testCases = listOf(
levelTestCase("create branch", "git branch test_code"), levelTestCase("create branch", "git branch test_code"),
levelTestCase("create branch from master", "git branch test_code master"),
), ),
) )

View File

@@ -24,5 +24,7 @@ internal fun checkoutFileLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.find { it.name == "config.rb" }?.content == "This is the initial config file" }, validator = repoPredicate { repo -> repo.files.find { it.name == "config.rb" }?.content == "This is the initial config file" },
testCases = listOf( testCases = listOf(
levelTestCase("checkout file from head", "git checkout -- config.rb"), levelTestCase("checkout file from head", "git checkout -- config.rb"),
levelTestCase("restore file from index", "git restore config.rb"),
levelTestCase("checkout file from explicit head", "git checkout HEAD -- config.rb"),
), ),
) )

View File

@@ -17,5 +17,7 @@ internal fun checkoutLevel(): Level = level(
validator = repoPredicate { repo -> repo.headBranch == "my_branch" && "my_branch" in repo.branches }, validator = repoPredicate { repo -> repo.headBranch == "my_branch" && "my_branch" in repo.branches },
testCases = listOf( testCases = listOf(
levelTestCase("checkout new branch", "git checkout -b my_branch"), levelTestCase("checkout new branch", "git checkout -b my_branch"),
levelTestCase("switch create branch", "git switch -c my_branch"),
levelTestCase("branch then checkout", "git branch my_branch", "git checkout my_branch"),
), ),
) )

View File

@@ -22,6 +22,7 @@ internal fun checkoutTagLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("checkout tag", "git checkout v1.2"), levelTestCase("checkout tag", "git checkout v1.2"),
levelTestCase("checkout explicit tag", "git checkout tags/v1.2"), levelTestCase("checkout explicit tag", "git checkout tags/v1.2"),
levelTestCase("switch detached tag", "git switch --detach v1.2"),
), ),
) )

View File

@@ -26,5 +26,6 @@ internal fun checkoutTagOverBranchLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("checkout tag namespace", "git checkout tags/v1.2"), levelTestCase("checkout tag namespace", "git checkout tags/v1.2"),
levelTestCase("checkout refs tag", "git checkout refs/tags/v1.2"), levelTestCase("checkout refs tag", "git checkout refs/tags/v1.2"),
levelTestCase("switch detached tag namespace", "git switch --detach tags/v1.2"),
), ),
) )

View File

@@ -17,5 +17,7 @@ internal fun commitAmendLevel(): Level = level(
validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "forgotten_file.rb" && it.tracked && !it.staged } }, validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "forgotten_file.rb" && it.tracked && !it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("amend after add", "git add forgotten_file.rb", "git commit --amend --no-edit"), levelTestCase("amend after add", "git add forgotten_file.rb", "git commit --amend --no-edit"),
levelTestCase("amend with message option", "git add forgotten_file.rb", "git commit --amend -m \"Initial commit\""),
levelTestCase("amend through editor", "git add forgotten_file.rb", "GIT_EDITOR=true git commit --amend"),
), ),
) )

View File

@@ -22,6 +22,7 @@ internal fun commitInFutureLevel(): Level = level(
}, },
testCases = listOf( testCases = listOf(
levelTestCase("commit with date option", "git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""), levelTestCase("commit with date option", "git commit --date 2037-01-01T00:00:00+0000 -m \"Future commit\""),
levelTestCase("commit with author date environment", "GIT_AUTHOR_DATE=2037-01-01T00:00:00+0000 git commit -m \"Future commit\""),
), ),
negativeTestCases = listOf( negativeTestCases = listOf(
levelTestCase("current date commit does not solve", "git commit -m \"Current date commit\""), levelTestCase("current date commit does not solve", "git commit -m \"Current date commit\""),

View File

@@ -18,5 +18,6 @@ internal fun commitLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("commit with message", "git commit -m \"Initial commit\""), levelTestCase("commit with message", "git commit -m \"Initial commit\""),
levelTestCase("commit with alternate message", "git commit -m \"Add README\""), levelTestCase("commit with alternate message", "git commit -m \"Add README\""),
levelTestCase("commit through editor", "GIT_EDITOR=\"sed -i '1iInitial commit'\" git commit"),
), ),
) )

View File

@@ -18,5 +18,6 @@ internal fun configLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("name then email", "git config user.name GitHug", "git config user.email githug@example.com"), levelTestCase("name then email", "git config user.name GitHug", "git config user.email githug@example.com"),
levelTestCase("email then name", "git config user.email githug@example.com", "git config user.name GitHug"), levelTestCase("email then name", "git config user.email githug@example.com", "git config user.name GitHug"),
levelTestCase("explicit local config", "git config --local user.name GitHug", "git config --local user.email githug@example.com"),
), ),
) )

View File

@@ -49,5 +49,6 @@ internal fun fetchLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("fetch origin", "git fetch origin"), levelTestCase("fetch origin", "git fetch origin"),
levelTestCase("fetch default", "git fetch"), levelTestCase("fetch default", "git fetch"),
levelTestCase("fetch all remotes", "git fetch --all"),
), ),
) )

View File

@@ -46,6 +46,7 @@ internal fun findOldBranchLevel(): Level = level(
validator = repoPredicate { repo -> repo.headBranch == "solve_world_hunger" }, validator = repoPredicate { repo -> repo.headBranch == "solve_world_hunger" },
testCases = listOf( testCases = listOf(
levelTestCase("checkout old branch", "git checkout solve_world_hunger"), levelTestCase("checkout old branch", "git checkout solve_world_hunger"),
levelTestCase("switch old branch", "git switch solve_world_hunger"),
), ),
setupChecks = listOf( setupChecks = listOf(
levelSetupCheck( levelSetupCheck(

View File

@@ -17,6 +17,7 @@ internal fun initLevel(): Level = level(
validator = repoPredicate { it.initialized }, validator = repoPredicate { it.initialized },
testCases = listOf( testCases = listOf(
levelTestCase("plain init", "git init"), levelTestCase("plain init", "git init"),
levelTestCase("init current directory explicitly", "git init ."),
), ),
setupChecks = listOf( setupChecks = listOf(
levelSetupCheck( levelSetupCheck(

View File

@@ -131,6 +131,7 @@ private fun loggingValidator(
levelTitle: String, levelTitle: String,
validator: (RepoState, String) -> Boolean, validator: (RepoState, String) -> Boolean,
): (RepoState, String) -> Boolean = { repo, command -> ): (RepoState, String) -> Boolean = { repo, command ->
val startedAt = System.nanoTime()
AppLog.d( AppLog.d(
"Validation", "Validation",
buildString { buildString {
@@ -141,43 +142,10 @@ private fun loggingValidator(
append(" command='") append(" command='")
append(command) append(command)
append("' repo=") append("' repo=")
append(repo.validationSnapshot()) append(repo.diagnosticSnapshot())
}, },
) )
val result = validator(repo, command) val result = validator(repo, command)
AppLog.d("Validation", "Result level=$levelId passed=$result") AppLog.d("Validation", "Result level=$levelId passed=$result durationMs=${elapsedMillisSince(startedAt)}")
result result
} }
private fun RepoState.validationSnapshot(): String = buildString {
append("initialized=")
append(initialized)
append(", headBranch=")
append(headBranch)
append(", branches=")
append(branches.keys.sorted())
append(", tags=")
append(tags.sorted())
append(", remotes=")
append(remotes.toSortedMap())
append(", fetchedBranches=")
append(fetchedBranches.sorted())
append(", fetchHeadCount=")
append(fetchHeadCount)
append(", pushedBranches=")
append(pushedBranches.sorted())
append(", pushedTags=")
append(pushedTags.sorted())
append(", stashes=")
append(stashes)
append(", submodules=")
append(submodules.toSortedMap())
append(", maintenanceActions=")
append(maintenanceActions.sorted())
append(", config=")
append(config.toSortedMap())
append(", files=")
append(files.map { file -> "${file.name}(staged=${file.staged},tracked=${file.tracked})" }.sorted())
append(", commits=")
append(commits.map { it.id to it.message })
}

View File

@@ -30,6 +30,7 @@ internal fun mergeLevel(): Level = level(
}, },
testCases = listOf( testCases = listOf(
levelTestCase("merge feature", "git merge feature"), levelTestCase("merge feature", "git merge feature"),
levelTestCase("merge feature with explicit merge commit", "git merge --no-ff feature -m \"Merge feature\""),
), ),
negativeTestCases = listOf( negativeTestCases = listOf(
levelTestCase("switching to feature does not solve", "git switch feature"), levelTestCase("switching to feature does not solve", "git switch feature"),

View File

@@ -12,7 +12,7 @@ internal fun mergeSquashLevel(): Level = level(
title = "Merge Squash", title = "Merge Squash",
description = "Merge all commits from the long-feature-branch as a single commit.", description = "Merge all commits from the long-feature-branch as a single commit.",
hints = listOf("Take a look at the `--squash` option of the merge command. Don't forget to commit the merge!"), hints = listOf("Take a look at the `--squash` option of the merge command. Don't forget to commit the merge!"),
commandSuggestions = listOf("git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""), commandSuggestions = listOf("git merge --squash long-feature-branch", "git commit -m \"<message>\""),
setup = { RepoState(initialized = true, files = listOf(GitFile("file1", tracked = true)), branches = mapOf("master" to 2, "long-feature-branch" to 4)) }, setup = { RepoState(initialized = true, files = listOf(GitFile("file1", tracked = true)), branches = mapOf("master" to 2, "long-feature-branch" to 4)) },
nativeSetup = { nativeSetup = {
resetFiles() resetFiles()
@@ -31,11 +31,21 @@ internal fun mergeSquashLevel(): Level = level(
true true
}, },
validator = repoPredicate { repo -> validator = repoPredicate { repo ->
repo.commits.firstOrNull()?.message == "Merge long feature" && repo.headBranch == "master" &&
repo.commits.size == 3 &&
repo.commits.firstOrNull()?.parentCount == 1 && repo.commits.firstOrNull()?.parentCount == 1 &&
repo.files.any { it.name == "file3" && it.tracked } repo.files.any { it.name == "file3" && it.tracked && it.content == mergeSquashLevelFile3Content() }
}, },
testCases = listOf( testCases = listOf(
levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""), levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""),
levelTestCase("squash merge with arbitrary message", "git merge --squash long-feature-branch", "git commit -m \"Any message works\""),
levelTestCase("squash no commit then commit", "git merge --squash --no-commit long-feature-branch", "git commit -m \"Finished feature branch\""),
),
negativeTestCases = listOf(
levelTestCase("squash merge without commit", "git merge --squash long-feature-branch"),
levelTestCase("regular merge is not a squash", "git merge --no-edit long-feature-branch"),
), ),
) )
private fun mergeSquashLevelFile3Content(): String =
"some feature\ngetting awesomer\nand awesomer!\n"

View File

@@ -40,5 +40,6 @@ internal fun pullLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("pull explicit remote branch", "git pull origin master"), levelTestCase("pull explicit remote branch", "git pull origin master"),
levelTestCase("pull default origin", "git pull"), levelTestCase("pull default origin", "git pull"),
levelTestCase("fetch then merge", "git fetch origin", "git merge origin/master"),
), ),
) )

View File

@@ -45,5 +45,6 @@ internal fun pushBranchLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("push named branch", "git push origin test_branch"), levelTestCase("push named branch", "git push origin test_branch"),
levelTestCase("push current branch refspec", "git push origin test_branch:test_branch"), levelTestCase("push current branch refspec", "git push origin test_branch:test_branch"),
levelTestCase("push fully qualified branch refspec", "git push origin refs/heads/test_branch:refs/heads/test_branch"),
), ),
) )

View File

@@ -86,5 +86,6 @@ internal fun pushLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("pull rebase then push", "git pull --rebase origin master", "git push origin master"), levelTestCase("pull rebase then push", "git pull --rebase origin master", "git push origin master"),
levelTestCase("fetch rebase push", "git fetch origin", "git rebase origin/master", "git push origin master"), levelTestCase("fetch rebase push", "git fetch origin", "git rebase origin/master", "git push origin master"),
levelTestCase("fetch rebase upstream push", "git fetch", "git rebase origin/master", "git push"),
), ),
) )

View File

@@ -35,5 +35,6 @@ internal fun pushTagsLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("push all tags", "git push --tags"), levelTestCase("push all tags", "git push --tags"),
levelTestCase("push tags to origin", "git push origin --tags"), levelTestCase("push tags to origin", "git push origin --tags"),
levelTestCase("push named tag", "git push origin tag tag_to_be_pushed"),
), ),
) )

View File

@@ -33,5 +33,6 @@ internal fun rebaseLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("checkout feature then rebase", "git checkout feature", "git rebase master"), levelTestCase("checkout feature then rebase", "git checkout feature", "git rebase master"),
levelTestCase("rebase named branch", "git rebase master feature"), levelTestCase("rebase named branch", "git rebase master feature"),
levelTestCase("switch feature then rebase", "git switch feature", "git rebase master"),
), ),
) )

View File

@@ -44,5 +44,6 @@ internal fun rebaseOntoLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"), levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"),
levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"), levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"),
levelTestCase("rebase onto full branch ref", "git rebase --onto refs/heads/master wrong_branch readme-update"),
), ),
) )

View File

@@ -17,5 +17,6 @@ internal fun remoteAddLevel(): Level = level(
validator = repoPredicate { repo -> repo.remotes["origin"] == "https://github.com/githug/githug" }, validator = repoPredicate { repo -> repo.remotes["origin"] == "https://github.com/githug/githug" },
testCases = listOf( testCases = listOf(
levelTestCase("add origin remote", "git remote add origin https://github.com/githug/githug"), levelTestCase("add origin remote", "git remote add origin https://github.com/githug/githug"),
levelTestCase("add origin remote with tracked branch option", "git remote add -t master origin https://github.com/githug/githug"),
), ),
) )

View File

@@ -30,5 +30,17 @@ internal fun renameCommitLevel(): Level = level(
"interactive rebase rename", "interactive rebase rename",
"GIT_SEQUENCE_EDITOR=\"sed -i '1s/^pick /reword /'\" GIT_EDITOR=\"sed -i '1s/First coommit/First commit/'\" git rebase -i HEAD~2", "GIT_SEQUENCE_EDITOR=\"sed -i '1s/^pick /reword /'\" GIT_EDITOR=\"sed -i '1s/First coommit/First commit/'\" git rebase -i HEAD~2",
), ),
levelTestCase(
"interactive rebase then amend message option",
"GIT_SEQUENCE_EDITOR=\"sed -i '1s/^pick /reword /'\" GIT_EDITOR=false git rebase -i HEAD~2",
"git commit --amend -m \"First commit\"",
"git rebase --continue",
),
levelTestCase(
"interactive rebase then amend editor",
"GIT_SEQUENCE_EDITOR=\"sed -i '1s/^pick /reword /'\" GIT_EDITOR=false git rebase -i HEAD~2",
"GIT_EDITOR=\"sed -i '1s/First coommit/First commit/'\" git commit --amend",
"git rebase --continue",
),
), ),
) )

View File

@@ -17,5 +17,6 @@ internal fun renameLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.any { it.name == "newfile.txt" } && repo.files.none { it.name == "oldfile.txt" && !it.deleted } }, validator = repoPredicate { repo -> repo.files.any { it.name == "newfile.txt" } && repo.files.none { it.name == "oldfile.txt" && !it.deleted } },
testCases = listOf( testCases = listOf(
levelTestCase("git mv", "git mv oldfile.txt newfile.txt"), levelTestCase("git mv", "git mv oldfile.txt newfile.txt"),
levelTestCase("git mv force", "git mv -f oldfile.txt newfile.txt"),
), ),
) )

View File

@@ -26,5 +26,6 @@ internal fun resetLevel(): Level = level(
validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "to_commit_first.rb" && it.staged } && repo.files.any { it.name == "to_commit_second.rb" && !it.staged } }, validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "to_commit_first.rb" && it.staged } && repo.files.any { it.name == "to_commit_second.rb" && !it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("reset path", "git reset to_commit_second.rb"), levelTestCase("reset path", "git reset to_commit_second.rb"),
levelTestCase("restore staged path", "git restore --staged to_commit_second.rb"),
), ),
) )

View File

@@ -25,5 +25,6 @@ internal fun resetSoftLevel(): Level = level(
validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "newfile.rb" && it.staged } }, validator = repoPredicate { repo -> repo.commits.size == 1 && repo.files.any { it.name == "newfile.rb" && it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("soft reset caret", "git reset --soft HEAD^"), levelTestCase("soft reset caret", "git reset --soft HEAD^"),
levelTestCase("soft reset previous commit", "git reset --soft HEAD~1"),
), ),
) )

View File

@@ -28,5 +28,6 @@ internal fun restoreLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.any { it.name == "file3" && it.tracked } }, validator = repoPredicate { repo -> repo.files.any { it.name == "file3" && it.tracked } },
testCases = listOf( testCases = listOf(
levelTestCase("checkout file from reflog commit", "git checkout HEAD@{1} -- file3"), levelTestCase("checkout file from reflog commit", "git checkout HEAD@{1} -- file3"),
levelTestCase("restore file from reflog commit", "git restore --source=HEAD@{1} --staged --worktree file3"),
), ),
) )

View File

@@ -18,5 +18,6 @@ internal fun restructureLevel(): Level = level(
testCases = listOf( testCases = listOf(
levelTestCase("move one by one", "mkdir src", "git mv about.html src/about.html", "git mv contact.html src/contact.html", "git mv index.html src/index.html"), levelTestCase("move one by one", "mkdir src", "git mv about.html src/about.html", "git mv contact.html src/contact.html", "git mv index.html src/index.html"),
levelTestCase("move with wildcard", "mkdir src", "git mv *.html src"), levelTestCase("move with wildcard", "mkdir src", "git mv *.html src"),
levelTestCase("move with wildcard into slash directory", "mkdir src", "git mv *.html src/"),
), ),
) )

View File

@@ -24,8 +24,20 @@ internal fun revertLevel(): Level = level(
addCommit("Second commit", "file2") addCommit("Second commit", "file2")
true true
}, },
validator = repoPredicate { repo -> repo.commits.any { it.message.startsWith("Revert") } }, validator = repoPredicate { repo ->
repo.headBranch == "master" &&
repo.commits.size >= 4 &&
repo.commits.firstOrNull()?.parentCount == 1 &&
repo.files.none { it.name == "file3" && !it.deleted } &&
repo.commits.map { it.message }.containsAll(listOf("First commit", "Bad commit", "Second commit"))
},
testCases = listOf( testCases = listOf(
levelTestCase("revert middle commit", "git revert HEAD~1"), levelTestCase("revert middle commit", "git revert HEAD~1"),
levelTestCase("revert middle commit without editor", "git revert --no-edit HEAD~1"),
levelTestCase("revert caret commit", "git revert HEAD^"),
levelTestCase("revert without commit then arbitrary message", "git revert -n HEAD~1", "git commit -m \"Undo unwanted file\""),
),
negativeTestCases = listOf(
levelTestCase("revert without replacement commit", "git revert -n HEAD~1"),
), ),
) )

View File

@@ -17,5 +17,6 @@ internal fun rmCachedLevel(): Level = level(
validator = repoPredicate { repo -> repo.files.any { it.name == "deleteme.rb" && !it.staged && !it.tracked && !it.deleted } }, validator = repoPredicate { repo -> repo.files.any { it.name == "deleteme.rb" && !it.staged && !it.tracked && !it.deleted } },
testCases = listOf( testCases = listOf(
levelTestCase("rm cached", "git rm --cached deleteme.rb"), levelTestCase("rm cached", "git rm --cached deleteme.rb"),
levelTestCase("reset staged path", "git reset deleteme.rb"),
), ),
) )

View File

@@ -20,5 +20,6 @@ internal fun rmLevel(): Level = level(
}, },
testCases = listOf( testCases = listOf(
levelTestCase("git rm deleted path", "git rm deleteme.rb"), levelTestCase("git rm deleted path", "git rm deleteme.rb"),
levelTestCase("stage deleted path", "git add -u deleteme.rb"),
), ),
) )

View File

@@ -39,6 +39,12 @@ internal fun squashLevel(): Level = level(
"git reset --soft HEAD~4", "git reset --soft HEAD~4",
"git commit -m \"New commit message\"", "git commit -m \"New commit message\"",
), ),
levelTestCase(
"mixed reset add and recommit",
"git reset HEAD~4",
"git add README",
"git commit -m \"New commit message\"",
),
), ),
negativeTestCases = listOf( negativeTestCases = listOf(
levelTestCase("reset without replacement commit", "git reset --soft HEAD~4"), levelTestCase("reset without replacement commit", "git reset --soft HEAD~4"),

View File

@@ -12,7 +12,7 @@ internal fun stageLinesLevel(): Level = level(
title = "Stage Lines", title = "Stage Lines",
description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.", description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.",
hints = listOf("Read about the flags which can be passed to the `add` command."), hints = listOf("Read about the flags which can be passed to the `add` command."),
commandSuggestions = listOf("git add feature.rb"), commandSuggestions = listOf("git add -p feature.rb"),
setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) }, setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) },
nativeSetup = { nativeSetup = {
resetFiles() resetFiles()
@@ -21,8 +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(
"patch add with git editor",
"GIT_EDITOR=\"sed -i '/second feature/d'\" git add -p feature.rb",
"e",
),
),
negativeTestCases = listOf(
levelTestCase("full file add stages both feature lines", "git add feature.rb"),
levelTestCase("full file stage alias stages both feature lines", "git stage feature.rb"),
), ),
) )

View File

@@ -24,6 +24,8 @@ internal fun stashLevel(): Level = level(
validator = repoPredicate { repo -> repo.stashes.isNotEmpty() && repo.files.none { it.staged } }, validator = repoPredicate { repo -> repo.stashes.isNotEmpty() && repo.files.none { it.staged } },
testCases = listOf( testCases = listOf(
levelTestCase("stash changes", "git stash"), levelTestCase("stash changes", "git stash"),
levelTestCase("stash push", "git stash push"),
levelTestCase("stash with message", "git stash push -m \"save lyrics\""),
), ),
setupChecks = listOf( setupChecks = listOf(
levelSetupCheck( levelSetupCheck(

View File

@@ -40,5 +40,11 @@ internal fun submoduleLevel(): Level = level(
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source", "git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
"git add .gitmodules", "git add .gitmodules",
), ),
levelTestCase(
"record submodule metadata url first",
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
"git config -f .gitmodules submodule.githug-include-me.path githug-include-me",
"git add .gitmodules",
),
), ),
) )

View File

@@ -17,5 +17,7 @@ internal fun tagLevel(): Level = level(
validator = repoPredicate { repo -> "new_tag" in repo.tags }, validator = repoPredicate { repo -> "new_tag" in repo.tags },
testCases = listOf( testCases = listOf(
levelTestCase("create tag", "git tag new_tag"), levelTestCase("create tag", "git tag new_tag"),
levelTestCase("create tag at head", "git tag new_tag HEAD"),
levelTestCase("create annotated tag", "git tag -a new_tag -m \"New tag\""),
), ),
) )

View File

@@ -11,6 +11,7 @@ class GitEditorCommandsTest {
assertEquals(GitEditorCommandKind.COMMIT_MESSAGE, invocation?.kind) assertEquals(GitEditorCommandKind.COMMIT_MESSAGE, invocation?.kind)
assertEquals("Edit Commit Message", invocation?.title) assertEquals("Edit Commit Message", invocation?.title)
assertEquals(".git/COMMIT_EDITMSG", invocation?.displayPath)
} }
@Test @Test
@@ -34,6 +35,7 @@ class GitEditorCommandsTest {
assertEquals(GitEditorCommandKind.REBASE_TODO, invocation?.kind) assertEquals(GitEditorCommandKind.REBASE_TODO, invocation?.kind)
assertEquals("Edit Rebase Todo", invocation?.title) assertEquals("Edit Rebase Todo", invocation?.title)
assertEquals(".git/rebase-merge/git-rebase-todo", invocation?.displayPath)
} }
@Test @Test
@@ -47,6 +49,7 @@ class GitEditorCommandsTest {
assertEquals(GitEditorCommandKind.TAG_MESSAGE, invocation?.kind) assertEquals(GitEditorCommandKind.TAG_MESSAGE, invocation?.kind)
assertEquals("Edit Tag Message", invocation?.title) assertEquals("Edit Tag Message", invocation?.title)
assertEquals(".git/TAG_EDITMSG", invocation?.displayPath)
} }
@Test @Test

View File

@@ -2,6 +2,8 @@ package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue import org.junit.Assume.assumeTrue
import org.junit.Test import org.junit.Test
@@ -268,12 +270,12 @@ class GitSandboxEngineTest {
val level = configLevel() val level = configLevel()
val repo = runtime.prepareLevel(level) val repo = runtime.prepareLevel(level)
val (namedRepo, _) = runtime.execute(level, repo, "git config user.name GitHug") val (namedRepo, _) = runtime.execute(level, repo, "git config user.name githug")
val (configuredRepo, _) = runtime.execute(level, namedRepo, "git config user.email githug@example.com") val (configuredRepo, _) = runtime.execute(level, namedRepo, "git config user.email xxx@yy.com")
assertEquals("GitHug", configuredRepo.config["user.name"]) assertEquals("githug", configuredRepo.config["user.name"])
assertEquals("githug@example.com", configuredRepo.config["user.email"]) assertEquals("xxx@yy.com", configuredRepo.config["user.email"])
assertTrue(level.validator(configuredRepo, "git config user.email githug@example.com")) assertTrue(level.validator(configuredRepo, "git config user.email xxx@yy.com"))
} finally { } finally {
root.deleteRecursively() root.deleteRecursively()
} }
@@ -451,6 +453,39 @@ class GitSandboxEngineTest {
} }
} }
@Test
fun nativeInteractiveRebaseRewordRequestsCommitMessageEditorAndContinues() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-interactive-rebase-reword-editor").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = renameCommitLevel()
val repo = runtime.prepareLevel(level)
val invocation = parseGitEditorInvocation("git rebase -i HEAD~2")
?: error("Expected interactive rebase editor invocation")
val todo = runtime.gitEditorInitialContent(level, repo, invocation)
val rewordTodo = todo.replaceFirst(Regex("(?m)^pick "), "reword ")
val rebaseResult = runtime.executeGitEditorCommandWithResult(level, repo, invocation, rewordTodo)
val nextEditor = rebaseResult.nextEditor
assertNotNull(nextEditor)
val message = nextEditor!!.content.replaceFirst("First coommit", "First commit")
val completedResult = runtime.executeGitEditorCommandWithResult(
level = level,
currentRepo = rebaseResult.repo,
invocation = nextEditor.invocation,
message = message,
)
assertEquals(".git/COMMIT_EDITMSG", nextEditor.invocation.displayPath)
assertNull(completedResult.nextEditor)
assertTrue(level.validator(completedResult.repo, nextEditor.invocation.command))
} finally {
root.deleteRecursively()
}
}
@Test @Test
fun nativeExecutableShortcutRunsScriptThroughShell() { fun nativeExecutableShortcutRunsScriptThroughShell() {
val git = testGitBinary() val git = testGitBinary()
@@ -469,6 +504,90 @@ class GitSandboxEngineTest {
} }
} }
@Test
fun nativeBisectRunScriptShortcutRunsThroughShell() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-bisect-run-script").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = bisectLevel()
var repo = runtime.prepareLevel(level)
listOf(
"git bisect start",
"git bisect bad HEAD",
"git bisect good known-good",
).forEach { command ->
val (nextRepo, _) = runtime.execute(level, repo, command)
repo = nextRepo
}
val (_, output) = runtime.execute(level, repo, "git bisect run ./test-balance.sh")
val text = output.joinToString("\n")
assertFalse(text, text.contains("Permission denied", ignoreCase = true))
assertFalse(text, text.contains("can't execute", ignoreCase = true))
assertFalse(text, text.contains("bogus exit code", ignoreCase = true))
assertTrue(text, text.contains("first bad commit", ignoreCase = true))
} finally {
root.deleteRecursively()
}
}
@Test
fun nativeInteractiveAddKeepsCompiledGitSessionOpenForInput() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-native-add-i").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = addLevel()
var repo = runtime.prepareLevel(level)
val (menuRepo, menuOutput) = runtime.execute(level, repo, "git add -i")
repo = menuRepo
val (quitRepo, quitOutput) = runtime.execute(level, repo, "q")
assertNotNull(menuRepo.nativeGitSession)
assertTrue(menuOutput.joinToString("\n"), menuOutput.any { it.contains("What now") })
assertNull(quitRepo.nativeGitSession)
assertTrue(quitOutput.joinToString("\n"), quitOutput.any { it.contains("Bye") })
} finally {
root.deleteRecursively()
}
}
@Test
fun nativePatchAddUsesCompiledGitEditorAndStagesOnlyEditedStageLinesHunk() {
val git = testGitBinary()
assumeTrue(git.exists() && git.canExecute())
val root = Files.createTempDirectory("githug-stage-lines-native-patch").toFile()
try {
val runtime = GitRepositoryRuntime(root, git)
val level = stageLinesLevel()
var repo = runtime.prepareLevel(level)
val (patchRepo, patchOutput) = runtime.execute(
level,
repo,
"GIT_EDITOR=\"sed -i '/second feature/d'\" git add -p feature.rb",
)
repo = patchRepo
val (resultRepo, _) = runtime.execute(level, repo, "e")
val file = resultRepo.files.single { it.name == "feature.rb" }
assertNotNull(patchRepo.nativeGitSession)
assertTrue(patchOutput.joinToString("\n"), patchOutput.any { it.contains("Stage this hunk") })
assertNull(resultRepo.nativeGitSession)
assertTrue(resultRepo.diagnosticSnapshot(), level.validator(resultRepo, "e"))
assertTrue(file.stagedContent.orEmpty(), file.stagedContent.orEmpty().contains("This change belongs to the first feature"))
assertFalse(file.stagedContent.orEmpty(), file.stagedContent.orEmpty().contains("This change belongs to the second feature"))
} finally {
root.deleteRecursively()
}
}
private fun testGitBinary(): File { private fun testGitBinary(): File {
System.getenv("GITHUG_TEST_GIT_BINARY") System.getenv("GITHUG_TEST_GIT_BINARY")
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }

View File

@@ -1,200 +0,0 @@
package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class InteractiveAddEngineTest {
@Test
fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(output.any { it.contains("What now>") })
assertFalse(output.any { it.contains("What now> update") })
assertFalse(output.any { it.contains("What now> quit") })
assertFalse(output.any { it.contains("GitHug Android") })
}
@Test
fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(updatedRepo.interactiveAddSession != null)
assertTrue(output.any { it.contains("What now>") })
assertFalse(output.any { it.contains("What now> update") })
assertFalse(output.any { it.contains("What now> quit") })
}
@Test
fun interactiveAddAcceptsUpdateSelectionFromNextInput() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i")
val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2")
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1")
val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7")
assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true)
assertTrue(updateOutput.any { it.contains("Update>>") })
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false)
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
assertTrue(quitRepo.interactiveAddSession == null)
assertTrue(quitOutput.any { it.contains("Bye.") })
}
@Test
fun interactiveAddHandlesEveryDisplayedMenuCommand() {
val menuCommands = listOf(
"1" to "What now> 1",
"2" to "Update>>",
"3" to "Revert>>",
"4" to "Add untracked>>",
"5" to "Patch update>>",
"6" to "Diff>>",
"7" to "Bye.",
"8" to "What now> 8",
)
menuCommands.forEach { (command, expectedOutput) ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
if (command in listOf("2", "3", "4", "5", "6")) {
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
}
}
}
@Test
fun interactiveAddHandlesMenuCommandAliases() {
val aliases = listOf(
"status" to "What now> status",
"update" to "Update>>",
"revert" to "Revert>>",
"add untracked" to "Add untracked>>",
"patch" to "Patch update>>",
"diff" to "Diff>>",
"quit" to "Bye.",
"help" to "What now> help",
)
aliases.forEach { (command, expectedOutput) ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
}
}
}
@Test
fun interactiveAddPatchSelectionStagesSelectedPath() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
val (hunkRepo, hunkOutput) = GitSandboxEngine.execute(patchRepo, "1")
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(hunkRepo, "y")
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
assertTrue(patchOutput.any { it.contains("Patch update>>") })
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
}
@Test
fun patchAddStartsPatchHunkDialogWithoutStagingImmediately() {
listOf("git add -p README", "git add --patch README").forEach { command ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (patchRepo, output) = GitSandboxEngine.execute(repo, command)
assertFalse("$command should not stage before a selection", patchRepo.files.single { it.name == "README" }.staged)
assertEquals("patch-hunk", patchRepo.interactiveAddSession?.selectionAction)
assertTrue(output.any { it.startsWith("diff --git a/README b/README") })
assertTrue(output.any { it.contains("Stage this hunk") })
}
}
@Test
fun patchAddSelectionStagesSelectedPath() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
val (selectedRepo, output) = GitSandboxEngine.execute(patchRepo, "y")
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(output.any { it.contains("Stage this hunk") && it.contains("y") })
assertTrue(selectedRepo.interactiveAddSession == null)
}
@Test
fun patchAddHunkEditOpensPatchEditorInvocation() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
assertEquals(GitEditorCommandKind.PATCH_HUNK, invocation?.kind)
assertEquals("Edit Patch Hunk", invocation?.title)
assertTrue(invocation?.initialContent.orEmpty().startsWith("# Manual hunk edit mode -- see bottom for a quick guide."))
assertFalse(invocation?.initialContent.orEmpty().contains("diff --git a/README b/README"))
assertFalse(invocation?.initialContent.orEmpty().contains("--- a/README"))
assertTrue(invocation?.initialContent.orEmpty().contains("# ---"))
assertTrue(invocation?.initialContent.orEmpty().contains("# To remove '+' lines, delete them."))
assertTrue(invocation?.initialContent.orEmpty().contains("+A"))
assertFalse(invocation?.initialContent.orEmpty().contains("Stage this hunk"))
}
@Test
fun editedPatchHunkStagesCurrentPatchTarget() {
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
?: error("Expected patch editor invocation")
val (selectedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(patchRepo, invocation.initialContent)
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
assertTrue(selectedRepo.interactiveAddSession == null)
assertTrue(output.any { it.contains("Applied edited hunk.") })
}
@Test
fun patchAddHunkDialogHandlesAdvertisedCommands() {
val commands = listOf("y", "n", "q", "a", "d", "s", "e", "p", "P", "?")
commands.forEach { command ->
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
val (updatedRepo, output) = GitSandboxEngine.execute(patchRepo, command)
assertFalse("$command should not be rejected", output.any { it.contains("Unknown command '$command'") })
assertTrue("$command should echo hunk prompt", output.any { it.contains("Stage this hunk") })
if (command == "e") {
assertTrue(output.any { it.contains("Opening patch editor") })
}
if (command in listOf("y", "a")) {
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
assertTrue(updatedRepo.interactiveAddSession == null)
}
}
}
}

View File

@@ -222,7 +222,10 @@ class LevelSolutionsTest {
appendLine(" <none>") appendLine(" <none>")
} else { } else {
files.sortedBy { it.name }.forEach { file -> files.sortedBy { it.name }.forEach { file ->
appendLine(" ${file.name} staged=${file.staged} tracked=${file.tracked} content=${file.content.toEvidenceValue()}") appendLine(
" ${file.name} staged=${file.staged} tracked=${file.tracked} " +
"content=${file.content.toEvidenceValue()} stagedContent=${file.stagedContent.toNullableEvidenceValue()}",
)
} }
} }
appendLine("commits=") appendLine("commits=")
@@ -240,6 +243,10 @@ class LevelSolutionsTest {
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"") return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
} }
fun String?.toNullableEvidenceValue(): String {
return this?.toEvidenceValue() ?: "<none>"
}
val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf( val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf(
"init", "init",
"config", "config",

View File

@@ -0,0 +1,49 @@
diff --git a/common-main.c b/common-main.c
index 0000000000..0000000000 100644
--- a/common-main.c
+++ b/common-main.c
@@ -1,6 +1,27 @@
#include "git-compat-util.h"
#include "common-init.h"
+#ifdef GITHUG_EMBEDDED_MAIN
+void githug_exit(int code)
+{
+ code &= 0xff;
+ fflush(stdout);
+ fflush(stderr);
+ _exit(code);
+}
+
+int githug_git_main(int argc, const char **argv)
+{
+ int result;
+
+ init_git(argv);
+ result = cmd_main(argc, argv);
+
+ /* Match Git's real main(), but return instead of calling libc exit(). */
+ return common_exit(__FILE__, __LINE__, result);
+}
+#endif
+
int main(int argc, const char **argv)
{
int result;
diff --git a/git-compat-util.h b/git-compat-util.h
index 0000000000..0000000000 100644
--- a/git-compat-util.h
+++ b/git-compat-util.h
@@ -1047,7 +1047,12 @@ int cmd_main(int, const char **);
* optionally emit a message before calling the real exit().
*/
int common_exit(const char *file, int line, int code);
+#ifdef GITHUG_EMBEDDED_MAIN
+NORETURN void githug_exit(int code);
+#define exit(code) githug_exit(common_exit(__FILE__, __LINE__, (code)))
+#else
#define exit(code) exit(common_exit(__FILE__, __LINE__, (code)))
+#endif
/*
* This include must come after system headers, since it introduces macros that