Compare commits
10 Commits
1e87bd9aa7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
943bf03ca0 | ||
|
|
d703f539d3 | ||
|
|
caaf26a541 | ||
|
|
4b94173ae5 | ||
|
|
91913a9de8 | ||
|
|
781b5090f7 | ||
|
|
2db8fdd328 | ||
|
|
60dd3c82f7 | ||
|
|
035851ac05 | ||
|
|
82e38de3bb |
@@ -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"
|
||||
STATE_TTL_SECONDS=86400
|
||||
HIDE_EMULATOR_WINDOW="false"
|
||||
QUIET_LOG_DIR="$PROJECT_DIR/build/reports/android-project-tooling"
|
||||
|
||||
log() {
|
||||
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() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "Missing required tool: $1" >&2
|
||||
@@ -450,7 +505,7 @@ build_android_git_for_abi() {
|
||||
local output_dir="$ANDROID_JNI_DIR/$abi"
|
||||
local output_binary="$output_dir/libgit.so"
|
||||
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"
|
||||
|
||||
@@ -470,6 +525,7 @@ build_android_git_for_abi() {
|
||||
NO_PTHREADS=YesPlease \
|
||||
NO_LIBGEN_H=YesPlease \
|
||||
HAVE_DEV_TTY=YesPlease \
|
||||
CFLAGS_APPEND=-DGITHUG_EMBEDDED_MAIN \
|
||||
CC="$cc" \
|
||||
AR=llvm-ar \
|
||||
RANLIB=llvm-ranlib \
|
||||
@@ -794,7 +850,7 @@ maybe_run_operation() {
|
||||
|
||||
print_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-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
|
||||
--hide-emulator-window Run --test-emulator with the emulator window hidden
|
||||
--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
|
||||
}
|
||||
|
||||
validate_args() {
|
||||
local mode="${1:-}"
|
||||
if [ "$mode" = "--quiet" ]; then
|
||||
mode="${2:-}"
|
||||
shift || true
|
||||
fi
|
||||
shift || true
|
||||
|
||||
case "$mode" in
|
||||
@@ -822,6 +883,8 @@ validate_args() {
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--quiet)
|
||||
;;
|
||||
--hide-emulator-window)
|
||||
if [ "$mode" != "--test-emulator" ]; then
|
||||
echo "--hide-emulator-window can only be used with --test-emulator" >&2
|
||||
@@ -841,6 +904,7 @@ validate_args() {
|
||||
}
|
||||
|
||||
main() {
|
||||
run_quiet_if_requested "$@"
|
||||
validate_args "$@"
|
||||
|
||||
require_tool curl
|
||||
|
||||
21
README.md
21
README.md
@@ -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 --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:
|
||||
|
||||
```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`
|
||||
- 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:
|
||||
|
||||
```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 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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
applicationId = "solutions.tretter.githugandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 174
|
||||
versionName = "0.1.173"
|
||||
versionCode = 184
|
||||
versionName = "0.1.183"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -3,15 +3,23 @@ package solutions.tretter.githugandroid
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performImeAction
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
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.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -42,16 +50,105 @@ class GitRepositoryRuntimeInstrumentedTest {
|
||||
launchFreshApp().use {
|
||||
allGithugLevels().forEachIndexed { index, level ->
|
||||
waitForExercise(level)
|
||||
if (level.id == renameCommitLevel().id) {
|
||||
completeRenameCommitWithEditor()
|
||||
} else {
|
||||
level.testCases.first().commands.forEach(::submitTerminalCommand)
|
||||
}
|
||||
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> {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
File(context.applicationInfo.dataDir, "files").deleteRecursively()
|
||||
File(context.filesDir, "githug-sandboxes").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)
|
||||
waitForExercise(initLevel())
|
||||
return scenario
|
||||
@@ -77,6 +174,87 @@ class GitRepositoryRuntimeInstrumentedTest {
|
||||
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) {
|
||||
val input = composeRule.onNodeWithTag("terminal-command-input")
|
||||
input.performClick()
|
||||
|
||||
@@ -4,15 +4,21 @@
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define LOG_TAG "GitHugNativeGit"
|
||||
#define GIT_COMMAND_TIMEOUT_MS 30000
|
||||
#define GIT_SESSION_MAX_READ_WAIT_MS 1000
|
||||
|
||||
typedef int (*git_main_fn)(int argc, const char **argv);
|
||||
typedef int (*githug_git_main_fn)(int argc, const char **argv);
|
||||
typedef void (*git_init_fn)(const char **argv);
|
||||
|
||||
struct output_buffer {
|
||||
@@ -24,8 +30,19 @@ struct output_buffer {
|
||||
static pthread_mutex_t git_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static void *git_handle = NULL;
|
||||
static git_main_fn git_main = NULL;
|
||||
static githug_git_main_fn githug_git_main = NULL;
|
||||
static git_init_fn git_init = NULL;
|
||||
|
||||
struct git_session {
|
||||
int id;
|
||||
pid_t child;
|
||||
int pty_fd;
|
||||
struct git_session *next;
|
||||
};
|
||||
|
||||
static struct git_session *git_sessions = NULL;
|
||||
static int next_git_session_id = 1;
|
||||
|
||||
static int append_output(struct output_buffer *buffer, const char *data, size_t length) {
|
||||
if (length == 0) {
|
||||
return 0;
|
||||
@@ -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) {
|
||||
jclass string_class = (*env)->FindClass(env, "java/lang/String");
|
||||
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;
|
||||
}
|
||||
|
||||
static jobjectArray make_session_result(JNIEnv *env, int session_id, int running, int exit_code, const char *output) {
|
||||
jclass string_class = (*env)->FindClass(env, "java/lang/String");
|
||||
jobjectArray result = (*env)->NewObjectArray(env, 4, string_class, NULL);
|
||||
char session_text[32];
|
||||
char running_text[8];
|
||||
char exit_text[32];
|
||||
snprintf(session_text, sizeof(session_text), "%d", session_id);
|
||||
snprintf(running_text, sizeof(running_text), "%d", running ? 1 : 0);
|
||||
if (exit_code >= 0) {
|
||||
snprintf(exit_text, sizeof(exit_text), "%d", exit_code);
|
||||
} else {
|
||||
exit_text[0] = '\0';
|
||||
}
|
||||
jstring session_string = (*env)->NewStringUTF(env, session_text);
|
||||
jstring running_string = (*env)->NewStringUTF(env, running_text);
|
||||
jstring exit_string = (*env)->NewStringUTF(env, exit_text);
|
||||
jstring output_string = (*env)->NewStringUTF(env, output != NULL ? output : "");
|
||||
(*env)->SetObjectArrayElement(env, result, 0, session_string);
|
||||
(*env)->SetObjectArrayElement(env, result, 1, running_string);
|
||||
(*env)->SetObjectArrayElement(env, result, 2, exit_string);
|
||||
(*env)->SetObjectArrayElement(env, result, 3, output_string);
|
||||
(*env)->DeleteLocalRef(env, session_string);
|
||||
(*env)->DeleteLocalRef(env, running_string);
|
||||
(*env)->DeleteLocalRef(env, exit_string);
|
||||
(*env)->DeleteLocalRef(env, output_string);
|
||||
return result;
|
||||
}
|
||||
|
||||
static jobjectArray make_error(JNIEnv *env, const char *message) {
|
||||
return make_result(env, -1, message);
|
||||
}
|
||||
@@ -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());
|
||||
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");
|
||||
if (git_main == NULL) {
|
||||
__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);
|
||||
}
|
||||
|
||||
static struct git_session *find_session(int session_id, struct git_session ***link_out) {
|
||||
struct git_session **link = &git_sessions;
|
||||
while (*link != NULL) {
|
||||
if ((*link)->id == session_id) {
|
||||
if (link_out != NULL) {
|
||||
*link_out = link;
|
||||
}
|
||||
return *link;
|
||||
}
|
||||
link = &((*link)->next);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void remove_session(struct git_session **link) {
|
||||
if (link == NULL) {
|
||||
return;
|
||||
}
|
||||
struct git_session *session = *link;
|
||||
if (session == NULL) {
|
||||
return;
|
||||
}
|
||||
*link = session->next;
|
||||
if (session->pty_fd >= 0) {
|
||||
close(session->pty_fd);
|
||||
}
|
||||
free(session);
|
||||
}
|
||||
|
||||
static int child_exit_code_from_status(int status) {
|
||||
if (WIFEXITED(status)) {
|
||||
return WEXITSTATUS(status);
|
||||
}
|
||||
if (WIFSIGNALED(status)) {
|
||||
return 128 + WTERMSIG(status);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct saved_env {
|
||||
char *name;
|
||||
char *previous;
|
||||
@@ -227,6 +320,17 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
}
|
||||
|
||||
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();
|
||||
if (child < 0) {
|
||||
@@ -241,6 +345,7 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
}
|
||||
|
||||
if (child == 0) {
|
||||
setpgid(0, 0);
|
||||
close(pipe_fds[0]);
|
||||
dup2(pipe_fds[1], STDOUT_FILENO);
|
||||
dup2(pipe_fds[1], STDERR_FILENO);
|
||||
@@ -251,20 +356,18 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
(void)saved_env;
|
||||
chdir(working_directory_chars);
|
||||
|
||||
if (load_git(library_path_chars) != 0) {
|
||||
const char *error = dlerror();
|
||||
fprintf(stderr, "%s\n", error != NULL ? error : "Native Git execution failed: init_git/cmd_main not found");
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
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);
|
||||
int child_exit_code = git_main(argc, (const char **)argv);
|
||||
child_exit_code = git_main(argc, (const char **)argv);
|
||||
}
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_exit(child_exit_code);
|
||||
}
|
||||
setpgid(child, child);
|
||||
|
||||
close(pipe_fds[1]);
|
||||
struct output_buffer output = {0};
|
||||
@@ -276,6 +379,8 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
int child_status = 0;
|
||||
int child_done = 0;
|
||||
int saw_eof = 0;
|
||||
int timed_out = 0;
|
||||
long long started_at = monotonic_millis();
|
||||
while (!child_done || !saw_eof) {
|
||||
if (drain_available_output(pipe_fds[0], &output, &saw_eof) != 0) {
|
||||
break;
|
||||
@@ -288,6 +393,16 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
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 (!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);
|
||||
|
||||
int exit_code = -1;
|
||||
if (WIFEXITED(child_status)) {
|
||||
if (timed_out) {
|
||||
exit_code = 124;
|
||||
} else if (WIFEXITED(child_status)) {
|
||||
exit_code = WEXITSTATUS(child_status);
|
||||
} else if (WIFSIGNALED(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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -13,3 +13,47 @@ object AppLog {
|
||||
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})"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,27 @@ internal fun directoryCompletionCandidates(repo: RepoState): List<String> {
|
||||
.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 {
|
||||
return if (currentDir == ".") "" else currentDir.trimEnd('/') + "/"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ data class GitFile(
|
||||
val staged: Boolean = false,
|
||||
val tracked: Boolean = false,
|
||||
val deleted: Boolean = false,
|
||||
val stagedContent: String? = null,
|
||||
)
|
||||
|
||||
data class CommitNode(
|
||||
@@ -21,11 +22,9 @@ data class CommitNode(
|
||||
val parentCount: Int = 0,
|
||||
)
|
||||
|
||||
data class InteractiveAddSession(
|
||||
val target: String? = null,
|
||||
val awaitingUpdateSelection: Boolean = false,
|
||||
val selectionPrompt: String = "Update>>",
|
||||
val selectionAction: String = "update",
|
||||
data class NativeGitSession(
|
||||
val id: Int,
|
||||
val command: String,
|
||||
)
|
||||
|
||||
data class RepoState(
|
||||
@@ -45,7 +44,7 @@ data class RepoState(
|
||||
val pushedTags: Set<String> = emptySet(),
|
||||
val submodules: Map<String, String> = emptyMap(),
|
||||
val maintenanceActions: Set<String> = emptySet(),
|
||||
val interactiveAddSession: InteractiveAddSession? = null,
|
||||
val nativeGitSession: NativeGitSession? = null,
|
||||
)
|
||||
|
||||
data class Level(
|
||||
|
||||
@@ -4,13 +4,13 @@ enum class GitEditorCommandKind {
|
||||
COMMIT_MESSAGE,
|
||||
REBASE_TODO,
|
||||
TAG_MESSAGE,
|
||||
PATCH_HUNK,
|
||||
}
|
||||
|
||||
data class GitEditorInvocation(
|
||||
val command: String,
|
||||
val kind: GitEditorCommandKind,
|
||||
val title: String,
|
||||
val displayPath: String,
|
||||
val initialContent: String = "",
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ private fun parseGitCommitEditor(command: String, arguments: List<String>): GitE
|
||||
command = command,
|
||||
kind = GitEditorCommandKind.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,
|
||||
kind = GitEditorCommandKind.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,
|
||||
kind = GitEditorCommandKind.TAG_MESSAGE,
|
||||
title = "Edit Tag Message",
|
||||
displayPath = ".git/TAG_EDITMSG",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,17 @@ package solutions.tretter.githugandroid
|
||||
|
||||
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(
|
||||
private val requireNativeGit: () -> File,
|
||||
private val prepareLevel: (Level) -> RepoState,
|
||||
@@ -11,8 +22,6 @@ internal class GitEditorWorkflow(
|
||||
private val shellExecutable: () -> String,
|
||||
) {
|
||||
fun initialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
||||
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) return invocation.initialContent
|
||||
|
||||
val nativeGit = requireNativeGit()
|
||||
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
|
||||
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
||||
@@ -32,7 +41,6 @@ internal class GitEditorWorkflow(
|
||||
GitEditorCommandKind.REBASE_TODO -> "GIT_SEQUENCE_EDITOR"
|
||||
GitEditorCommandKind.COMMIT_MESSAGE,
|
||||
GitEditorCommandKind.TAG_MESSAGE -> "GIT_EDITOR"
|
||||
GitEditorCommandKind.PATCH_HUNK -> return invocation.initialContent
|
||||
}
|
||||
|
||||
runGit(
|
||||
@@ -57,7 +65,7 @@ internal class GitEditorWorkflow(
|
||||
currentRepo: RepoState,
|
||||
invocation: GitEditorInvocation,
|
||||
message: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
): GitEditorExecutionResult {
|
||||
val nativeGit = requireNativeGit()
|
||||
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
|
||||
return when (invocation.kind) {
|
||||
@@ -82,9 +90,6 @@ internal class GitEditorWorkflow(
|
||||
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,
|
||||
invocation: GitEditorInvocation,
|
||||
message: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
): GitEditorExecutionResult {
|
||||
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
|
||||
parentFile?.mkdirs()
|
||||
writeText(message)
|
||||
}
|
||||
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command)
|
||||
val commandTokens = GitSandboxEngine.tokenizeCommand(invocation.command)
|
||||
val arguments = commandTokens
|
||||
.drop(1)
|
||||
.toMutableList()
|
||||
.apply { addAll(listOf("-F", messageFile.absolutePath)) }
|
||||
val result = runGit(nativeGit, workingDir, arguments, emptyMap())
|
||||
messageFile.delete()
|
||||
|
||||
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||
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(
|
||||
@@ -119,7 +144,7 @@ internal class GitEditorWorkflow(
|
||||
workingDir: File,
|
||||
invocation: GitEditorInvocation,
|
||||
todo: String,
|
||||
): Pair<RepoState, List<String>> {
|
||||
): GitEditorExecutionResult {
|
||||
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
||||
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
|
||||
writeText(todo)
|
||||
@@ -132,19 +157,106 @@ internal class GitEditorWorkflow(
|
||||
|""".trimMargin(),
|
||||
)
|
||||
}
|
||||
val capture = editorCaptureFiles(gitDir)
|
||||
capture.content.delete()
|
||||
capture.path.delete()
|
||||
val messageEditorScript = createCaptureEditorScript(gitDir, capture)
|
||||
val result = runGit(
|
||||
nativeGit,
|
||||
workingDir,
|
||||
GitSandboxEngine.tokenizeCommand(invocation.command).drop(1),
|
||||
mapOf(
|
||||
"GIT_SEQUENCE_EDITOR" to editorCommand(editorScript),
|
||||
"GIT_EDITOR" to "true",
|
||||
"GIT_EDITOR" to editorCommand(messageEditorScript),
|
||||
),
|
||||
)
|
||||
todoFile.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> {
|
||||
@@ -166,6 +278,10 @@ internal class GitEditorWorkflow(
|
||||
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 {
|
||||
return "'" + replace("'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
@@ -34,8 +34,10 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun GitHugApp() {
|
||||
@@ -73,6 +75,8 @@ fun GitHugApp() {
|
||||
var historyIndex by remember { mutableStateOf(-1) }
|
||||
var historyDraft by remember { mutableStateOf("") }
|
||||
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 gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
|
||||
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
|
||||
@@ -107,8 +111,10 @@ fun GitHugApp() {
|
||||
}
|
||||
|
||||
LaunchedEffect(currentLevelIndex) {
|
||||
AppLog.d("GitHugApp", "Current level index changed to $currentLevelIndex id=${levels[currentLevelIndex].id}")
|
||||
delay(100)
|
||||
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) {
|
||||
@@ -210,7 +216,9 @@ fun GitHugApp() {
|
||||
}
|
||||
|
||||
fun resetCurrentLevel(message: String = "Level reset.") {
|
||||
val startedAt = System.nanoTime()
|
||||
val resetLevelId = currentLevel.id
|
||||
AppLog.d("GitHugApp", "Reset requested level=$resetLevelId")
|
||||
val updatedCompletedLevels = completedLevels - resetLevelId
|
||||
completedLevels = updatedCompletedLevels
|
||||
scope.launch { persistProgress(updatedCompletedLevels, resetLevelId) }
|
||||
@@ -227,17 +235,25 @@ fun GitHugApp() {
|
||||
gitMessageEditorState = null
|
||||
manPageState = null
|
||||
applyRecommendedPaneWeights(persist = false, outputLineCount = terminalOutputLineCount(listOf(message)))
|
||||
AppLog.d(
|
||||
"GitHugApp",
|
||||
"Reset finished level=$resetLevelId durationMs=${elapsedMillisSince(startedAt)} repo=${repo.diagnosticSnapshot()}",
|
||||
)
|
||||
}
|
||||
|
||||
fun loadLevel(
|
||||
index: Int,
|
||||
message: List<String> = listOf("Loaded level: ${levels[index].title}"),
|
||||
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
|
||||
repo = runtime.prepareLevel(levels[index])
|
||||
output = message
|
||||
output = if (prepareInBackground) listOf("Preparing level: ${level.title}") else message
|
||||
clearCommandInput(recreateField = true)
|
||||
if (!keepSuppressedImeEcho) {
|
||||
suppressedImeEcho = null
|
||||
@@ -250,10 +266,54 @@ fun GitHugApp() {
|
||||
editorState = null
|
||||
gitMessageEditorState = 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(
|
||||
weights = paneLayout.weights + recommendedPaneWeights(
|
||||
heights = recommendedPaneHeights(
|
||||
level = levels[index],
|
||||
level = level,
|
||||
levelCount = levels.size,
|
||||
outputLineCount = 1,
|
||||
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) {
|
||||
@@ -305,7 +369,12 @@ fun GitHugApp() {
|
||||
if (token.isBlank() && !isCdCompletion) return
|
||||
|
||||
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
|
||||
|
||||
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) {
|
||||
val startedAt = System.nanoTime()
|
||||
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
|
||||
AppLog.d(
|
||||
"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 {
|
||||
addAll(output)
|
||||
@@ -370,6 +446,10 @@ fun GitHugApp() {
|
||||
output = 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) {
|
||||
@@ -416,7 +496,6 @@ fun GitHugApp() {
|
||||
val initialContent = runtime.gitEditorInitialContent(currentLevel, repo, invocation)
|
||||
val openedMessage = when (invocation.kind) {
|
||||
GitEditorCommandKind.REBASE_TODO -> "Opened Git rebase editor"
|
||||
GitEditorCommandKind.PATCH_HUNK -> "Opened Git patch editor"
|
||||
else -> "Opened Git message editor"
|
||||
}
|
||||
val newOutput = buildList {
|
||||
@@ -443,21 +522,39 @@ fun GitHugApp() {
|
||||
|
||||
fun saveGitMessageEditor() {
|
||||
val state = gitMessageEditorState ?: return
|
||||
val (newRepo, lines) = runtime.executeGitEditorCommand(
|
||||
val result = runtime.executeGitEditorCommandWithResult(
|
||||
level = currentLevel,
|
||||
currentRepo = repo,
|
||||
invocation = state.invocation,
|
||||
message = state.content,
|
||||
)
|
||||
gitMessageEditorState = null
|
||||
applyCommandResult(state.invocation.command, newRepo, lines, echoCommand = false)
|
||||
gitMessageEditorState = result.nextEditor?.let { nextEditor ->
|
||||
GitMessageEditorState(
|
||||
invocation = nextEditor.invocation,
|
||||
content = nextEditor.content,
|
||||
)
|
||||
}
|
||||
applyCommandResult(state.invocation.command, result.repo, result.outputLines, echoCommand = false)
|
||||
}
|
||||
|
||||
fun runCommand() {
|
||||
val startedAt = System.nanoTime()
|
||||
val submittedText = commandInput.text
|
||||
val raw = submittedText.trim()
|
||||
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
|
||||
showTerminalInputHint = false
|
||||
@@ -489,14 +586,9 @@ fun GitHugApp() {
|
||||
return
|
||||
}
|
||||
|
||||
val patchHunkEditorInvocation = GitSandboxEngine.parsePatchHunkEditorInvocation(repo, raw)
|
||||
if (patchHunkEditorInvocation != null) {
|
||||
openGitMessageEditor(patchHunkEditorInvocation)
|
||||
return
|
||||
}
|
||||
|
||||
val (newRepo, lines) = runtime.execute(currentLevel, repo, raw)
|
||||
applyCommandResult(raw, newRepo, lines, echoCommand = !isInteractiveInput)
|
||||
AppLog.d("GitHugApp", "Command handling finished level=$submittedLevelId raw='$raw' durationMs=${elapsedMillisSince(startedAt)}")
|
||||
}
|
||||
|
||||
fun showCommandHelp() {
|
||||
@@ -560,7 +652,9 @@ fun GitHugApp() {
|
||||
})
|
||||
},
|
||||
levelsContent = {
|
||||
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
|
||||
LevelsPane(levels, currentLevelIndex, completedLevels) { index ->
|
||||
loadLevel(index, prepareInBackground = true)
|
||||
}
|
||||
},
|
||||
visualContent = { VisualPane(repo) },
|
||||
exerciseContent = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,18 @@ package solutions.tretter.githugandroid
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
internal class GitProcessRunner(
|
||||
private val context: Context?,
|
||||
private val sandboxesRoot: File,
|
||||
) {
|
||||
private val nextHostSessionId = AtomicInteger(1)
|
||||
private val hostSessions = ConcurrentHashMap<Int, HostGitSession>()
|
||||
|
||||
private companion object {
|
||||
val RequiredGitCommandAliases = listOf(
|
||||
"add",
|
||||
@@ -54,11 +60,50 @@ internal class GitProcessRunner(
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult {
|
||||
if (context != null) {
|
||||
gitExecDirectory(binary)
|
||||
return NativeGitBridge.runGitMain(binary, workingDir, arguments, gitEnvironment(binary, workingDir, environment))
|
||||
val startedAt = System.nanoTime()
|
||||
val execStartedAt = System.nanoTime()
|
||||
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 {
|
||||
@@ -84,15 +129,14 @@ internal class GitProcessRunner(
|
||||
binary: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
extraEnvironment: Map<String, String> = emptyMap(),
|
||||
environment: Map<String, String>,
|
||||
): ProcessExecutionResult {
|
||||
return try {
|
||||
val gitExecPath = gitExecDirectory(binary)
|
||||
val process = ProcessBuilder(listOf(binary.absolutePath) + arguments)
|
||||
.directory(workingDir)
|
||||
.redirectErrorStream(true)
|
||||
.apply {
|
||||
environment().putAll(gitEnvironment(binary, workingDir, extraEnvironment, gitExecPath))
|
||||
environment().putAll(environment)
|
||||
}
|
||||
.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 {
|
||||
val directory = if (context != null) {
|
||||
File(context.filesDir, "git-exec")
|
||||
@@ -151,6 +256,7 @@ internal class GitProcessRunner(
|
||||
put("GIT_COMMITTER_NAME", "GitHug")
|
||||
put("GIT_COMMITTER_EMAIL", "githug@example.com")
|
||||
put("LC_ALL", "C")
|
||||
put("TERM", "xterm-256color")
|
||||
putAll(extraEnvironment)
|
||||
}
|
||||
}
|
||||
@@ -192,9 +298,57 @@ internal class GitProcessRunner(
|
||||
}
|
||||
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(
|
||||
val exitCode: Int,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
|
||||
internal data class GitSessionResult(
|
||||
val sessionId: Int,
|
||||
val running: Boolean,
|
||||
val exitCode: Int?,
|
||||
val outputLines: List<String>,
|
||||
)
|
||||
|
||||
private data class HostGitSession(
|
||||
val process: Process,
|
||||
)
|
||||
|
||||
private fun InputStream.readAvailableText(): String {
|
||||
val output = StringBuilder()
|
||||
val buffer = ByteArray(4096)
|
||||
while (available() > 0) {
|
||||
val count = read(buffer)
|
||||
if (count <= 0) break
|
||||
output.append(String(buffer, 0, count))
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun String.toOutputLines(): List<String> =
|
||||
replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.lineSequence()
|
||||
.toList()
|
||||
.dropLastWhile { it.isEmpty() }
|
||||
|
||||
@@ -7,33 +7,51 @@ internal class GitRepositoryInspector(
|
||||
private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
|
||||
) {
|
||||
fun inspectConfig(sandbox: File, currentDir: String = "."): Map<String, String> {
|
||||
val startedAt = System.nanoTime()
|
||||
val git = nativeGit()
|
||||
val workingDir = File(sandbox, currentDir).canonicalFile
|
||||
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
|
||||
?: 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 {
|
||||
val startedAt = System.nanoTime()
|
||||
val git = nativeGit()
|
||||
val workingDir = File(sandbox, currentDir).canonicalFile
|
||||
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
|
||||
?: sandbox
|
||||
val repositoryRoot = repositoryRoot(sandbox, workingDir)
|
||||
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()
|
||||
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
|
||||
.orEmpty()
|
||||
.toList()
|
||||
|
||||
if (repositoryRoot == null) {
|
||||
return RepoState(
|
||||
val repo = RepoState(
|
||||
initialized = false,
|
||||
files = filesOnDisk.map {
|
||||
files = filesOnDisk.mapNotNull {
|
||||
if (!it.isFile) return@mapNotNull null
|
||||
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
|
||||
},
|
||||
)
|
||||
AppLog.d(
|
||||
"GitInspector",
|
||||
"inspect finish sandbox=${sandbox.name} durationMs=${elapsedMillisSince(startedAt)} repo=${repo.diagnosticSnapshot()}",
|
||||
)
|
||||
return repo
|
||||
}
|
||||
|
||||
val statusResult = run(git, inspectionRoot, listOf("status", "--porcelain"))
|
||||
@@ -124,9 +142,10 @@ internal class GitRepositoryInspector(
|
||||
?.let { "tags/$it" }
|
||||
?: "DETACHED"
|
||||
|
||||
return RepoState(
|
||||
val repo = RepoState(
|
||||
initialized = true,
|
||||
files = filesOnDisk.map { file ->
|
||||
files = filesOnDisk.mapNotNull { file ->
|
||||
if (!file.isFile) return@mapNotNull null
|
||||
val relativePath = file.relativeTo(inspectionRoot).path
|
||||
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
||||
GitFile(
|
||||
@@ -134,6 +153,7 @@ internal class GitRepositoryInspector(
|
||||
content = file.readText(),
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
stagedContent = stagedContent(git, inspectionRoot, relativePath, staged),
|
||||
)
|
||||
} + deletedStatusPaths
|
||||
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
|
||||
@@ -144,6 +164,7 @@ internal class GitRepositoryInspector(
|
||||
staged = staged,
|
||||
tracked = tracked,
|
||||
deleted = true,
|
||||
stagedContent = stagedContent(git, inspectionRoot, deletedPath, staged),
|
||||
)
|
||||
},
|
||||
commits = commits,
|
||||
@@ -172,6 +193,11 @@ internal class GitRepositoryInspector(
|
||||
submodules = submodules,
|
||||
maintenanceActions = maintenanceActions,
|
||||
)
|
||||
AppLog.d(
|
||||
"GitInspector",
|
||||
"inspect finish sandbox=${sandbox.name} durationMs=${elapsedMillisSince(startedAt)} repo=${repo.diagnosticSnapshot()}",
|
||||
)
|
||||
return repo
|
||||
}
|
||||
|
||||
private fun run(
|
||||
@@ -181,6 +207,14 @@ internal class GitRepositoryInspector(
|
||||
environment: Map<String, String> = emptyMap(),
|
||||
): ProcessExecutionResult = runGit(binary, workingDir, arguments, environment)
|
||||
|
||||
private fun stagedContent(git: File, inspectionRoot: File, path: String, staged: Boolean): String? {
|
||||
if (!staged) return null
|
||||
val result = run(git, inspectionRoot, listOf("show", ":$path"))
|
||||
return result.outputLines
|
||||
.takeIf { result.exitCode == 0 }
|
||||
?.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun repositoryRoot(sandbox: File, workingDir: File): File? {
|
||||
val sandboxPath = sandbox.canonicalPath
|
||||
return generateSequence(workingDir) { directory ->
|
||||
@@ -200,9 +234,33 @@ internal class GitRepositoryInspector(
|
||||
userEmailResult.outputLines.firstOrNull()
|
||||
?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() }
|
||||
?.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 {
|
||||
val remotes = remoteLines.mapNotNull { line ->
|
||||
val parts = line.trim().split(Regex("\\s+"))
|
||||
|
||||
@@ -68,14 +68,19 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
|
||||
fun prepareLevel(level: Level): RepoState {
|
||||
val startedAt = System.nanoTime()
|
||||
AppLog.d("GitRuntime", "Preparing level id=${level.id} title=${level.title}")
|
||||
val nativeGit = requireNativeGit()
|
||||
|
||||
val sandbox = sandboxDir(level)
|
||||
val resetStartedAt = System.nanoTime()
|
||||
sandbox.deleteRecursively()
|
||||
sandbox.mkdirs()
|
||||
val resetMs = elapsedMillisSince(resetStartedAt)
|
||||
|
||||
val desired = level.setup()
|
||||
AppLog.d("GitRuntime", "Level setup desired id=${level.id} repo=${desired.diagnosticSnapshot()}")
|
||||
val filesStartedAt = System.nanoTime()
|
||||
desired.files.forEach { file ->
|
||||
File(sandbox, file.name).apply {
|
||||
parentFile?.mkdirs()
|
||||
@@ -83,8 +88,10 @@ class GitRepositoryRuntime private constructor(
|
||||
}
|
||||
}
|
||||
File(sandbox, desired.currentDir).mkdirs()
|
||||
val filesMs = elapsedMillisSince(filesStartedAt)
|
||||
|
||||
val needsGit = desired.initialized || desired.files.any { it.staged || it.tracked } || desired.commits.isNotEmpty()
|
||||
val materializeStartedAt = System.nanoTime()
|
||||
if (needsGit) {
|
||||
val initResult = runGit(nativeGit, sandbox, listOf("init", "-b", desired.headBranch))
|
||||
if (initResult.exitCode != 0) {
|
||||
@@ -94,12 +101,22 @@ class GitRepositoryRuntime private constructor(
|
||||
|
||||
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>> {
|
||||
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 sandbox = sandboxDir(level)
|
||||
@@ -120,18 +137,98 @@ class GitRepositoryRuntime private constructor(
|
||||
} else {
|
||||
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()) {
|
||||
"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()
|
||||
else -> helperCommands.executeExecutableShortcut(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens)
|
||||
}
|
||||
|
||||
val refreshStartedAt = System.nanoTime()
|
||||
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
|
||||
}
|
||||
|
||||
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> {
|
||||
requireNativeGit()
|
||||
val sandbox = sandboxDir(level)
|
||||
@@ -161,7 +258,7 @@ class GitRepositoryRuntime private constructor(
|
||||
addAll(GitSandboxEngine.commandReferenceLines())
|
||||
add("Native Git runtime:")
|
||||
add(" binary path: nativeLibraryDir/libgit.so")
|
||||
add(" invocation: init_git(argv), then cmd_main(argc, argv)")
|
||||
add(" invocation: githug_git_main(argc, argv)")
|
||||
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
|
||||
add(" helper commands: ls/dir, pwd, cat, sh <script>, ./<script>, touch, mkdir/md, cd.., rm/del, echo")
|
||||
add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad")
|
||||
@@ -230,6 +327,16 @@ class GitRepositoryRuntime private constructor(
|
||||
invocation: GitEditorInvocation,
|
||||
message: 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config"
|
||||
}
|
||||
|
||||
@@ -6,12 +6,6 @@ object GitSandboxEngine {
|
||||
val quoted: Boolean = false,
|
||||
)
|
||||
|
||||
fun parsePatchHunkEditorInvocation(repo: RepoState, command: String): GitEditorInvocation? =
|
||||
InteractiveAddEngine.parsePatchHunkEditorInvocation(repo, command)
|
||||
|
||||
fun applyPatchHunkEdit(repo: RepoState, content: String): Pair<RepoState, List<String>> =
|
||||
InteractiveAddEngine.applyPatchHunkEdit(repo, content)
|
||||
|
||||
fun commandReferenceLines(): List<String> = listOf(
|
||||
"Available sandbox commands:",
|
||||
" git ",
|
||||
@@ -31,9 +25,6 @@ object GitSandboxEngine {
|
||||
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
|
||||
val shellParts = tokenizeShellCommand(command)
|
||||
if (shellParts.isEmpty()) return repo to emptyList()
|
||||
repo.interactiveAddSession?.let {
|
||||
return InteractiveAddEngine.handleInput(repo, command)
|
||||
}
|
||||
return SandboxCommandEngine.execute(repo, shellParts)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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()
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,81 @@ internal object NativeGitBridge {
|
||||
)
|
||||
}
|
||||
|
||||
fun startGitSession(
|
||||
library: File,
|
||||
workingDir: File,
|
||||
arguments: List<String>,
|
||||
environment: Map<String, String>,
|
||||
): GitSessionResult {
|
||||
loadResult.getOrElse { error ->
|
||||
return GitSessionResult(
|
||||
sessionId = 0,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
|
||||
val argv = (listOf("git") + arguments).toTypedArray()
|
||||
val env = environment.entries.map { (key, value) -> "$key=$value" }.toTypedArray()
|
||||
return sessionResult(
|
||||
startGitSessionNative(
|
||||
library.absolutePath,
|
||||
workingDir.absolutePath,
|
||||
argv,
|
||||
env,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun writeGitSession(sessionId: Int, input: String): GitSessionResult {
|
||||
loadResult.getOrElse { error ->
|
||||
return GitSessionResult(
|
||||
sessionId = sessionId,
|
||||
running = false,
|
||||
exitCode = -1,
|
||||
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
|
||||
)
|
||||
}
|
||||
return sessionResult(writeGitSessionNative(sessionId, input))
|
||||
}
|
||||
|
||||
private fun sessionResult(result: Array<String>): GitSessionResult {
|
||||
val sessionId = result.getOrNull(0)?.toIntOrNull() ?: 0
|
||||
val running = result.getOrNull(1) == "1"
|
||||
val exitCode = result.getOrNull(2)?.takeIf { it.isNotBlank() }?.toIntOrNull()
|
||||
val output = result.getOrNull(3).orEmpty()
|
||||
return GitSessionResult(
|
||||
sessionId = sessionId,
|
||||
running = running,
|
||||
exitCode = exitCode,
|
||||
outputLines = output.toTerminalOutputLines(),
|
||||
)
|
||||
}
|
||||
|
||||
private external fun runGitMainNative(
|
||||
libraryPath: String,
|
||||
workingDirectory: String,
|
||||
argv: Array<String>,
|
||||
environment: Array<String>,
|
||||
): Array<String>
|
||||
|
||||
private external fun startGitSessionNative(
|
||||
libraryPath: String,
|
||||
workingDirectory: String,
|
||||
argv: Array<String>,
|
||||
environment: Array<String>,
|
||||
): Array<String>
|
||||
|
||||
private external fun writeGitSessionNative(
|
||||
sessionId: Int,
|
||||
input: String,
|
||||
): Array<String>
|
||||
}
|
||||
|
||||
private fun String.toTerminalOutputLines(): List<String> =
|
||||
replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.lineSequence()
|
||||
.toList()
|
||||
.dropLastWhile { it.isEmpty() }
|
||||
|
||||
@@ -7,7 +7,16 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
listOf(
|
||||
state.initialized,
|
||||
state.headBranch,
|
||||
state.files.flatMap { listOf(it.name, it.content, it.staged.toString(), it.tracked.toString(), it.deleted.toString()) },
|
||||
state.files.flatMap {
|
||||
listOf(
|
||||
it.name,
|
||||
it.content,
|
||||
it.staged.toString(),
|
||||
it.tracked.toString(),
|
||||
it.deleted.toString(),
|
||||
it.stagedContent.orEmpty(),
|
||||
)
|
||||
},
|
||||
state.commits.flatMap {
|
||||
listOf(
|
||||
it.id,
|
||||
@@ -28,14 +37,8 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
state.submodules.flatMap { listOf(it.key, it.value) },
|
||||
state.maintenanceActions.toList(),
|
||||
state.fetchHeadCount,
|
||||
state.interactiveAddSession?.let {
|
||||
listOf(
|
||||
it.target.orEmpty(),
|
||||
it.awaitingUpdateSelection.toString(),
|
||||
it.selectionPrompt,
|
||||
it.selectionAction,
|
||||
)
|
||||
}.orEmpty(),
|
||||
emptyList<Any>(),
|
||||
emptyList<Any>(),
|
||||
)
|
||||
},
|
||||
restore = { saved ->
|
||||
@@ -54,17 +57,23 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
val submoduleParts = saved.getOrNull(13) as? List<*> ?: emptyList<Any>()
|
||||
val maintenanceActions = saved.getOrNull(14) as? List<*> ?: emptyList<Any>()
|
||||
val fetchHeadCount = saved.getOrNull(15) as? Int ?: 0
|
||||
val interactiveAddSessionParts = saved.getOrNull(16) as? List<*> ?: emptyList<Any>()
|
||||
RepoState(
|
||||
initialized = initialized,
|
||||
headBranch = headBranch,
|
||||
files = fileParts.chunked(if (fileParts.size % 5 == 0) 5 else 4).map {
|
||||
files = fileParts.chunked(
|
||||
when {
|
||||
fileParts.size % 6 == 0 -> 6
|
||||
fileParts.size % 5 == 0 -> 5
|
||||
else -> 4
|
||||
},
|
||||
).map {
|
||||
GitFile(
|
||||
name = it[0] as String,
|
||||
content = it[1] as String,
|
||||
staged = (it[2] as String).toBoolean(),
|
||||
tracked = (it[3] as String).toBoolean(),
|
||||
deleted = (it.getOrNull(4) as? String)?.toBoolean() ?: false,
|
||||
stagedContent = (it.getOrNull(5) as? String)?.takeIf { value -> value.isNotEmpty() },
|
||||
)
|
||||
},
|
||||
commits = commitParts.restoreCommitNodes(),
|
||||
@@ -80,14 +89,6 @@ val RepoStateSaver = listSaver<RepoState, Any>(
|
||||
pushedTags = pushedTags.filterIsInstance<String>().toSet(),
|
||||
submodules = submoduleParts.chunked(2).associate { (it[0] as String) to (it[1] as String) },
|
||||
maintenanceActions = maintenanceActions.filterIsInstance<String>().toSet(),
|
||||
interactiveAddSession = interactiveAddSessionParts.takeIf { it.size >= 2 }?.let {
|
||||
InteractiveAddSession(
|
||||
target = (it[0] as String).ifBlank { null },
|
||||
awaitingUpdateSelection = (it[1] as String).toBoolean(),
|
||||
selectionPrompt = it.getOrNull(2) as? String ?: "Update>>",
|
||||
selectionAction = it.getOrNull(3) as? String ?: "update",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -157,10 +157,10 @@ internal object SandboxCommandEngine {
|
||||
|
||||
private fun stagePaths(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
|
||||
if (parts.drop(2).any { it == "-i" || it == "--interactive" }) {
|
||||
return InteractiveAddEngine.start(repo, parts.drop(2))
|
||||
return repo to listOf("Interactive Git commands require the native Git runtime.")
|
||||
}
|
||||
if (parts.drop(2).any { it == "-p" || it == "--patch" }) {
|
||||
return InteractiveAddEngine.startPatch(repo, parts.drop(2))
|
||||
return repo to listOf("Interactive Git commands require the native Git runtime.")
|
||||
}
|
||||
val target = parts.drop(2).lastOrNull { !it.startsWith("-") }
|
||||
?: return repo to listOf("usage: git add <path>")
|
||||
|
||||
@@ -7,13 +7,11 @@ 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.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
@@ -26,17 +24,16 @@ 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.Color
|
||||
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.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -50,6 +47,11 @@ data class TextEditorState(
|
||||
val saveAsPath: String,
|
||||
)
|
||||
|
||||
data class GitMessageEditorState(
|
||||
val invocation: GitEditorInvocation,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TextEditorDialog(
|
||||
state: TextEditorState,
|
||||
@@ -57,16 +59,75 @@ fun TextEditorDialog(
|
||||
onSaveAsPathChange: (String) -> Unit,
|
||||
onClose: () -> 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 editorVerticalScroll = rememberScrollState()
|
||||
val dialogScroll = rememberScrollState()
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(state.originalCommand, state.path) {
|
||||
contentFocusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
LaunchedEffect(resetKey) {
|
||||
dialogScroll.scrollTo(0)
|
||||
editorVerticalScroll.scrollTo(0)
|
||||
editorHorizontalScroll.scrollTo(0)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
@@ -92,7 +153,8 @@ fun TextEditorDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Edit File",
|
||||
text = title,
|
||||
modifier = titleTag?.let { Modifier.testTag(it) } ?: Modifier,
|
||||
color = TextPrimary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
@@ -101,16 +163,29 @@ fun TextEditorDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
EditorButton(label = "Save", enabled = state.saveAsPath.isNotBlank(), onClick = onSave)
|
||||
EditorButton(label = "Dismiss", onClick = onClose)
|
||||
EditorButton(
|
||||
label = "Save",
|
||||
enabled = saveEnabled,
|
||||
modifier = Modifier.testTag("$testTagPrefix-save"),
|
||||
onClick = onSave,
|
||||
)
|
||||
EditorButton(
|
||||
label = "Dismiss",
|
||||
modifier = Modifier.testTag("$testTagPrefix-dismiss"),
|
||||
onClick = onClose,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${state.editor} ${state.path.ifBlank { "<new file>" }}",
|
||||
color = TextPrimary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
text = metadata,
|
||||
modifier = metadataTag?.let { Modifier.testTag(it) } ?: Modifier,
|
||||
color = metadataStyle.color,
|
||||
fontFamily = metadataStyle.fontFamily,
|
||||
fontWeight = metadataStyle.fontWeight,
|
||||
fontSize = metadataStyle.fontSize,
|
||||
)
|
||||
saveAsPath?.let { path ->
|
||||
OutlinedTextField(
|
||||
value = state.saveAsPath,
|
||||
value = path,
|
||||
onValueChange = onSaveAsPathChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
@@ -121,6 +196,7 @@ fun TextEditorDialog(
|
||||
),
|
||||
label = { Text("File name") },
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -132,11 +208,11 @@ fun TextEditorDialog(
|
||||
.verticalScroll(editorVerticalScroll),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.content,
|
||||
value = content,
|
||||
onValueChange = onContentChange,
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = 1200.dp, minHeight = 1200.dp)
|
||||
.focusRequester(contentFocusRequester),
|
||||
.testTag("$testTagPrefix-content"),
|
||||
textStyle = TextStyle(
|
||||
color = TextPrimary,
|
||||
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
|
||||
private fun EditorButton(
|
||||
label: String,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Accent,
|
||||
contentColor = AppBackground,
|
||||
|
||||
@@ -18,5 +18,7 @@ internal fun addLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("add exact file", "git add README"),
|
||||
levelTestCase("add all", "git add ."),
|
||||
levelTestCase("stage exact file", "git stage README"),
|
||||
levelTestCase("add with path separator", "git add -- README"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -91,6 +91,14 @@ internal fun bisectLevel(): Level = level(
|
||||
"git bisect run ./test-balance.sh",
|
||||
"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(
|
||||
levelTestCase(
|
||||
|
||||
@@ -28,5 +28,6 @@ internal fun branchAtLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("branch at previous commit", "git branch test_branch HEAD~1"),
|
||||
levelTestCase("branch at caret", "git branch test_branch HEAD^"),
|
||||
levelTestCase("branch at explicit master parent", "git branch test_branch master^"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -17,5 +17,6 @@ internal fun branchLevel(): Level = level(
|
||||
validator = repoPredicate { repo -> "test_code" in repo.branches && repo.headBranch == "master" },
|
||||
testCases = listOf(
|
||||
levelTestCase("create branch", "git branch test_code"),
|
||||
levelTestCase("create branch from master", "git branch test_code master"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
testCases = listOf(
|
||||
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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -17,5 +17,7 @@ internal fun checkoutLevel(): Level = level(
|
||||
validator = repoPredicate { repo -> repo.headBranch == "my_branch" && "my_branch" in repo.branches },
|
||||
testCases = listOf(
|
||||
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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ internal fun checkoutTagLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("checkout tag", "git checkout v1.2"),
|
||||
levelTestCase("checkout explicit tag", "git checkout tags/v1.2"),
|
||||
levelTestCase("switch detached tag", "git switch --detach v1.2"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -26,5 +26,6 @@ internal fun checkoutTagOverBranchLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("checkout tag namespace", "git checkout tags/v1.2"),
|
||||
levelTestCase("checkout refs tag", "git checkout refs/tags/v1.2"),
|
||||
levelTestCase("switch detached tag namespace", "git switch --detach tags/v1.2"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 } },
|
||||
testCases = listOf(
|
||||
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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ internal fun commitInFutureLevel(): Level = level(
|
||||
},
|
||||
testCases = listOf(
|
||||
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(
|
||||
levelTestCase("current date commit does not solve", "git commit -m \"Current date commit\""),
|
||||
|
||||
@@ -18,5 +18,6 @@ internal fun commitLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("commit with message", "git commit -m \"Initial commit\""),
|
||||
levelTestCase("commit with alternate message", "git commit -m \"Add README\""),
|
||||
levelTestCase("commit through editor", "GIT_EDITOR=\"sed -i '1iInitial commit'\" git commit"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -18,5 +18,6 @@ internal fun configLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
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("explicit local config", "git config --local user.name GitHug", "git config --local user.email githug@example.com"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -49,5 +49,6 @@ internal fun fetchLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("fetch origin", "git fetch origin"),
|
||||
levelTestCase("fetch default", "git fetch"),
|
||||
levelTestCase("fetch all remotes", "git fetch --all"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -46,6 +46,7 @@ internal fun findOldBranchLevel(): Level = level(
|
||||
validator = repoPredicate { repo -> repo.headBranch == "solve_world_hunger" },
|
||||
testCases = listOf(
|
||||
levelTestCase("checkout old branch", "git checkout solve_world_hunger"),
|
||||
levelTestCase("switch old branch", "git switch solve_world_hunger"),
|
||||
),
|
||||
setupChecks = listOf(
|
||||
levelSetupCheck(
|
||||
|
||||
@@ -17,6 +17,7 @@ internal fun initLevel(): Level = level(
|
||||
validator = repoPredicate { it.initialized },
|
||||
testCases = listOf(
|
||||
levelTestCase("plain init", "git init"),
|
||||
levelTestCase("init current directory explicitly", "git init ."),
|
||||
),
|
||||
setupChecks = listOf(
|
||||
levelSetupCheck(
|
||||
|
||||
@@ -131,6 +131,7 @@ private fun loggingValidator(
|
||||
levelTitle: String,
|
||||
validator: (RepoState, String) -> Boolean,
|
||||
): (RepoState, String) -> Boolean = { repo, command ->
|
||||
val startedAt = System.nanoTime()
|
||||
AppLog.d(
|
||||
"Validation",
|
||||
buildString {
|
||||
@@ -141,43 +142,10 @@ private fun loggingValidator(
|
||||
append(" command='")
|
||||
append(command)
|
||||
append("' repo=")
|
||||
append(repo.validationSnapshot())
|
||||
append(repo.diagnosticSnapshot())
|
||||
},
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ internal fun mergeLevel(): Level = level(
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("merge feature", "git merge feature"),
|
||||
levelTestCase("merge feature with explicit merge commit", "git merge --no-ff feature -m \"Merge feature\""),
|
||||
),
|
||||
negativeTestCases = listOf(
|
||||
levelTestCase("switching to feature does not solve", "git switch feature"),
|
||||
|
||||
@@ -12,7 +12,7 @@ internal fun mergeSquashLevel(): Level = level(
|
||||
title = "Merge Squash",
|
||||
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!"),
|
||||
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)) },
|
||||
nativeSetup = {
|
||||
resetFiles()
|
||||
@@ -31,11 +31,21 @@ internal fun mergeSquashLevel(): Level = level(
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo ->
|
||||
repo.commits.firstOrNull()?.message == "Merge long feature" &&
|
||||
repo.headBranch == "master" &&
|
||||
repo.commits.size == 3 &&
|
||||
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(
|
||||
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"
|
||||
|
||||
@@ -40,5 +40,6 @@ internal fun pullLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("pull explicit remote branch", "git pull origin master"),
|
||||
levelTestCase("pull default origin", "git pull"),
|
||||
levelTestCase("fetch then merge", "git fetch origin", "git merge origin/master"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -45,5 +45,6 @@ internal fun pushBranchLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("push named branch", "git push origin 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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -86,5 +86,6 @@ internal fun pushLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
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 upstream push", "git fetch", "git rebase origin/master", "git push"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -35,5 +35,6 @@ internal fun pushTagsLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("push all tags", "git push --tags"),
|
||||
levelTestCase("push tags to origin", "git push origin --tags"),
|
||||
levelTestCase("push named tag", "git push origin tag tag_to_be_pushed"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -33,5 +33,6 @@ internal fun rebaseLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("checkout feature then rebase", "git checkout feature", "git rebase master"),
|
||||
levelTestCase("rebase named branch", "git rebase master feature"),
|
||||
levelTestCase("switch feature then rebase", "git switch feature", "git rebase master"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -44,5 +44,6 @@ internal fun rebaseOntoLevel(): Level = level(
|
||||
testCases = listOf(
|
||||
levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"),
|
||||
levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"),
|
||||
levelTestCase("rebase onto full branch ref", "git rebase --onto refs/heads/master wrong_branch readme-update"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -17,5 +17,6 @@ internal fun remoteAddLevel(): Level = level(
|
||||
validator = repoPredicate { repo -> repo.remotes["origin"] == "https://github.com/githug/githug" },
|
||||
testCases = listOf(
|
||||
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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,5 +30,17 @@ internal fun renameCommitLevel(): Level = level(
|
||||
"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",
|
||||
),
|
||||
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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 } },
|
||||
testCases = listOf(
|
||||
levelTestCase("git mv", "git mv oldfile.txt newfile.txt"),
|
||||
levelTestCase("git mv force", "git mv -f oldfile.txt newfile.txt"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 } },
|
||||
testCases = listOf(
|
||||
levelTestCase("reset path", "git reset to_commit_second.rb"),
|
||||
levelTestCase("restore staged path", "git restore --staged to_commit_second.rb"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 } },
|
||||
testCases = listOf(
|
||||
levelTestCase("soft reset caret", "git reset --soft HEAD^"),
|
||||
levelTestCase("soft reset previous commit", "git reset --soft HEAD~1"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -28,5 +28,6 @@ internal fun restoreLevel(): Level = level(
|
||||
validator = repoPredicate { repo -> repo.files.any { it.name == "file3" && it.tracked } },
|
||||
testCases = listOf(
|
||||
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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -18,5 +18,6 @@ internal fun restructureLevel(): Level = level(
|
||||
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 with wildcard", "mkdir src", "git mv *.html src"),
|
||||
levelTestCase("move with wildcard into slash directory", "mkdir src", "git mv *.html src/"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -24,8 +24,20 @@ internal fun revertLevel(): Level = level(
|
||||
addCommit("Second commit", "file2")
|
||||
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(
|
||||
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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 } },
|
||||
testCases = listOf(
|
||||
levelTestCase("rm cached", "git rm --cached deleteme.rb"),
|
||||
levelTestCase("reset staged path", "git reset deleteme.rb"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -20,5 +20,6 @@ internal fun rmLevel(): Level = level(
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("git rm deleted path", "git rm deleteme.rb"),
|
||||
levelTestCase("stage deleted path", "git add -u deleteme.rb"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -39,6 +39,12 @@ internal fun squashLevel(): Level = level(
|
||||
"git reset --soft HEAD~4",
|
||||
"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(
|
||||
levelTestCase("reset without replacement commit", "git reset --soft HEAD~4"),
|
||||
|
||||
@@ -12,7 +12,7 @@ internal fun stageLinesLevel(): Level = level(
|
||||
title = "Stage Lines",
|
||||
description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.",
|
||||
hints = listOf("Read about the flags which can be passed to the `add` command."),
|
||||
commandSuggestions = listOf("git add feature.rb"),
|
||||
commandSuggestions = listOf("git add -p feature.rb"),
|
||||
setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) },
|
||||
nativeSetup = {
|
||||
resetFiles()
|
||||
@@ -21,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")
|
||||
true
|
||||
},
|
||||
validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } },
|
||||
validator = repoPredicate { repo ->
|
||||
val file = repo.files.firstOrNull { it.name == "feature.rb" && !it.deleted } ?: return@repoPredicate false
|
||||
val stagedContent = file.stagedContent ?: return@repoPredicate false
|
||||
"This change belongs to the first feature" in stagedContent &&
|
||||
"This change belongs to the second feature" !in stagedContent &&
|
||||
"This change belongs to the first feature" in file.content &&
|
||||
"This change belongs to the second feature" in file.content
|
||||
},
|
||||
testCases = listOf(
|
||||
levelTestCase("stage feature file", "git add feature.rb"),
|
||||
levelTestCase(
|
||||
"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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -24,6 +24,8 @@ internal fun stashLevel(): Level = level(
|
||||
validator = repoPredicate { repo -> repo.stashes.isNotEmpty() && repo.files.none { it.staged } },
|
||||
testCases = listOf(
|
||||
levelTestCase("stash changes", "git stash"),
|
||||
levelTestCase("stash push", "git stash push"),
|
||||
levelTestCase("stash with message", "git stash push -m \"save lyrics\""),
|
||||
),
|
||||
setupChecks = listOf(
|
||||
levelSetupCheck(
|
||||
|
||||
@@ -40,5 +40,11 @@ internal fun submoduleLevel(): Level = level(
|
||||
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
|
||||
"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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -17,5 +17,7 @@ internal fun tagLevel(): Level = level(
|
||||
validator = repoPredicate { repo -> "new_tag" in repo.tags },
|
||||
testCases = listOf(
|
||||
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\""),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ class GitEditorCommandsTest {
|
||||
|
||||
assertEquals(GitEditorCommandKind.COMMIT_MESSAGE, invocation?.kind)
|
||||
assertEquals("Edit Commit Message", invocation?.title)
|
||||
assertEquals(".git/COMMIT_EDITMSG", invocation?.displayPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -34,6 +35,7 @@ class GitEditorCommandsTest {
|
||||
|
||||
assertEquals(GitEditorCommandKind.REBASE_TODO, invocation?.kind)
|
||||
assertEquals("Edit Rebase Todo", invocation?.title)
|
||||
assertEquals(".git/rebase-merge/git-rebase-todo", invocation?.displayPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -47,6 +49,7 @@ class GitEditorCommandsTest {
|
||||
|
||||
assertEquals(GitEditorCommandKind.TAG_MESSAGE, invocation?.kind)
|
||||
assertEquals("Edit Tag Message", invocation?.title)
|
||||
assertEquals(".git/TAG_EDITMSG", invocation?.displayPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,6 +2,8 @@ package solutions.tretter.githugandroid
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
@@ -268,12 +270,12 @@ class GitSandboxEngineTest {
|
||||
val level = configLevel()
|
||||
val repo = runtime.prepareLevel(level)
|
||||
|
||||
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 (namedRepo, _) = runtime.execute(level, repo, "git config user.name githug")
|
||||
val (configuredRepo, _) = runtime.execute(level, namedRepo, "git config user.email xxx@yy.com")
|
||||
|
||||
assertEquals("GitHug", configuredRepo.config["user.name"])
|
||||
assertEquals("githug@example.com", configuredRepo.config["user.email"])
|
||||
assertTrue(level.validator(configuredRepo, "git config user.email githug@example.com"))
|
||||
assertEquals("githug", configuredRepo.config["user.name"])
|
||||
assertEquals("xxx@yy.com", configuredRepo.config["user.email"])
|
||||
assertTrue(level.validator(configuredRepo, "git config user.email xxx@yy.com"))
|
||||
} finally {
|
||||
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
|
||||
fun nativeExecutableShortcutRunsScriptThroughShell() {
|
||||
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 {
|
||||
System.getenv("GITHUG_TEST_GIT_BINARY")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InteractiveAddEngineTest {
|
||||
@Test
|
||||
fun interactiveStageShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
assertFalse(output.any { it.contains("GitHug Android") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddShowsMenuWithoutStagingOrAutoCommands() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
|
||||
assertFalse(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession != null)
|
||||
assertTrue(output.any { it.contains("What now>") })
|
||||
assertFalse(output.any { it.contains("What now> update") })
|
||||
assertFalse(output.any { it.contains("What now> quit") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddAcceptsUpdateSelectionFromNextInput() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git stage -i")
|
||||
val (updateRepo, updateOutput) = GitSandboxEngine.execute(menuRepo, "2")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(updateRepo, "1")
|
||||
val (quitRepo, quitOutput) = GitSandboxEngine.execute(selectedRepo, "7")
|
||||
|
||||
assertTrue(updateRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(updateOutput.any { it.contains("Update>>") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession?.awaitingUpdateSelection == false)
|
||||
assertTrue(selectionOutput.any { it.contains("updated 1 path(s)") })
|
||||
assertTrue(quitRepo.interactiveAddSession == null)
|
||||
assertTrue(quitOutput.any { it.contains("Bye.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesEveryDisplayedMenuCommand() {
|
||||
val menuCommands = listOf(
|
||||
"1" to "What now> 1",
|
||||
"2" to "Update>>",
|
||||
"3" to "Revert>>",
|
||||
"4" to "Add untracked>>",
|
||||
"5" to "Patch update>>",
|
||||
"6" to "Diff>>",
|
||||
"7" to "Bye.",
|
||||
"8" to "What now> 8",
|
||||
)
|
||||
|
||||
menuCommands.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("2", "3", "4", "5", "6")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddHandlesMenuCommandAliases() {
|
||||
val aliases = listOf(
|
||||
"status" to "What now> status",
|
||||
"update" to "Update>>",
|
||||
"revert" to "Revert>>",
|
||||
"add untracked" to "Add untracked>>",
|
||||
"patch" to "Patch update>>",
|
||||
"diff" to "Diff>>",
|
||||
"quit" to "Bye.",
|
||||
"help" to "What now> help",
|
||||
)
|
||||
|
||||
aliases.forEach { (command, expectedOutput) ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(menuRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Huh ($command)?") })
|
||||
assertTrue("$command should produce $expectedOutput", output.any { it.contains(expectedOutput) })
|
||||
if (command in listOf("update", "revert", "add untracked", "patch", "diff")) {
|
||||
assertTrue(updatedRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun interactiveAddPatchSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (menuRepo, _) = GitSandboxEngine.execute(repo, "git add -i")
|
||||
val (patchRepo, patchOutput) = GitSandboxEngine.execute(menuRepo, "patch")
|
||||
val (hunkRepo, hunkOutput) = GitSandboxEngine.execute(patchRepo, "1")
|
||||
val (selectedRepo, selectionOutput) = GitSandboxEngine.execute(hunkRepo, "y")
|
||||
|
||||
assertTrue(patchRepo.interactiveAddSession?.awaitingUpdateSelection == true)
|
||||
assertTrue(patchOutput.any { it.contains("Patch update>>") })
|
||||
assertTrue(hunkOutput.any { it.contains("Stage this hunk") })
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectionOutput.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddStartsPatchHunkDialogWithoutStagingImmediately() {
|
||||
listOf("git add -p README", "git add --patch README").forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, output) = GitSandboxEngine.execute(repo, command)
|
||||
|
||||
assertFalse("$command should not stage before a selection", patchRepo.files.single { it.name == "README" }.staged)
|
||||
assertEquals("patch-hunk", patchRepo.interactiveAddSession?.selectionAction)
|
||||
assertTrue(output.any { it.startsWith("diff --git a/README b/README") })
|
||||
assertTrue(output.any { it.contains("Stage this hunk") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddSelectionStagesSelectedPath() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README")))
|
||||
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (selectedRepo, output) = GitSandboxEngine.execute(patchRepo, "y")
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(output.any { it.contains("Stage this hunk") && it.contains("y") })
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkEditOpensPatchEditorInvocation() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
|
||||
assertEquals(GitEditorCommandKind.PATCH_HUNK, invocation?.kind)
|
||||
assertEquals("Edit Patch Hunk", invocation?.title)
|
||||
assertTrue(invocation?.initialContent.orEmpty().startsWith("# Manual hunk edit mode -- see bottom for a quick guide."))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("diff --git a/README b/README"))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("--- a/README"))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("# ---"))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("# To remove '+' lines, delete them."))
|
||||
assertTrue(invocation?.initialContent.orEmpty().contains("+A"))
|
||||
assertFalse(invocation?.initialContent.orEmpty().contains("Stage this hunk"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editedPatchHunkStagesCurrentPatchTarget() {
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
||||
?: error("Expected patch editor invocation")
|
||||
|
||||
val (selectedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(patchRepo, invocation.initialContent)
|
||||
|
||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(selectedRepo.interactiveAddSession == null)
|
||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun patchAddHunkDialogHandlesAdvertisedCommands() {
|
||||
val commands = listOf("y", "n", "q", "a", "d", "s", "e", "p", "P", "?")
|
||||
|
||||
commands.forEach { command ->
|
||||
val repo = RepoState(initialized = true, files = listOf(GitFile("README", "A\nB\n", tracked = true)))
|
||||
val (patchRepo, _) = GitSandboxEngine.execute(repo, "git add -p README")
|
||||
val (updatedRepo, output) = GitSandboxEngine.execute(patchRepo, command)
|
||||
|
||||
assertFalse("$command should not be rejected", output.any { it.contains("Unknown command '$command'") })
|
||||
assertTrue("$command should echo hunk prompt", output.any { it.contains("Stage this hunk") })
|
||||
if (command == "e") {
|
||||
assertTrue(output.any { it.contains("Opening patch editor") })
|
||||
}
|
||||
if (command in listOf("y", "a")) {
|
||||
assertTrue(updatedRepo.files.single { it.name == "README" }.staged)
|
||||
assertTrue(updatedRepo.interactiveAddSession == null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -222,7 +222,10 @@ class LevelSolutionsTest {
|
||||
appendLine(" <none>")
|
||||
} else {
|
||||
files.sortedBy { it.name }.forEach { file ->
|
||||
appendLine(" ${file.name} staged=${file.staged} tracked=${file.tracked} content=${file.content.toEvidenceValue()}")
|
||||
appendLine(
|
||||
" ${file.name} staged=${file.staged} tracked=${file.tracked} " +
|
||||
"content=${file.content.toEvidenceValue()} stagedContent=${file.stagedContent.toNullableEvidenceValue()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
appendLine("commits=")
|
||||
@@ -240,6 +243,10 @@ class LevelSolutionsTest {
|
||||
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
|
||||
}
|
||||
|
||||
fun String?.toNullableEvidenceValue(): String {
|
||||
return this?.toEvidenceValue() ?: "<none>"
|
||||
}
|
||||
|
||||
val IMPLEMENTED_UPSTREAM_LEVEL_ORDER = listOf(
|
||||
"init",
|
||||
"config",
|
||||
|
||||
49
patches/git/0001-githug-embedded-git-main.patch
Normal file
49
patches/git/0001-githug-embedded-git-main.patch
Normal 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
|
||||
Reference in New Issue
Block a user