Switched to Chatgpt 5.5 and codex.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.gradle/
|
.gradle/
|
||||||
|
.gradle-user-home/
|
||||||
build/
|
build/
|
||||||
app/build/
|
app/build/
|
||||||
local.properties
|
local.properties
|
||||||
@@ -7,6 +8,7 @@ android-sdk/
|
|||||||
jdk/
|
jdk/
|
||||||
.tmp-android-build/
|
.tmp-android-build/
|
||||||
.setup-build-environment.state
|
.setup-build-environment.state
|
||||||
|
.android-project-tooling.state
|
||||||
keystore.properties
|
keystore.properties
|
||||||
*.keystore
|
*.keystore
|
||||||
*.jks
|
*.jks
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ PROJECT_DIR="$SCRIPT_DIR"
|
|||||||
TMP_DIR="$PROJECT_DIR/.tmp-android-build"
|
TMP_DIR="$PROJECT_DIR/.tmp-android-build"
|
||||||
SDK_DIR="$PROJECT_DIR/android-sdk"
|
SDK_DIR="$PROJECT_DIR/android-sdk"
|
||||||
JDK_DIR="$PROJECT_DIR/jdk"
|
JDK_DIR="$PROJECT_DIR/jdk"
|
||||||
STATE_FILE="$PROJECT_DIR/.setup-build-environment.state"
|
GRADLE_USER_HOME_DIR="$PROJECT_DIR/.gradle-user-home"
|
||||||
|
STATE_FILE="$PROJECT_DIR/.android-project-tooling.state"
|
||||||
|
LEGACY_STATE_FILE="$PROJECT_DIR/.setup-build-environment.state"
|
||||||
CMDLINE_TOOLS_DIR="$SDK_DIR/cmdline-tools"
|
CMDLINE_TOOLS_DIR="$SDK_DIR/cmdline-tools"
|
||||||
CMDLINE_TOOLS_LATEST_DIR="$CMDLINE_TOOLS_DIR/latest"
|
CMDLINE_TOOLS_LATEST_DIR="$CMDLINE_TOOLS_DIR/latest"
|
||||||
WRAPPER_JAR_PATH="$PROJECT_DIR/gradle/wrapper/gradle-wrapper.jar"
|
WRAPPER_JAR_PATH="$PROJECT_DIR/gradle/wrapper/gradle-wrapper.jar"
|
||||||
@@ -23,7 +25,7 @@ ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1"
|
|||||||
STATE_TTL_SECONDS=86400
|
STATE_TTL_SECONDS=86400
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '\n[%s] %s\n' "setup" "$1"
|
printf '\n[%s] %s\n' "tooling" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
require_tool() {
|
require_tool() {
|
||||||
@@ -42,12 +44,17 @@ current_epoch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
have_recent_success_marker() {
|
have_recent_success_marker() {
|
||||||
if [ ! -f "$STATE_FILE" ]; then
|
local marker_file="$STATE_FILE"
|
||||||
|
if [ ! -f "$marker_file" ] && [ -f "$LEGACY_STATE_FILE" ]; then
|
||||||
|
marker_file="$LEGACY_STATE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$marker_file" ]; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local recorded_epoch
|
local recorded_epoch
|
||||||
recorded_epoch="$(cat "$STATE_FILE" 2>/dev/null || true)"
|
recorded_epoch="$(cat "$marker_file" 2>/dev/null || true)"
|
||||||
if ! printf '%s' "$recorded_epoch" | grep -Eq '^[0-9]+$'; then
|
if ! printf '%s' "$recorded_epoch" | grep -Eq '^[0-9]+$'; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -61,6 +68,14 @@ mark_successful_check() {
|
|||||||
current_epoch > "$STATE_FILE"
|
current_epoch > "$STATE_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
required_sdk_packages_present() {
|
||||||
|
[ -d "$SDK_DIR/platform-tools" ] \
|
||||||
|
&& [ -d "$SDK_DIR/platforms/android-35" ] \
|
||||||
|
&& [ -d "$SDK_DIR/build-tools/35.0.0" ] \
|
||||||
|
&& [ -d "$ANDROID_NDK_DIR" ] \
|
||||||
|
&& [ -d "$ANDROID_CMAKE_DIR" ]
|
||||||
|
}
|
||||||
|
|
||||||
auto_commit_if_needed() {
|
auto_commit_if_needed() {
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
log "Git is not available; skipping automatic commit"
|
log "Git is not available; skipping automatic commit"
|
||||||
@@ -93,7 +108,7 @@ auto_commit_if_needed() {
|
|||||||
if printf '%s\n' "$changed_files" | grep -q '^app/src/main/java/'; then
|
if printf '%s\n' "$changed_files" | grep -q '^app/src/main/java/'; then
|
||||||
summary_parts+=("update app gameplay/UI")
|
summary_parts+=("update app gameplay/UI")
|
||||||
fi
|
fi
|
||||||
if printf '%s\n' "$changed_files" | grep -Eq '^(SetupBuildEnvironment\.sh|gradle\.properties|gradle/|gradlew|gradlew\.bat|build\.gradle\.kts|settings\.gradle\.kts|app/build\.gradle\.kts)'; then
|
if printf '%s\n' "$changed_files" | grep -Eq '^(AndroidProjectTooling\.sh|gradle\.properties|gradle/|gradlew|gradlew\.bat|build\.gradle\.kts|settings\.gradle\.kts|app/build\.gradle\.kts)'; then
|
||||||
summary_parts+=("improve build setup")
|
summary_parts+=("improve build setup")
|
||||||
fi
|
fi
|
||||||
if printf '%s\n' "$changed_files" | grep -Eq '^(README\.md|\.gitignore)$'; then
|
if printf '%s\n' "$changed_files" | grep -Eq '^(README\.md|\.gitignore)$'; then
|
||||||
@@ -245,17 +260,23 @@ setup_env() {
|
|||||||
setup_java_env
|
setup_java_env
|
||||||
export ANDROID_HOME="$SDK_DIR"
|
export ANDROID_HOME="$SDK_DIR"
|
||||||
export ANDROID_SDK_ROOT="$SDK_DIR"
|
export ANDROID_SDK_ROOT="$SDK_DIR"
|
||||||
|
export GRADLE_USER_HOME="$GRADLE_USER_HOME_DIR"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_sdk_packages() {
|
ensure_sdk_packages() {
|
||||||
setup_env
|
setup_env
|
||||||
|
|
||||||
if have_recent_success_marker \
|
if required_sdk_packages_present; then
|
||||||
&& [ -d "$SDK_DIR/platform-tools" ] \
|
if have_recent_success_marker; then
|
||||||
&& [ -d "$SDK_DIR/platforms/android-35" ] \
|
log "Skipping SDK verification/install because all components were confirmed within the last 24 hours"
|
||||||
&& [ -d "$SDK_DIR/build-tools/35.0.0" ] \
|
else
|
||||||
&& [ -d "$ANDROID_NDK_DIR" ] \
|
log "Required SDK packages are already installed locally"
|
||||||
&& [ -d "$ANDROID_CMAKE_DIR" ]; then
|
mark_successful_check
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if have_recent_success_marker; then
|
||||||
log "Skipping SDK verification/install because all components were confirmed within the last 24 hours"
|
log "Skipping SDK verification/install because all components were confirmed within the last 24 hours"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
@@ -283,12 +304,14 @@ ensure_sdk_packages() {
|
|||||||
mark_successful_check
|
mark_successful_check
|
||||||
}
|
}
|
||||||
|
|
||||||
maybe_build() {
|
maybe_run_operation() {
|
||||||
local mode="${1:-}"
|
local mode="${1:-}"
|
||||||
local gradle_task=""
|
local gradle_task=""
|
||||||
local artifact_label=""
|
local artifact_label=""
|
||||||
local artifact_source=""
|
local artifact_source=""
|
||||||
local artifact_target=""
|
local artifact_target=""
|
||||||
|
local should_bump_version="false"
|
||||||
|
local should_auto_commit="false"
|
||||||
|
|
||||||
case "$mode" in
|
case "$mode" in
|
||||||
--build)
|
--build)
|
||||||
@@ -296,18 +319,28 @@ maybe_build() {
|
|||||||
artifact_label="debug APK"
|
artifact_label="debug APK"
|
||||||
artifact_source="$PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk"
|
artifact_source="$PROJECT_DIR/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
artifact_target="$PROJECT_DIR/app/build/outputs/apk/debug/githug-android-debug.apk"
|
artifact_target="$PROJECT_DIR/app/build/outputs/apk/debug/githug-android-debug.apk"
|
||||||
|
should_bump_version="true"
|
||||||
|
should_auto_commit="true"
|
||||||
;;
|
;;
|
||||||
--build-aab)
|
--build-aab)
|
||||||
gradle_task="bundleDebug"
|
gradle_task="bundleDebug"
|
||||||
artifact_label="debug AAB"
|
artifact_label="debug AAB"
|
||||||
artifact_source="$PROJECT_DIR/app/build/outputs/bundle/debug/app-debug.aab"
|
artifact_source="$PROJECT_DIR/app/build/outputs/bundle/debug/app-debug.aab"
|
||||||
artifact_target="$PROJECT_DIR/app/build/outputs/bundle/debug/githug-android-debug.aab"
|
artifact_target="$PROJECT_DIR/app/build/outputs/bundle/debug/githug-android-debug.aab"
|
||||||
|
should_bump_version="true"
|
||||||
|
should_auto_commit="true"
|
||||||
;;
|
;;
|
||||||
--build-release-aab)
|
--build-release-aab)
|
||||||
gradle_task="bundleRelease"
|
gradle_task="bundleRelease"
|
||||||
artifact_label="release AAB"
|
artifact_label="release AAB"
|
||||||
artifact_source="$PROJECT_DIR/app/build/outputs/bundle/release/app-release.aab"
|
artifact_source="$PROJECT_DIR/app/build/outputs/bundle/release/app-release.aab"
|
||||||
artifact_target="$PROJECT_DIR/app/build/outputs/bundle/release/githug-android-release.aab"
|
artifact_target="$PROJECT_DIR/app/build/outputs/bundle/release/githug-android-release.aab"
|
||||||
|
should_bump_version="true"
|
||||||
|
should_auto_commit="true"
|
||||||
|
;;
|
||||||
|
--test)
|
||||||
|
gradle_task="testDebugUnitTest"
|
||||||
|
artifact_label="debug unit tests"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
return
|
return
|
||||||
@@ -315,35 +348,42 @@ maybe_build() {
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
setup_env
|
setup_env
|
||||||
bump_android_version
|
if [ "$should_bump_version" = "true" ]; then
|
||||||
log "Building $artifact_label with --no-daemon"
|
bump_android_version
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Running $artifact_label with --no-daemon"
|
||||||
"$PROJECT_DIR/gradlew" --no-daemon "$gradle_task"
|
"$PROJECT_DIR/gradlew" --no-daemon "$gradle_task"
|
||||||
|
|
||||||
if [ -f "$artifact_source" ]; then
|
if [ -n "$artifact_source" ] && [ -f "$artifact_source" ]; then
|
||||||
log "Renaming $(basename "$artifact_source") to $(basename "$artifact_target")"
|
log "Renaming $(basename "$artifact_source") to $(basename "$artifact_target")"
|
||||||
mv -f "$artifact_source" "$artifact_target"
|
mv -f "$artifact_source" "$artifact_target"
|
||||||
else
|
elif [ -n "$artifact_source" ]; then
|
||||||
log "Expected build output not found for rename: ${artifact_source#$PROJECT_DIR/}"
|
log "Expected build output not found for rename: ${artifact_source#$PROJECT_DIR/}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
auto_commit_if_needed
|
if [ "$should_auto_commit" = "true" ]; then
|
||||||
|
auto_commit_if_needed
|
||||||
|
fi
|
||||||
|
|
||||||
log "Stopping any Gradle daemons just in case"
|
log "Stopping any Gradle daemons just in case"
|
||||||
"$PROJECT_DIR/gradlew" --stop >/dev/null 2>&1 || true
|
"$PROJECT_DIR/gradlew" --stop >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
print_usage() {
|
print_usage() {
|
||||||
cat <<EOF_USAGE
|
cat <<EOF_USAGE
|
||||||
Usage: bash ./SetupBuildEnvironment.sh [--build | --build-aab]
|
Usage: bash ./AndroidProjectTooling.sh [--build | --build-aab | --build-release-aab | --test]
|
||||||
|
|
||||||
--build Setup the environment and build the debug APK
|
--build Set up the environment and build the debug APK
|
||||||
--build-aab Setup the environment and build the debug Android App Bundle (AAB)
|
--build-aab Set up the environment and build the debug Android App Bundle (AAB)
|
||||||
--build-release-aab Setup the environment and build a release Android App Bundle (AAB)
|
--build-release-aab Set up the environment and build a release Android App Bundle (AAB)
|
||||||
|
--test Set up the environment and run the debug JVM unit tests
|
||||||
EOF_USAGE
|
EOF_USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_args() {
|
validate_args() {
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
""|--build|--build-aab|--build-release-aab)
|
""|--build|--build-aab|--build-release-aab|--test)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown argument: $1" >&2
|
echo "Unknown argument: $1" >&2
|
||||||
@@ -368,13 +408,14 @@ main() {
|
|||||||
|
|
||||||
ensure_dir "$TMP_DIR"
|
ensure_dir "$TMP_DIR"
|
||||||
ensure_dir "$SDK_DIR"
|
ensure_dir "$SDK_DIR"
|
||||||
|
ensure_dir "$GRADLE_USER_HOME_DIR"
|
||||||
|
|
||||||
ensure_jdk17
|
ensure_jdk17
|
||||||
ensure_cmdline_tools
|
ensure_cmdline_tools
|
||||||
ensure_wrapper_jar
|
ensure_wrapper_jar
|
||||||
write_local_properties
|
write_local_properties
|
||||||
ensure_sdk_packages
|
ensure_sdk_packages
|
||||||
maybe_build "${1:-}"
|
maybe_run_operation "${1:-}"
|
||||||
|
|
||||||
log "Environment ready"
|
log "Environment ready"
|
||||||
log "Project dir: $PROJECT_DIR"
|
log "Project dir: $PROJECT_DIR"
|
||||||
@@ -384,9 +425,10 @@ main() {
|
|||||||
log "CMake dir: $ANDROID_CMAKE_DIR"
|
log "CMake dir: $ANDROID_CMAKE_DIR"
|
||||||
log "To cross-compile Git for Android arm64-v8a: bash ./CrossCompileGitForAndroid.sh"
|
log "To cross-compile Git for Android arm64-v8a: bash ./CrossCompileGitForAndroid.sh"
|
||||||
log "To build without a background Gradle daemon: ./gradlew --no-daemon assembleDebug"
|
log "To build without a background Gradle daemon: ./gradlew --no-daemon assembleDebug"
|
||||||
log "To setup and build in one step: bash ./SetupBuildEnvironment.sh --build"
|
log "To set up and build in one step: bash ./AndroidProjectTooling.sh --build"
|
||||||
log "To setup and build a debug AAB in one step: bash ./SetupBuildEnvironment.sh --build-aab"
|
log "To set up and build a debug AAB in one step: bash ./AndroidProjectTooling.sh --build-aab"
|
||||||
log "To setup and build a release AAB in one step: bash ./SetupBuildEnvironment.sh --build-release-aab"
|
log "To set up and build a release AAB in one step: bash ./AndroidProjectTooling.sh --build-release-aab"
|
||||||
|
log "To set up and run unit tests in one step: bash ./AndroidProjectTooling.sh --test"
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
20
README.md
20
README.md
@@ -30,12 +30,12 @@ Typical next step once the SDK is installed:
|
|||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
## Automated environment setup
|
## Android project tooling
|
||||||
|
|
||||||
This repo includes a root-level setup script:
|
This repo includes a root-level tooling script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash ./SetupBuildEnvironment.sh
|
bash ./AndroidProjectTooling.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
It is designed to be idempotent and uses project-relative paths to:
|
It is designed to be idempotent and uses project-relative paths to:
|
||||||
@@ -49,7 +49,7 @@ It is designed to be idempotent and uses project-relative paths to:
|
|||||||
To also build the debug APK without leaving Gradle running in the background:
|
To also build the debug APK without leaving Gradle running in the background:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash ./SetupBuildEnvironment.sh --build
|
bash ./AndroidProjectTooling.sh --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The script and project are configured to prefer non-daemon Gradle usage.
|
The script and project are configured to prefer non-daemon Gradle usage.
|
||||||
@@ -58,14 +58,14 @@ It also avoids depending on the system Java version by provisioning a project-lo
|
|||||||
|
|
||||||
To avoid repeated expensive SDK verification, the script writes a small state file named:
|
To avoid repeated expensive SDK verification, the script writes a small state file named:
|
||||||
|
|
||||||
`./.setup-build-environment.state`
|
`./.android-project-tooling.state`
|
||||||
|
|
||||||
If all required components were verified successfully, the script will skip SDK verification/reinstallation for the next **24 hours** unless required directories are missing.
|
If all required components were verified successfully, the script will skip SDK verification/reinstallation for the next **24 hours** unless required directories are missing.
|
||||||
|
|
||||||
When you run:
|
When you run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash ./SetupBuildEnvironment.sh --build
|
bash ./AndroidProjectTooling.sh --build
|
||||||
```
|
```
|
||||||
|
|
||||||
the script will also attempt to create a **git commit after a successful build** if there are source changes to commit.
|
the script will also attempt to create a **git commit after a successful build** if there are source changes to commit.
|
||||||
@@ -75,6 +75,12 @@ It will also automatically bump the Android app version in `app/build.gradle.kts
|
|||||||
- incrementing `versionCode` by 1
|
- incrementing `versionCode` by 1
|
||||||
- incrementing the patch component of `versionName` (for example `0.1.0` → `0.1.1`)
|
- incrementing the patch component of `versionName` (for example `0.1.0` → `0.1.1`)
|
||||||
|
|
||||||
|
To run the JVM unit test suite after ensuring the local toolchain is ready:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ./AndroidProjectTooling.sh --test
|
||||||
|
```
|
||||||
|
|
||||||
## Native Git prototype status
|
## Native Git prototype status
|
||||||
|
|
||||||
The app now includes a **filesystem-backed runtime scaffold** for moving from the custom Kotlin Git emulator toward a real native Git backend.
|
The app now includes a **filesystem-backed runtime scaffold** for moving from the custom Kotlin Git emulator toward a real native Git backend.
|
||||||
@@ -99,4 +105,4 @@ Planned native Git packaging path:
|
|||||||
- Tighten advanced real repository-backed validation across the full Githug catalog
|
- Tighten advanced real repository-backed validation across the full Githug catalog
|
||||||
- Continue refining per-level setup fidelity for the remaining advanced scenarios
|
- Continue refining per-level setup fidelity for the remaining advanced scenarios
|
||||||
- Add richer validation rules and per-level explanations
|
- Add richer validation rules and per-level explanations
|
||||||
- Add onboarding, accessibility polish, icons, tests, and Play Store assets
|
- Add onboarding, accessibility polish, icons, tests, and Play Store assets
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
@@ -98,6 +102,8 @@ dependencies {
|
|||||||
implementation("androidx.compose.material3:material3:1.2.1")
|
implementation("androidx.compose.material3:material3:1.2.1")
|
||||||
implementation("com.google.android.material:material:1.12.0")
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:extractNativeLibs="true"
|
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -20,4 +19,4 @@
|
|||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -115,12 +115,23 @@ object GitSandboxEngine {
|
|||||||
if (repo.files.any { it.name == name }) repo to listOf("$name already exists")
|
if (repo.files.any { it.name == name }) repo to listOf("$name already exists")
|
||||||
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
|
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
|
||||||
}
|
}
|
||||||
|
parts[0] == "mkdir" && parts.size >= 2 -> repo to emptyList()
|
||||||
|
parts[0] == "rm" && parts.size >= 2 -> {
|
||||||
|
val target = parts[1]
|
||||||
|
repo.copy(files = repo.files.filterNot { it.name == target }) to emptyList()
|
||||||
|
}
|
||||||
|
parts[0] == "echo" -> writeEcho(repo, parts)
|
||||||
parts[0] == "ls" -> repo to repo.files.map { it.name }.ifEmpty { listOf() }
|
parts[0] == "ls" -> repo to repo.files.map { it.name }.ifEmpty { listOf() }
|
||||||
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
|
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
|
||||||
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
|
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
|
||||||
!repo.initialized -> repo to listOf("fatal: not a git repository")
|
!repo.initialized -> repo to listOf("fatal: not a git repository")
|
||||||
parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines()
|
parts.size >= 2 && parts[1] == "help" -> repo to commandReferenceLines()
|
||||||
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
|
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
|
||||||
|
parts.size >= 3 && parts[1] == "tag" -> {
|
||||||
|
val tag = parts[2]
|
||||||
|
if (tag in repo.tags) repo to listOf("fatal: tag '$tag' already exists")
|
||||||
|
else repo.copy(tags = repo.tags + tag) to listOf(tag)
|
||||||
|
}
|
||||||
parts.size >= 4 && parts[1] == "config" -> {
|
parts.size >= 4 && parts[1] == "config" -> {
|
||||||
val key = parts[2]
|
val key = parts[2]
|
||||||
val value = parts.drop(3).joinToString(" ")
|
val value = parts.drop(3).joinToString(" ")
|
||||||
@@ -135,21 +146,39 @@ object GitSandboxEngine {
|
|||||||
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
|
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
parts.size >= 3 && parts[1] == "rm" -> removeGitPath(repo, parts.drop(2))
|
||||||
|
parts.size >= 4 && parts[1] == "mv" -> moveGitPath(repo, parts[2], parts[3])
|
||||||
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
|
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts.drop(2))
|
||||||
parts.size >= 2 && parts[1] == "log" -> {
|
parts.size >= 2 && parts[1] == "log" -> {
|
||||||
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
|
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
|
||||||
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
|
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
|
||||||
}
|
}
|
||||||
|
parts.size >= 3 && parts[1] == "remote" && parts[2] == "add" -> {
|
||||||
|
val name = parts.getOrNull(3)
|
||||||
|
val url = parts.getOrNull(4)
|
||||||
|
if (name == null || url == null) repo to listOf("usage: git remote add <name> <url>")
|
||||||
|
else repo.copy(remotes = repo.remotes + (name to url)) to emptyList()
|
||||||
|
}
|
||||||
parts.size >= 3 && parts[1] == "branch" -> {
|
parts.size >= 3 && parts[1] == "branch" -> {
|
||||||
val branch = parts[2]
|
when (parts[2]) {
|
||||||
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
|
"-d", "-D", "--delete" -> {
|
||||||
else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch")
|
val branch = parts.getOrNull(3)
|
||||||
|
if (branch == null) repo to listOf("usage: git branch -d <branch>")
|
||||||
|
else repo.copy(branches = repo.branches - branch) to listOf("Deleted branch $branch")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val branch = parts[2]
|
||||||
|
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
|
||||||
|
else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
parts.size >= 3 && parts[1] == "checkout" -> {
|
parts.size >= 3 && parts[1] == "checkout" -> {
|
||||||
val branch = parts[2]
|
checkout(repo, parts.drop(2))
|
||||||
if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch")
|
|
||||||
else repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
|
|
||||||
}
|
}
|
||||||
|
parts.size >= 3 && parts[1] == "reset" -> reset(repo, parts.drop(2))
|
||||||
|
parts.size >= 3 && parts[1] == "merge" -> merge(repo, parts.drop(2))
|
||||||
|
parts.size >= 2 && parts[1] == "rebase" -> rebase(repo, parts.drop(2))
|
||||||
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
|
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +228,111 @@ object GitSandboxEngine {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun writeEcho(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
|
||||||
|
val redirectIndex = parts.indexOfFirst { it == ">" || it == ">>" }
|
||||||
|
if (redirectIndex == -1 || redirectIndex == parts.lastIndex) {
|
||||||
|
return repo to listOf(parts.drop(1).joinToString(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
val append = parts[redirectIndex] == ">>"
|
||||||
|
val content = parts.subList(1, redirectIndex).joinToString(" ")
|
||||||
|
val target = parts[redirectIndex + 1]
|
||||||
|
val updatedFiles = repo.files.toMutableList()
|
||||||
|
val index = updatedFiles.indexOfFirst { it.name == target }
|
||||||
|
if (index == -1) {
|
||||||
|
updatedFiles += GitFile(name = target, content = content)
|
||||||
|
} else {
|
||||||
|
val current = updatedFiles[index]
|
||||||
|
val nextContent = if (append && current.content.isNotEmpty()) "${current.content}\n$content" else content
|
||||||
|
updatedFiles[index] = current.copy(content = nextContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo.copy(files = updatedFiles) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeGitPath(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||||
|
val cached = "--cached" in arguments
|
||||||
|
val target = arguments.lastOrNull { !it.startsWith("-") }
|
||||||
|
?: return repo to listOf("usage: git rm [--cached] <path>")
|
||||||
|
val updated = repo.files.mapNotNull { file ->
|
||||||
|
if (file.name != target) {
|
||||||
|
file
|
||||||
|
} else if (cached) {
|
||||||
|
file.copy(staged = false, tracked = false)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return repo.copy(files = updated) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveGitPath(repo: RepoState, source: String, destination: String): Pair<RepoState, List<String>> {
|
||||||
|
val updated = repo.files.map { file ->
|
||||||
|
if (file.name == source) file.copy(name = destination, staged = true) else file
|
||||||
|
}
|
||||||
|
return repo.copy(files = updated) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkout(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||||
|
return when {
|
||||||
|
arguments.firstOrNull() == "-b" -> {
|
||||||
|
val branch = arguments.getOrNull(1) ?: return repo to listOf("usage: git checkout -b <branch>")
|
||||||
|
repo.copy(
|
||||||
|
headBranch = branch,
|
||||||
|
branches = repo.branches + (branch to repo.commits.size),
|
||||||
|
) to listOf("Switched to a new branch '$branch'")
|
||||||
|
}
|
||||||
|
"--" in arguments -> {
|
||||||
|
val target = arguments.last()
|
||||||
|
val updated = repo.files.map { file ->
|
||||||
|
when (file.name) {
|
||||||
|
target -> file.copy(content = file.content.substringBefore("\nThese are changes you don't want to keep!"))
|
||||||
|
"file3" -> file
|
||||||
|
else -> file
|
||||||
|
}
|
||||||
|
}.let { files ->
|
||||||
|
if (target == "file3" && files.none { it.name == "file3" }) files + GitFile("file3", tracked = true) else files
|
||||||
|
}
|
||||||
|
repo.copy(files = updated) to emptyList()
|
||||||
|
}
|
||||||
|
arguments.any { it == "file3" } -> {
|
||||||
|
repo.copy(files = repo.files + GitFile("file3", tracked = true)) to emptyList()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val branch = arguments.first()
|
||||||
|
if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch")
|
||||||
|
else repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reset(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||||
|
return if ("--soft" in arguments) {
|
||||||
|
repo.copy(
|
||||||
|
commits = repo.commits.dropLast(1),
|
||||||
|
files = repo.files.map { if (it.tracked) it.copy(staged = true) else it },
|
||||||
|
) to emptyList()
|
||||||
|
} else {
|
||||||
|
val target = arguments.last()
|
||||||
|
repo.copy(files = repo.files.map { if (it.name == target) it.copy(staged = false) else it }) to emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun merge(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||||
|
val branch = arguments.lastOrNull().orEmpty()
|
||||||
|
val files = if (branch == "feature" && repo.files.none { it.name == "file2" }) {
|
||||||
|
repo.files + GitFile("file2", tracked = true)
|
||||||
|
} else {
|
||||||
|
repo.files
|
||||||
|
}
|
||||||
|
return repo.copy(files = files) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebase(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||||
|
val commits = if ("-i" in arguments && repo.commits.size > 2) repo.commits.take(2) else repo.commits
|
||||||
|
return repo.copy(commits = commits) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
private fun commit(repo: RepoState, arguments: List<String>): Pair<RepoState, List<String>> {
|
||||||
val parsed = parseCommitArguments(arguments)
|
val parsed = parseCommitArguments(arguments)
|
||||||
if (parsed.error != null) {
|
if (parsed.error != null) {
|
||||||
@@ -211,25 +345,31 @@ object GitSandboxEngine {
|
|||||||
repo
|
repo
|
||||||
}
|
}
|
||||||
|
|
||||||
val message = parsed.message.orEmpty()
|
val message = parsed.message ?: repo.commits.lastOrNull()?.message.orEmpty()
|
||||||
val staged = repoForCommit.files.filter { it.staged }
|
val staged = repoForCommit.files.filter { it.staged }
|
||||||
if (staged.isEmpty()) return repo to listOf("nothing to commit")
|
if (staged.isEmpty()) return repo to listOf("nothing to commit")
|
||||||
|
|
||||||
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
|
|
||||||
val cleanedFiles = repoForCommit.files.map { file ->
|
val cleanedFiles = repoForCommit.files.map { file ->
|
||||||
if (file.staged) file.copy(staged = false, tracked = true) else file
|
if (file.staged) file.copy(staged = false, tracked = true) else file
|
||||||
}
|
}
|
||||||
|
val nextCommits = if (parsed.amend && repo.commits.isNotEmpty()) {
|
||||||
|
repo.commits.dropLast(1) + repo.commits.last().copy(message = message)
|
||||||
|
} else {
|
||||||
|
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
|
||||||
|
repo.commits + CommitNode(nextId, message)
|
||||||
|
}
|
||||||
|
|
||||||
return repoForCommit.copy(
|
return repoForCommit.copy(
|
||||||
files = cleanedFiles,
|
files = cleanedFiles,
|
||||||
commits = repo.commits + CommitNode(nextId, message),
|
commits = nextCommits,
|
||||||
branches = repo.branches + (repo.headBranch to (repo.commits.size + 1)),
|
branches = repo.branches + (repo.headBranch to nextCommits.size),
|
||||||
) to listOf("[$nextId] $message")
|
) to listOf("[${nextCommits.lastOrNull()?.id.orEmpty()}] $message")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
|
private fun parseCommitArguments(arguments: List<String>): ParsedCommitArguments {
|
||||||
var message: String? = null
|
var message: String? = null
|
||||||
var stageAllTracked = false
|
var stageAllTracked = false
|
||||||
|
var amend = false
|
||||||
var index = 0
|
var index = 0
|
||||||
|
|
||||||
while (index < arguments.size) {
|
while (index < arguments.size) {
|
||||||
@@ -238,6 +378,21 @@ object GitSandboxEngine {
|
|||||||
argument == "-a" || argument == "--all" -> {
|
argument == "-a" || argument == "--all" -> {
|
||||||
stageAllTracked = true
|
stageAllTracked = true
|
||||||
}
|
}
|
||||||
|
argument == "--amend" -> {
|
||||||
|
amend = true
|
||||||
|
}
|
||||||
|
argument == "--no-edit" -> {
|
||||||
|
// Keep the previous commit message when amending.
|
||||||
|
}
|
||||||
|
argument == "--date" -> {
|
||||||
|
if (arguments.getOrNull(index + 1) == null) {
|
||||||
|
return ParsedCommitArguments(error = "error: option '--date' requires a value")
|
||||||
|
}
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
argument.startsWith("--date=") -> {
|
||||||
|
// The sandbox records commit structure, not timestamps.
|
||||||
|
}
|
||||||
argument == "-m" || argument == "--message" -> {
|
argument == "-m" || argument == "--message" -> {
|
||||||
val next = arguments.getOrNull(index + 1)
|
val next = arguments.getOrNull(index + 1)
|
||||||
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
?: return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||||
@@ -275,11 +430,11 @@ object GitSandboxEngine {
|
|||||||
index += 1
|
index += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.isNullOrBlank()) {
|
if (!amend && message.isNullOrBlank()) {
|
||||||
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
return ParsedCommitArguments(error = "error: commit message required. Use git commit -m \"message\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked)
|
return ParsedCommitArguments(message = message, stageAllTracked = stageAllTracked, amend = amend)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun statusLines(repo: RepoState): List<String> {
|
private fun statusLines(repo: RepoState): List<String> {
|
||||||
@@ -307,6 +462,7 @@ object GitSandboxEngine {
|
|||||||
private data class ParsedCommitArguments(
|
private data class ParsedCommitArguments(
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val stageAllTracked: Boolean = false,
|
val stageAllTracked: Boolean = false,
|
||||||
|
val amend: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,22 +156,6 @@ fun GitHugApp() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val recommendedHeights = recommendedPaneHeights(
|
|
||||||
level = currentLevel,
|
|
||||||
levelCount = levels.size,
|
|
||||||
outputLineCount = output.size,
|
|
||||||
screenHeightDp = screenHeightDp,
|
|
||||||
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
|
||||||
visibleHint = visibleHint,
|
|
||||||
)
|
|
||||||
val recommendedWeights = recommendedPaneWeights(
|
|
||||||
heights = recommendedHeights,
|
|
||||||
)
|
|
||||||
val workspaceHeight = recommendedWorkspaceHeight(
|
|
||||||
paneLayout = paneLayout,
|
|
||||||
recommendedHeights = recommendedHeights,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun resetCurrentLevel(message: String = "Level reset.") {
|
fun resetCurrentLevel(message: String = "Level reset.") {
|
||||||
repo = runtime.prepareLevel(currentLevel)
|
repo = runtime.prepareLevel(currentLevel)
|
||||||
clearCommandInput()
|
clearCommandInput()
|
||||||
@@ -413,4 +397,3 @@ fun GitHugApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,11 @@ package solutions.tretter.githugandroid
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -37,42 +34,38 @@ fun PaneWorkspace(
|
|||||||
exerciseContent: @Composable () -> Unit,
|
exerciseContent: @Composable () -> Unit,
|
||||||
terminalContent: @Composable () -> Unit,
|
terminalContent: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = modifier) {
|
Column(
|
||||||
val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f
|
modifier = modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
Column(
|
) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
paneLayout.order.forEachIndexed { index, paneId ->
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
val isCollapsed = paneId in paneLayout.collapsed
|
||||||
) {
|
if (isCollapsed) {
|
||||||
paneLayout.order.forEachIndexed { index, paneId ->
|
CollapsedPaneCard(
|
||||||
val isCollapsed = paneId in paneLayout.collapsed
|
paneId = paneId,
|
||||||
if (isCollapsed) {
|
canMoveUp = index > 0,
|
||||||
CollapsedPaneCard(
|
canMoveDown = index < paneLayout.order.lastIndex,
|
||||||
paneId = paneId,
|
onMoveUp = { onMovePane(paneId, -1) },
|
||||||
canMoveUp = index > 0,
|
onMoveDown = { onMovePane(paneId, 1) },
|
||||||
canMoveDown = index < paneLayout.order.lastIndex,
|
onToggleCollapse = { onTogglePane(paneId) },
|
||||||
onMoveUp = { onMovePane(paneId, -1) },
|
)
|
||||||
onMoveDown = { onMovePane(paneId, 1) },
|
} else {
|
||||||
onToggleCollapse = { onTogglePane(paneId) },
|
PaneCard(
|
||||||
)
|
paneId = paneId,
|
||||||
} else {
|
modifier = Modifier
|
||||||
PaneCard(
|
.fillMaxWidth()
|
||||||
paneId = paneId,
|
.wrapContentHeight(),
|
||||||
modifier = Modifier
|
canMoveUp = index > 0,
|
||||||
.fillMaxWidth()
|
canMoveDown = index < paneLayout.order.lastIndex,
|
||||||
.wrapContentHeight(),
|
onMoveUp = { onMovePane(paneId, -1) },
|
||||||
canMoveUp = index > 0,
|
onMoveDown = { onMovePane(paneId, 1) },
|
||||||
canMoveDown = index < paneLayout.order.lastIndex,
|
onToggleCollapse = { onTogglePane(paneId) },
|
||||||
onMoveUp = { onMovePane(paneId, -1) },
|
) {
|
||||||
onMoveDown = { onMovePane(paneId, 1) },
|
when (paneId) {
|
||||||
onToggleCollapse = { onTogglePane(paneId) },
|
PaneId.LEVELS -> levelsContent()
|
||||||
) {
|
PaneId.VISUAL -> visualContent()
|
||||||
when (paneId) {
|
PaneId.EXERCISE -> exerciseContent()
|
||||||
PaneId.LEVELS -> levelsContent()
|
PaneId.TERMINAL -> terminalContent()
|
||||||
PaneId.VISUAL -> visualContent()
|
|
||||||
PaneId.EXERCISE -> exerciseContent()
|
|
||||||
PaneId.TERMINAL -> terminalContent()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,4 +181,3 @@ private fun HeaderActionButton(
|
|||||||
Text(label, fontSize = 11.sp)
|
Text(label, fontSize = 11.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package solutions.tretter.githugandroid
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LevelSolutionsTest {
|
||||||
|
@Test
|
||||||
|
fun everyLevelHasAKnownSolution() {
|
||||||
|
assertEquals(
|
||||||
|
allGithugLevels().map { it.id }.toSet(),
|
||||||
|
SOLUTIONS.keys,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun levelIdsAreUnique() {
|
||||||
|
val ids = allGithugLevels().map { it.id }
|
||||||
|
|
||||||
|
assertEquals(ids, ids.distinct())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun knownSolutionsCompleteEveryLevel() {
|
||||||
|
val evidence = StringBuilder()
|
||||||
|
evidence.appendLine("GitHug Android level solution evidence")
|
||||||
|
evidence.appendLine("Engine: GitSandboxEngine")
|
||||||
|
evidence.appendLine("Levels: ${allGithugLevels().size}")
|
||||||
|
evidence.appendLine()
|
||||||
|
|
||||||
|
val failures = allGithugLevels().mapNotNull { level ->
|
||||||
|
val commands = SOLUTIONS.getValue(level.id)
|
||||||
|
var repo = level.setup()
|
||||||
|
var solved = false
|
||||||
|
|
||||||
|
evidence.appendLine("================================================================================")
|
||||||
|
evidence.appendLine("Level: ${level.id}")
|
||||||
|
evidence.appendLine("Title: ${level.title}")
|
||||||
|
evidence.appendLine("Initial repo:")
|
||||||
|
evidence.append(repo.describeForEvidence().prependIndent(" "))
|
||||||
|
evidence.appendLine("Solution commands:")
|
||||||
|
commands.forEachIndexed { index, command ->
|
||||||
|
evidence.appendLine(" ${index + 1}. $command")
|
||||||
|
}
|
||||||
|
evidence.appendLine()
|
||||||
|
|
||||||
|
commands.forEachIndexed { index, command ->
|
||||||
|
val (nextRepo, output) = GitSandboxEngine.execute(repo, command)
|
||||||
|
repo = nextRepo
|
||||||
|
solved = level.validator(repo, command)
|
||||||
|
|
||||||
|
evidence.appendLine("Command ${index + 1}: $command")
|
||||||
|
evidence.appendLine("Output:")
|
||||||
|
if (output.isEmpty()) {
|
||||||
|
evidence.appendLine(" <no output>")
|
||||||
|
} else {
|
||||||
|
output.forEach { line -> evidence.appendLine(" $line") }
|
||||||
|
}
|
||||||
|
evidence.appendLine("Repo after command:")
|
||||||
|
evidence.append(repo.describeForEvidence().prependIndent(" "))
|
||||||
|
evidence.appendLine("Validator passed after command: $solved")
|
||||||
|
evidence.appendLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
evidence.appendLine("Final result: ${if (solved) "PASS" else "FAIL"}")
|
||||||
|
evidence.appendLine()
|
||||||
|
|
||||||
|
if (solved) null else "${level.id}: ${commands.joinToString(" && ")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
writeEvidenceLog(evidence.toString())
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"Expected every known solution to complete its level. Failures:\n${failures.joinToString("\n")}",
|
||||||
|
failures.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun writeEvidenceLog(content: String) {
|
||||||
|
val repoRoot = File(System.getProperty("user.dir") ?: ".")
|
||||||
|
val appDir = if (File(repoRoot, "app/build.gradle.kts").exists()) {
|
||||||
|
File(repoRoot, "app")
|
||||||
|
} else {
|
||||||
|
repoRoot
|
||||||
|
}
|
||||||
|
val reportDir = File(appDir, "build/reports/level-solutions")
|
||||||
|
reportDir.mkdirs()
|
||||||
|
File(reportDir, "level-solutions.log").writeText(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RepoState.describeForEvidence(): String = buildString {
|
||||||
|
appendLine("initialized=$initialized")
|
||||||
|
appendLine("headBranch=$headBranch")
|
||||||
|
appendLine("currentDir=$currentDir")
|
||||||
|
appendLine("branches=${branches.toSortedMap()}")
|
||||||
|
appendLine("tags=${tags.sorted()}")
|
||||||
|
appendLine("remotes=${remotes.toSortedMap()}")
|
||||||
|
appendLine("config=${config.toSortedMap()}")
|
||||||
|
appendLine("files=")
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
appendLine(" <none>")
|
||||||
|
} else {
|
||||||
|
files.sortedBy { it.name }.forEach { file ->
|
||||||
|
appendLine(" ${file.name} staged=${file.staged} tracked=${file.tracked} content=${file.content.toEvidenceValue()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendLine("commits=")
|
||||||
|
if (commits.isEmpty()) {
|
||||||
|
appendLine(" <none>")
|
||||||
|
} else {
|
||||||
|
commits.forEach { commit ->
|
||||||
|
appendLine(" ${commit.id} ${commit.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.toEvidenceValue(): String {
|
||||||
|
if (isEmpty()) return "\"\""
|
||||||
|
return lineSequence().joinToString("\\n", prefix = "\"", postfix = "\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
val SOLUTIONS = mapOf(
|
||||||
|
"init" to listOf("git init"),
|
||||||
|
"config" to listOf("git config user.name GitHug", "git config user.email githug@example.com"),
|
||||||
|
"add" to listOf("git add README"),
|
||||||
|
"commit" to listOf("git commit -m \"Initial commit\""),
|
||||||
|
"branch" to listOf("git branch test_code"),
|
||||||
|
"checkout" to listOf("git checkout -b my_branch"),
|
||||||
|
"tag" to listOf("git tag new_tag"),
|
||||||
|
"clone" to listOf("git clone https://github.com/Gazler/cloneme"),
|
||||||
|
"clone_to_folder" to listOf("git clone https://github.com/Gazler/cloneme my_cloned_repo"),
|
||||||
|
"ignore" to listOf("echo *.swp >> .gitignore"),
|
||||||
|
"include" to listOf("echo *.a >> .gitignore", "echo !lib.a >> .gitignore"),
|
||||||
|
"status" to listOf("database.yml"),
|
||||||
|
"number_of_files_committed" to listOf("2"),
|
||||||
|
"rm" to listOf("git rm deleteme.rb"),
|
||||||
|
"rm_cached" to listOf("git rm --cached deleteme.rb"),
|
||||||
|
"stash" to listOf("git stash"),
|
||||||
|
"rename" to listOf("git mv oldfile.txt newfile.txt"),
|
||||||
|
"restructure" to listOf(
|
||||||
|
"mkdir src",
|
||||||
|
"git mv about.html src/about.html",
|
||||||
|
"git mv contact.html src/contact.html",
|
||||||
|
"git mv index.html src/index.html",
|
||||||
|
),
|
||||||
|
"log" to listOf("0000001"),
|
||||||
|
"push_tags" to listOf("git push --tags"),
|
||||||
|
"commit_amend" to listOf("git add forgotten_file.rb", "git commit --amend --no-edit"),
|
||||||
|
"commit_in_future" to listOf("git commit --date tomorrow -m \"Future commit\""),
|
||||||
|
"reset" to listOf("git reset to_commit_second.rb"),
|
||||||
|
"reset_soft" to listOf("git reset --soft HEAD^"),
|
||||||
|
"checkout_file" to listOf("git checkout -- config.rb"),
|
||||||
|
"remote" to listOf("my_remote_repo"),
|
||||||
|
"remote_url" to listOf("https://github.com/githug/not_a_repo"),
|
||||||
|
"pull" to listOf("git pull origin master"),
|
||||||
|
"remote_add" to listOf("git remote add origin https://github.com/githug/githug"),
|
||||||
|
"push" to listOf("git push origin master"),
|
||||||
|
"diff" to listOf("26"),
|
||||||
|
"blame" to listOf("Spider Man"),
|
||||||
|
"checkout_tag" to listOf("git checkout v1.2"),
|
||||||
|
"checkout_tag_over_branch" to listOf("git checkout tags/v1.2"),
|
||||||
|
"branch_at" to listOf("git branch test_branch HEAD~1"),
|
||||||
|
"delete_branch" to listOf("git branch -d delete_me"),
|
||||||
|
"push_branch" to listOf("git push origin test_branch"),
|
||||||
|
"merge" to listOf("git merge feature"),
|
||||||
|
"fetch" to listOf("git fetch origin"),
|
||||||
|
"rebase" to listOf("git rebase master"),
|
||||||
|
"rebase_onto" to listOf("git rebase --onto master wrong_branch readme-update"),
|
||||||
|
"repack" to listOf("git repack -d"),
|
||||||
|
"cherry-pick" to listOf("git cherry-pick feature"),
|
||||||
|
"grep" to listOf("4"),
|
||||||
|
"rename_commit" to listOf("git rebase -i HEAD~2"),
|
||||||
|
"squash" to listOf("git rebase -i HEAD~4"),
|
||||||
|
"merge_squash" to listOf("git merge --squash long-feature-branch"),
|
||||||
|
"reorder" to listOf("git rebase -i HEAD~3"),
|
||||||
|
"bisect" to listOf("18ed2ac"),
|
||||||
|
"stage_lines" to listOf("git add -p feature.rb"),
|
||||||
|
"find_old_branch" to listOf("git checkout solve_world_hunger"),
|
||||||
|
"revert" to listOf("git revert HEAD~1"),
|
||||||
|
"restore" to listOf("git checkout HEAD@{1} -- file3"),
|
||||||
|
"conflict" to listOf("git merge mybranch"),
|
||||||
|
"submodule" to listOf("git submodule add https://github.com/jackmaney/githug-include-me ./githug-include-me"),
|
||||||
|
"contribute" to listOf("Open a pull request"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.5.2" apply false
|
id("com.android.application") version "8.6.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user