diff --git a/.gitignore b/.gitignore index 606c396..37286bf 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AndroidProjectTooling.sh b/AndroidProjectTooling.sh index 71a6348..c004165 100755 --- a/AndroidProjectTooling.sh +++ b/AndroidProjectTooling.sh @@ -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 <&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 "$@" diff --git a/LevelsCompare.md b/LevelsCompare.md index ee8b3f3..7869636 100644 --- a/LevelsCompare.md +++ b/LevelsCompare.md @@ -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. diff --git a/README.md b/README.md index ce22d5c..55fffcd 100644 --- a/README.md +++ b/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 --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.apk` | | `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v.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