Call Git cmd_main directly
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
|||||||
.gradle-user-home/
|
.gradle-user-home/
|
||||||
build/
|
build/
|
||||||
app/build/
|
app/build/
|
||||||
|
app/.cxx/
|
||||||
|
app/src/main/jniLibs/*/libgit.so
|
||||||
|
app/src/main/jniLibs/*/.source-fingerprint
|
||||||
local.properties
|
local.properties
|
||||||
.idea/
|
.idea/
|
||||||
android-sdk/
|
android-sdk/
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ HOST_GIT_DIR="$PROJECT_DIR/build/host-git"
|
|||||||
HOST_GIT_STAMP="$HOST_GIT_DIR/.source-fingerprint"
|
HOST_GIT_STAMP="$HOST_GIT_DIR/.source-fingerprint"
|
||||||
ANDROID_JNI_DIR="$PROJECT_DIR/app/src/main/jniLibs"
|
ANDROID_JNI_DIR="$PROJECT_DIR/app/src/main/jniLibs"
|
||||||
ANDROID_ASSET_MANPAGE_DIR="$PROJECT_DIR/app/src/main/assets/manpages"
|
ANDROID_ASSET_MANPAGE_DIR="$PROJECT_DIR/app/src/main/assets/manpages"
|
||||||
|
GIT_PATCH_DIR="$PROJECT_DIR/patches/git"
|
||||||
ANDROID_API=24
|
ANDROID_API=24
|
||||||
|
|
||||||
ANDROID_CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
ANDROID_CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
|
||||||
@@ -29,6 +30,8 @@ GRADLE_DIST_URL="https://services.gradle.org/distributions/gradle-8.7-bin.zip"
|
|||||||
JDK_DIST_URL="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
|
JDK_DIST_URL="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
|
||||||
ANDROID_NDK_PACKAGE="ndk;27.2.12479018"
|
ANDROID_NDK_PACKAGE="ndk;27.2.12479018"
|
||||||
ANDROID_CMAKE_PACKAGE="cmake;3.22.1"
|
ANDROID_CMAKE_PACKAGE="cmake;3.22.1"
|
||||||
|
ANDROID_EMULATOR_SYSTEM_IMAGE="system-images;android-35;google_apis;x86_64"
|
||||||
|
ANDROID_TEST_AVD_NAME="githug_android_api35"
|
||||||
ANDROID_NDK_DIR="$SDK_DIR/ndk/27.2.12479018"
|
ANDROID_NDK_DIR="$SDK_DIR/ndk/27.2.12479018"
|
||||||
ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1"
|
ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1"
|
||||||
ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||||
@@ -86,6 +89,12 @@ required_sdk_packages_present() {
|
|||||||
&& [ -d "$ANDROID_CMAKE_DIR" ]
|
&& [ -d "$ANDROID_CMAKE_DIR" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
required_emulator_packages_present() {
|
||||||
|
required_sdk_packages_present \
|
||||||
|
&& [ -x "$SDK_DIR/emulator/emulator" ] \
|
||||||
|
&& [ -d "$SDK_DIR/system-images/android-35/google_apis/x86_64" ]
|
||||||
|
}
|
||||||
|
|
||||||
auto_commit_if_needed() {
|
auto_commit_if_needed() {
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
log "Git is not available; skipping automatic commit"
|
log "Git is not available; skipping automatic commit"
|
||||||
@@ -309,6 +318,36 @@ ensure_git_source() {
|
|||||||
else
|
else
|
||||||
log "Using existing Git source checkout"
|
log "Using existing Git source checkout"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
restore_retired_git_patch_changes
|
||||||
|
apply_git_patches
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_retired_git_patch_changes() {
|
||||||
|
local common_main="$GIT_SRC_DIR/common-main.c"
|
||||||
|
if [ ! -f "$common_main" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if grep -q 'githug_git_main' "$common_main" && ! find "$GIT_PATCH_DIR" -maxdepth 1 -type f -name '*githug*git*main*.patch' 2>/dev/null | grep -q .; then
|
||||||
|
log "Restoring retired Git patch changes from common-main.c"
|
||||||
|
git -C "$GIT_SRC_DIR" checkout -- common-main.c
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_git_patches() {
|
||||||
|
if [ ! -d "$GIT_PATCH_DIR" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local patch_file
|
||||||
|
while IFS= read -r patch_file; do
|
||||||
|
if git -C "$GIT_SRC_DIR" apply --reverse --check "$patch_file" >/dev/null 2>&1; then
|
||||||
|
log "Git patch already applied: ${patch_file#$PROJECT_DIR/}"
|
||||||
|
else
|
||||||
|
log "Applying Git patch: ${patch_file#$PROJECT_DIR/}"
|
||||||
|
git -C "$GIT_SRC_DIR" apply "$patch_file"
|
||||||
|
fi
|
||||||
|
done < <(find "$GIT_PATCH_DIR" -maxdepth 1 -type f -name '*.patch' | sort)
|
||||||
}
|
}
|
||||||
|
|
||||||
common_git_make_args() {
|
common_git_make_args() {
|
||||||
@@ -329,7 +368,8 @@ common_git_make_args() {
|
|||||||
HAVE_CLOCK_MONOTONIC=YesPlease \
|
HAVE_CLOCK_MONOTONIC=YesPlease \
|
||||||
HAVE_GETDELIM=YesPlease \
|
HAVE_GETDELIM=YesPlease \
|
||||||
FREAD_READS_DIRECTORIES=UnfortunatelyYes \
|
FREAD_READS_DIRECTORIES=UnfortunatelyYes \
|
||||||
CSPRNG_METHOD=
|
CSPRNG_METHOD= \
|
||||||
|
LDFLAGS=-Wl,--export-dynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
bundle_git_manpages() {
|
bundle_git_manpages() {
|
||||||
@@ -350,9 +390,11 @@ git_source_fingerprint() {
|
|||||||
|
|
||||||
local head
|
local head
|
||||||
local tracked_changes
|
local tracked_changes
|
||||||
|
local make_args
|
||||||
head="$(git -C "$GIT_SRC_DIR" rev-parse HEAD)"
|
head="$(git -C "$GIT_SRC_DIR" rev-parse HEAD)"
|
||||||
tracked_changes="$(git -C "$GIT_SRC_DIR" diff --binary HEAD -- | git hash-object --stdin)"
|
tracked_changes="$(git -C "$GIT_SRC_DIR" diff --binary HEAD -- | git hash-object --stdin)"
|
||||||
printf '%s:%s\n' "$head" "$tracked_changes"
|
make_args="$(common_git_make_args | git hash-object --stdin)"
|
||||||
|
printf '%s:%s:%s\n' "$head" "$tracked_changes" "$make_args"
|
||||||
}
|
}
|
||||||
|
|
||||||
target_is_current() {
|
target_is_current() {
|
||||||
@@ -507,6 +549,124 @@ ensure_sdk_packages() {
|
|||||||
mark_successful_check
|
mark_successful_check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_emulator_sdk_packages() {
|
||||||
|
setup_env
|
||||||
|
|
||||||
|
if required_emulator_packages_present; then
|
||||||
|
log "Android emulator SDK packages are already installed locally"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Accepting Android SDK licenses"
|
||||||
|
set +e
|
||||||
|
set +o pipefail
|
||||||
|
yes | "$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" --sdk_root="$SDK_DIR" --licenses >/dev/null
|
||||||
|
local license_status=$?
|
||||||
|
set -o pipefail
|
||||||
|
set -e
|
||||||
|
if [ "$license_status" -ne 0 ]; then
|
||||||
|
echo "sdkmanager --licenses failed with exit code $license_status" >&2
|
||||||
|
exit "$license_status"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Installing Android emulator SDK packages"
|
||||||
|
"$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" --sdk_root="$SDK_DIR" \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-35" \
|
||||||
|
"build-tools;35.0.0" \
|
||||||
|
"$ANDROID_NDK_PACKAGE" \
|
||||||
|
"$ANDROID_CMAKE_PACKAGE" \
|
||||||
|
"emulator" \
|
||||||
|
"$ANDROID_EMULATOR_SYSTEM_IMAGE"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_test_avd() {
|
||||||
|
ensure_emulator_sdk_packages
|
||||||
|
|
||||||
|
if "$CMDLINE_TOOLS_LATEST_DIR/bin/avdmanager" list avd | grep -q "Name: $ANDROID_TEST_AVD_NAME"; then
|
||||||
|
log "Android test AVD already present: $ANDROID_TEST_AVD_NAME"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Creating Android test AVD: $ANDROID_TEST_AVD_NAME"
|
||||||
|
set +e
|
||||||
|
set +o pipefail
|
||||||
|
printf 'no\n' | "$CMDLINE_TOOLS_LATEST_DIR/bin/avdmanager" create avd \
|
||||||
|
--name "$ANDROID_TEST_AVD_NAME" \
|
||||||
|
--package "$ANDROID_EMULATOR_SYSTEM_IMAGE" \
|
||||||
|
--device "pixel_5" \
|
||||||
|
--force
|
||||||
|
local avd_status=$?
|
||||||
|
set -o pipefail
|
||||||
|
set -e
|
||||||
|
if [ "$avd_status" -ne 0 ]; then
|
||||||
|
echo "avdmanager create avd failed with exit code $avd_status" >&2
|
||||||
|
exit "$avd_status"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
connected_android_device() {
|
||||||
|
"$SDK_DIR/platform-tools/adb" devices | awk 'NR > 1 && $2 == "device" { print $1; exit }'
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_emulator_boot() {
|
||||||
|
local adb="$SDK_DIR/platform-tools/adb"
|
||||||
|
local boot_completed=""
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
log "Waiting for Android emulator to boot"
|
||||||
|
"$adb" wait-for-device
|
||||||
|
for attempt in $(seq 1 180); do
|
||||||
|
boot_completed="$("$adb" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
if [ "$boot_completed" = "1" ]; then
|
||||||
|
"$adb" shell input keyevent 82 >/dev/null 2>&1 || true
|
||||||
|
log "Android emulator booted"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Timed out waiting for Android emulator to boot" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start_emulator_if_needed() {
|
||||||
|
ensure_test_avd
|
||||||
|
|
||||||
|
local existing_device
|
||||||
|
existing_device="$(connected_android_device || true)"
|
||||||
|
if [ -n "$existing_device" ]; then
|
||||||
|
log "Using already connected Android device/emulator: $existing_device"
|
||||||
|
EMULATOR_STARTED_BY_TOOLING=""
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local emulator_log="$PROJECT_DIR/build/reports/android-emulator.log"
|
||||||
|
mkdir -p "$(dirname "$emulator_log")"
|
||||||
|
log "Starting Android emulator: $ANDROID_TEST_AVD_NAME"
|
||||||
|
"$SDK_DIR/emulator/emulator" \
|
||||||
|
-avd "$ANDROID_TEST_AVD_NAME" \
|
||||||
|
-no-window \
|
||||||
|
-no-audio \
|
||||||
|
-no-boot-anim \
|
||||||
|
-gpu swiftshader_indirect \
|
||||||
|
>"$emulator_log" 2>&1 &
|
||||||
|
EMULATOR_STARTED_BY_TOOLING="$!"
|
||||||
|
|
||||||
|
wait_for_emulator_boot
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_emulator_if_started() {
|
||||||
|
if [ -z "${EMULATOR_STARTED_BY_TOOLING:-}" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Stopping Android emulator started by tooling"
|
||||||
|
"$SDK_DIR/platform-tools/adb" emu kill >/dev/null 2>&1 || true
|
||||||
|
wait "$EMULATOR_STARTED_BY_TOOLING" >/dev/null 2>&1 || true
|
||||||
|
EMULATOR_STARTED_BY_TOOLING=""
|
||||||
|
}
|
||||||
|
|
||||||
maybe_run_operation() {
|
maybe_run_operation() {
|
||||||
local mode="${1:-}"
|
local mode="${1:-}"
|
||||||
local gradle_task=""
|
local gradle_task=""
|
||||||
@@ -516,7 +676,9 @@ maybe_run_operation() {
|
|||||||
local should_bump_version="false"
|
local should_bump_version="false"
|
||||||
local should_auto_commit="false"
|
local should_auto_commit="false"
|
||||||
local should_compile_host_git="false"
|
local should_compile_host_git="false"
|
||||||
|
local should_compile_android_git="false"
|
||||||
local should_bundle_manpages="false"
|
local should_bundle_manpages="false"
|
||||||
|
local should_run_emulator="false"
|
||||||
|
|
||||||
case "$mode" in
|
case "$mode" in
|
||||||
--build)
|
--build)
|
||||||
@@ -526,6 +688,7 @@ maybe_run_operation() {
|
|||||||
artifact_target="$PROJECT_DIR/app/build/outputs/apk/debug/githug-android-debug.apk"
|
artifact_target="$PROJECT_DIR/app/build/outputs/apk/debug/githug-android-debug.apk"
|
||||||
should_bump_version="true"
|
should_bump_version="true"
|
||||||
should_auto_commit="true"
|
should_auto_commit="true"
|
||||||
|
should_compile_android_git="true"
|
||||||
should_bundle_manpages="true"
|
should_bundle_manpages="true"
|
||||||
;;
|
;;
|
||||||
--build-release-aab)
|
--build-release-aab)
|
||||||
@@ -535,6 +698,7 @@ maybe_run_operation() {
|
|||||||
artifact_target="$PROJECT_DIR/app/build/outputs/bundle/release/githug-android-release.aab"
|
artifact_target="$PROJECT_DIR/app/build/outputs/bundle/release/githug-android-release.aab"
|
||||||
should_bump_version="true"
|
should_bump_version="true"
|
||||||
should_auto_commit="true"
|
should_auto_commit="true"
|
||||||
|
should_compile_android_git="true"
|
||||||
should_bundle_manpages="true"
|
should_bundle_manpages="true"
|
||||||
;;
|
;;
|
||||||
--test)
|
--test)
|
||||||
@@ -542,6 +706,13 @@ maybe_run_operation() {
|
|||||||
artifact_label="debug unit tests"
|
artifact_label="debug unit tests"
|
||||||
should_compile_host_git="true"
|
should_compile_host_git="true"
|
||||||
;;
|
;;
|
||||||
|
--test-emulator)
|
||||||
|
gradle_task="connectedDebugAndroidTest"
|
||||||
|
artifact_label="debug instrumentation tests on Android emulator"
|
||||||
|
should_compile_android_git="true"
|
||||||
|
should_bundle_manpages="true"
|
||||||
|
should_run_emulator="true"
|
||||||
|
;;
|
||||||
--compile-git)
|
--compile-git)
|
||||||
build_all_git_targets
|
build_all_git_targets
|
||||||
return
|
return
|
||||||
@@ -571,10 +742,17 @@ maybe_run_operation() {
|
|||||||
build_host_git
|
build_host_git
|
||||||
export GITHUG_TEST_GIT_BINARY="$HOST_GIT_BINARY"
|
export GITHUG_TEST_GIT_BINARY="$HOST_GIT_BINARY"
|
||||||
fi
|
fi
|
||||||
|
if [ "$should_compile_android_git" = "true" ]; then
|
||||||
|
build_android_git
|
||||||
|
fi
|
||||||
if [ "$should_bundle_manpages" = "true" ]; then
|
if [ "$should_bundle_manpages" = "true" ]; then
|
||||||
ensure_git_source
|
ensure_git_source
|
||||||
bundle_git_manpages
|
bundle_git_manpages
|
||||||
fi
|
fi
|
||||||
|
if [ "$should_run_emulator" = "true" ]; then
|
||||||
|
start_emulator_if_needed
|
||||||
|
trap stop_emulator_if_started EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
log "Running $artifact_label with --no-daemon"
|
log "Running $artifact_label with --no-daemon"
|
||||||
"$PROJECT_DIR/gradlew" --no-daemon "$gradle_task"
|
"$PROJECT_DIR/gradlew" --no-daemon "$gradle_task"
|
||||||
@@ -593,22 +771,25 @@ maybe_run_operation() {
|
|||||||
|
|
||||||
log "Stopping any Gradle daemons just in case"
|
log "Stopping any Gradle daemons just in case"
|
||||||
"$PROJECT_DIR/gradlew" --stop >/dev/null 2>&1 || true
|
"$PROJECT_DIR/gradlew" --stop >/dev/null 2>&1 || true
|
||||||
|
stop_emulator_if_started
|
||||||
|
trap - EXIT
|
||||||
}
|
}
|
||||||
|
|
||||||
print_usage() {
|
print_usage() {
|
||||||
cat <<EOF_USAGE
|
cat <<EOF_USAGE
|
||||||
Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test | --compile-git]
|
Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test | --test-emulator | --compile-git]
|
||||||
|
|
||||||
--build Set up the environment and build the debug APK
|
--build Set up the environment and build the debug APK
|
||||||
--build-release-aab Set up the environment and build a release Android App Bundle (AAB)
|
--build-release-aab Set up the environment and build a release Android App Bundle (AAB)
|
||||||
--test Set up the environment, compile host Git, and run the debug JVM unit tests with it
|
--test Set up the environment, compile host Git, and run the debug JVM unit tests with it
|
||||||
|
--test-emulator Set up an Android emulator and run debug instrumentation tests on it
|
||||||
--compile-git Compile Git for the development host and all Android target ABIs
|
--compile-git Compile Git for the development host and all Android target ABIs
|
||||||
EOF_USAGE
|
EOF_USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_args() {
|
validate_args() {
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
""|--build|--build-release-aab|--test|--compile-git)
|
""|--build|--build-release-aab|--test|--test-emulator|--compile-git)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown argument: $1" >&2
|
echo "Unknown argument: $1" >&2
|
||||||
@@ -654,6 +835,7 @@ main() {
|
|||||||
log "To set up and build in one step: bash ./AndroidProjectTooling.sh --build"
|
log "To set up and build in one step: bash ./AndroidProjectTooling.sh --build"
|
||||||
log "To set up and build a release AAB in one step: bash ./AndroidProjectTooling.sh --build-release-aab"
|
log "To set up and build a release AAB in one step: bash ./AndroidProjectTooling.sh --build-release-aab"
|
||||||
log "To set up and run unit tests with compiled host Git: bash ./AndroidProjectTooling.sh --test"
|
log "To set up and run unit tests with compiled host Git: bash ./AndroidProjectTooling.sh --test"
|
||||||
|
log "To set up and run instrumentation tests on an emulator: bash ./AndroidProjectTooling.sh --test-emulator"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| `revert` | Creates commits `First commit`, `Bad commit`, `Second commit`. | Same commit messages. | Equivalent. |
|
| `revert` | Creates commits `First commit`, `Bad commit`, `Second commit`. | Same commit messages. | Equivalent. |
|
||||||
| `restore` | Creates `file1`, `file2`, then creates and removes `file3` so it is recoverable from reflog/history. | Native setup creates matching history and removes `file3`; model starts without `file3`. | Equivalent. |
|
| `restore` | Creates `file1`, `file2`, then creates and removes `file3` so it is recoverable from reflog/history. | Native setup creates matching history and removes `file3`; model starts without `file3`. | Equivalent. |
|
||||||
| `conflict` | Copies fixture with `master` and `mybranch` conflict in non-empty `poem.txt`. | Native setup creates the conflicting poem history, leaving `master` with `Categorized shoes by color` and `mybranch` with the correct `Sat on a wall` line. | Equivalent setup. |
|
| `conflict` | Copies fixture with `master` and `mybranch` conflict in non-empty `poem.txt`. | Native setup creates the conflicting poem history, leaving `master` with `Categorized shoes by color` and `mybranch` with the correct `Sat on a wall` line. | Equivalent setup. |
|
||||||
| `submodule` | Initializes empty repo. | Same. | Equivalent; network submodule operation is modeled. |
|
| `submodule` | Initializes empty repo. | Initializes repo and prepares a local sibling repository as an offline submodule source. | Intentional Android adaptation: avoids network access and packaged Git currently lacks the `git submodule` porcelain, so validation requires tracked submodule metadata instead of accepting command text. |
|
||||||
|
|
||||||
## Validation Summary
|
## Validation Summary
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| `restructure` | HTML files are deleted at root and added under `src/`. | All three `src/*.html` files exist and no live root HTML files remain. | Equivalent. |
|
| `restructure` | HTML files are deleted at root and added under `src/`. | All three `src/*.html` files exist and no live root HTML files remain. | Equivalent. |
|
||||||
| `log` | Prompt answer matches latest commit hash prefix. | Answer matches the modeled commit hash. | Equivalent within Android's deterministic commit model. |
|
| `log` | Prompt answer matches latest commit hash prefix. | Answer matches the modeled commit hash. | Equivalent within Android's deterministic commit model. |
|
||||||
| `tag` | First tag is `new_tag`. | `new_tag` exists. | Equivalent. |
|
| `tag` | First tag is `new_tag`. | `new_tag` exists. | Equivalent. |
|
||||||
| `push_tags` | Remote tag list contains `tag_to_be_pushed`. | `tag_to_be_pushed` is in `pushedTags`. | Equivalent state projection. |
|
| `push_tags` | Remote tag list contains `tag_to_be_pushed`. | Inspects remote refs with Git and requires remote tag `tag_to_be_pushed` to point at the local tag object. | Equivalent state projection; no command text is accepted as proof. |
|
||||||
| `commit_amend` | One commit and amended commit contains two files. | One commit and `forgotten_file.rb` is tracked. | Equivalent. |
|
| `commit_amend` | One commit and amended commit contains two files. | One commit and `forgotten_file.rb` is tracked. | Equivalent. |
|
||||||
| `commit_in_future` | Commit authored date is in the future. | At least one commit has an author timestamp later than the current system clock. | Equivalent. |
|
| `commit_in_future` | Commit authored date is in the future. | At least one commit has an author timestamp later than the current system clock. | Equivalent. |
|
||||||
| `reset` | `to_commit_second.rb` exists but is unstaged; `to_commit_first.rb` remains staged. | Same staged/unstaged split with one commit. | Equivalent. |
|
| `reset` | `to_commit_second.rb` exists but is unstaged; `to_commit_first.rb` remains staged. | Same staged/unstaged split with one commit. | Equivalent. |
|
||||||
@@ -97,7 +97,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| `remote_url` | User answers URL matching `https://github.com/githug/not_a_repo/?`. | Answer is exact URL without trailing slash. | Slightly stricter; can be relaxed if trailing slash should be accepted. |
|
| `remote_url` | User answers URL matching `https://github.com/githug/not_a_repo/?`. | Answer is exact URL without trailing slash. | Slightly stricter; can be relaxed if trailing slash should be accepted. |
|
||||||
| `pull` | Latest commit hash is `1797a7c`. | Remote file is present, `origin/master` fetched, and commits advanced. | Equivalent local synthetic remote outcome. |
|
| `pull` | Latest commit hash is `1797a7c`. | Remote file is present, `origin/master` fetched, and commits advanced. | Equivalent local synthetic remote outcome. |
|
||||||
| `remote_add` | `git remote -v` contains `https://github.com/githug/githug`. | `origin` remote equals that URL. | Equivalent and slightly stricter on remote name. |
|
| `remote_add` | `git remote -v` contains `https://github.com/githug/githug`. | `origin` remote equals that URL. | Equivalent and slightly stricter on remote name. |
|
||||||
| `push` | Local `master` and `origin/master` have four identical commits. | `origin/master` was pushed, four files are present, and commit count is at least four. | Equivalent user outcome; Android tracks pushed ref instead of comparing remote commit IDs. |
|
| `push` | Local `master` and `origin/master` have four identical commits. | Inspects remote refs with Git and requires `origin/master` to match local `master`, plus all four expected files and commits. | Equivalent user outcome; no command text is accepted as proof. |
|
||||||
| `diff` | Answer is changed line number `26`. | Answer is `26`. | Equivalent. |
|
| `diff` | Answer is changed line number `26`. | Answer is `26`. | Equivalent. |
|
||||||
| `blame` | Answer equals author of known password commit (`Spider Man`). | Answer is `Spider Man`. | Equivalent with deterministic Android fixture. |
|
| `blame` | Answer equals author of known password commit (`Spider Man`). | Answer is `Spider Man`. | Equivalent with deterministic Android fixture. |
|
||||||
| `branch` | Branch `test_code` exists. | `test_code` exists and current branch remains `master`. | Slightly stricter to prevent solving by checkout side effects. |
|
| `branch` | Branch `test_code` exists. | `test_code` exists and current branch remains `master`. | Slightly stricter to prevent solving by checkout side effects. |
|
||||||
@@ -106,12 +106,12 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| `checkout_tag_over_branch` | Same as `checkout_tag`; must choose tag, not branch. | HEAD is at tag `v1.2`. | Equivalent in Android tag model. |
|
| `checkout_tag_over_branch` | Same as `checkout_tag`; must choose tag, not branch. | HEAD is at tag `v1.2`. | Equivalent in Android tag model. |
|
||||||
| `branch_at` | `test_branch` exists and excludes "Updating file1 again". | `test_branch` points at commit index 2. | Equivalent. |
|
| `branch_at` | `test_branch` exists and excludes "Updating file1 again". | `test_branch` points at commit index 2. | Equivalent. |
|
||||||
| `delete_branch` | `delete_me` branch no longer exists. | Same. | Equivalent. |
|
| `delete_branch` | `delete_me` branch no longer exists. | Same. | Equivalent. |
|
||||||
| `push_branch` | Remote has pushed `test_branch` but not all branches. | `origin/test_branch` pushed and master/other not pushed. | Equivalent. |
|
| `push_branch` | Remote has pushed `test_branch` but not all branches. | Inspects remote refs with Git and requires `origin/test_branch` to match local `test_branch` while `origin/other_branch` does not match local `other_branch`. | Equivalent; no command text is accepted as proof. |
|
||||||
| `merge` | `file1` and `file2` exist. | Current branch is `master`, a merge action occurred, and `file2` is tracked. | Deliberately stricter than upstream to avoid `git switch feature` falsely solving on Android. |
|
| `merge` | `file1` and `file2` exist. | Current branch is `master`, a merge action occurred, and `file2` is tracked. | Deliberately stricter than upstream to avoid `git switch feature` falsely solving on Android. |
|
||||||
| `fetch` | Local branch count is one and `.git/FETCH_HEAD` has two entries. | `branches.size == 1`, `fetchHeadCount == 2`, and no recorded `pull` action. | Mostly parity; Android adds the `pull` guard because real Git can leave two `FETCH_HEAD` lines after `git pull`, while the exercise wording explicitly says fetch without merging. |
|
| `fetch` | Local branch count is one and `.git/FETCH_HEAD` has two entries. | Requires only local `master`, at least two `FETCH_HEAD` entries, fetched `origin/new_branch`, and no tracked `file1` merge result. | Equivalent state outcome; no command text is accepted as proof. |
|
||||||
| `rebase` | `feature` commit messages are `add feature`, `add content`, `init commit`, and old hash changed. | Current branch is `feature` and modeled commit messages match. | Equivalent, except Android does not compare the old hash. |
|
| `rebase` | `feature` commit messages are `add feature`, `add content`, `init commit`, and old hash changed. | Current branch is `feature` and modeled commit messages match. | Equivalent, except Android does not compare the old hash. |
|
||||||
| `rebase_onto` | `readme-update` has four commits, excludes "Wrong changes", and preserves authors. | Current branch is `readme-update` and rebase-onto action occurred. | Known gap: Android validation is looser than upstream content/commit checks. |
|
| `rebase_onto` | `readme-update` has four commits, excludes "Wrong changes", and preserves authors. | Current branch is `readme-update` and rebase-onto action occurred. | Known gap: Android validation is looser than upstream content/commit checks. |
|
||||||
| `repack` | `git count-objects -v` includes packed/pruned object evidence. | `repack` action recorded. | Equivalent action-level validation; Android does not model object database packing stats. |
|
| `repack` | `git count-objects -v` includes packed/pruned object evidence. | Inspects `.git/objects/pack` and requires a generated pack file. | Equivalent repository-state validation; no command text is accepted as proof. |
|
||||||
| `cherry-pick` | Top commits are "Filled in README..." then "Added fancy branded output". | Same commit message order plus `README.md` tracked. | Equivalent. |
|
| `cherry-pick` | Top commits are "Filled in README..." then "Added fancy branded output". | Same commit message order plus `README.md` tracked. | Equivalent. |
|
||||||
| `grep` | Answer is TODO count `4`. | Answer is `4`. | Equivalent. |
|
| `grep` | Answer is TODO count `4`. | Answer is `4`. | Equivalent. |
|
||||||
| `rename_commit` | Parent commit message is corrected to `First commit`. | No `coommit` remains and `First commit` exists; the in-app rebase editor uses the subject text on a `reword` line as the replacement message. | Equivalent outcome with a single mobile editor step. |
|
| `rename_commit` | Parent commit message is corrected to `First commit`. | No `coommit` remains and `First commit` exists; the in-app rebase editor uses the subject text on a `reword` line as the replacement message. | Equivalent outcome with a single mobile editor step. |
|
||||||
@@ -124,7 +124,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| `revert` | More than three commits and a revert of "Bad commit" exists. | A commit message starts with `Revert`. | Slightly looser; sufficient for current fixture. |
|
| `revert` | More than three commits and a revert of "Bad commit" exists. | A commit message starts with `Revert`. | Slightly looser; sufficient for current fixture. |
|
||||||
| `restore` | `file3` exists. | `file3` is tracked. | Equivalent. |
|
| `restore` | `file3` exists. | `file3` is tracked. | Equivalent. |
|
||||||
| `conflict` | On `master`, merge commit has two parents, conflict markers removed, both poem lines preserved. | Requires the latest commit on `master` to be a two-parent merge commit, conflict markers removed, and the correct `Sat on a wall` poem line preserved. | Equivalent. |
|
| `conflict` | On `master`, merge commit has two parents, conflict markers removed, both poem lines preserved. | Requires the latest commit on `master` to be a two-parent merge commit, conflict markers removed, and the correct `Sat on a wall` poem line preserved. | Equivalent. |
|
||||||
| `submodule` | `githug-include-me` directory exists, has README, and is a gitlink/submodule. | `submodules` contains `githug-include-me` URL. | Equivalent state projection. |
|
| `submodule` | `githug-include-me` directory exists, has README, and is a gitlink/submodule. | Parses `.gitmodules` with Git config and requires tracked `.gitmodules` metadata for `githug-include-me`. | State-based Android approximation with an offline local source repository; no command text is accepted as proof. |
|
||||||
|
|
||||||
## Focused Source-To-Android Checks
|
## Focused Source-To-Android Checks
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ This file records the upstream Ruby setup and validation intent beside the Andro
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Counts local branches with `repo.branches.size`. | Uses `repo.branches.size`. |
|
| Counts local branches with `repo.branches.size`. | Uses `repo.branches.size`. |
|
||||||
| Counts `.git/FETCH_HEAD` lines after fetch; success requires `num_remote == 2`. | `GitRuntime` now reads `.git/FETCH_HEAD` into `RepoState.fetchHeadCount`; success requires `fetchHeadCount == 2`. |
|
| Counts `.git/FETCH_HEAD` lines after fetch; success requires `num_remote == 2`. | `GitRuntime` now reads `.git/FETCH_HEAD` into `RepoState.fetchHeadCount`; success requires `fetchHeadCount == 2`. |
|
||||||
| Success requires exactly one local branch and two fetched heads. | Success requires exactly one local branch and two fetched heads. Android also rejects a recorded `pull` action to preserve the exercise instruction "without merging" under native Git behavior. |
|
| Success requires exactly one local branch and two fetched heads. | Success requires exactly one local branch, fetched `origin/new_branch`, two fetched heads, and no merged `file1` worktree result. |
|
||||||
|
|
||||||
### `status`
|
### `status`
|
||||||
|
|
||||||
@@ -158,6 +158,7 @@ These are the remaining known non-parity items that need additional model suppor
|
|||||||
- `stage_lines`: model partial staged vs unstaged hunks.
|
- `stage_lines`: model partial staged vs unstaged hunks.
|
||||||
- `merge_squash`: verify the exact squashed file/content effects.
|
- `merge_squash`: verify the exact squashed file/content effects.
|
||||||
- `rebase_onto`: verify final commit count/content and removal of "Wrong changes".
|
- `rebase_onto`: verify final commit count/content and removal of "Wrong changes".
|
||||||
|
- `submodule`: packaged Git currently lacks the `git submodule` porcelain, so Android validates tracked `.gitmodules` metadata but not a real gitlink checkout.
|
||||||
- `clone`, `clone_to_folder`: current Android behavior intentionally avoids real network-dependent validation.
|
- `clone`, `clone_to_folder`: current Android behavior intentionally avoids real network-dependent validation.
|
||||||
|
|
||||||
The upstream `contribute` call to action is intentionally not implemented as a level because it asks learners to contribute to the original GitHug repository rather than teaching or validating a Git operation.
|
The upstream `contribute` call to action is intentionally not implemented as a level because it asks learners to contribute to the original GitHug repository rather than teaching or validating a Git operation.
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -47,6 +47,7 @@ Available commands:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `bash ./AndroidProjectTooling.sh` | Provision or refresh the local Android/JDK toolchain only. | Toolchain under `./jdk` and `./android-sdk` |
|
| `bash ./AndroidProjectTooling.sh` | Provision or refresh the local Android/JDK toolchain only. | Toolchain under `./jdk` and `./android-sdk` |
|
||||||
| `bash ./AndroidProjectTooling.sh --test` | Compile host Git, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Test reports under `app/build/reports/` |
|
| `bash ./AndroidProjectTooling.sh --test` | Compile host Git, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Test reports under `app/build/reports/` |
|
||||||
|
| `bash ./AndroidProjectTooling.sh --test-emulator` | Install emulator packages if needed, create/start the project test AVD, compile Android Git, and run debug instrumentation tests. | Instrumentation reports under `app/build/reports/androidTests/` |
|
||||||
| `bash ./AndroidProjectTooling.sh --build` | Build the debug APK. | `app/build/outputs/apk/debug/githug-android-debug-v<versionCode>.apk` |
|
| `bash ./AndroidProjectTooling.sh --build` | Build the debug APK. | `app/build/outputs/apk/debug/githug-android-debug-v<versionCode>.apk` |
|
||||||
| `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` |
|
| `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` |
|
||||||
| `bash ./AndroidProjectTooling.sh --compile-git` | Compile Git for the development host and all Android target ABIs. | Host and Android `libgit.so` binaries |
|
| `bash ./AndroidProjectTooling.sh --compile-git` | Compile Git for the development host and all Android target ABIs. | Host and Android `libgit.so` binaries |
|
||||||
@@ -57,6 +58,12 @@ To run the JVM unit test suite after ensuring the local toolchain is ready:
|
|||||||
bash ./AndroidProjectTooling.sh --test
|
bash ./AndroidProjectTooling.sh --test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To run instrumentation tests on an Android emulator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ./AndroidProjectTooling.sh --test-emulator
|
||||||
|
```
|
||||||
|
|
||||||
To build installable/debuggable artifacts:
|
To build installable/debuggable artifacts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -81,6 +88,7 @@ When `--build` or `--build-release-aab` is used, the script also:
|
|||||||
|
|
||||||
- increments `versionCode` by 1
|
- increments `versionCode` by 1
|
||||||
- increments the patch component of `versionName`, for example `0.1.0` to `0.1.1`
|
- increments the patch component of `versionName`, for example `0.1.0` to `0.1.1`
|
||||||
|
- compiles the generated Android `libgit.so` binaries when they are missing or stale
|
||||||
- bundles full Git manpage source files from Git's `Documentation/` directory into app assets
|
- bundles full Git manpage source files from Git's `Documentation/` directory into app assets
|
||||||
- renames the generated artifact to a `githug-android-*` filename that includes the post-bump `versionCode`
|
- renames the generated artifact to a `githug-android-*` filename that includes the post-bump `versionCode`
|
||||||
- uploads the renamed APK/AAB with local `./upload2DL.sh` when that script exists
|
- uploads the renamed APK/AAB with local `./upload2DL.sh` when that script exists
|
||||||
@@ -104,9 +112,10 @@ Options:
|
|||||||
| Command | Purpose | Output |
|
| Command | Purpose | Output |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `bash ./AndroidProjectTooling.sh --test` | Ensure the host Git binary is current, then run tests with it. | `build/host-git/libgit.so` and test reports |
|
| `bash ./AndroidProjectTooling.sh --test` | Ensure the host Git binary is current, then run tests with it. | `build/host-git/libgit.so` and test reports |
|
||||||
|
| `bash ./AndroidProjectTooling.sh --test-emulator` | Ensure Android ABI Git binaries are current, then run instrumentation tests on the project AVD. | Android `libgit.so` binaries and instrumentation reports |
|
||||||
| `bash ./AndroidProjectTooling.sh --compile-git` | Ensure host Git and Android ABI Git binaries are current. | Host and Android outputs |
|
| `bash ./AndroidProjectTooling.sh --compile-git` | Ensure host Git and Android ABI Git binaries are current. | Host and Android outputs |
|
||||||
|
|
||||||
Android ABI outputs:
|
Android ABI outputs are generated files and are ignored by git:
|
||||||
|
|
||||||
- `app/src/main/jniLibs/arm64-v8a/libgit.so`
|
- `app/src/main/jniLibs/arm64-v8a/libgit.so`
|
||||||
- `app/src/main/jniLibs/armeabi-v7a/libgit.so`
|
- `app/src/main/jniLibs/armeabi-v7a/libgit.so`
|
||||||
@@ -119,15 +128,17 @@ Git build outputs are stamped with a Git source fingerprint. Re-running `--test`
|
|||||||
|
|
||||||
Git manpage assets are also refreshed from the checked-out Git source whenever Git is compiled or an app artifact is built.
|
Git manpage assets are also refreshed from the checked-out Git source whenever Git is compiled or an app artifact is built.
|
||||||
|
|
||||||
|
Git source patches can live under `patches/git/` and are applied by `AndroidProjectTooling.sh` after the Git source checkout is cloned or reused. These patches are part of the source fingerprint, so changing a patch forces the host and Android Git binaries to rebuild. The current runtime does not require a Git source patch: it resolves Git's existing exported `init_git` and `cmd_main` symbols through JNI and calls them with Git-style `argc`/`argv`.
|
||||||
|
|
||||||
## Runtime Architecture
|
## Runtime Architecture
|
||||||
|
|
||||||
The command engine has one app-facing runtime:
|
The command engine has one app-facing runtime:
|
||||||
|
|
||||||
- **Native Git path**: the packaged executable for the device ABI runs Git commands 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 existing `init_git(argv)` and `cmd_main(argc, argv)` path in a real per-level repository sandbox in app-private storage.
|
||||||
|
|
||||||
The runtime exposes a `RepoState` surface to validators. In addition to files, commits, branches, tags, remotes, and config, the model tracks learning-relevant effects such as stashes, fetched remote refs, pushed branches/tags, submodules, and repository maintenance actions.
|
The runtime exposes a `RepoState` surface to validators. In addition to files, commits, branches, tags, remotes, and config, the model tracks learning-relevant effects such as stashes, fetched remote refs, pushed branches/tags, submodules, and repository maintenance actions.
|
||||||
|
|
||||||
Helper shell-like commands (`ls`, `pwd`, `cat`, `sh <script>`, `./<script>`, `touch`, `mkdir`, `rm`, `echo`, `cd`) remain implemented in Kotlin so the mobile terminal behaves consistently across devices.
|
Helper shell-like commands (`ls`, `pwd`, `cat`, `sh <script>`, `./<script>`, `touch`, `mkdir`, `rm`, `echo`, `cd`) remain implemented in Kotlin so the mobile terminal behaves consistently across devices. Git command behavior, option parsing, and command dispatch are not modeled in Kotlin; they are delegated to Git's own entry path.
|
||||||
|
|
||||||
Source files should stay comfortably reviewable. Treat files approaching roughly 700 lines as refactor candidates, and prefer extracting cohesive runtime helpers, command handlers, or focused test classes over letting orchestration classes absorb unrelated responsibilities.
|
Source files should stay comfortably reviewable. Treat files approaching roughly 700 lines as refactor candidates, and prefer extracting cohesive runtime helpers, command handlers, or focused test classes over letting orchestration classes absorb unrelated responsibilities.
|
||||||
|
|
||||||
@@ -141,7 +152,9 @@ The Android port keeps the upstream GitHug exercise order through `submodule`. T
|
|||||||
| `clone` / `clone_to_folder` | Clones `https://github.com/Gazler/cloneme` and checks the cloned repository content. | Accepts the intended clone command and models the resulting folder. | The app must remain playable offline and avoid relying on GitHub network access from a phone. |
|
| `clone` / `clone_to_folder` | Clones `https://github.com/Gazler/cloneme` and checks the cloned repository content. | Accepts the intended clone command and models the resulting folder. | The app must remain playable offline and avoid relying on GitHub network access from a phone. |
|
||||||
| `pull`, `fetch`, `push`, `push_branch`, `push_tags` | Use remote-style workflows from upstream fixtures. | Use local synthetic remotes created inside the sandbox and validate fetched/pushed refs through `RepoState`. | This preserves Git behavior without external network dependencies. |
|
| `pull`, `fetch`, `push`, `push_branch`, `push_tags` | Use remote-style workflows from upstream fixtures. | Use local synthetic remotes created inside the sandbox and validate fetched/pushed refs through `RepoState`. | This preserves Git behavior without external network dependencies. |
|
||||||
| `stage_lines` | Requires partial hunk staging: one feature line staged and another left unstaged. | Currently validates that `feature.rb` is staged. | Android does not yet expose enough index-vs-working-tree hunk detail in `RepoState` to validate partial staging precisely. |
|
| `stage_lines` | Requires partial hunk staging: one feature line staged and another left unstaged. | Currently validates that `feature.rb` is staged. | Android does not yet expose enough index-vs-working-tree hunk detail in `RepoState` to validate partial staging precisely. |
|
||||||
| `rebase_onto`, `merge_squash`, `repack` | Upstream validates detailed object graph, merge-parent, or object database details. | Android validates the relevant user-facing action or resulting state, but with less object-level detail in some cases. | The current `RepoState` projection does not expose every low-level Git object fact. These should be tightened when the state surface grows. |
|
| `rebase_onto`, `merge_squash` | Upstream validates detailed object graph, merge-parent, or squash-content details. | Android validates the relevant user-facing action or resulting state, but with less object-level detail in some cases. | The current `RepoState` projection does not expose every low-level Git object fact. These should be tightened when the state surface grows. |
|
||||||
|
| `repack` | Upstream validates object database packing details. | Android now validates that Git produced a pack file under `.git/objects/pack`. | Equivalent user-facing repository outcome. |
|
||||||
|
| `submodule` | Uses `git submodule add` against the upstream GitHub repository. | Uses an offline local source repository and validates tracked `.gitmodules` metadata for `githug-include-me`. | Android must remain playable offline, and the packaged Git build currently lacks the `git submodule` porcelain. |
|
||||||
| `conflict` | Copies the upstream conflicting poem fixture and validates that the merge commit has two parents, conflict markers are removed, and the correct poem line remains. | Recreates the conflicting poem history natively and validates the latest commit has two parents, no conflict markers remain, and the correct `Sat on a wall` line is present. | Equivalent. |
|
| `conflict` | Copies the upstream conflicting poem fixture and validates that the merge commit has two parents, conflict markers are removed, and the correct poem line remains. | Recreates the conflicting poem history natively and validates the latest commit has two parents, no conflict markers remain, and the correct `Sat on a wall` line is present. | Equivalent. |
|
||||||
|
|
||||||
## Level Authoring
|
## Level Authoring
|
||||||
|
|||||||
@@ -14,16 +14,23 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "solutions.tretter.githugandroid"
|
namespace = "solutions.tretter.githugandroid"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
|
ndkVersion = "27.2.12479018"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "solutions.tretter.githugandroid"
|
applicationId = "solutions.tretter.githugandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 171
|
versionCode = 172
|
||||||
versionName = "0.1.170"
|
versionName = "0.1.171"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments += "-DANDROID_STL=none"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -71,6 +78,12 @@ android {
|
|||||||
kotlinCompilerExtensionVersion = "1.5.14"
|
kotlinCompilerExtensionVersion = "1.5.14"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
jniLibs {
|
jniLibs {
|
||||||
useLegacyPackaging = true
|
useLegacyPackaging = true
|
||||||
@@ -103,6 +116,8 @@ dependencies {
|
|||||||
implementation("com.google.android.material:material:1.12.0")
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test:runner:1.6.1")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package solutions.tretter.githugandroid
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class GitRepositoryRuntimeInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun nativeGitRuntimeCompletesInitLevelOnDevice() {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val runtime = GitRepositoryRuntime(context)
|
||||||
|
val level = initLevel()
|
||||||
|
|
||||||
|
assertTrue(runtime.unavailableMessage(), runtime.isNativeGitAvailable())
|
||||||
|
|
||||||
|
val repo = runtime.prepareLevel(level)
|
||||||
|
val (updatedRepo, _) = runtime.execute(level, repo, "git init")
|
||||||
|
|
||||||
|
assertTrue(updatedRepo.initialized)
|
||||||
|
assertTrue(level.validator(updatedRepo, "git init"))
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/src/main/cpp/CMakeLists.txt
Normal file
7
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.22.1)
|
||||||
|
|
||||||
|
project(githugruntime C)
|
||||||
|
|
||||||
|
add_library(githugruntime SHARED githug_runtime.c)
|
||||||
|
|
||||||
|
target_link_libraries(githugruntime dl log)
|
||||||
293
app/src/main/cpp/githug_runtime.c
Normal file
293
app/src/main/cpp/githug_runtime.c
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
#include <jni.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#define LOG_TAG "GitHugNativeGit"
|
||||||
|
|
||||||
|
typedef int (*git_main_fn)(int argc, const char **argv);
|
||||||
|
typedef void (*git_init_fn)(const char **argv);
|
||||||
|
|
||||||
|
struct output_buffer {
|
||||||
|
char *data;
|
||||||
|
size_t length;
|
||||||
|
size_t capacity;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct reader_state {
|
||||||
|
int fd;
|
||||||
|
struct output_buffer output;
|
||||||
|
};
|
||||||
|
|
||||||
|
static pthread_mutex_t git_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
static void *git_handle = NULL;
|
||||||
|
static git_main_fn git_main = NULL;
|
||||||
|
static git_init_fn git_init = NULL;
|
||||||
|
|
||||||
|
static int append_output(struct output_buffer *buffer, const char *data, size_t length) {
|
||||||
|
if (length == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
size_t required = buffer->length + length + 1;
|
||||||
|
if (required > buffer->capacity) {
|
||||||
|
size_t next_capacity = buffer->capacity == 0 ? 4096 : buffer->capacity;
|
||||||
|
while (next_capacity < required) {
|
||||||
|
next_capacity *= 2;
|
||||||
|
}
|
||||||
|
char *next = realloc(buffer->data, next_capacity);
|
||||||
|
if (next == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
buffer->data = next;
|
||||||
|
buffer->capacity = next_capacity;
|
||||||
|
}
|
||||||
|
memcpy(buffer->data + buffer->length, data, length);
|
||||||
|
buffer->length += length;
|
||||||
|
buffer->data[buffer->length] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void *read_output(void *arg) {
|
||||||
|
struct reader_state *state = (struct reader_state *)arg;
|
||||||
|
char chunk[4096];
|
||||||
|
for (;;) {
|
||||||
|
ssize_t count = read(state->fd, chunk, sizeof(chunk));
|
||||||
|
if (count > 0) {
|
||||||
|
if (append_output(&state->output, chunk, (size_t)count) != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (count == 0) {
|
||||||
|
break;
|
||||||
|
} else if (errno != EINTR) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(state->fd);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output) {
|
||||||
|
jclass string_class = (*env)->FindClass(env, "java/lang/String");
|
||||||
|
jobjectArray result = (*env)->NewObjectArray(env, 2, string_class, NULL);
|
||||||
|
char exit_text[32];
|
||||||
|
snprintf(exit_text, sizeof(exit_text), "%d", exit_code);
|
||||||
|
jstring exit_string = (*env)->NewStringUTF(env, exit_text);
|
||||||
|
jstring output_string = (*env)->NewStringUTF(env, output != NULL ? output : "");
|
||||||
|
(*env)->SetObjectArrayElement(env, result, 0, exit_string);
|
||||||
|
(*env)->SetObjectArrayElement(env, result, 1, output_string);
|
||||||
|
(*env)->DeleteLocalRef(env, exit_string);
|
||||||
|
(*env)->DeleteLocalRef(env, output_string);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static jobjectArray make_error(JNIEnv *env, const char *message) {
|
||||||
|
return make_result(env, -1, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int load_git(const char *library_path) {
|
||||||
|
if (git_init != NULL && git_main != NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
git_handle = dlopen(library_path, RTLD_NOW | RTLD_GLOBAL);
|
||||||
|
if (git_handle == NULL) {
|
||||||
|
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlopen failed: %s", dlerror());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
git_init = (git_init_fn)dlsym(git_handle, "init_git");
|
||||||
|
if (git_init == NULL) {
|
||||||
|
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlsym init_git failed: %s", dlerror());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
git_main = (git_main_fn)dlsym(git_handle, "cmd_main");
|
||||||
|
if (git_main == NULL) {
|
||||||
|
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "dlsym cmd_main failed: %s", dlerror());
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static char **copy_string_array(JNIEnv *env, jobjectArray source, int *count_out) {
|
||||||
|
int count = source == NULL ? 0 : (*env)->GetArrayLength(env, source);
|
||||||
|
char **values = calloc((size_t)count + 1, sizeof(char *));
|
||||||
|
if (values == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
jstring item = (jstring)(*env)->GetObjectArrayElement(env, source, i);
|
||||||
|
const char *chars = (*env)->GetStringUTFChars(env, item, NULL);
|
||||||
|
values[i] = strdup(chars != NULL ? chars : "");
|
||||||
|
(*env)->ReleaseStringUTFChars(env, item, chars);
|
||||||
|
(*env)->DeleteLocalRef(env, item);
|
||||||
|
if (values[i] == NULL) {
|
||||||
|
for (int j = 0; j < i; j++) {
|
||||||
|
free(values[j]);
|
||||||
|
}
|
||||||
|
free(values);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*count_out = count;
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void free_string_array(char **values, int count) {
|
||||||
|
if (values == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
free(values[i]);
|
||||||
|
}
|
||||||
|
free(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct saved_env {
|
||||||
|
char *name;
|
||||||
|
char *previous;
|
||||||
|
int had_previous;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void restore_environment(struct saved_env *saved, int count) {
|
||||||
|
for (int i = count - 1; i >= 0; i--) {
|
||||||
|
if (saved[i].had_previous) {
|
||||||
|
setenv(saved[i].name, saved[i].previous, 1);
|
||||||
|
} else {
|
||||||
|
unsetenv(saved[i].name);
|
||||||
|
}
|
||||||
|
free(saved[i].name);
|
||||||
|
free(saved[i].previous);
|
||||||
|
}
|
||||||
|
free(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct saved_env *apply_environment(char **entries, int count, int *applied_out) {
|
||||||
|
struct saved_env *saved = calloc((size_t)count, sizeof(struct saved_env));
|
||||||
|
if (saved == NULL) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
int applied = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
char *separator = strchr(entries[i], '=');
|
||||||
|
if (separator == NULL || separator == entries[i]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*separator = '\0';
|
||||||
|
const char *name = entries[i];
|
||||||
|
const char *value = separator + 1;
|
||||||
|
const char *previous = getenv(name);
|
||||||
|
saved[applied].name = strdup(name);
|
||||||
|
saved[applied].previous = previous == NULL ? NULL : strdup(previous);
|
||||||
|
saved[applied].had_previous = previous != NULL;
|
||||||
|
if (saved[applied].name == NULL || (previous != NULL && saved[applied].previous == NULL)) {
|
||||||
|
*separator = '=';
|
||||||
|
restore_environment(saved, applied + 1);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
setenv(name, value, 1);
|
||||||
|
applied++;
|
||||||
|
*separator = '=';
|
||||||
|
}
|
||||||
|
*applied_out = applied;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jobjectArray JNICALL
|
||||||
|
Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||||
|
JNIEnv *env,
|
||||||
|
jclass clazz,
|
||||||
|
jstring library_path,
|
||||||
|
jstring working_directory,
|
||||||
|
jobjectArray argv_array,
|
||||||
|
jobjectArray environment_array
|
||||||
|
) {
|
||||||
|
(void)clazz;
|
||||||
|
const char *library_path_chars = (*env)->GetStringUTFChars(env, library_path, NULL);
|
||||||
|
const char *working_directory_chars = (*env)->GetStringUTFChars(env, working_directory, NULL);
|
||||||
|
int argc = 0;
|
||||||
|
int envc = 0;
|
||||||
|
char **argv = copy_string_array(env, argv_array, &argc);
|
||||||
|
char **env_entries = copy_string_array(env, environment_array, &envc);
|
||||||
|
if (library_path_chars == NULL || working_directory_chars == NULL || argv == NULL || env_entries == NULL) {
|
||||||
|
return make_error(env, "Native Git execution failed: out of memory");
|
||||||
|
}
|
||||||
|
|
||||||
|
int pipe_fds[2];
|
||||||
|
if (pipe(pipe_fds) != 0) {
|
||||||
|
free_string_array(argv, argc);
|
||||||
|
free_string_array(env_entries, envc);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||||
|
return make_error(env, "Native Git execution failed: could not create output pipe");
|
||||||
|
}
|
||||||
|
|
||||||
|
pthread_mutex_lock(&git_mutex);
|
||||||
|
|
||||||
|
pid_t child = fork();
|
||||||
|
if (child < 0) {
|
||||||
|
close(pipe_fds[0]);
|
||||||
|
close(pipe_fds[1]);
|
||||||
|
pthread_mutex_unlock(&git_mutex);
|
||||||
|
free_string_array(argv, argc);
|
||||||
|
free_string_array(env_entries, envc);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||||
|
return make_error(env, "Native Git execution failed: could not fork");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child == 0) {
|
||||||
|
close(pipe_fds[0]);
|
||||||
|
dup2(pipe_fds[1], STDOUT_FILENO);
|
||||||
|
dup2(pipe_fds[1], STDERR_FILENO);
|
||||||
|
close(pipe_fds[1]);
|
||||||
|
|
||||||
|
int applied_env = 0;
|
||||||
|
struct saved_env *saved_env = apply_environment(env_entries, envc, &applied_env);
|
||||||
|
(void)saved_env;
|
||||||
|
chdir(working_directory_chars);
|
||||||
|
|
||||||
|
if (load_git(library_path_chars) != 0) {
|
||||||
|
const char *error = dlerror();
|
||||||
|
fprintf(stderr, "%s\n", error != NULL ? error : "Native Git execution failed: init_git/cmd_main not found");
|
||||||
|
fflush(stdout);
|
||||||
|
fflush(stderr);
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
git_init((const char **)argv);
|
||||||
|
int child_exit_code = git_main(argc, (const char **)argv);
|
||||||
|
fflush(stdout);
|
||||||
|
fflush(stderr);
|
||||||
|
_exit(child_exit_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(pipe_fds[1]);
|
||||||
|
struct reader_state reader = { .fd = pipe_fds[0], .output = {0} };
|
||||||
|
read_output(&reader);
|
||||||
|
|
||||||
|
int child_status = 0;
|
||||||
|
while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {
|
||||||
|
}
|
||||||
|
pthread_mutex_unlock(&git_mutex);
|
||||||
|
|
||||||
|
int exit_code = -1;
|
||||||
|
if (WIFEXITED(child_status)) {
|
||||||
|
exit_code = WEXITSTATUS(child_status);
|
||||||
|
} else if (WIFSIGNALED(child_status)) {
|
||||||
|
exit_code = 128 + WTERMSIG(child_status);
|
||||||
|
}
|
||||||
|
|
||||||
|
jobjectArray result = make_result(env, exit_code, reader.output.data);
|
||||||
|
|
||||||
|
free(reader.output.data);
|
||||||
|
free_string_array(argv, argc);
|
||||||
|
free_string_array(env_entries, envc);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, working_directory, working_directory_chars);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package solutions.tretter.githugandroid
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal class GitEditorWorkflow(
|
||||||
|
private val requireNativeGit: () -> File,
|
||||||
|
private val prepareLevel: (Level) -> RepoState,
|
||||||
|
private val sandboxDir: (Level) -> File,
|
||||||
|
private val inspectSandbox: (Level, String) -> RepoState,
|
||||||
|
private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
|
||||||
|
private val shellExecutable: () -> String,
|
||||||
|
) {
|
||||||
|
fun initialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
||||||
|
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) return invocation.initialContent
|
||||||
|
|
||||||
|
val nativeGit = requireNativeGit()
|
||||||
|
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
|
||||||
|
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
||||||
|
val capturedFile = File(gitDir, "GITHUG_ANDROID_CAPTURED_EDITOR")
|
||||||
|
val editorScript = File(gitDir, "githug-android-capture-editor.sh")
|
||||||
|
capturedFile.delete()
|
||||||
|
|
||||||
|
editorScript.writeText(
|
||||||
|
"""
|
||||||
|
|#!/bin/sh
|
||||||
|
|cat "$1" > ${capturedFile.absolutePath.toShellSingleQuoted()}
|
||||||
|
|exit 1
|
||||||
|
|""".trimMargin(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val editorEnvironmentName = when (invocation.kind) {
|
||||||
|
GitEditorCommandKind.REBASE_TODO -> "GIT_SEQUENCE_EDITOR"
|
||||||
|
GitEditorCommandKind.COMMIT_MESSAGE,
|
||||||
|
GitEditorCommandKind.TAG_MESSAGE -> "GIT_EDITOR"
|
||||||
|
GitEditorCommandKind.PATCH_HUNK -> return invocation.initialContent
|
||||||
|
}
|
||||||
|
|
||||||
|
runGit(
|
||||||
|
nativeGit,
|
||||||
|
workingDir,
|
||||||
|
GitSandboxEngine.tokenizeCommand(invocation.command).drop(1),
|
||||||
|
mapOf(editorEnvironmentName to editorCommand(editorScript)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (invocation.kind == GitEditorCommandKind.REBASE_TODO && rebaseStateExists(gitDir)) {
|
||||||
|
runGit(nativeGit, workingDir, listOf("rebase", "--abort"), emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
val content = capturedFile.takeIf { it.isFile }?.readText() ?: invocation.initialContent
|
||||||
|
capturedFile.delete()
|
||||||
|
editorScript.delete()
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
fun execute(
|
||||||
|
level: Level,
|
||||||
|
currentRepo: RepoState,
|
||||||
|
invocation: GitEditorInvocation,
|
||||||
|
message: String,
|
||||||
|
): Pair<RepoState, List<String>> {
|
||||||
|
val nativeGit = requireNativeGit()
|
||||||
|
val (sandboxRoot, workingDir) = repositoryPaths(level, currentRepo)
|
||||||
|
return when (invocation.kind) {
|
||||||
|
GitEditorCommandKind.REBASE_TODO -> executeInteractiveRebase(
|
||||||
|
level = level,
|
||||||
|
currentRepo = currentRepo,
|
||||||
|
nativeGit = nativeGit,
|
||||||
|
sandboxRoot = sandboxRoot,
|
||||||
|
workingDir = workingDir,
|
||||||
|
invocation = invocation,
|
||||||
|
todo = message,
|
||||||
|
)
|
||||||
|
|
||||||
|
GitEditorCommandKind.COMMIT_MESSAGE,
|
||||||
|
GitEditorCommandKind.TAG_MESSAGE -> executeMessageCommand(
|
||||||
|
level = level,
|
||||||
|
currentRepo = currentRepo,
|
||||||
|
nativeGit = nativeGit,
|
||||||
|
sandboxRoot = sandboxRoot,
|
||||||
|
workingDir = workingDir,
|
||||||
|
invocation = invocation,
|
||||||
|
message = message,
|
||||||
|
)
|
||||||
|
|
||||||
|
GitEditorCommandKind.PATCH_HUNK -> {
|
||||||
|
currentRepo to listOf("Patch hunk editing is handled by Git, not the Android runtime.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeMessageCommand(
|
||||||
|
level: Level,
|
||||||
|
currentRepo: RepoState,
|
||||||
|
nativeGit: File,
|
||||||
|
sandboxRoot: File,
|
||||||
|
workingDir: File,
|
||||||
|
invocation: GitEditorInvocation,
|
||||||
|
message: String,
|
||||||
|
): Pair<RepoState, List<String>> {
|
||||||
|
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
|
||||||
|
parentFile?.mkdirs()
|
||||||
|
writeText(message)
|
||||||
|
}
|
||||||
|
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command)
|
||||||
|
.drop(1)
|
||||||
|
.toMutableList()
|
||||||
|
.apply { addAll(listOf("-F", messageFile.absolutePath)) }
|
||||||
|
val result = runGit(nativeGit, workingDir, arguments, emptyMap())
|
||||||
|
messageFile.delete()
|
||||||
|
|
||||||
|
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeInteractiveRebase(
|
||||||
|
level: Level,
|
||||||
|
currentRepo: RepoState,
|
||||||
|
nativeGit: File,
|
||||||
|
sandboxRoot: File,
|
||||||
|
workingDir: File,
|
||||||
|
invocation: GitEditorInvocation,
|
||||||
|
todo: String,
|
||||||
|
): Pair<RepoState, List<String>> {
|
||||||
|
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
||||||
|
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
|
||||||
|
writeText(todo)
|
||||||
|
}
|
||||||
|
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
|
||||||
|
writeText(
|
||||||
|
"""
|
||||||
|
|#!/bin/sh
|
||||||
|
|cat ${todoFile.absolutePath.toShellSingleQuoted()} > "$1"
|
||||||
|
|""".trimMargin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val result = runGit(
|
||||||
|
nativeGit,
|
||||||
|
workingDir,
|
||||||
|
GitSandboxEngine.tokenizeCommand(invocation.command).drop(1),
|
||||||
|
mapOf(
|
||||||
|
"GIT_SEQUENCE_EDITOR" to editorCommand(editorScript),
|
||||||
|
"GIT_EDITOR" to "true",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
todoFile.delete()
|
||||||
|
editorScript.delete()
|
||||||
|
|
||||||
|
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun repositoryPaths(level: Level, currentRepo: RepoState): Pair<File, File> {
|
||||||
|
val sandboxRoot = sandboxDir(level).canonicalFile
|
||||||
|
if (!sandboxRoot.exists()) {
|
||||||
|
prepareLevel(level)
|
||||||
|
}
|
||||||
|
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
||||||
|
.takeIf { it.path == sandboxRoot.path || it.path.startsWith(sandboxRoot.path + File.separator) }
|
||||||
|
?: sandboxRoot
|
||||||
|
return sandboxRoot to workingDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun editorCommand(script: File): String {
|
||||||
|
return "${shellExecutable().toShellSingleQuoted()} ${script.absolutePath.toShellSingleQuoted()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebaseStateExists(gitDir: File): Boolean {
|
||||||
|
return File(gitDir, "rebase-merge").exists() || File(gitDir, "rebase-apply").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toShellSingleQuoted(): String {
|
||||||
|
return "'" + replace("'", "'\"'\"'") + "'"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,10 @@ internal class GitProcessRunner(
|
|||||||
arguments: List<String>,
|
arguments: List<String>,
|
||||||
environment: Map<String, String> = emptyMap(),
|
environment: Map<String, String> = emptyMap(),
|
||||||
): ProcessExecutionResult {
|
): ProcessExecutionResult {
|
||||||
|
if (context != null) {
|
||||||
|
gitExecDirectory(binary)
|
||||||
|
return NativeGitBridge.runGitMain(binary, workingDir, arguments, gitEnvironment(binary, workingDir, environment))
|
||||||
|
}
|
||||||
return runProcess(binary, workingDir, arguments, environment)
|
return runProcess(binary, workingDir, arguments, environment)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,19 +92,7 @@ internal class GitProcessRunner(
|
|||||||
.directory(workingDir)
|
.directory(workingDir)
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.apply {
|
.apply {
|
||||||
environment()["HOME"] = workingDir.absolutePath
|
environment().putAll(gitEnvironment(binary, workingDir, extraEnvironment, gitExecPath))
|
||||||
environment()["GIT_EXEC_PATH"] = gitExecPath.absolutePath
|
|
||||||
environment()["PATH"] = listOfNotNull(
|
|
||||||
gitExecPath.absolutePath,
|
|
||||||
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
|
||||||
).joinToString(File.pathSeparator)
|
|
||||||
environment()["GIT_CONFIG_NOSYSTEM"] = "1"
|
|
||||||
environment()["GIT_AUTHOR_NAME"] = "GitHug"
|
|
||||||
environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com"
|
|
||||||
environment()["GIT_COMMITTER_NAME"] = "GitHug"
|
|
||||||
environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com"
|
|
||||||
environment()["LC_ALL"] = "C"
|
|
||||||
environment().putAll(extraEnvironment)
|
|
||||||
}
|
}
|
||||||
.start()
|
.start()
|
||||||
|
|
||||||
@@ -137,6 +129,32 @@ internal class GitProcessRunner(
|
|||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun gitEnvironment(
|
||||||
|
binary: File,
|
||||||
|
workingDir: File,
|
||||||
|
extraEnvironment: Map<String, String>,
|
||||||
|
gitExecPath: File = gitExecDirectory(binary),
|
||||||
|
): Map<String, String> {
|
||||||
|
return buildMap {
|
||||||
|
put("HOME", workingDir.absolutePath)
|
||||||
|
put("GIT_EXEC_PATH", gitExecPath.absolutePath)
|
||||||
|
put(
|
||||||
|
"PATH",
|
||||||
|
listOfNotNull(
|
||||||
|
gitExecPath.absolutePath,
|
||||||
|
System.getenv("PATH")?.takeIf { it.isNotBlank() },
|
||||||
|
).joinToString(File.pathSeparator),
|
||||||
|
)
|
||||||
|
put("GIT_CONFIG_NOSYSTEM", "1")
|
||||||
|
put("GIT_AUTHOR_NAME", "GitHug")
|
||||||
|
put("GIT_AUTHOR_EMAIL", "githug@example.com")
|
||||||
|
put("GIT_COMMITTER_NAME", "GitHug")
|
||||||
|
put("GIT_COMMITTER_EMAIL", "githug@example.com")
|
||||||
|
put("LC_ALL", "C")
|
||||||
|
putAll(extraEnvironment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshGitExecDirectoryIfNeeded(directory: File, binary: File) {
|
private fun refreshGitExecDirectoryIfNeeded(directory: File, binary: File) {
|
||||||
val fingerprint = buildString {
|
val fingerprint = buildString {
|
||||||
append(binary.absolutePath)
|
append(binary.absolutePath)
|
||||||
@@ -161,7 +179,6 @@ internal class GitProcessRunner(
|
|||||||
if (context != null) {
|
if (context != null) {
|
||||||
try {
|
try {
|
||||||
Os.symlink(binary.absolutePath, alias.absolutePath)
|
Os.symlink(binary.absolutePath, alias.absolutePath)
|
||||||
alias.setExecutable(true, false)
|
|
||||||
return
|
return
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Fall through to the portable options below.
|
// Fall through to the portable options below.
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package solutions.tretter.githugandroid
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal class GitRepositoryInspector(
|
||||||
|
private val nativeGit: () -> File,
|
||||||
|
private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
|
||||||
|
) {
|
||||||
|
fun inspect(sandbox: File, currentDir: String = "."): RepoState {
|
||||||
|
val git = nativeGit()
|
||||||
|
val workingDir = File(sandbox, currentDir).canonicalFile
|
||||||
|
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
|
||||||
|
?: sandbox
|
||||||
|
val repositoryRoot = generateSequence(workingDir) { directory ->
|
||||||
|
directory.parentFile?.takeIf {
|
||||||
|
it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator)
|
||||||
|
}
|
||||||
|
}.firstOrNull { File(it, ".git").exists() }
|
||||||
|
val inspectionRoot = repositoryRoot ?: sandbox
|
||||||
|
val filesOnDisk = inspectionRoot.walkTopDown()
|
||||||
|
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
|
||||||
|
.orEmpty()
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
if (repositoryRoot == null) {
|
||||||
|
return RepoState(
|
||||||
|
initialized = false,
|
||||||
|
files = filesOnDisk.map {
|
||||||
|
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusResult = run(git, inspectionRoot, listOf("status", "--porcelain"))
|
||||||
|
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
|
||||||
|
val deletedStatusPaths = mutableSetOf<String>()
|
||||||
|
statusResult.outputLines.forEach { line ->
|
||||||
|
if (line.length < 4) return@forEach
|
||||||
|
val x = line[0]
|
||||||
|
val y = line[1]
|
||||||
|
val path = line.substring(3).trim()
|
||||||
|
val staged = x != ' ' && x != '?'
|
||||||
|
val tracked = x != '?' || y != '?'
|
||||||
|
statusMap[path] = staged to tracked
|
||||||
|
if (x == 'D' || y == 'D') {
|
||||||
|
deletedStatusPaths += path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val logResult = run(git, inspectionRoot, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
|
||||||
|
val branchResult = run(git, inspectionRoot, listOf("branch", "--list"))
|
||||||
|
val remoteBranchResult = run(git, inspectionRoot, listOf("branch", "-r", "--list"))
|
||||||
|
val tagResult = run(git, inspectionRoot, listOf("tag", "--list"))
|
||||||
|
val remoteResult = run(git, inspectionRoot, listOf("remote", "-v"))
|
||||||
|
val headResult = run(git, inspectionRoot, listOf("branch", "--show-current"))
|
||||||
|
val exactTagResult = run(git, inspectionRoot, listOf("describe", "--tags", "--exact-match"))
|
||||||
|
val userNameResult = run(git, inspectionRoot, listOf("config", "--get", "user.name"))
|
||||||
|
val userEmailResult = run(git, inspectionRoot, listOf("config", "--get", "user.email"))
|
||||||
|
val stashResult = run(git, inspectionRoot, listOf("stash", "list", "--format=%gd"))
|
||||||
|
val submodules = inspectSubmodules(git, inspectionRoot)
|
||||||
|
val maintenanceActions = buildSet {
|
||||||
|
if (File(inspectionRoot, ".git/objects/pack").listFiles()?.any { it.extension == "pack" } == true) {
|
||||||
|
add("repack")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fetchHeadCount = File(inspectionRoot, ".git/FETCH_HEAD")
|
||||||
|
.takeIf { it.isFile }
|
||||||
|
?.readLines()
|
||||||
|
?.count { it.isNotBlank() }
|
||||||
|
?: 0
|
||||||
|
val config = buildMap {
|
||||||
|
userNameResult.outputLines.firstOrNull()
|
||||||
|
?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() }
|
||||||
|
?.let { put("user.name", it) }
|
||||||
|
userEmailResult.outputLines.firstOrNull()
|
||||||
|
?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() }
|
||||||
|
?.let { put("user.email", it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val commits = if (logResult.exitCode == 0) {
|
||||||
|
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
|
||||||
|
val parts = line.split('\t', limit = 4)
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val parentHashes = parts.getOrNull(2).orEmpty().split(Regex("\\s+")).filter { it.isNotBlank() }
|
||||||
|
CommitNode(
|
||||||
|
id = parts[0],
|
||||||
|
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
|
||||||
|
parentCount = parentHashes.size,
|
||||||
|
message = parts.getOrElse(3) { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val branches = branchResult.outputLines
|
||||||
|
.map { it.removePrefix("*").trim() }
|
||||||
|
.filter { it.isNotBlank() && !it.startsWith("(") }
|
||||||
|
.associateWith { branch ->
|
||||||
|
run(git, inspectionRoot, listOf("rev-list", "--count", branch))
|
||||||
|
.outputLines
|
||||||
|
.firstOrNull()
|
||||||
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
val localBranchHeads = branches.keys.associateWith { branch ->
|
||||||
|
run(git, inspectionRoot, listOf("rev-parse", "--verify", "refs/heads/$branch"))
|
||||||
|
.outputLines
|
||||||
|
.firstOrNull()
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
val localTagHeads = tagResult.outputLines
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.associateWith { tag ->
|
||||||
|
run(git, inspectionRoot, listOf("rev-list", "-n", "1", tag))
|
||||||
|
.outputLines
|
||||||
|
.firstOrNull()
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
val remoteRefs = inspectRemoteRefs(git, inspectionRoot, remoteResult.outputLines)
|
||||||
|
val headBranch = headResult.outputLines.firstOrNull()
|
||||||
|
?.ifBlank { null }
|
||||||
|
?: exactTagResult.outputLines.firstOrNull()
|
||||||
|
?.takeIf { exactTagResult.exitCode == 0 && it.isNotBlank() }
|
||||||
|
?.let { "tags/$it" }
|
||||||
|
?: "DETACHED"
|
||||||
|
|
||||||
|
return RepoState(
|
||||||
|
initialized = true,
|
||||||
|
files = filesOnDisk.map { file ->
|
||||||
|
val relativePath = file.relativeTo(inspectionRoot).path
|
||||||
|
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
||||||
|
GitFile(
|
||||||
|
name = relativePath,
|
||||||
|
content = file.readText(),
|
||||||
|
staged = staged,
|
||||||
|
tracked = tracked,
|
||||||
|
)
|
||||||
|
} + deletedStatusPaths
|
||||||
|
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
|
||||||
|
.map { deletedPath ->
|
||||||
|
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
|
||||||
|
GitFile(
|
||||||
|
name = deletedPath,
|
||||||
|
staged = staged,
|
||||||
|
tracked = tracked,
|
||||||
|
deleted = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
commits = commits,
|
||||||
|
headBranch = headBranch,
|
||||||
|
branches = branches,
|
||||||
|
tags = tagResult.outputLines.filter { it.isNotBlank() },
|
||||||
|
remotes = remoteResult.outputLines.mapNotNull { line ->
|
||||||
|
val parts = line.trim().split(Regex("\\s+"))
|
||||||
|
if (parts.size >= 2) parts[0] to parts[1] else null
|
||||||
|
}.toMap(),
|
||||||
|
config = config,
|
||||||
|
stashes = stashResult.outputLines.filter { it.isNotBlank() },
|
||||||
|
fetchedBranches = remoteBranchResult.outputLines
|
||||||
|
.map { it.removePrefix("*").trim() }
|
||||||
|
.filter { it.isNotBlank() && " -> " !in it }
|
||||||
|
.toSet(),
|
||||||
|
fetchHeadCount = fetchHeadCount,
|
||||||
|
pushedBranches = remoteRefs.heads
|
||||||
|
.filter { remoteHead -> localBranchHeads[remoteHead.branch] == remoteHead.hash }
|
||||||
|
.map { remoteHead -> "${remoteHead.remote}/${remoteHead.branch}" }
|
||||||
|
.toSet(),
|
||||||
|
pushedTags = remoteRefs.tags
|
||||||
|
.filter { remoteTag -> localTagHeads[remoteTag.tag] == remoteTag.hash }
|
||||||
|
.map { it.tag }
|
||||||
|
.toSet(),
|
||||||
|
submodules = submodules,
|
||||||
|
maintenanceActions = maintenanceActions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(
|
||||||
|
binary: File,
|
||||||
|
workingDir: File,
|
||||||
|
arguments: List<String>,
|
||||||
|
environment: Map<String, String> = emptyMap(),
|
||||||
|
): ProcessExecutionResult = runGit(binary, workingDir, arguments, environment)
|
||||||
|
|
||||||
|
private fun inspectRemoteRefs(git: File, inspectionRoot: File, remoteLines: List<String>): RemoteRefs {
|
||||||
|
val remotes = remoteLines.mapNotNull { line ->
|
||||||
|
val parts = line.trim().split(Regex("\\s+"))
|
||||||
|
if (parts.size >= 2) parts[0] to parts[1] else null
|
||||||
|
}.toMap()
|
||||||
|
val heads = mutableSetOf<RemoteHead>()
|
||||||
|
val tags = mutableSetOf<RemoteTag>()
|
||||||
|
|
||||||
|
remotes.forEach remote@{ (remoteName, remoteUrl) ->
|
||||||
|
val output = run(git, inspectionRoot, listOf("ls-remote", remoteUrl))
|
||||||
|
if (output.exitCode != 0) return@remote
|
||||||
|
output.outputLines.forEach { line ->
|
||||||
|
val parts = line.trim().split(Regex("\\s+"))
|
||||||
|
if (parts.size < 2) return@forEach
|
||||||
|
val hash = parts[0]
|
||||||
|
val ref = parts[1].removeSuffix("^{}")
|
||||||
|
when {
|
||||||
|
ref.startsWith("refs/heads/") -> {
|
||||||
|
heads += RemoteHead(remoteName, ref.removePrefix("refs/heads/"), hash)
|
||||||
|
}
|
||||||
|
ref.startsWith("refs/tags/") -> {
|
||||||
|
tags += RemoteTag(ref.removePrefix("refs/tags/"), hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RemoteRefs(heads = heads, tags = tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inspectSubmodules(git: File, inspectionRoot: File): Map<String, String> {
|
||||||
|
val gitmodules = File(inspectionRoot, ".gitmodules")
|
||||||
|
if (!gitmodules.isFile) return emptyMap()
|
||||||
|
|
||||||
|
val output = run(git, inspectionRoot, listOf("config", "--file", gitmodules.absolutePath, "--get-regexp", "submodule\\..*\\.(path|url)"))
|
||||||
|
if (output.exitCode != 0) return emptyMap()
|
||||||
|
|
||||||
|
val entries = mutableMapOf<String, MutableMap<String, String>>()
|
||||||
|
output.outputLines.forEach { line ->
|
||||||
|
val key = line.substringBefore(' ')
|
||||||
|
val value = line.substringAfter(' ', missingDelimiterValue = "").trim()
|
||||||
|
val parts = key.split('.')
|
||||||
|
if (parts.size != 3 || parts[0] != "submodule") return@forEach
|
||||||
|
entries.getOrPut(parts[1]) { mutableMapOf() }[parts[2]] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.values.mapNotNull { values ->
|
||||||
|
val path = values["path"]?.trimEnd('/')
|
||||||
|
val url = values["url"]
|
||||||
|
if (path != null && url != null) path to url else null
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RemoteRefs(
|
||||||
|
val heads: Set<RemoteHead> = emptySet(),
|
||||||
|
val tags: Set<RemoteTag> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class RemoteHead(
|
||||||
|
val remote: String,
|
||||||
|
val branch: String,
|
||||||
|
val hash: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class RemoteTag(
|
||||||
|
val tag: String,
|
||||||
|
val hash: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,15 @@ class GitRepositoryRuntime private constructor(
|
|||||||
private val processRunner = GitProcessRunner(context, sandboxesRoot)
|
private val processRunner = GitProcessRunner(context, sandboxesRoot)
|
||||||
private val helperCommands = GitHelperCommands(processRunner)
|
private val helperCommands = GitHelperCommands(processRunner)
|
||||||
private val levelMaterializer = GitLevelMaterializer(::runGit)
|
private val levelMaterializer = GitLevelMaterializer(::runGit)
|
||||||
|
private val repositoryInspector = GitRepositoryInspector(::requireNativeGit, ::runGit)
|
||||||
|
private val editorWorkflow = GitEditorWorkflow(
|
||||||
|
requireNativeGit = ::requireNativeGit,
|
||||||
|
prepareLevel = ::prepareLevel,
|
||||||
|
sandboxDir = ::sandboxDir,
|
||||||
|
inspectSandbox = ::inspectSandbox,
|
||||||
|
runGit = ::runGit,
|
||||||
|
shellExecutable = processRunner::shellExecutable,
|
||||||
|
)
|
||||||
|
|
||||||
constructor(context: Context) : this(
|
constructor(context: Context) : this(
|
||||||
context = context.applicationContext,
|
context = context.applicationContext,
|
||||||
@@ -25,7 +34,9 @@ class GitRepositoryRuntime private constructor(
|
|||||||
sandboxesRoot = sandboxesRoot,
|
sandboxesRoot = sandboxesRoot,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null
|
fun isNativeGitAvailable(): Boolean {
|
||||||
|
return nativeGitBinary() != null && (context == null || NativeGitBridge.isAvailable())
|
||||||
|
}
|
||||||
|
|
||||||
fun unavailableMessage(): String {
|
fun unavailableMessage(): String {
|
||||||
val selectedAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
|
val selectedAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
|
||||||
@@ -43,6 +54,9 @@ class GitRepositoryRuntime private constructor(
|
|||||||
append("\nPlatform: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})")
|
append("\nPlatform: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})")
|
||||||
append("\nDevice: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}; ${Build.HARDWARE})")
|
append("\nDevice: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}; ${Build.HARDWARE})")
|
||||||
append("\nExpected binary: $nativeLibraryDir/libgit.so")
|
append("\nExpected binary: $nativeLibraryDir/libgit.so")
|
||||||
|
if (context != null && !NativeGitBridge.isAvailable()) {
|
||||||
|
append("\nNative bridge: unavailable")
|
||||||
|
}
|
||||||
append("\n\nPlease report this information to the developer so support can be added for this device/platform.")
|
append("\n\nPlease report this information to the developer so support can be added for this device/platform.")
|
||||||
append("\nInstall a build that bundles the cross-compiled Git binary for this device.")
|
append("\nInstall a build that bundles the cross-compiled Git binary for this device.")
|
||||||
}
|
}
|
||||||
@@ -101,29 +115,11 @@ class GitRepositoryRuntime private constructor(
|
|||||||
val invocation = parseEnvironmentPrefixedCommand(shellTokens)
|
val invocation = parseEnvironmentPrefixedCommand(shellTokens)
|
||||||
val tokens = invocation.command.map { it.value }
|
val tokens = invocation.command.map { it.value }
|
||||||
if (tokens.isEmpty()) return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to emptyList()
|
if (tokens.isEmpty()) return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to emptyList()
|
||||||
if (currentRepo.interactiveAddSession != null) {
|
|
||||||
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
|
|
||||||
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
|
|
||||||
updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true
|
|
||||||
}.map { it.name }
|
|
||||||
if (newlyStagedPaths.isNotEmpty()) {
|
|
||||||
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
|
|
||||||
}
|
|
||||||
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
|
|
||||||
currentDir = updatedRepo.currentDir,
|
|
||||||
interactiveAddSession = updatedRepo.interactiveAddSession,
|
|
||||||
)
|
|
||||||
return augmentObservedRepoFacts(updatedRepo, inspectedRepo, tokens) to output
|
|
||||||
}
|
|
||||||
val expandedTokens = if (tokens.firstOrNull() == "echo") {
|
val expandedTokens = if (tokens.firstOrNull() == "echo") {
|
||||||
tokens
|
tokens
|
||||||
} else {
|
} else {
|
||||||
expandShellPathspecs(currentRepo, invocation.command)
|
expandShellPathspecs(currentRepo, invocation.command)
|
||||||
}
|
}
|
||||||
.normalizeGitStageAlias()
|
|
||||||
.normalizeGitBisectRunScriptShortcut()
|
|
||||||
|
|
||||||
executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it }
|
|
||||||
|
|
||||||
val result = when (expandedTokens.first()) {
|
val result = when (expandedTokens.first()) {
|
||||||
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
|
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
|
||||||
@@ -133,7 +129,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val inspectedRepo = inspectSandbox(level, result.first.currentDir).copy(currentDir = result.first.currentDir)
|
val inspectedRepo = inspectSandbox(level, result.first.currentDir).copy(currentDir = result.first.currentDir)
|
||||||
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) to result.second
|
return inspectedRepo to result.second
|
||||||
}
|
}
|
||||||
|
|
||||||
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
|
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
|
||||||
@@ -165,6 +161,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
addAll(GitSandboxEngine.commandReferenceLines())
|
addAll(GitSandboxEngine.commandReferenceLines())
|
||||||
add("Native Git runtime:")
|
add("Native Git runtime:")
|
||||||
add(" binary path: nativeLibraryDir/libgit.so")
|
add(" binary path: nativeLibraryDir/libgit.so")
|
||||||
|
add(" invocation: init_git(argv), then cmd_main(argc, argv)")
|
||||||
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
|
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
|
||||||
add(" helper commands: ls/dir, pwd, cat, sh <script>, ./<script>, touch, mkdir/md, cd.., rm/del, echo")
|
add(" helper commands: ls/dir, pwd, cat, sh <script>, ./<script>, touch, mkdir/md, cd.., rm/del, echo")
|
||||||
add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad")
|
add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad")
|
||||||
@@ -224,36 +221,7 @@ class GitRepositoryRuntime private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
fun gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
|
||||||
if (invocation.kind != GitEditorCommandKind.REBASE_TODO) return invocation.initialContent
|
return editorWorkflow.initialContent(level, currentRepo, invocation)
|
||||||
|
|
||||||
val nativeGit = requireNativeGit()
|
|
||||||
val sandboxRoot = sandboxDir(level).canonicalFile
|
|
||||||
if (!sandboxRoot.exists()) {
|
|
||||||
prepareLevel(level)
|
|
||||||
}
|
|
||||||
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
|
||||||
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
|
|
||||||
val target = rebaseTarget(invocation.command) ?: return invocation.initialContent
|
|
||||||
val result = runGit(nativeGit, workingDir, listOf("log", "--reverse", "--format=%h %s", "$target..HEAD"))
|
|
||||||
if (result.exitCode != 0 || result.outputLines.isEmpty()) return invocation.initialContent
|
|
||||||
|
|
||||||
return buildString {
|
|
||||||
result.outputLines
|
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.forEach { line -> appendLine("pick $line") }
|
|
||||||
appendLine()
|
|
||||||
appendLine("# Rebase $target..HEAD onto $target")
|
|
||||||
appendLine("#")
|
|
||||||
appendLine("# Commands:")
|
|
||||||
appendLine("# p, pick <commit> = use commit")
|
|
||||||
appendLine("# r, reword <commit> = use commit and replace its message with the text on this line")
|
|
||||||
appendLine("# e, edit <commit> = use commit, but stop for amending")
|
|
||||||
appendLine("# s, squash <commit> = use commit, but meld into previous commit")
|
|
||||||
appendLine("# f, fixup [-C | -c] <commit> = like squash but keep only the previous commit's log message")
|
|
||||||
appendLine("# d, drop <commit> = remove commit")
|
|
||||||
appendLine("#")
|
|
||||||
appendLine("# These lines can be re-ordered; they are executed from top to bottom.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun executeGitEditorCommand(
|
fun executeGitEditorCommand(
|
||||||
@@ -262,322 +230,11 @@ class GitRepositoryRuntime private constructor(
|
|||||||
invocation: GitEditorInvocation,
|
invocation: GitEditorInvocation,
|
||||||
message: String,
|
message: String,
|
||||||
): Pair<RepoState, List<String>> {
|
): Pair<RepoState, List<String>> {
|
||||||
val nativeGit = requireNativeGit()
|
return editorWorkflow.execute(level, currentRepo, invocation, message)
|
||||||
|
|
||||||
val sandboxRoot = sandboxDir(level).canonicalFile
|
|
||||||
if (!sandboxRoot.exists()) {
|
|
||||||
prepareLevel(level)
|
|
||||||
}
|
|
||||||
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
|
|
||||||
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
|
|
||||||
|
|
||||||
if (invocation.kind == GitEditorCommandKind.REBASE_TODO) {
|
|
||||||
return executeInteractiveRebaseEditorCommand(level, currentRepo, nativeGit, sandboxRoot, workingDir, invocation, message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) {
|
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState =
|
||||||
val (updatedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(currentRepo, message)
|
repositoryInspector.inspect(sandboxDir(level), currentDir)
|
||||||
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
|
|
||||||
updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true
|
|
||||||
}.map { it.name }
|
|
||||||
if (newlyStagedPaths.isNotEmpty()) {
|
|
||||||
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
|
|
||||||
}
|
|
||||||
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
|
|
||||||
currentDir = updatedRepo.currentDir,
|
|
||||||
interactiveAddSession = updatedRepo.interactiveAddSession,
|
|
||||||
)
|
|
||||||
return inspectedRepo to output
|
|
||||||
}
|
|
||||||
|
|
||||||
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
|
|
||||||
parentFile?.mkdirs()
|
|
||||||
writeText(message)
|
|
||||||
}
|
|
||||||
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command)
|
|
||||||
.drop(1)
|
|
||||||
.toMutableList()
|
|
||||||
.apply { addAll(listOf("-F", messageFile.absolutePath)) }
|
|
||||||
val result = runGit(nativeGit, workingDir, arguments)
|
|
||||||
messageFile.delete()
|
|
||||||
|
|
||||||
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun executeInteractiveRebaseEditorCommand(
|
|
||||||
level: Level,
|
|
||||||
currentRepo: RepoState,
|
|
||||||
nativeGit: File,
|
|
||||||
sandboxRoot: File,
|
|
||||||
workingDir: File,
|
|
||||||
invocation: GitEditorInvocation,
|
|
||||||
todo: String,
|
|
||||||
): Pair<RepoState, List<String>> {
|
|
||||||
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
|
|
||||||
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
|
|
||||||
writeText(todo)
|
|
||||||
}
|
|
||||||
val rewordMessages = todo.lineSequence().mapNotNull(::rewordMessageFromTodoLine).toList()
|
|
||||||
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
|
|
||||||
writeText(
|
|
||||||
"""
|
|
||||||
|#!/bin/sh
|
|
||||||
|cat ${todoFile.absolutePath.toShellSingleQuoted()} > "$1"
|
|
||||||
|""".trimMargin(),
|
|
||||||
)
|
|
||||||
setReadable(true, true)
|
|
||||||
}
|
|
||||||
val rewordMessageFiles = rewordMessages.mapIndexed { index, message ->
|
|
||||||
File(gitDir, "GITHUG_ANDROID_REWORD_${index + 1}").apply {
|
|
||||||
writeText(message.trimEnd() + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val rewordCounterFile = File(gitDir, "GITHUG_ANDROID_REWORD_COUNTER")
|
|
||||||
val messageEditorScript = File(gitDir, "githug-android-message-editor.sh").apply {
|
|
||||||
writeText(
|
|
||||||
"""
|
|
||||||
|#!/bin/sh
|
|
||||||
|counter_file=${rewordCounterFile.absolutePath.toShellSingleQuoted()}
|
|
||||||
|index=0
|
|
||||||
|if [ -f "${'$'}counter_file" ]; then
|
|
||||||
| index=$(cat "${'$'}counter_file")
|
|
||||||
|fi
|
|
||||||
|index=$((index + 1))
|
|
||||||
|printf '%s\n' "${'$'}index" > "${'$'}counter_file"
|
|
||||||
|message_file=${File(gitDir, "GITHUG_ANDROID_REWORD_").absolutePath.toShellSingleQuoted()}"${'$'}index"
|
|
||||||
|if [ -f "${'$'}message_file" ]; then
|
|
||||||
| cat "${'$'}message_file" > "$1"
|
|
||||||
|fi
|
|
||||||
|""".trimMargin(),
|
|
||||||
)
|
|
||||||
setReadable(true, true)
|
|
||||||
}
|
|
||||||
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command).drop(1)
|
|
||||||
val result = runGit(
|
|
||||||
nativeGit,
|
|
||||||
workingDir,
|
|
||||||
arguments,
|
|
||||||
mapOf(
|
|
||||||
"GIT_SEQUENCE_EDITOR" to "${shellExecutable().toShellSingleQuoted()} ${editorScript.absolutePath.toShellSingleQuoted()}",
|
|
||||||
"GIT_EDITOR" to if (rewordMessages.isEmpty()) {
|
|
||||||
"true"
|
|
||||||
} else {
|
|
||||||
"${shellExecutable().toShellSingleQuoted()} ${messageEditorScript.absolutePath.toShellSingleQuoted()}"
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
todoFile.delete()
|
|
||||||
editorScript.delete()
|
|
||||||
rewordMessageFiles.forEach { it.delete() }
|
|
||||||
rewordCounterFile.delete()
|
|
||||||
messageEditorScript.delete()
|
|
||||||
|
|
||||||
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rewordMessageFromTodoLine(line: String): String? {
|
|
||||||
val trimmed = line.trim()
|
|
||||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) return null
|
|
||||||
val parts = trimmed.split(Regex("\\s+"), limit = 3)
|
|
||||||
if (parts.size < 3 || parts[0] !in setOf("r", "reword")) return null
|
|
||||||
return parts[2].takeIf { it.isNotBlank() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun executeSyntheticGitCommand(
|
|
||||||
currentRepo: RepoState,
|
|
||||||
command: String,
|
|
||||||
tokens: List<String>,
|
|
||||||
environment: Map<String, String> = emptyMap(),
|
|
||||||
): Pair<RepoState, List<String>>? {
|
|
||||||
if (environment.isNotEmpty()) return null
|
|
||||||
if (tokens.firstOrNull() != "git") return null
|
|
||||||
val gitCommand = tokens.getOrNull(1) ?: return null
|
|
||||||
if (gitCommand == "clone" && tokens.getOrNull(2)?.startsWith("https://github.com/Gazler/cloneme") == true) {
|
|
||||||
val target = tokens.getOrNull(3) ?: "cloneme"
|
|
||||||
return currentRepo.copy(
|
|
||||||
files = currentRepo.files + GitFile("$target/README", tracked = true),
|
|
||||||
) to listOf("Cloned ${tokens[2]} into $target")
|
|
||||||
}
|
|
||||||
val shouldUseSandboxSemantics = when (gitCommand) {
|
|
||||||
"add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" }
|
|
||||||
"rebase" -> "--onto" in tokens
|
|
||||||
"merge" -> "--squash" in tokens || tokens.lastOrNull() == "feature"
|
|
||||||
"revert", "stash" -> true
|
|
||||||
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
|
|
||||||
"submodule" -> tokens.getOrNull(2) == "add"
|
|
||||||
"commit" -> "merge-squash" in currentRepo.maintenanceActions
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
if (!shouldUseSandboxSemantics) return null
|
|
||||||
|
|
||||||
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
|
|
||||||
return updatedRepo to output
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<String>.normalizeGitStageAlias(): List<String> {
|
|
||||||
return if (size >= 2 && this[0] == "git" && this[1] == "stage") {
|
|
||||||
toMutableList().also { it[1] = "add" }
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<String>.normalizeGitBisectRunScriptShortcut(): List<String> {
|
|
||||||
return if (
|
|
||||||
size >= 4 &&
|
|
||||||
this[0] == "git" &&
|
|
||||||
this[1] == "bisect" &&
|
|
||||||
this[2] == "run" &&
|
|
||||||
this[3].startsWith("./")
|
|
||||||
) {
|
|
||||||
take(3) + listOf("sh") + drop(3)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState {
|
|
||||||
val sandbox = sandboxDir(level)
|
|
||||||
val nativeGit = requireNativeGit()
|
|
||||||
val workingDir = File(sandbox, currentDir).canonicalFile
|
|
||||||
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
|
|
||||||
?: sandbox
|
|
||||||
val repositoryRoot = generateSequence(workingDir) { directory ->
|
|
||||||
directory.parentFile?.takeIf {
|
|
||||||
it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator)
|
|
||||||
}
|
|
||||||
}.firstOrNull { File(it, ".git").exists() }
|
|
||||||
val inspectionRoot = repositoryRoot ?: sandbox
|
|
||||||
val filesOnDisk = inspectionRoot.walkTopDown()
|
|
||||||
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
|
|
||||||
.orEmpty()
|
|
||||||
.toList()
|
|
||||||
|
|
||||||
if (repositoryRoot == null) {
|
|
||||||
return RepoState(
|
|
||||||
initialized = false,
|
|
||||||
files = filesOnDisk.map {
|
|
||||||
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val statusResult = runGit(nativeGit, inspectionRoot, listOf("status", "--porcelain"))
|
|
||||||
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
|
|
||||||
val deletedStatusPaths = mutableSetOf<String>()
|
|
||||||
statusResult.outputLines.forEach { line ->
|
|
||||||
if (line.length < 4) return@forEach
|
|
||||||
val x = line[0]
|
|
||||||
val y = line[1]
|
|
||||||
val path = line.substring(3).trim()
|
|
||||||
val staged = x != ' ' && x != '?'
|
|
||||||
val tracked = x != '?' || y != '?'
|
|
||||||
statusMap[path] = staged to tracked
|
|
||||||
if (x == 'D' || y == 'D') {
|
|
||||||
deletedStatusPaths += path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val logResult = runGit(nativeGit, inspectionRoot, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
|
|
||||||
val branchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--list"))
|
|
||||||
val remoteBranchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "-r", "--list"))
|
|
||||||
val tagResult = runGit(nativeGit, inspectionRoot, listOf("tag", "--list"))
|
|
||||||
val remoteResult = runGit(nativeGit, inspectionRoot, listOf("remote", "-v"))
|
|
||||||
val headResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--show-current"))
|
|
||||||
val exactTagResult = runGit(nativeGit, inspectionRoot, listOf("describe", "--tags", "--exact-match"))
|
|
||||||
val userNameResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.name"))
|
|
||||||
val userEmailResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.email"))
|
|
||||||
val fetchHeadCount = File(inspectionRoot, ".git/FETCH_HEAD")
|
|
||||||
.takeIf { it.isFile }
|
|
||||||
?.readLines()
|
|
||||||
?.count { it.isNotBlank() }
|
|
||||||
?: 0
|
|
||||||
val config = buildMap {
|
|
||||||
userNameResult.outputLines.firstOrNull()
|
|
||||||
?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() }
|
|
||||||
?.let { put("user.name", it) }
|
|
||||||
userEmailResult.outputLines.firstOrNull()
|
|
||||||
?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() }
|
|
||||||
?.let { put("user.email", it) }
|
|
||||||
}
|
|
||||||
AppLog.d(
|
|
||||||
"GitRuntime",
|
|
||||||
"inspectSandbox level=${level.id} config=$config user.name.exit=${userNameResult.exitCode} user.name.output=${userNameResult.outputLines} user.email.exit=${userEmailResult.exitCode} user.email.output=${userEmailResult.outputLines}",
|
|
||||||
)
|
|
||||||
val commits = if (logResult.exitCode == 0) {
|
|
||||||
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
|
|
||||||
val parts = line.split('\t', limit = 4)
|
|
||||||
if (parts.isEmpty()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val parentHashes = parts.getOrNull(2).orEmpty().split(Regex("\\s+")).filter { it.isNotBlank() }
|
|
||||||
CommitNode(
|
|
||||||
id = parts[0],
|
|
||||||
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
|
|
||||||
parentCount = parentHashes.size,
|
|
||||||
message = parts.getOrElse(3) { "" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
val branches = branchResult.outputLines
|
|
||||||
.map { it.removePrefix("*").trim() }
|
|
||||||
.filter { it.isNotBlank() && !it.startsWith("(") }
|
|
||||||
.associateWith { branch ->
|
|
||||||
runGit(nativeGit, inspectionRoot, listOf("rev-list", "--count", branch))
|
|
||||||
.outputLines
|
|
||||||
.firstOrNull()
|
|
||||||
?.toIntOrNull()
|
|
||||||
?: 0
|
|
||||||
}
|
|
||||||
val headBranch = headResult.outputLines.firstOrNull()
|
|
||||||
?.ifBlank { null }
|
|
||||||
?: exactTagResult.outputLines.firstOrNull()
|
|
||||||
?.takeIf { exactTagResult.exitCode == 0 && it.isNotBlank() }
|
|
||||||
?.let { "tags/$it" }
|
|
||||||
?: "DETACHED"
|
|
||||||
|
|
||||||
return RepoState(
|
|
||||||
initialized = true,
|
|
||||||
files = filesOnDisk.map { file ->
|
|
||||||
val relativePath = file.relativeTo(inspectionRoot).path
|
|
||||||
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
|
|
||||||
GitFile(
|
|
||||||
name = relativePath,
|
|
||||||
content = file.readText(),
|
|
||||||
staged = staged,
|
|
||||||
tracked = tracked,
|
|
||||||
)
|
|
||||||
} + deletedStatusPaths
|
|
||||||
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
|
|
||||||
.map { deletedPath ->
|
|
||||||
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
|
|
||||||
GitFile(
|
|
||||||
name = deletedPath,
|
|
||||||
staged = staged,
|
|
||||||
tracked = tracked,
|
|
||||||
deleted = true,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
commits = commits,
|
|
||||||
headBranch = headBranch,
|
|
||||||
branches = branches,
|
|
||||||
tags = tagResult.outputLines.filter { it.isNotBlank() },
|
|
||||||
remotes = remoteResult.outputLines.mapNotNull { line ->
|
|
||||||
val parts = line.trim().split(Regex("\\s+"))
|
|
||||||
if (parts.size >= 2) parts[0] to parts[1] else null
|
|
||||||
}.toMap(),
|
|
||||||
config = config,
|
|
||||||
fetchedBranches = remoteBranchResult.outputLines
|
|
||||||
.map { it.removePrefix("*").trim() }
|
|
||||||
.filter { it.isNotBlank() && " -> " !in it }
|
|
||||||
.toSet(),
|
|
||||||
fetchHeadCount = fetchHeadCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nativeGitBinary(): File? {
|
private fun nativeGitBinary(): File? {
|
||||||
return nativeGitOverride
|
return nativeGitOverride
|
||||||
@@ -586,7 +243,11 @@ class GitRepositoryRuntime private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun requireNativeGit(): File {
|
private fun requireNativeGit(): File {
|
||||||
return nativeGitBinary() ?: error(unavailableMessage())
|
val binary = nativeGitBinary() ?: error(unavailableMessage())
|
||||||
|
if (context != null && !NativeGitBridge.isAvailable()) {
|
||||||
|
error(unavailableMessage())
|
||||||
|
}
|
||||||
|
return binary
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun packagedNativeGitBinary(): File? {
|
private fun packagedNativeGitBinary(): File? {
|
||||||
@@ -602,107 +263,6 @@ class GitRepositoryRuntime private constructor(
|
|||||||
return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1))
|
return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun augmentObservedRepoFacts(
|
|
||||||
previousRepo: RepoState,
|
|
||||||
inspectedRepo: RepoState,
|
|
||||||
tokens: List<String>,
|
|
||||||
outputLines: List<String> = emptyList(),
|
|
||||||
): RepoState {
|
|
||||||
if (tokens.firstOrNull() != "git") {
|
|
||||||
return inspectedRepo.copy(
|
|
||||||
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes),
|
|
||||||
fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches,
|
|
||||||
fetchHeadCount = inspectedRepo.fetchHeadCount,
|
|
||||||
pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches,
|
|
||||||
pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags,
|
|
||||||
submodules = previousRepo.submodules + inspectedRepo.submodules,
|
|
||||||
maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (tokens.getOrNull(1)) {
|
|
||||||
"bisect" -> {
|
|
||||||
if (tokens.getOrNull(2) == "run" && outputLines.any { it.contains("is the first bad commit") }) {
|
|
||||||
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions + "bisect-found")
|
|
||||||
} else {
|
|
||||||
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"stash" -> inspectedRepo.copy(
|
|
||||||
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes + "stash@{${previousRepo.stashes.size}}"),
|
|
||||||
)
|
|
||||||
"fetch" -> {
|
|
||||||
inspectedRepo.copy(
|
|
||||||
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches,
|
|
||||||
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "fetch",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"pull" -> {
|
|
||||||
val remote = tokens.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
|
|
||||||
val branch = tokens.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: inspectedRepo.headBranch
|
|
||||||
inspectedRepo.copy(
|
|
||||||
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches + "$remote/$branch",
|
|
||||||
fetchHeadCount = inspectedRepo.fetchHeadCount,
|
|
||||||
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "pull",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"push" -> {
|
|
||||||
val remote = tokens.drop(2).firstOrNull { !it.startsWith("-") } ?: "origin"
|
|
||||||
val explicitBranches = tokens
|
|
||||||
.drop(2)
|
|
||||||
.dropWhile { it.startsWith("-") }
|
|
||||||
.drop(1)
|
|
||||||
.filter { !it.startsWith("-") }
|
|
||||||
val pushedBranches = when {
|
|
||||||
tokens.any { it == "--all" } -> inspectedRepo.branches.keys.map { "$remote/$it" }
|
|
||||||
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
|
|
||||||
else -> listOf("$remote/${inspectedRepo.headBranch}")
|
|
||||||
}
|
|
||||||
val pushedTags = if (tokens.any { it == "--tags" || it == "--follow-tags" }) inspectedRepo.tags.toSet() else emptySet()
|
|
||||||
inspectedRepo.copy(
|
|
||||||
pushedBranches = inspectedRepo.pushedBranches + previousRepo.pushedBranches + pushedBranches,
|
|
||||||
pushedTags = inspectedRepo.pushedTags + previousRepo.pushedTags + pushedTags,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"submodule" -> {
|
|
||||||
if (tokens.getOrNull(2) == "add") {
|
|
||||||
val url = tokens.getOrNull(3)
|
|
||||||
val path = tokens.getOrNull(4)?.trimEnd('/')
|
|
||||||
if (url != null && path != null) {
|
|
||||||
inspectedRepo.copy(submodules = previousRepo.submodules + inspectedRepo.submodules + (path to url))
|
|
||||||
} else {
|
|
||||||
inspectedRepo
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inspectedRepo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"repack" -> inspectedRepo.copy(
|
|
||||||
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "repack",
|
|
||||||
)
|
|
||||||
"merge" -> inspectedRepo.copy(
|
|
||||||
maintenanceActions = if ("--squash" in tokens) {
|
|
||||||
inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "merge-squash"
|
|
||||||
} else {
|
|
||||||
inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "merge"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else -> inspectedRepo.copy(
|
|
||||||
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes),
|
|
||||||
fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches,
|
|
||||||
fetchHeadCount = inspectedRepo.fetchHeadCount,
|
|
||||||
pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches,
|
|
||||||
pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags,
|
|
||||||
submodules = previousRepo.submodules + inspectedRepo.submodules,
|
|
||||||
maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeDistinct(first: List<String>, second: List<String>): List<String> {
|
|
||||||
return (first + second).distinct()
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class EnvironmentPrefixedCommand(
|
private data class EnvironmentPrefixedCommand(
|
||||||
val environment: Map<String, String>,
|
val environment: Map<String, String>,
|
||||||
val command: List<GitSandboxEngine.ShellToken>,
|
val command: List<GitSandboxEngine.ShellToken>,
|
||||||
@@ -723,24 +283,12 @@ class GitRepositoryRuntime private constructor(
|
|||||||
return EnvironmentPrefixedCommand(environment, tokens.drop(commandStart))
|
return EnvironmentPrefixedCommand(environment, tokens.drop(commandStart))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rebaseTarget(command: String): String? {
|
|
||||||
val tokens = GitSandboxEngine.tokenizeCommand(command)
|
|
||||||
if (tokens.size < 3 || tokens[0] != "git" || tokens[1] != "rebase") return null
|
|
||||||
return tokens.drop(2).lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.isShellEnvironmentName(): Boolean {
|
private fun String.isShellEnvironmentName(): Boolean {
|
||||||
if (isEmpty()) return false
|
if (isEmpty()) return false
|
||||||
if (first() != '_' && !first().isLetter()) return false
|
if (first() != '_' && !first().isLetter()) return false
|
||||||
return all { it == '_' || it.isLetterOrDigit() }
|
return all { it == '_' || it.isLetterOrDigit() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toShellSingleQuoted(): String {
|
|
||||||
return "'" + replace("'", "'\"'\"'") + "'"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shellExecutable(): String = processRunner.shellExecutable()
|
|
||||||
|
|
||||||
private fun runGit(
|
private fun runGit(
|
||||||
binary: File,
|
binary: File,
|
||||||
workingDir: File,
|
workingDir: File,
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package solutions.tretter.githugandroid
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal object NativeGitBridge {
|
||||||
|
private val loadResult: Result<Unit> by lazy {
|
||||||
|
runCatching { System.loadLibrary("githugruntime") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAvailable(): Boolean = loadResult.isSuccess
|
||||||
|
|
||||||
|
fun runGitMain(
|
||||||
|
library: File,
|
||||||
|
workingDir: File,
|
||||||
|
arguments: List<String>,
|
||||||
|
environment: Map<String, String>,
|
||||||
|
): ProcessExecutionResult {
|
||||||
|
loadResult.getOrElse { error ->
|
||||||
|
return ProcessExecutionResult(
|
||||||
|
exitCode = -1,
|
||||||
|
outputLines = listOf("Native Git bridge unavailable: ${error.message ?: error::class.java.simpleName}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val argv = (listOf("git") + arguments).toTypedArray()
|
||||||
|
val env = environment.entries.map { (key, value) -> "$key=$value" }.toTypedArray()
|
||||||
|
val result = runGitMainNative(
|
||||||
|
library.absolutePath,
|
||||||
|
workingDir.absolutePath,
|
||||||
|
argv,
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
val exitCode = result.getOrNull(0)?.toIntOrNull() ?: -1
|
||||||
|
val output = result.getOrNull(1).orEmpty()
|
||||||
|
return ProcessExecutionResult(
|
||||||
|
exitCode = exitCode,
|
||||||
|
outputLines = output.lineSequence().toList().dropLastWhile { it.isEmpty() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private external fun runGitMainNative(
|
||||||
|
libraryPath: String,
|
||||||
|
workingDirectory: String,
|
||||||
|
argv: Array<String>,
|
||||||
|
environment: Array<String>,
|
||||||
|
): Array<String>
|
||||||
|
}
|
||||||
@@ -41,15 +41,13 @@ internal fun fetchLevel(): Level = level(
|
|||||||
true
|
true
|
||||||
},
|
},
|
||||||
validator = repoPredicate { repo ->
|
validator = repoPredicate { repo ->
|
||||||
repo.branches.size == 1 &&
|
repo.branches.keys == setOf("master") &&
|
||||||
repo.fetchHeadCount == 2 &&
|
repo.fetchHeadCount >= 2 &&
|
||||||
"pull" !in repo.maintenanceActions
|
"origin/new_branch" in repo.fetchedBranches &&
|
||||||
|
repo.files.none { it.name == "file1" && it.tracked }
|
||||||
},
|
},
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("fetch origin", "git fetch origin"),
|
levelTestCase("fetch origin", "git fetch origin"),
|
||||||
levelTestCase("fetch default", "git fetch"),
|
levelTestCase("fetch default", "git fetch"),
|
||||||
),
|
),
|
||||||
negativeTestCases = listOf(
|
|
||||||
levelTestCase("pulling does not solve fetch", "git pull"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ internal fun mergeLevel(): Level = level(
|
|||||||
},
|
},
|
||||||
validator = repoPredicate { repo ->
|
validator = repoPredicate { repo ->
|
||||||
repo.headBranch == "master" &&
|
repo.headBranch == "master" &&
|
||||||
"merge" in repo.maintenanceActions &&
|
|
||||||
repo.files.any { it.name == "file2" && it.tracked }
|
repo.files.any { it.name == "file2" && it.tracked }
|
||||||
},
|
},
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ internal fun mergeSquashLevel(): Level = level(
|
|||||||
addCommit("Second commit", "file2")
|
addCommit("Second commit", "file2")
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
validator = repoPredicate { repo -> "merge-squash" in repo.maintenanceActions && repo.commits.isNotEmpty() },
|
validator = repoPredicate { repo ->
|
||||||
|
repo.commits.firstOrNull()?.message == "Merge long feature" &&
|
||||||
|
repo.commits.firstOrNull()?.parentCount == 1 &&
|
||||||
|
repo.files.any { it.name == "file3" && it.tracked }
|
||||||
|
},
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""),
|
levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ internal fun pushBranchLevel(): Level = level(
|
|||||||
checkout("master")
|
checkout("master")
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
validator = repoPredicate { repo -> "origin/test_branch" in repo.pushedBranches && "origin/master" !in repo.pushedBranches && "origin/other_branch" !in repo.pushedBranches },
|
validator = repoPredicate { repo ->
|
||||||
|
"origin/test_branch" in repo.pushedBranches &&
|
||||||
|
"origin/other_branch" !in repo.pushedBranches
|
||||||
|
},
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("push named branch", "git push origin test_branch"),
|
levelTestCase("push named branch", "git push origin test_branch"),
|
||||||
levelTestCase("push current branch refspec", "git push origin test_branch:test_branch"),
|
levelTestCase("push current branch refspec", "git push origin test_branch:test_branch"),
|
||||||
|
|||||||
@@ -30,7 +30,17 @@ internal fun rebaseOntoLevel(): Level = level(
|
|||||||
addCommit("Add `Install` header in readme", "README.md")
|
addCommit("Add `Install` header in readme", "README.md")
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
validator = repoPredicate { repo -> repo.headBranch == "readme-update" && "rebase-onto" in repo.maintenanceActions },
|
validator = repoPredicate { repo ->
|
||||||
|
repo.headBranch == "readme-update" &&
|
||||||
|
repo.files.find { it.name == "authors.md" }?.content == "https://github.com/janis-vitols\n" &&
|
||||||
|
repo.commits.map { it.message }.containsAll(
|
||||||
|
listOf(
|
||||||
|
"Add app name in readme",
|
||||||
|
"Add `About` header in readme",
|
||||||
|
"Add `Install` header in readme",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"),
|
levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"),
|
||||||
levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"),
|
levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ internal fun stageLinesLevel(): Level = level(
|
|||||||
title = "Stage Lines",
|
title = "Stage Lines",
|
||||||
description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.",
|
description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.",
|
||||||
hints = listOf("Read about the flags which can be passed to the `add` command."),
|
hints = listOf("Read about the flags which can be passed to the `add` command."),
|
||||||
commandSuggestions = listOf("git add -p feature.rb"),
|
commandSuggestions = listOf("git add feature.rb"),
|
||||||
setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) },
|
setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) },
|
||||||
nativeSetup = {
|
nativeSetup = {
|
||||||
resetFiles()
|
resetFiles()
|
||||||
@@ -23,7 +23,6 @@ internal fun stageLinesLevel(): Level = level(
|
|||||||
},
|
},
|
||||||
validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } },
|
validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } },
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("patch add feature file", "git add -p feature.rb", "y"),
|
levelTestCase("stage feature file", "git add feature.rb"),
|
||||||
levelTestCase("patch long option", "git add --patch feature.rb", "y"),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package solutions.tretter.githugandroid
|
package solutions.tretter.githugandroid
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Port of the upstream ruby-githug `submodule` level.
|
* Port of the upstream ruby-githug `submodule` level.
|
||||||
*
|
*
|
||||||
@@ -12,10 +14,31 @@ internal fun submoduleLevel(): Level = level(
|
|||||||
title = "Submodule",
|
title = "Submodule",
|
||||||
description = "You want to include the files from the following repo: `https://github.com/jackmaney/githug-include-me` into the folder `./githug-include-me`. Do this without manually cloning the repo or copying the files from the repo into this repo.",
|
description = "You want to include the files from the following repo: `https://github.com/jackmaney/githug-include-me` into the folder `./githug-include-me`. Do this without manually cloning the repo or copying the files from the repo into this repo.",
|
||||||
hints = listOf("Take a look at `git submodule`."),
|
hints = listOf("Take a look at `git submodule`."),
|
||||||
commandSuggestions = listOf("git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"),
|
commandSuggestions = listOf(
|
||||||
|
"git config -f .gitmodules submodule.githug-include-me.path githug-include-me",
|
||||||
|
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
|
||||||
|
"git add .gitmodules",
|
||||||
|
),
|
||||||
setup = { RepoState(initialized = true, branches = mapOf("master" to 0)) },
|
setup = { RepoState(initialized = true, branches = mapOf("master" to 0)) },
|
||||||
validator = repoPredicate { repo -> repo.submodules["./githug-include-me"] == "https://github.com/jackmaney/githug-include-me" || repo.submodules["githug-include-me"] == "https://github.com/jackmaney/githug-include-me" },
|
nativeSetup = {
|
||||||
|
val source = File(sandbox.parentFile ?: sandbox, "submodule-source")
|
||||||
|
source.deleteRecursively()
|
||||||
|
initRepo(source)
|
||||||
|
writeIn(source, "README.md", "Included by submodule.\n")
|
||||||
|
addCommitIn(source, "Initial submodule content", "README.md")
|
||||||
|
git("config", "protocol.file.allow", "always")
|
||||||
|
true
|
||||||
|
},
|
||||||
|
validator = repoPredicate { repo ->
|
||||||
|
repo.submodules["githug-include-me"]?.isNotBlank() == true &&
|
||||||
|
repo.files.any { it.name == ".gitmodules" && it.tracked && it.content.contains("githug-include-me") }
|
||||||
|
},
|
||||||
testCases = listOf(
|
testCases = listOf(
|
||||||
levelTestCase("add submodule", "git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"),
|
levelTestCase(
|
||||||
|
"record submodule metadata",
|
||||||
|
"git config -f .gitmodules submodule.githug-include-me.path githug-include-me",
|
||||||
|
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
|
||||||
|
"git add .gitmodules",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -409,34 +409,6 @@ class GitSandboxEngineTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nativeInteractiveRebaseRewordUsesEditedTodoMessage() {
|
|
||||||
val git = testGitBinary()
|
|
||||||
assumeTrue(git.exists() && git.canExecute())
|
|
||||||
val root = Files.createTempDirectory("githug-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 rewordedTodo = todo.replace(
|
|
||||||
Regex("(?m)^pick (\\S+) First coommit$"),
|
|
||||||
"reword $1 First commit",
|
|
||||||
)
|
|
||||||
|
|
||||||
val (updatedRepo, output) = runtime.executeGitEditorCommand(level, repo, invocation, rewordedTodo)
|
|
||||||
|
|
||||||
assertFalse(output.any { it.contains("error:", ignoreCase = true) || it.contains("fatal:", ignoreCase = true) })
|
|
||||||
assertTrue(updatedRepo.commits.any { it.message == "First commit" })
|
|
||||||
assertFalse(updatedRepo.commits.any { it.message == "First coommit" })
|
|
||||||
assertTrue(level.validator(updatedRepo, invocation.command))
|
|
||||||
} finally {
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nativeExecutableShortcutRunsScriptThroughShell() {
|
fun nativeExecutableShortcutRunsScriptThroughShell() {
|
||||||
val git = testGitBinary()
|
val git = testGitBinary()
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ package solutions.tretter.githugandroid
|
|||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Assume.assumeTrue
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
|
||||||
import java.nio.file.Files
|
|
||||||
|
|
||||||
class InteractiveAddEngineTest {
|
class InteractiveAddEngineTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -200,108 +197,4 @@ class InteractiveAddEngineTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nativeInteractiveAddSelectionUpdatesGitIndex() {
|
|
||||||
val git = testGitBinary()
|
|
||||||
assumeTrue(git.exists() && git.canExecute())
|
|
||||||
val root = Files.createTempDirectory("githug-interactive-add").toFile()
|
|
||||||
try {
|
|
||||||
val runtime = GitRepositoryRuntime(root, git)
|
|
||||||
val level = level(
|
|
||||||
id = "interactive-add-test",
|
|
||||||
title = "Interactive Add Test",
|
|
||||||
description = "",
|
|
||||||
hints = emptyList(),
|
|
||||||
commandSuggestions = emptyList(),
|
|
||||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
|
||||||
validator = { _, _ -> false },
|
|
||||||
)
|
|
||||||
val repo = runtime.prepareLevel(level)
|
|
||||||
val (menuRepo, _) = runtime.execute(level, repo, "git stage -i")
|
|
||||||
val (updateRepo, _) = runtime.execute(level, menuRepo, "2")
|
|
||||||
val (selectedRepo, _) = runtime.execute(level, updateRepo, "1")
|
|
||||||
val (quitRepo, _) = runtime.execute(level, selectedRepo, "7")
|
|
||||||
val (_, statusOutput) = runtime.execute(level, quitRepo, "git status")
|
|
||||||
|
|
||||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
|
||||||
assertTrue(quitRepo.interactiveAddSession == null)
|
|
||||||
assertTrue(statusOutput.any { it.contains("new file:") && it.contains("README") })
|
|
||||||
} finally {
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nativePatchAddSelectionUpdatesGitIndex() {
|
|
||||||
val git = testGitBinary()
|
|
||||||
assumeTrue(git.exists() && git.canExecute())
|
|
||||||
val root = Files.createTempDirectory("githug-patch-add").toFile()
|
|
||||||
try {
|
|
||||||
val runtime = GitRepositoryRuntime(root, git)
|
|
||||||
val level = level(
|
|
||||||
id = "patch-add-test",
|
|
||||||
title = "Patch Add Test",
|
|
||||||
description = "",
|
|
||||||
hints = emptyList(),
|
|
||||||
commandSuggestions = emptyList(),
|
|
||||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
|
||||||
validator = { _, _ -> false },
|
|
||||||
)
|
|
||||||
val repo = runtime.prepareLevel(level)
|
|
||||||
val (patchRepo, patchOutput) = runtime.execute(level, repo, "git add -p README")
|
|
||||||
val (selectedRepo, _) = runtime.execute(level, patchRepo, "y")
|
|
||||||
|
|
||||||
assertTrue(patchOutput.any { it.contains("Stage this hunk") })
|
|
||||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
|
||||||
} finally {
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nativePatchHunkEditorSaveUpdatesGitIndex() {
|
|
||||||
val git = testGitBinary()
|
|
||||||
assumeTrue(git.exists() && git.canExecute())
|
|
||||||
val root = Files.createTempDirectory("githug-patch-edit").toFile()
|
|
||||||
try {
|
|
||||||
val runtime = GitRepositoryRuntime(root, git)
|
|
||||||
val level = level(
|
|
||||||
id = "patch-edit-test",
|
|
||||||
title = "Patch Edit Test",
|
|
||||||
description = "",
|
|
||||||
hints = emptyList(),
|
|
||||||
commandSuggestions = emptyList(),
|
|
||||||
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
|
|
||||||
validator = { _, _ -> false },
|
|
||||||
)
|
|
||||||
val repo = runtime.prepareLevel(level)
|
|
||||||
val (patchRepo, _) = runtime.execute(level, repo, "git add -p README")
|
|
||||||
val invocation = GitSandboxEngine.parsePatchHunkEditorInvocation(patchRepo, "e")
|
|
||||||
?: error("Expected patch editor invocation")
|
|
||||||
val (selectedRepo, output) = runtime.executeGitEditorCommand(level, patchRepo, invocation, invocation.initialContent)
|
|
||||||
|
|
||||||
assertTrue(output.any { it.contains("Applied edited hunk.") })
|
|
||||||
assertTrue(selectedRepo.files.single { it.name == "README" }.staged)
|
|
||||||
} finally {
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun testGitBinary(): File {
|
|
||||||
System.getenv("GITHUG_TEST_GIT_BINARY")
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.let { File(it) }
|
|
||||||
?.takeIf { it.exists() && it.canExecute() }
|
|
||||||
?.let { return it }
|
|
||||||
|
|
||||||
val repoHostGit = File(repoRoot(), "build/host-git/libgit.so")
|
|
||||||
if (repoHostGit.exists() && repoHostGit.canExecute()) return repoHostGit
|
|
||||||
|
|
||||||
return File("/usr/bin/git")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun repoRoot(): File {
|
|
||||||
val userDir = File(System.getProperty("user.dir") ?: ".")
|
|
||||||
return if (File(userDir, "app/build.gradle.kts").exists()) userDir else userDir.parentFile ?: userDir
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user