Switched to Chatgpt 5.5 and codex.

This commit is contained in:
Joe Tretter
2026-05-02 20:29:31 -05:00
parent 015135da5a
commit 8bcfa2bee4
10 changed files with 485 additions and 111 deletions

2
.gitignore vendored
View File

@@ -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

View File

@@ -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
if [ "$should_bump_version" = "true" ]; then
bump_android_version bump_android_version
log "Building $artifact_label with --no-daemon" 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
if [ "$should_auto_commit" = "true" ]; then
auto_commit_if_needed 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 "$@"

View File

@@ -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.

View File

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

View File

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

View File

@@ -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" -> {
when (parts[2]) {
"-d", "-D", "--delete" -> {
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] val branch = parts[2]
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists") 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") else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch")
} }
parts.size >= 3 && parts[1] == "checkout" -> {
val branch = parts[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] == "checkout" -> {
checkout(repo, parts.drop(2))
}
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,
) )
} }

View File

@@ -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() {
} }
} }
} }

View File

@@ -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,11 +34,8 @@ fun PaneWorkspace(
exerciseContent: @Composable () -> Unit, exerciseContent: @Composable () -> Unit,
terminalContent: @Composable () -> Unit, terminalContent: @Composable () -> Unit,
) { ) {
BoxWithConstraints(modifier = modifier) {
val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
) { ) {
paneLayout.order.forEachIndexed { index, paneId -> paneLayout.order.forEachIndexed { index, paneId ->
@@ -77,7 +71,6 @@ fun PaneWorkspace(
} }
} }
} }
}
} }
@Composable @Composable
@@ -188,4 +181,3 @@ private fun HeaderActionButton(
Text(label, fontSize = 11.sp) Text(label, fontSize = 11.sp)
} }
} }

View File

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

View File

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