diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad7f77f6b..acdf5237b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,10 +59,13 @@ jobs: mobile: - 'mobile/**' - 'scripts/mobile-release.sh' + - 'scripts/mobile-worktree-overrides.sh' + - 'scripts/mobile-worktree-clean.sh' - 'scripts/publish-mobile-release-candidate.sh' - 'scripts/release-rulesets.sh' - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' + - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract @@ -71,6 +74,8 @@ jobs: run: | scripts/test-mobile-release-contract.sh scripts/test-mobile-release-candidate-publisher.sh + - name: Mobile worktree identity contract + run: scripts/test-mobile-worktree-overrides.sh rust-lint: name: Rust Lint diff --git a/AGENTS.md b/AGENTS.md index 79bae9d49..cb4843a9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -555,6 +555,15 @@ To run the app locally (starts Docker, relay, iOS simulator automatically): just mobile-dev ``` +When run from a git worktree, `just mobile-dev` (and `just +mobile-build-android`) give the debug build a per-worktree app identifier +(keyed to the worktree directory name) and a branch-labelled app name via +`scripts/mobile-worktree-overrides.sh`, so builds from multiple worktrees +install side by side. Release builds are unaffected. `just mobile-clean` +removes stale worktree-suffixed installs from simulators/emulators. See +[mobile/README.md](mobile/README.md) for direct Xcode / Android Studio +usage. + ### Testing Conventions - Prefer **widget tests** over unit tests for UI components — test the diff --git a/Justfile b/Justfile index 701a1f06b..bcef8983b 100644 --- a/Justfile +++ b/Justfile @@ -620,11 +620,12 @@ mobile-check: mobile-test: unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test -# Compile an unsigned Android debug APK +# Compile an unsigned Android debug APK (worktree-aware debug identity) mobile-build-android: + ./scripts/mobile-worktree-overrides.sh unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter build apk --debug --no-pub -# Run the mobile app on iOS simulator +# Run the mobile app on iOS simulator (worktree-aware debug identity) mobile-dev: #!/usr/bin/env bash set -euo pipefail @@ -632,10 +633,15 @@ mobile-dev: open -a Simulator sleep 3 fi + ./scripts/mobile-worktree-overrides.sh cd {{mobile_dir}} unset GIT_DIR GIT_WORK_TREE flutter run +# Uninstall stale worktree-suffixed Buzz debug installs (production apps kept) +mobile-clean: + ./scripts/mobile-worktree-clean.sh + # ─── Database ───────────────────────────────────────────────────────────────── # Apply database migrations diff --git a/mobile/README.md b/mobile/README.md index d96c0b1a1..ada0568e2 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -19,6 +19,40 @@ just mobile-dev cd mobile && flutter run ``` +### Worktree-aware debug identity + +Debug builds produced from a git worktree get a unique app identifier keyed +to the **worktree directory name** (`com.buzz.buzzMobile.` on iOS, +`xyz.block.buzz.mobile.` on Android) plus a display-only branch label +in the app name (`Buzz (my-branch)`, or a short SHA when the worktree is +detached). Because the identifier follows the directory rather than the +branch, one worktree keeps exactly one installed app — and its login state — +across branch switches, and builds from multiple worktrees install side by +side, mirroring the desktop dev experience. Release and profile builds +always keep the production identity and name. + +`just mobile-dev` and `just mobile-build-android` apply this automatically by +running `scripts/mobile-worktree-overrides.sh`, which writes two gitignored +files: + +- `mobile/ios/Flutter/WorktreeOverrides.xcconfig` (included by Debug builds + only; a developer's `AppOverrides.xcconfig` is included after it, so + app-specific overrides like a personal `BUNDLE_IDENTIFIER` for device + signing always win) +- `mobile/android/worktree.properties` (read by the debug build type only) + +For direct Xcode / Android Studio / `flutter run` development, run +`./scripts/mobile-worktree-overrides.sh` from the repo root once per branch +switch to refresh the display label (the install identity never changes); +the persisted files are then picked up by any subsequent build. In the main +checkout the script is a no-op that removes stale override files, restoring +the plain `Buzz` identity. + +To remove leftover worktree-suffixed installs from booted iOS simulators and +connected Android emulators, run `just mobile-clean` (add `--dry-run` via +`./scripts/mobile-worktree-clean.sh --dry-run` to preview). Production +installs are never touched. + ## Checks ```bash diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore index be3943c96..32f598b0f 100644 --- a/mobile/android/.gitignore +++ b/mobile/android/.gitignore @@ -6,6 +6,7 @@ gradle-wrapper.jar /local.properties GeneratedPluginRegistrant.java .cxx/ +/worktree.properties # Remember to never publicly share your keystore. # See https://flutter.dev/to/reference-keystore diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index b3de0e9e7..501a31c46 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -19,6 +21,31 @@ val uploadSigningValues = val missingUploadSigningValues = uploadSigningValues.filterValues { it.isNullOrBlank() }.keys val hasUploadSigning = missingUploadSigningValues.isEmpty() +// Worktree-aware debug identity (gitignored, written by +// scripts/mobile-worktree-overrides.sh): debug builds from a git worktree get a +// branch-labelled app name and a unique applicationId suffix so builds from +// multiple worktrees install side by side. Release builds never read this. +val worktreePropsFile = rootProject.file("worktree.properties") +val worktreeProps = + Properties().apply { + if (worktreePropsFile.isFile) worktreePropsFile.inputStream().use { load(it) } + } +val worktreeLabel = worktreeProps.getProperty("label")?.takeIf { it.isNotBlank() } +if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+"""))) { + throw GradleException( + "worktree.properties label must match [A-Za-z0-9._-]+ (safe for string " + + "resources), got: " + worktreeLabel, + ) +} +val worktreeIdSuffix = + worktreeProps.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() } +if (worktreeIdSuffix != null && !worktreeIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) { + throw GradleException( + "worktree.properties applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " + + worktreeIdSuffix, + ) +} + // Release signing modes: // - "upload-keystore" (default): sign with the CI-vended upload keystore; // release builds fail loudly when any credential is missing. @@ -64,6 +91,7 @@ android { versionCode = flutter.versionCode versionName = flutter.versionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + resValue("string", "app_name", "Buzz") } signingConfigs { @@ -78,6 +106,16 @@ android { } buildTypes { + debug { + // Only debug builds take the worktree identity; release/profile + // keep the production applicationId and label. + if (worktreeIdSuffix != null) { + applicationIdSuffix = worktreeIdSuffix + } + if (worktreeLabel != null) { + resValue("string", "app_name", "Buzz ($worktreeLabel)") + } + } release { if (hasUploadSigning) { signingConfig = signingConfigs.getByName("upload") diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 93c3f1283..17a16742a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Buzz + $(APP_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/scripts/mobile-worktree-clean.sh b/scripts/mobile-worktree-clean.sh new file mode 100755 index 000000000..a64f52a0d --- /dev/null +++ b/scripts/mobile-worktree-clean.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Uninstalls stale worktree-suffixed Buzz debug builds from booted iOS +# simulators and connected Android devices/emulators. Production installs +# (com.buzz.buzzMobile / xyz.block.buzz.mobile, no suffix) are never touched: +# only identifiers with a worktree suffix appended after the production id +# are matched. Run `just mobile-clean` (or this script directly); pass +# --dry-run to list what would be removed without uninstalling. +set -euo pipefail + +ios_prefix="com.buzz.buzzMobile." +android_prefix="xyz.block.buzz.mobile." + +dry_run=0 +if [[ "${1:-}" == "--dry-run" ]]; then + dry_run=1 +fi + +removed=0 + +# ── iOS: booted simulators only ────────────────────────────────────────────── +if command -v xcrun &>/dev/null; then + booted=$(xcrun simctl list devices booted 2>/dev/null | sed -n 's/.*(\([0-9A-F-]\{36\}\)) (Booted).*/\1/p' || true) + for udid in $booted; do + # `simctl listapps` emits a plist keyed by bundle id; extract keys + # that carry the worktree suffix. + bundles=$(xcrun simctl listapps "$udid" 2>/dev/null \ + | plutil -convert json -o - -- - 2>/dev/null \ + | python3 -c 'import json,sys; print("\n".join(k for k in json.load(sys.stdin) if k.startswith(sys.argv[1])))' "$ios_prefix" \ + || true) + for bundle in $bundles; do + if [[ "$dry_run" == "1" ]]; then + echo "would uninstall (iOS $udid): $bundle" + else + echo "uninstalling (iOS $udid): $bundle" + xcrun simctl uninstall "$udid" "$bundle" + fi + removed=$((removed + 1)) + done + done +fi + +# ── Android: all connected devices/emulators ──────────────────────────────── +if command -v adb &>/dev/null; then + devices=$(adb devices 2>/dev/null | awk 'NR>1 && $2=="device" {print $1}' || true) + for serial in $devices; do + packages=$(adb -s "$serial" shell pm list packages 2>/dev/null \ + | tr -d '\r' | sed -n "s/^package:\(${android_prefix//./\\.}[a-z0-9_]*\)$/\1/p" || true) + for pkg in $packages; do + if [[ "$dry_run" == "1" ]]; then + echo "would uninstall (Android $serial): $pkg" + else + echo "uninstalling (Android $serial): $pkg" + adb -s "$serial" uninstall "$pkg" >/dev/null + fi + removed=$((removed + 1)) + done + done +fi + +if [[ "$removed" == "0" ]]; then + echo "no worktree-suffixed Buzz installs found (production apps untouched)" +elif [[ "$dry_run" == "1" ]]; then + echo "dry run: $removed worktree install(s) would be removed (production apps untouched)" +else + echo "removed $removed worktree install(s) (production apps untouched)" +fi diff --git a/scripts/mobile-worktree-overrides.sh b/scripts/mobile-worktree-overrides.sh new file mode 100755 index 000000000..176180db1 --- /dev/null +++ b/scripts/mobile-worktree-overrides.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Writes worktree-aware identity overrides for mobile debug builds, mirroring +# the desktop dev experience in scripts/instance-env.sh: debug builds produced +# from a git worktree get a unique app identifier keyed to the WORKTREE +# DIRECTORY NAME (stable across branch switches, so installs and login state +# are bounded by worktree count) plus a display-only branch label (short SHA +# when detached). +# +# In a worktree this writes two gitignored override files consumed by the +# native build systems (so direct Xcode / Android Studio / `flutter run` +# builds pick them up too): +# mobile/ios/Flutter/WorktreeOverrides.xcconfig (Debug builds only; a +# developer's AppOverrides.xcconfig takes precedence per variable) +# mobile/android/worktree.properties (debug build type only) +# In the main checkout it removes any stale override files. Release and +# profile builds never read these overrides. +set -euo pipefail + +# `just mobile-*` recipes unset these before invoking Flutter; do the same so +# an inherited GIT_DIR pointing elsewhere cannot misdetect the worktree. +unset GIT_DIR GIT_WORK_TREE + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ios_overrides="$repo_root/mobile/ios/Flutter/WorktreeOverrides.xcconfig" +android_props="$repo_root/mobile/android/worktree.properties" + +# Worktree detection: in the main working tree --git-dir and --git-common-dir +# are identical; in any linked worktree they differ (same check as +# scripts/instance-env.sh). +in_worktree=0 +if git -C "$repo_root" rev-parse --is-inside-work-tree &>/dev/null; then + git_dir=$(git -C "$repo_root" rev-parse --git-dir) + git_common_dir=$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null) + if [[ -n "$git_common_dir" && "$git_dir" != "$git_common_dir" ]]; then + in_worktree=1 + fi +fi + +if [[ "$in_worktree" != "1" ]]; then + rm -f "$ios_overrides" "$android_props" + exit 0 +fi + +# Install identity comes from the worktree directory name: stable across +# branch switches, so one worktree keeps one installed app (and its login +# state) no matter how many branches it visits. +worktree_name="$(basename "$repo_root")" + +# Display label is context only: the branch name, or a short SHA when the +# worktree is detached. Sanitized to [A-Za-z0-9._-] so no valid Git ref can +# break Android string resources or xcconfig values. +branch=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD) +if [[ "$branch" == "HEAD" ]]; then + label_raw=$(git -C "$repo_root" rev-parse --short HEAD) +else + label_raw="${branch##*/}" +fi +label=$(printf '%s' "$label_raw" | sed -e 's/[^A-Za-z0-9._-]/-/g' -e 's/--*/-/g' -e 's/^-//' -e 's/-$//') +[[ -n "$label" ]] || label="worktree" + +# iOS bundle-identifier slug: lowercase, non-alphanumerics collapsed to +# hyphens (hyphens are valid in bundle identifiers). +ios_slug=$(printf '%s' "$worktree_name" | tr '[:upper:]' '[:lower:]' | sed -e 's/[^a-z0-9]/-/g' -e 's/--*/-/g' -e 's/^-//' -e 's/-$//') +[[ -n "$ios_slug" ]] || ios_slug="worktree" + +# Android applicationId segments must match [a-z][a-z0-9_]*: swap hyphens for +# underscores and prefix a letter when the slug starts with a digit. +android_slug="${ios_slug//-/_}" +case "$android_slug" in + [0-9]*) android_slug="w_$android_slug" ;; +esac + +ios_bundle_id="com.buzz.buzzMobile.${ios_slug}" +android_suffix=".${android_slug}" + +cat > "$ios_overrides" < "$android_props" <&2 + failures=$((failures + 1)) +} +pass() { + printf 'ok: %s\n' "$1" +} + +make_repo() { + # $1: repo dir, $2: initial branch name + local repo="$1" branch="$2" + mkdir -p "$repo/scripts" "$repo/mobile/ios/Flutter" "$repo/mobile/android" + cp "$script" "$repo/scripts/mobile-worktree-overrides.sh" + git -C "$repo" init -q -b "$branch" + git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init +} + +make_worktree() { + # $1: source repo, $2: worktree dir, $3: branch to create + local repo="$1" wt="$2" branch="$3" + git -C "$repo" worktree add -q -b "$branch" "$wt" + mkdir -p "$wt/scripts" "$wt/mobile/ios/Flutter" "$wt/mobile/android" + cp "$script" "$wt/scripts/mobile-worktree-overrides.sh" +} + +# ── Main checkout: no overrides, stale files removed ───────────────────────── +repo="$tmp/main-checkout" +make_repo "$repo" main +echo stale > "$repo/mobile/ios/Flutter/WorktreeOverrides.xcconfig" +echo stale > "$repo/mobile/android/worktree.properties" +"$repo/scripts/mobile-worktree-overrides.sh" > /dev/null +if [[ -e "$repo/mobile/ios/Flutter/WorktreeOverrides.xcconfig" || -e "$repo/mobile/android/worktree.properties" ]]; then + fail "main checkout must remove stale worktree override files" +else + pass "main checkout removes stale worktree override files" +fi + +# ── Worktree: identity from DIRECTORY name, label from branch ──────────────── +wt="$tmp/Feature_Work-1" +make_worktree "$repo" "$wt" "tho/Fix_Thing-2" +out="$("$wt/scripts/mobile-worktree-overrides.sh")" +ios="$wt/mobile/ios/Flutter/WorktreeOverrides.xcconfig" +android="$wt/mobile/android/worktree.properties" +[[ -f "$ios" && -f "$android" ]] || fail "worktree must write both override files" +grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \ + && pass "iOS bundle identifier keys to the sanitized worktree directory name" \ + || fail "iOS bundle identifier must key to the worktree dir, got: $(cat "$ios")" +grep -q '^APP_DISPLAY_NAME = Buzz (Fix_Thing-2)$' "$ios" \ + && pass "iOS display name carries the branch label" \ + || fail "iOS display name wrong: $(cat "$ios")" +grep -q '^label=Fix_Thing-2$' "$android" \ + && pass "Android label carries the branch label" \ + || fail "Android label wrong: $(cat "$android")" +grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \ + && pass "Android applicationIdSuffix keys to the worktree directory name" \ + || fail "Android applicationIdSuffix wrong: $(cat "$android")" +printf '%s' "$out" | grep -q 'Worktree Feature_Work-1' \ + && pass "worktree run reports the worktree name" \ + || fail "worktree run must report the worktree name, got: $out" + +# ── Branch switch in the same worktree: identity stable, label follows ─────── +git -C "$wt" checkout -q -b "another/branch-name" +"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null +grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \ + && grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \ + && pass "branch switch keeps the install identity stable (per worktree)" \ + || fail "install identity must not change on branch switch" +grep -q '^label=branch-name$' "$android" \ + && pass "branch switch updates the display label" \ + || fail "display label must follow the branch, got: $(cat "$android")" + +# ── Apostrophes / exotic-but-valid refs: label is sanitized ────────────────── +git -C "$wt" checkout -q -b "it's-\$a\"branch" +"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null +grep -q "^label=it-s-a-branch$" "$android" \ + && pass "apostrophes and shell metacharacters are sanitized out of the label" \ + || fail "label must sanitize special chars, got: $(cat "$android")" +grep -Eq "^APP_DISPLAY_NAME = Buzz \([A-Za-z0-9._-]+\)$" "$ios" \ + && pass "iOS display name only contains resource-safe characters" \ + || fail "iOS display name has unsafe characters: $(cat "$ios")" + +# ── Detached HEAD: label falls back to the short SHA ────────────────────────── +sha="$(git -C "$wt" rev-parse --short HEAD)" +git -C "$wt" checkout -q --detach +"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null +grep -q "^label=${sha}$" "$android" \ + && pass "detached HEAD labels with the short SHA instead of literal HEAD" \ + || fail "detached HEAD must use short SHA, got: $(cat "$android")" +grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \ + && pass "detached HEAD keeps the per-worktree install identity" \ + || fail "detached HEAD must not change the install identity" + +# ── Digit-leading worktree dir gets a letter-prefixed Android segment ──────── +wt2="$tmp/2fast" +make_worktree "$repo" "$wt2" "some-branch" +"$wt2/scripts/mobile-worktree-overrides.sh" > /dev/null +grep -q '^applicationIdSuffix=\.w_2fast$' "$wt2/mobile/android/worktree.properties" \ + && pass "digit-leading worktree dir yields a valid Android package segment" \ + || fail "digit-leading dir segment wrong: $(cat "$wt2/mobile/android/worktree.properties")" + +# ── Tracked build files: overrides are debug-only, release stays production ── +debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig" +release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig" +gradle="$repo_root/mobile/android/app/build.gradle.kts" +manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" +plist="$repo_root/mobile/ios/Runner/Info.plist" + +grep -q 'WorktreeOverrides.xcconfig' "$debug_xcconfig" \ + && pass "Debug.xcconfig includes WorktreeOverrides" \ + || fail "Debug.xcconfig must include WorktreeOverrides.xcconfig" +worktree_line=$(grep -n 'WorktreeOverrides.xcconfig' "$debug_xcconfig" | cut -d: -f1 | head -1) +app_line=$(grep -n 'AppOverrides.xcconfig' "$debug_xcconfig" | grep '#include' | cut -d: -f1 | tail -1) +if [[ -n "$worktree_line" && -n "$app_line" && "$worktree_line" -lt "$app_line" ]]; then + pass "AppOverrides is included after WorktreeOverrides (developer overrides win)" +else + fail "Debug.xcconfig must include AppOverrides.xcconfig after WorktreeOverrides.xcconfig" +fi +grep -q 'WorktreeOverrides' "$release_xcconfig" \ + && fail "Release.xcconfig must not include WorktreeOverrides.xcconfig" \ + || pass "Release.xcconfig does not include WorktreeOverrides" +grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile$' "$release_xcconfig" \ + && pass "Release.xcconfig keeps the production bundle identifier" \ + || fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = com.buzz.buzzMobile" +grep -q '^APP_DISPLAY_NAME = Buzz$' "$release_xcconfig" \ + && pass "Release.xcconfig keeps the production display name" \ + || fail "Release.xcconfig must keep APP_DISPLAY_NAME = Buzz" +grep -q '$(APP_DISPLAY_NAME)' "$plist" \ + && pass "Info.plist display name resolves from build settings" \ + || fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)" +grep -q 'android:label="@string/app_name"' "$manifest" \ + && pass "Android manifest label resolves from resources" \ + || fail "Android manifest label must be @string/app_name" +grep -q 'resValue("string", "app_name", "Buzz")' "$gradle" \ + && pass "Gradle default app_name stays Buzz" \ + || fail "Gradle must declare the default app_name resValue" +grep -q 'worktreeLabel.matches' "$gradle" \ + && pass "Gradle validates the worktree label before use" \ + || fail "Gradle must validate the worktree label against a safe pattern" + +# Extract a brace-balanced block: everything from the first line matching $2 +# to the line where its braces close. Unlike a /start/,/}/ awk range, nested +# blocks cannot end the scan early. +extract_block() { + # $1: file (or - for stdin), $2: start regex + awk -v start="$2" ' + !in_block && $0 ~ start { in_block = 1 } + in_block { + print + depth += gsub(/\{/, "{") - gsub(/\}/, "}") + if (depth <= 0) exit + } + ' "$1" +} + +# Self-test: the extractor must see past a nested block — this is exactly the +# hole the old /release \{/,/\}/ range had. +sneaky=$'buildTypes {\n release {\n if (nested) {\n x = 1\n }\n worktreeSneakyReference()\n }\n}' +printf '%s\n' "$sneaky" | extract_block - 'release \{' | grep -q 'worktreeSneakyReference' \ + && pass "release-block extractor scans past nested braces" \ + || fail "release-block extractor must not stop at the first nested close brace" + +# The worktree suffix/label must only appear inside the debug build type. +extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{' | grep -q 'worktree' \ + && fail "release build type must not reference worktree identity" \ + || pass "release build type does not reference worktree identity" + +git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfig \ + && pass "iOS override file is gitignored" \ + || fail "mobile/ios/Flutter/WorktreeOverrides.xcconfig must be gitignored" +git -C "$repo_root" check-ignore -q mobile/android/worktree.properties \ + && pass "Android override file is gitignored" \ + || fail "mobile/android/worktree.properties must be gitignored" +grep -Eq '^\s+\./scripts/mobile-worktree-overrides\.sh$' "$repo_root/Justfile" \ + && pass "just mobile-dev applies the worktree identity" \ + || fail "Justfile mobile-dev must run scripts/mobile-worktree-overrides.sh" +grep -Eq '^\s+\./scripts/mobile-worktree-clean\.sh$' "$repo_root/Justfile" \ + && pass "just mobile-clean is wired to the cleanup script" \ + || fail "Justfile mobile-clean must run scripts/mobile-worktree-clean.sh" + +# ── Cleanup safety: suffixed installs matched, production ids preserved ────── +stub_bin="$tmp/stub-bin" +mkdir -p "$stub_bin" +cat > "$stub_bin/adb" <<'STUB' +#!/usr/bin/env bash +case "$1 $2" in + "devices ") printf 'List of devices attached\nemulator-5554\tdevice\n' ;; +esac +if [[ "$1" == "devices" ]]; then exit 0; fi +if [[ "$3 $4 $5" == "shell pm list" ]]; then + printf 'package:xyz.block.buzz.mobile\n' + printf 'package:xyz.block.buzz.mobile.feature_work_1\n' + printf 'package:xyz.block.buzz.mobile.w_2fast\n' + printf 'package:com.android.settings\n' + exit 0 +fi +if [[ "$3" == "uninstall" ]]; then echo Success; exit 0; fi +exit 0 +STUB +chmod +x "$stub_bin/adb" +# No xcrun stub: the iOS pass is skipped when xcrun is absent, which also +# keeps this test honest on Linux CI. +clean_out="$(PATH="$stub_bin:/usr/bin:/bin" bash "$clean_script" --dry-run)" +printf '%s\n' "$clean_out" | grep -q 'xyz\.block\.buzz\.mobile\.feature_work_1' \ + && pass "cleanup targets worktree-suffixed Android installs" \ + || fail "cleanup must list suffixed installs, got: $clean_out" +printf '%s\n' "$clean_out" | grep -q 'xyz\.block\.buzz\.mobile\.w_2fast' \ + && pass "cleanup targets letter-prefixed suffixed installs" \ + || fail "cleanup must list w_-prefixed installs, got: $clean_out" +printf '%s\n' "$clean_out" | grep -q 'mobile\.feature_work_1' || true +if printf '%s\n' "$clean_out" | grep -Eq '(would uninstall|uninstalling).*xyz\.block\.buzz\.mobile$'; then + fail "cleanup must never target the production Android app id" +else + pass "cleanup preserves the production Android app id" +fi +if printf '%s\n' "$clean_out" | grep -q 'com\.android\.settings'; then + fail "cleanup must never target unrelated packages" +else + pass "cleanup ignores unrelated packages" +fi +printf '%s\n' "$clean_out" | grep -q 'dry run:' \ + && pass "cleanup --dry-run reports without uninstalling" \ + || fail "cleanup --dry-run must report a dry-run summary, got: $clean_out" + +if [[ "$failures" -gt 0 ]]; then + printf '%d failure(s)\n' "$failures" >&2 + exit 1 +fi +printf 'all mobile worktree identity contract checks passed\n'