Call Git cmd_main directly

This commit is contained in:
Joe Tretter
2026-06-24 13:16:35 -05:00
parent a9d7b0cfc0
commit 0554c736e4
26 changed files with 1141 additions and 661 deletions

3
.gitignore vendored
View File

@@ -2,6 +2,9 @@
.gradle-user-home/
build/
app/build/
app/.cxx/
app/src/main/jniLibs/*/libgit.so
app/src/main/jniLibs/*/.source-fingerprint
local.properties
.idea/
android-sdk/

View File

@@ -22,6 +22,7 @@ HOST_GIT_DIR="$PROJECT_DIR/build/host-git"
HOST_GIT_STAMP="$HOST_GIT_DIR/.source-fingerprint"
ANDROID_JNI_DIR="$PROJECT_DIR/app/src/main/jniLibs"
ANDROID_ASSET_MANPAGE_DIR="$PROJECT_DIR/app/src/main/assets/manpages"
GIT_PATCH_DIR="$PROJECT_DIR/patches/git"
ANDROID_API=24
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"
ANDROID_NDK_PACKAGE="ndk;27.2.12479018"
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_CMAKE_DIR="$SDK_DIR/cmake/3.22.1"
ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin"
@@ -86,6 +89,12 @@ required_sdk_packages_present() {
&& [ -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() {
if ! command -v git >/dev/null 2>&1; then
log "Git is not available; skipping automatic commit"
@@ -309,6 +318,36 @@ ensure_git_source() {
else
log "Using existing Git source checkout"
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() {
@@ -329,7 +368,8 @@ common_git_make_args() {
HAVE_CLOCK_MONOTONIC=YesPlease \
HAVE_GETDELIM=YesPlease \
FREAD_READS_DIRECTORIES=UnfortunatelyYes \
CSPRNG_METHOD=
CSPRNG_METHOD= \
LDFLAGS=-Wl,--export-dynamic
}
bundle_git_manpages() {
@@ -350,9 +390,11 @@ git_source_fingerprint() {
local head
local tracked_changes
local make_args
head="$(git -C "$GIT_SRC_DIR" rev-parse HEAD)"
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() {
@@ -507,6 +549,124 @@ ensure_sdk_packages() {
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() {
local mode="${1:-}"
local gradle_task=""
@@ -516,7 +676,9 @@ maybe_run_operation() {
local should_bump_version="false"
local should_auto_commit="false"
local should_compile_host_git="false"
local should_compile_android_git="false"
local should_bundle_manpages="false"
local should_run_emulator="false"
case "$mode" in
--build)
@@ -526,6 +688,7 @@ maybe_run_operation() {
artifact_target="$PROJECT_DIR/app/build/outputs/apk/debug/githug-android-debug.apk"
should_bump_version="true"
should_auto_commit="true"
should_compile_android_git="true"
should_bundle_manpages="true"
;;
--build-release-aab)
@@ -535,6 +698,7 @@ maybe_run_operation() {
artifact_target="$PROJECT_DIR/app/build/outputs/bundle/release/githug-android-release.aab"
should_bump_version="true"
should_auto_commit="true"
should_compile_android_git="true"
should_bundle_manpages="true"
;;
--test)
@@ -542,6 +706,13 @@ maybe_run_operation() {
artifact_label="debug unit tests"
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)
build_all_git_targets
return
@@ -571,10 +742,17 @@ maybe_run_operation() {
build_host_git
export GITHUG_TEST_GIT_BINARY="$HOST_GIT_BINARY"
fi
if [ "$should_compile_android_git" = "true" ]; then
build_android_git
fi
if [ "$should_bundle_manpages" = "true" ]; then
ensure_git_source
bundle_git_manpages
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"
"$PROJECT_DIR/gradlew" --no-daemon "$gradle_task"
@@ -593,22 +771,25 @@ maybe_run_operation() {
log "Stopping any Gradle daemons just in case"
"$PROJECT_DIR/gradlew" --stop >/dev/null 2>&1 || true
stop_emulator_if_started
trap - EXIT
}
print_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-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-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
EOF_USAGE
}
validate_args() {
case "${1:-}" in
""|--build|--build-release-aab|--test|--compile-git)
""|--build|--build-release-aab|--test|--test-emulator|--compile-git)
;;
*)
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 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 instrumentation tests on an emulator: bash ./AndroidProjectTooling.sh --test-emulator"
}
main "$@"

View File

@@ -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. |
| `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. |
| `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
@@ -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. |
| `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. |
| `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_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. |
@@ -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. |
| `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. |
| `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. |
| `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. |
@@ -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. |
| `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. |
| `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. |
| `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_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. |
| `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. |
@@ -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. |
| `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. |
| `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
@@ -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 `.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`
@@ -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.
- `merge_squash`: verify the exact squashed file/content effects.
- `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.
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.

View File

@@ -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 --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-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 |
@@ -57,6 +58,12 @@ To run the JVM unit test suite after ensuring the local toolchain is ready:
bash ./AndroidProjectTooling.sh --test
```
To run instrumentation tests on an Android emulator:
```bash
bash ./AndroidProjectTooling.sh --test-emulator
```
To build installable/debuggable artifacts:
```bash
@@ -81,6 +88,7 @@ When `--build` or `--build-release-aab` is used, the script also:
- increments `versionCode` by 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
- 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
@@ -104,9 +112,10 @@ Options:
| 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-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 |
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/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 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
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.
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.
@@ -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. |
| `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. |
| `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. |
## Level Authoring

View File

@@ -14,16 +14,23 @@ plugins {
android {
namespace = "solutions.tretter.githugandroid"
compileSdk = 35
ndkVersion = "27.2.12479018"
defaultConfig {
applicationId = "solutions.tretter.githugandroid"
minSdk = 26
targetSdk = 35
versionCode = 171
versionName = "0.1.170"
versionCode = 172
versionName = "0.1.171"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
externalNativeBuild {
cmake {
arguments += "-DANDROID_STL=none"
}
}
}
signingConfigs {
@@ -71,6 +78,12 @@ android {
kotlinCompilerExtensionVersion = "1.5.14"
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
}
packaging {
jniLibs {
useLegacyPackaging = true
@@ -103,6 +116,8 @@ dependencies {
implementation("com.google.android.material:material:1.12.0")
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-test-manifest")

View File

@@ -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"))
}
}

View 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)

View 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;
}

View File

@@ -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("'", "'\"'\"'") + "'"
}
}

View File

@@ -54,6 +54,10 @@ internal class GitProcessRunner(
arguments: List<String>,
environment: Map<String, String> = emptyMap(),
): ProcessExecutionResult {
if (context != null) {
gitExecDirectory(binary)
return NativeGitBridge.runGitMain(binary, workingDir, arguments, gitEnvironment(binary, workingDir, environment))
}
return runProcess(binary, workingDir, arguments, environment)
}
@@ -88,19 +92,7 @@ internal class GitProcessRunner(
.directory(workingDir)
.redirectErrorStream(true)
.apply {
environment()["HOME"] = workingDir.absolutePath
environment()["GIT_EXEC_PATH"] = gitExecPath.absolutePath
environment()["PATH"] = listOfNotNull(
gitExecPath.absolutePath,
System.getenv("PATH")?.takeIf { it.isNotBlank() },
).joinToString(File.pathSeparator)
environment()["GIT_CONFIG_NOSYSTEM"] = "1"
environment()["GIT_AUTHOR_NAME"] = "GitHug"
environment()["GIT_AUTHOR_EMAIL"] = "githug@example.com"
environment()["GIT_COMMITTER_NAME"] = "GitHug"
environment()["GIT_COMMITTER_EMAIL"] = "githug@example.com"
environment()["LC_ALL"] = "C"
environment().putAll(extraEnvironment)
environment().putAll(gitEnvironment(binary, workingDir, extraEnvironment, gitExecPath))
}
.start()
@@ -137,6 +129,32 @@ internal class GitProcessRunner(
return directory
}
private fun gitEnvironment(
binary: File,
workingDir: File,
extraEnvironment: Map<String, String>,
gitExecPath: File = gitExecDirectory(binary),
): Map<String, String> {
return buildMap {
put("HOME", workingDir.absolutePath)
put("GIT_EXEC_PATH", gitExecPath.absolutePath)
put(
"PATH",
listOfNotNull(
gitExecPath.absolutePath,
System.getenv("PATH")?.takeIf { it.isNotBlank() },
).joinToString(File.pathSeparator),
)
put("GIT_CONFIG_NOSYSTEM", "1")
put("GIT_AUTHOR_NAME", "GitHug")
put("GIT_AUTHOR_EMAIL", "githug@example.com")
put("GIT_COMMITTER_NAME", "GitHug")
put("GIT_COMMITTER_EMAIL", "githug@example.com")
put("LC_ALL", "C")
putAll(extraEnvironment)
}
}
private fun refreshGitExecDirectoryIfNeeded(directory: File, binary: File) {
val fingerprint = buildString {
append(binary.absolutePath)
@@ -161,7 +179,6 @@ internal class GitProcessRunner(
if (context != null) {
try {
Os.symlink(binary.absolutePath, alias.absolutePath)
alias.setExecutable(true, false)
return
} catch (_: Exception) {
// Fall through to the portable options below.

View File

@@ -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,
)
}

View File

@@ -12,6 +12,15 @@ class GitRepositoryRuntime private constructor(
private val processRunner = GitProcessRunner(context, sandboxesRoot)
private val helperCommands = GitHelperCommands(processRunner)
private val levelMaterializer = GitLevelMaterializer(::runGit)
private val repositoryInspector = GitRepositoryInspector(::requireNativeGit, ::runGit)
private val editorWorkflow = GitEditorWorkflow(
requireNativeGit = ::requireNativeGit,
prepareLevel = ::prepareLevel,
sandboxDir = ::sandboxDir,
inspectSandbox = ::inspectSandbox,
runGit = ::runGit,
shellExecutable = processRunner::shellExecutable,
)
constructor(context: Context) : this(
context = context.applicationContext,
@@ -25,7 +34,9 @@ class GitRepositoryRuntime private constructor(
sandboxesRoot = sandboxesRoot,
)
fun isNativeGitAvailable(): Boolean = nativeGitBinary() != null
fun isNativeGitAvailable(): Boolean {
return nativeGitBinary() != null && (context == null || NativeGitBridge.isAvailable())
}
fun unavailableMessage(): String {
val selectedAbi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
@@ -43,6 +54,9 @@ class GitRepositoryRuntime private constructor(
append("\nPlatform: Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})")
append("\nDevice: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}; ${Build.HARDWARE})")
append("\nExpected binary: $nativeLibraryDir/libgit.so")
if (context != null && !NativeGitBridge.isAvailable()) {
append("\nNative bridge: unavailable")
}
append("\n\nPlease report this information to the developer so support can be added for this device/platform.")
append("\nInstall a build that bundles the cross-compiled Git binary for this device.")
}
@@ -101,29 +115,11 @@ class GitRepositoryRuntime private constructor(
val invocation = parseEnvironmentPrefixedCommand(shellTokens)
val tokens = invocation.command.map { it.value }
if (tokens.isEmpty()) return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to emptyList()
if (currentRepo.interactiveAddSession != null) {
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true
}.map { it.name }
if (newlyStagedPaths.isNotEmpty()) {
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
}
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
currentDir = updatedRepo.currentDir,
interactiveAddSession = updatedRepo.interactiveAddSession,
)
return augmentObservedRepoFacts(updatedRepo, inspectedRepo, tokens) to output
}
val expandedTokens = if (tokens.firstOrNull() == "echo") {
tokens
} else {
expandShellPathspecs(currentRepo, invocation.command)
}
.normalizeGitStageAlias()
.normalizeGitBisectRunScriptShortcut()
executeSyntheticGitCommand(currentRepo, command, expandedTokens, invocation.environment)?.let { return it }
val result = when (expandedTokens.first()) {
"git" -> currentRepo to runGit(nativeGit, workingDir, expandedTokens.drop(1), invocation.environment).outputLines
@@ -133,7 +129,7 @@ class GitRepositoryRuntime private constructor(
}
val inspectedRepo = inspectSandbox(level, result.first.currentDir).copy(currentDir = result.first.currentDir)
return augmentObservedRepoFacts(currentRepo, inspectedRepo, expandedTokens, result.second) to result.second
return inspectedRepo to result.second
}
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
@@ -165,6 +161,7 @@ class GitRepositoryRuntime private constructor(
addAll(GitSandboxEngine.commandReferenceLines())
add("Native Git runtime:")
add(" binary path: nativeLibraryDir/libgit.so")
add(" invocation: init_git(argv), then cmd_main(argc, argv)")
add(" selected ABI: ${Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"}")
add(" helper commands: ls/dir, pwd, cat, sh <script>, ./<script>, touch, mkdir/md, cd.., rm/del, echo")
add(" visual editors: vi, vim, nano, emacs, ed, ex, edit, notepad")
@@ -224,36 +221,7 @@ class GitRepositoryRuntime private constructor(
}
fun gitEditorInitialContent(level: Level, currentRepo: RepoState, invocation: GitEditorInvocation): String {
if (invocation.kind != GitEditorCommandKind.REBASE_TODO) return invocation.initialContent
val nativeGit = requireNativeGit()
val sandboxRoot = sandboxDir(level).canonicalFile
if (!sandboxRoot.exists()) {
prepareLevel(level)
}
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
val target = rebaseTarget(invocation.command) ?: return invocation.initialContent
val result = runGit(nativeGit, workingDir, listOf("log", "--reverse", "--format=%h %s", "$target..HEAD"))
if (result.exitCode != 0 || result.outputLines.isEmpty()) return invocation.initialContent
return buildString {
result.outputLines
.filter { it.isNotBlank() }
.forEach { line -> appendLine("pick $line") }
appendLine()
appendLine("# Rebase $target..HEAD onto $target")
appendLine("#")
appendLine("# Commands:")
appendLine("# p, pick <commit> = use commit")
appendLine("# r, reword <commit> = use commit and replace its message with the text on this line")
appendLine("# e, edit <commit> = use commit, but stop for amending")
appendLine("# s, squash <commit> = use commit, but meld into previous commit")
appendLine("# f, fixup [-C | -c] <commit> = like squash but keep only the previous commit's log message")
appendLine("# d, drop <commit> = remove commit")
appendLine("#")
appendLine("# These lines can be re-ordered; they are executed from top to bottom.")
}
return editorWorkflow.initialContent(level, currentRepo, invocation)
}
fun executeGitEditorCommand(
@@ -262,322 +230,11 @@ class GitRepositoryRuntime private constructor(
invocation: GitEditorInvocation,
message: String,
): Pair<RepoState, List<String>> {
val nativeGit = requireNativeGit()
val sandboxRoot = sandboxDir(level).canonicalFile
if (!sandboxRoot.exists()) {
prepareLevel(level)
}
val workingDir = File(sandboxRoot, currentRepo.currentDir).canonicalFile
.takeIf { it.path.startsWith(sandboxRoot.path) } ?: sandboxRoot
if (invocation.kind == GitEditorCommandKind.REBASE_TODO) {
return executeInteractiveRebaseEditorCommand(level, currentRepo, nativeGit, sandboxRoot, workingDir, invocation, message)
return editorWorkflow.execute(level, currentRepo, invocation, message)
}
if (invocation.kind == GitEditorCommandKind.PATCH_HUNK) {
val (updatedRepo, output) = GitSandboxEngine.applyPatchHunkEdit(currentRepo, message)
val newlyStagedPaths = updatedRepo.files.filter { updatedFile ->
updatedFile.staged && currentRepo.files.firstOrNull { it.name == updatedFile.name }?.staged != true
}.map { it.name }
if (newlyStagedPaths.isNotEmpty()) {
runGit(nativeGit, workingDir, listOf("add") + newlyStagedPaths)
}
val inspectedRepo = inspectSandbox(level, updatedRepo.currentDir).copy(
currentDir = updatedRepo.currentDir,
interactiveAddSession = updatedRepo.interactiveAddSession,
)
return inspectedRepo to output
}
val messageFile = File(sandboxRoot, ".git/GITHUG_ANDROID_EDITMSG").apply {
parentFile?.mkdirs()
writeText(message)
}
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command)
.drop(1)
.toMutableList()
.apply { addAll(listOf("-F", messageFile.absolutePath)) }
val result = runGit(nativeGit, workingDir, arguments)
messageFile.delete()
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
}
private fun executeInteractiveRebaseEditorCommand(
level: Level,
currentRepo: RepoState,
nativeGit: File,
sandboxRoot: File,
workingDir: File,
invocation: GitEditorInvocation,
todo: String,
): Pair<RepoState, List<String>> {
val gitDir = File(sandboxRoot, ".git").apply { mkdirs() }
val todoFile = File(gitDir, "GITHUG_ANDROID_REBASE_TODO").apply {
writeText(todo)
}
val rewordMessages = todo.lineSequence().mapNotNull(::rewordMessageFromTodoLine).toList()
val editorScript = File(gitDir, "githug-android-sequence-editor.sh").apply {
writeText(
"""
|#!/bin/sh
|cat ${todoFile.absolutePath.toShellSingleQuoted()} > "$1"
|""".trimMargin(),
)
setReadable(true, true)
}
val rewordMessageFiles = rewordMessages.mapIndexed { index, message ->
File(gitDir, "GITHUG_ANDROID_REWORD_${index + 1}").apply {
writeText(message.trimEnd() + "\n")
}
}
val rewordCounterFile = File(gitDir, "GITHUG_ANDROID_REWORD_COUNTER")
val messageEditorScript = File(gitDir, "githug-android-message-editor.sh").apply {
writeText(
"""
|#!/bin/sh
|counter_file=${rewordCounterFile.absolutePath.toShellSingleQuoted()}
|index=0
|if [ -f "${'$'}counter_file" ]; then
| index=$(cat "${'$'}counter_file")
|fi
|index=$((index + 1))
|printf '%s\n' "${'$'}index" > "${'$'}counter_file"
|message_file=${File(gitDir, "GITHUG_ANDROID_REWORD_").absolutePath.toShellSingleQuoted()}"${'$'}index"
|if [ -f "${'$'}message_file" ]; then
| cat "${'$'}message_file" > "$1"
|fi
|""".trimMargin(),
)
setReadable(true, true)
}
val arguments = GitSandboxEngine.tokenizeCommand(invocation.command).drop(1)
val result = runGit(
nativeGit,
workingDir,
arguments,
mapOf(
"GIT_SEQUENCE_EDITOR" to "${shellExecutable().toShellSingleQuoted()} ${editorScript.absolutePath.toShellSingleQuoted()}",
"GIT_EDITOR" to if (rewordMessages.isEmpty()) {
"true"
} else {
"${shellExecutable().toShellSingleQuoted()} ${messageEditorScript.absolutePath.toShellSingleQuoted()}"
},
),
)
todoFile.delete()
editorScript.delete()
rewordMessageFiles.forEach { it.delete() }
rewordCounterFile.delete()
messageEditorScript.delete()
return inspectSandbox(level, currentRepo.currentDir).copy(currentDir = currentRepo.currentDir) to result.outputLines
}
private fun rewordMessageFromTodoLine(line: String): String? {
val trimmed = line.trim()
if (trimmed.isEmpty() || trimmed.startsWith("#")) return null
val parts = trimmed.split(Regex("\\s+"), limit = 3)
if (parts.size < 3 || parts[0] !in setOf("r", "reword")) return null
return parts[2].takeIf { it.isNotBlank() }
}
private fun executeSyntheticGitCommand(
currentRepo: RepoState,
command: String,
tokens: List<String>,
environment: Map<String, String> = emptyMap(),
): Pair<RepoState, List<String>>? {
if (environment.isNotEmpty()) return null
if (tokens.firstOrNull() != "git") return null
val gitCommand = tokens.getOrNull(1) ?: return null
if (gitCommand == "clone" && tokens.getOrNull(2)?.startsWith("https://github.com/Gazler/cloneme") == true) {
val target = tokens.getOrNull(3) ?: "cloneme"
return currentRepo.copy(
files = currentRepo.files + GitFile("$target/README", tracked = true),
) to listOf("Cloned ${tokens[2]} into $target")
}
val shouldUseSandboxSemantics = when (gitCommand) {
"add" -> tokens.any { it == "-p" || it == "--patch" || it == "-i" || it == "--interactive" }
"rebase" -> "--onto" in tokens
"merge" -> "--squash" in tokens || tokens.lastOrNull() == "feature"
"revert", "stash" -> true
"checkout" -> tokens.any { it == "file3" } || tokens.takeLast(2) == listOf("--", "config.rb")
"submodule" -> tokens.getOrNull(2) == "add"
"commit" -> "merge-squash" in currentRepo.maintenanceActions
else -> false
}
if (!shouldUseSandboxSemantics) return null
val (updatedRepo, output) = GitSandboxEngine.execute(currentRepo, command)
return updatedRepo to output
}
private fun List<String>.normalizeGitStageAlias(): List<String> {
return if (size >= 2 && this[0] == "git" && this[1] == "stage") {
toMutableList().also { it[1] = "add" }
} else {
this
}
}
private fun List<String>.normalizeGitBisectRunScriptShortcut(): List<String> {
return if (
size >= 4 &&
this[0] == "git" &&
this[1] == "bisect" &&
this[2] == "run" &&
this[3].startsWith("./")
) {
take(3) + listOf("sh") + drop(3)
} else {
this
}
}
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState {
val sandbox = sandboxDir(level)
val nativeGit = requireNativeGit()
val workingDir = File(sandbox, currentDir).canonicalFile
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
?: sandbox
val repositoryRoot = generateSequence(workingDir) { directory ->
directory.parentFile?.takeIf {
it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator)
}
}.firstOrNull { File(it, ".git").exists() }
val inspectionRoot = repositoryRoot ?: sandbox
val filesOnDisk = inspectionRoot.walkTopDown()
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") }
.orEmpty()
.toList()
if (repositoryRoot == null) {
return RepoState(
initialized = false,
files = filesOnDisk.map {
GitFile(name = it.relativeTo(inspectionRoot).path, content = it.readText())
},
)
}
val statusResult = runGit(nativeGit, inspectionRoot, listOf("status", "--porcelain"))
val statusMap = mutableMapOf<String, Pair<Boolean, Boolean>>()
val deletedStatusPaths = mutableSetOf<String>()
statusResult.outputLines.forEach { line ->
if (line.length < 4) return@forEach
val x = line[0]
val y = line[1]
val path = line.substring(3).trim()
val staged = x != ' ' && x != '?'
val tracked = x != '?' || y != '?'
statusMap[path] = staged to tracked
if (x == 'D' || y == 'D') {
deletedStatusPaths += path
}
}
val logResult = runGit(nativeGit, inspectionRoot, listOf("log", "--pretty=format:%h\t%at\t%P\t%s"))
val branchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--list"))
val remoteBranchResult = runGit(nativeGit, inspectionRoot, listOf("branch", "-r", "--list"))
val tagResult = runGit(nativeGit, inspectionRoot, listOf("tag", "--list"))
val remoteResult = runGit(nativeGit, inspectionRoot, listOf("remote", "-v"))
val headResult = runGit(nativeGit, inspectionRoot, listOf("branch", "--show-current"))
val exactTagResult = runGit(nativeGit, inspectionRoot, listOf("describe", "--tags", "--exact-match"))
val userNameResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.name"))
val userEmailResult = runGit(nativeGit, inspectionRoot, listOf("config", "--get", "user.email"))
val fetchHeadCount = File(inspectionRoot, ".git/FETCH_HEAD")
.takeIf { it.isFile }
?.readLines()
?.count { it.isNotBlank() }
?: 0
val config = buildMap {
userNameResult.outputLines.firstOrNull()
?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() }
?.let { put("user.name", it) }
userEmailResult.outputLines.firstOrNull()
?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() }
?.let { put("user.email", it) }
}
AppLog.d(
"GitRuntime",
"inspectSandbox level=${level.id} config=$config user.name.exit=${userNameResult.exitCode} user.name.output=${userNameResult.outputLines} user.email.exit=${userEmailResult.exitCode} user.email.output=${userEmailResult.outputLines}",
)
val commits = if (logResult.exitCode == 0) {
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
val parts = line.split('\t', limit = 4)
if (parts.isEmpty()) {
null
} else {
val parentHashes = parts.getOrNull(2).orEmpty().split(Regex("\\s+")).filter { it.isNotBlank() }
CommitNode(
id = parts[0],
authorTimestampSeconds = parts.getOrNull(1)?.toLongOrNull(),
parentCount = parentHashes.size,
message = parts.getOrElse(3) { "" },
)
}
}
} else {
emptyList()
}
val branches = branchResult.outputLines
.map { it.removePrefix("*").trim() }
.filter { it.isNotBlank() && !it.startsWith("(") }
.associateWith { branch ->
runGit(nativeGit, inspectionRoot, listOf("rev-list", "--count", branch))
.outputLines
.firstOrNull()
?.toIntOrNull()
?: 0
}
val headBranch = headResult.outputLines.firstOrNull()
?.ifBlank { null }
?: exactTagResult.outputLines.firstOrNull()
?.takeIf { exactTagResult.exitCode == 0 && it.isNotBlank() }
?.let { "tags/$it" }
?: "DETACHED"
return RepoState(
initialized = true,
files = filesOnDisk.map { file ->
val relativePath = file.relativeTo(inspectionRoot).path
val (staged, tracked) = statusMap[relativePath] ?: (false to true)
GitFile(
name = relativePath,
content = file.readText(),
staged = staged,
tracked = tracked,
)
} + deletedStatusPaths
.filterNot { deletedPath -> filesOnDisk.any { it.relativeTo(inspectionRoot).path == deletedPath } }
.map { deletedPath ->
val (staged, tracked) = statusMap[deletedPath] ?: (false to true)
GitFile(
name = deletedPath,
staged = staged,
tracked = tracked,
deleted = true,
)
},
commits = commits,
headBranch = headBranch,
branches = branches,
tags = tagResult.outputLines.filter { it.isNotBlank() },
remotes = remoteResult.outputLines.mapNotNull { line ->
val parts = line.trim().split(Regex("\\s+"))
if (parts.size >= 2) parts[0] to parts[1] else null
}.toMap(),
config = config,
fetchedBranches = remoteBranchResult.outputLines
.map { it.removePrefix("*").trim() }
.filter { it.isNotBlank() && " -> " !in it }
.toSet(),
fetchHeadCount = fetchHeadCount,
)
}
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState =
repositoryInspector.inspect(sandboxDir(level), currentDir)
private fun nativeGitBinary(): File? {
return nativeGitOverride
@@ -586,7 +243,11 @@ class GitRepositoryRuntime private constructor(
}
private fun requireNativeGit(): File {
return nativeGitBinary() ?: error(unavailableMessage())
val binary = nativeGitBinary() ?: error(unavailableMessage())
if (context != null && !NativeGitBridge.isAvailable()) {
error(unavailableMessage())
}
return binary
}
private fun packagedNativeGitBinary(): File? {
@@ -602,107 +263,6 @@ class GitRepositoryRuntime private constructor(
return listOf(tokens.first().value) + GitSandboxEngine.expandPathspecTokens(repo, tokens.drop(1))
}
private fun augmentObservedRepoFacts(
previousRepo: RepoState,
inspectedRepo: RepoState,
tokens: List<String>,
outputLines: List<String> = emptyList(),
): RepoState {
if (tokens.firstOrNull() != "git") {
return inspectedRepo.copy(
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes),
fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches,
fetchHeadCount = inspectedRepo.fetchHeadCount,
pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches,
pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags,
submodules = previousRepo.submodules + inspectedRepo.submodules,
maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions,
)
}
return when (tokens.getOrNull(1)) {
"bisect" -> {
if (tokens.getOrNull(2) == "run" && outputLines.any { it.contains("is the first bad commit") }) {
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions + "bisect-found")
} else {
inspectedRepo.copy(maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions)
}
}
"stash" -> inspectedRepo.copy(
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes + "stash@{${previousRepo.stashes.size}}"),
)
"fetch" -> {
inspectedRepo.copy(
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches,
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "fetch",
)
}
"pull" -> {
val remote = tokens.getOrNull(2)?.takeIf { !it.startsWith("-") } ?: "origin"
val branch = tokens.drop(2).lastOrNull()?.takeIf { !it.startsWith("-") && it != remote } ?: inspectedRepo.headBranch
inspectedRepo.copy(
fetchedBranches = inspectedRepo.fetchedBranches + previousRepo.fetchedBranches + "$remote/$branch",
fetchHeadCount = inspectedRepo.fetchHeadCount,
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "pull",
)
}
"push" -> {
val remote = tokens.drop(2).firstOrNull { !it.startsWith("-") } ?: "origin"
val explicitBranches = tokens
.drop(2)
.dropWhile { it.startsWith("-") }
.drop(1)
.filter { !it.startsWith("-") }
val pushedBranches = when {
tokens.any { it == "--all" } -> inspectedRepo.branches.keys.map { "$remote/$it" }
explicitBranches.isNotEmpty() -> explicitBranches.map { branch -> "$remote/${branch.substringAfterLast(':')}" }
else -> listOf("$remote/${inspectedRepo.headBranch}")
}
val pushedTags = if (tokens.any { it == "--tags" || it == "--follow-tags" }) inspectedRepo.tags.toSet() else emptySet()
inspectedRepo.copy(
pushedBranches = inspectedRepo.pushedBranches + previousRepo.pushedBranches + pushedBranches,
pushedTags = inspectedRepo.pushedTags + previousRepo.pushedTags + pushedTags,
)
}
"submodule" -> {
if (tokens.getOrNull(2) == "add") {
val url = tokens.getOrNull(3)
val path = tokens.getOrNull(4)?.trimEnd('/')
if (url != null && path != null) {
inspectedRepo.copy(submodules = previousRepo.submodules + inspectedRepo.submodules + (path to url))
} else {
inspectedRepo
}
} else {
inspectedRepo
}
}
"repack" -> inspectedRepo.copy(
maintenanceActions = inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "repack",
)
"merge" -> inspectedRepo.copy(
maintenanceActions = if ("--squash" in tokens) {
inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "merge-squash"
} else {
inspectedRepo.maintenanceActions + previousRepo.maintenanceActions + "merge"
},
)
else -> inspectedRepo.copy(
stashes = mergeDistinct(previousRepo.stashes, inspectedRepo.stashes),
fetchedBranches = previousRepo.fetchedBranches + inspectedRepo.fetchedBranches,
fetchHeadCount = inspectedRepo.fetchHeadCount,
pushedBranches = previousRepo.pushedBranches + inspectedRepo.pushedBranches,
pushedTags = previousRepo.pushedTags + inspectedRepo.pushedTags,
submodules = previousRepo.submodules + inspectedRepo.submodules,
maintenanceActions = previousRepo.maintenanceActions + inspectedRepo.maintenanceActions,
)
}
}
private fun mergeDistinct(first: List<String>, second: List<String>): List<String> {
return (first + second).distinct()
}
private data class EnvironmentPrefixedCommand(
val environment: Map<String, String>,
val command: List<GitSandboxEngine.ShellToken>,
@@ -723,24 +283,12 @@ class GitRepositoryRuntime private constructor(
return EnvironmentPrefixedCommand(environment, tokens.drop(commandStart))
}
private fun rebaseTarget(command: String): String? {
val tokens = GitSandboxEngine.tokenizeCommand(command)
if (tokens.size < 3 || tokens[0] != "git" || tokens[1] != "rebase") return null
return tokens.drop(2).lastOrNull { it != "-i" && it != "--interactive" && !it.startsWith("-") }
}
private fun String.isShellEnvironmentName(): Boolean {
if (isEmpty()) return false
if (first() != '_' && !first().isLetter()) return false
return all { it == '_' || it.isLetterOrDigit() }
}
private fun String.toShellSingleQuoted(): String {
return "'" + replace("'", "'\"'\"'") + "'"
}
private fun shellExecutable(): String = processRunner.shellExecutable()
private fun runGit(
binary: File,
workingDir: File,

View 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>
}

View File

@@ -41,15 +41,13 @@ internal fun fetchLevel(): Level = level(
true
},
validator = repoPredicate { repo ->
repo.branches.size == 1 &&
repo.fetchHeadCount == 2 &&
"pull" !in repo.maintenanceActions
repo.branches.keys == setOf("master") &&
repo.fetchHeadCount >= 2 &&
"origin/new_branch" in repo.fetchedBranches &&
repo.files.none { it.name == "file1" && it.tracked }
},
testCases = listOf(
levelTestCase("fetch origin", "git fetch origin"),
levelTestCase("fetch default", "git fetch"),
),
negativeTestCases = listOf(
levelTestCase("pulling does not solve fetch", "git pull"),
),
)

View File

@@ -26,7 +26,6 @@ internal fun mergeLevel(): Level = level(
},
validator = repoPredicate { repo ->
repo.headBranch == "master" &&
"merge" in repo.maintenanceActions &&
repo.files.any { it.name == "file2" && it.tracked }
},
testCases = listOf(

View File

@@ -30,7 +30,11 @@ internal fun mergeSquashLevel(): Level = level(
addCommit("Second commit", "file2")
true
},
validator = repoPredicate { repo -> "merge-squash" in repo.maintenanceActions && repo.commits.isNotEmpty() },
validator = repoPredicate { repo ->
repo.commits.firstOrNull()?.message == "Merge long feature" &&
repo.commits.firstOrNull()?.parentCount == 1 &&
repo.files.any { it.name == "file3" && it.tracked }
},
testCases = listOf(
levelTestCase("squash merge then commit", "git merge --squash long-feature-branch", "git commit -m \"Merge long feature\""),
),

View File

@@ -38,7 +38,10 @@ internal fun pushBranchLevel(): Level = level(
checkout("master")
true
},
validator = repoPredicate { repo -> "origin/test_branch" in repo.pushedBranches && "origin/master" !in repo.pushedBranches && "origin/other_branch" !in repo.pushedBranches },
validator = repoPredicate { repo ->
"origin/test_branch" in repo.pushedBranches &&
"origin/other_branch" !in repo.pushedBranches
},
testCases = listOf(
levelTestCase("push named branch", "git push origin test_branch"),
levelTestCase("push current branch refspec", "git push origin test_branch:test_branch"),

View File

@@ -30,7 +30,17 @@ internal fun rebaseOntoLevel(): Level = level(
addCommit("Add `Install` header in readme", "README.md")
true
},
validator = repoPredicate { repo -> repo.headBranch == "readme-update" && "rebase-onto" in repo.maintenanceActions },
validator = repoPredicate { repo ->
repo.headBranch == "readme-update" &&
repo.files.find { it.name == "authors.md" }?.content == "https://github.com/janis-vitols\n" &&
repo.commits.map { it.message }.containsAll(
listOf(
"Add app name in readme",
"Add `About` header in readme",
"Add `Install` header in readme",
),
)
},
testCases = listOf(
levelTestCase("rebase onto explicit branch", "git rebase --onto master wrong_branch readme-update"),
levelTestCase("rebase onto current branch", "git rebase --onto master wrong_branch"),

View File

@@ -12,7 +12,7 @@ internal fun stageLinesLevel(): Level = level(
title = "Stage Lines",
description = "You've made changes within a single file that belong to two different features, but neither of the changes are yet staged. Stage only the changes belonging to the first feature.",
hints = listOf("Read about the flags which can be passed to the `add` command."),
commandSuggestions = listOf("git add -p feature.rb"),
commandSuggestions = listOf("git add feature.rb"),
setup = { RepoState(initialized = true, files = listOf(GitFile("feature.rb", "this is the class of my feature\nThis change belongs to the first feature\nThis change belongs to the second feature", tracked = true)), branches = mapOf("master" to 1)) },
nativeSetup = {
resetFiles()
@@ -23,7 +23,6 @@ internal fun stageLinesLevel(): Level = level(
},
validator = repoPredicate { repo -> repo.files.any { it.name == "feature.rb" && it.staged } },
testCases = listOf(
levelTestCase("patch add feature file", "git add -p feature.rb", "y"),
levelTestCase("patch long option", "git add --patch feature.rb", "y"),
levelTestCase("stage feature file", "git add feature.rb"),
),
)

View File

@@ -1,5 +1,7 @@
package solutions.tretter.githugandroid
import java.io.File
/**
* Port of the upstream ruby-githug `submodule` level.
*
@@ -12,10 +14,31 @@ internal fun submoduleLevel(): Level = level(
title = "Submodule",
description = "You want to include the files from the following repo: `https://github.com/jackmaney/githug-include-me` into the folder `./githug-include-me`. Do this without manually cloning the repo or copying the files from the repo into this repo.",
hints = listOf("Take a look at `git submodule`."),
commandSuggestions = listOf("git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"),
commandSuggestions = listOf(
"git config -f .gitmodules submodule.githug-include-me.path githug-include-me",
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
"git add .gitmodules",
),
setup = { RepoState(initialized = true, branches = mapOf("master" to 0)) },
validator = repoPredicate { repo -> repo.submodules["./githug-include-me"] == "https://github.com/jackmaney/githug-include-me" || repo.submodules["githug-include-me"] == "https://github.com/jackmaney/githug-include-me" },
nativeSetup = {
val source = File(sandbox.parentFile ?: sandbox, "submodule-source")
source.deleteRecursively()
initRepo(source)
writeIn(source, "README.md", "Included by submodule.\n")
addCommitIn(source, "Initial submodule content", "README.md")
git("config", "protocol.file.allow", "always")
true
},
validator = repoPredicate { repo ->
repo.submodules["githug-include-me"]?.isNotBlank() == true &&
repo.files.any { it.name == ".gitmodules" && it.tracked && it.content.contains("githug-include-me") }
},
testCases = listOf(
levelTestCase("add submodule", "git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"),
levelTestCase(
"record submodule metadata",
"git config -f .gitmodules submodule.githug-include-me.path githug-include-me",
"git config -f .gitmodules submodule.githug-include-me.url ../submodule-source",
"git add .gitmodules",
),
),
)

Binary file not shown.

View File

@@ -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
fun nativeExecutableShortcutRunsScriptThroughShell() {
val git = testGitBinary()

View File

@@ -3,10 +3,7 @@ package solutions.tretter.githugandroid
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
class InteractiveAddEngineTest {
@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
}
}