From 1c15297ae6c5d794474fb6dd426d131461c90d31 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Wed, 24 Jun 2026 17:44:53 -0500 Subject: [PATCH] Speed up runtime state refresh --- AndroidProjectTooling.sh | 19 ++++- README.md | 2 + app/build.gradle.kts | 4 +- .../GitRepositoryRuntimeInstrumentedTest.kt | 17 +++++ .../githugandroid/GitRepositoryInspector.kt | 48 +++++++++---- .../tretter/githugandroid/GitRuntime.kt | 70 ++++++++++++++++++- .../githugandroid/GitSandboxEngineTest.kt | 42 +++++++++++ 7 files changed, 180 insertions(+), 22 deletions(-) diff --git a/AndroidProjectTooling.sh b/AndroidProjectTooling.sh index c004165..0d2afaa 100755 --- a/AndroidProjectTooling.sh +++ b/AndroidProjectTooling.sh @@ -679,6 +679,7 @@ maybe_run_operation() { local should_compile_android_git="false" local should_bundle_manpages="false" local should_run_emulator="false" + local should_clean_gradle="false" case "$mode" in --build) @@ -706,6 +707,12 @@ maybe_run_operation() { artifact_label="debug unit tests" should_compile_host_git="true" ;; + --clean-test) + gradle_task="testDebugUnitTest" + artifact_label="clean debug unit tests" + should_compile_host_git="true" + should_clean_gradle="true" + ;; --test-emulator) gradle_task="connectedDebugAndroidTest" artifact_label="debug instrumentation tests on Android emulator" @@ -755,7 +762,11 @@ maybe_run_operation() { fi log "Running $artifact_label with --no-daemon" - "$PROJECT_DIR/gradlew" --no-daemon "$gradle_task" + if [ "$should_clean_gradle" = "true" ]; then + "$PROJECT_DIR/gradlew" --no-daemon clean "$gradle_task" + else + "$PROJECT_DIR/gradlew" --no-daemon "$gradle_task" + fi if [ -n "$artifact_source" ] && [ -f "$artifact_source" ]; then log "Renaming $(basename "$artifact_source") to $(basename "$artifact_target")" @@ -777,11 +788,12 @@ maybe_run_operation() { print_usage() { cat <&2 @@ -835,6 +847,7 @@ main() { log "To set up and build in one step: bash ./AndroidProjectTooling.sh --build" log "To set up and build a release AAB in one step: bash ./AndroidProjectTooling.sh --build-release-aab" log "To set up and run unit tests with compiled host Git: bash ./AndroidProjectTooling.sh --test" + log "To clean and run unit tests with compiled host Git: bash ./AndroidProjectTooling.sh --clean-test" log "To set up and run instrumentation tests on an emulator: bash ./AndroidProjectTooling.sh --test-emulator" } diff --git a/README.md b/README.md index 55fffcd..bba7db7 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Available commands: | --- | --- | --- | | `bash ./AndroidProjectTooling.sh` | Provision or refresh the local Android/JDK toolchain only. | Toolchain under `./jdk` and `./android-sdk` | | `bash ./AndroidProjectTooling.sh --test` | Compile host Git, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Test reports under `app/build/reports/` | +| `bash ./AndroidProjectTooling.sh --clean-test` | Compile host Git, clean Gradle outputs, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Fresh test reports under `app/build/reports/` | | `bash ./AndroidProjectTooling.sh --test-emulator` | Install emulator packages if needed, create/start the project test AVD, compile Android Git, and run debug instrumentation tests. | Instrumentation reports under `app/build/reports/androidTests/` | | `bash ./AndroidProjectTooling.sh --build` | Build the debug APK. | `app/build/outputs/apk/debug/githug-android-debug-v.apk` | | `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v.aab` | @@ -112,6 +113,7 @@ Options: | Command | Purpose | Output | | --- | --- | --- | | `bash ./AndroidProjectTooling.sh --test` | Ensure the host Git binary is current, then run tests with it. | `build/host-git/libgit.so` and test reports | +| `bash ./AndroidProjectTooling.sh --clean-test` | Ensure the host Git binary is current, clean Gradle outputs, then run tests with it. | `build/host-git/libgit.so` and fresh test reports | | `bash ./AndroidProjectTooling.sh --test-emulator` | Ensure Android ABI Git binaries are current, then run instrumentation tests on the project AVD. | Android `libgit.so` binaries and instrumentation reports | | `bash ./AndroidProjectTooling.sh --compile-git` | Ensure host Git and Android ABI Git binaries are current. | Host and Android outputs | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 14474c1..13ed571 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "solutions.tretter.githugandroid" minSdk = 26 targetSdk = 35 - versionCode = 172 - versionName = "0.1.171" + versionCode = 173 + versionName = "0.1.172" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/androidTest/java/solutions/tretter/githugandroid/GitRepositoryRuntimeInstrumentedTest.kt b/app/src/androidTest/java/solutions/tretter/githugandroid/GitRepositoryRuntimeInstrumentedTest.kt index 2d70ca4..794a4be 100644 --- a/app/src/androidTest/java/solutions/tretter/githugandroid/GitRepositoryRuntimeInstrumentedTest.kt +++ b/app/src/androidTest/java/solutions/tretter/githugandroid/GitRepositoryRuntimeInstrumentedTest.kt @@ -22,4 +22,21 @@ class GitRepositoryRuntimeInstrumentedTest { assertTrue(updatedRepo.initialized) assertTrue(level.validator(updatedRepo, "git init")) } + + @Test + fun nativeGitRuntimeCompletesConfigLevelOnDevice() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val runtime = GitRepositoryRuntime(context) + val level = configLevel() + + assertTrue(runtime.unavailableMessage(), runtime.isNativeGitAvailable()) + + val repo = runtime.prepareLevel(level) + val (namedRepo, _) = runtime.execute(level, repo, "git config user.name GitHug") + val (configuredRepo, _) = runtime.execute(level, namedRepo, "git config user.email githug@example.com") + + assertTrue(configuredRepo.config["user.name"].orEmpty().isNotBlank()) + assertTrue(configuredRepo.config["user.email"].orEmpty().isNotBlank()) + assertTrue(level.validator(configuredRepo, "git config user.email githug@example.com")) + } } diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRepositoryInspector.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRepositoryInspector.kt index bda6538..fb3c208 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRepositoryInspector.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRepositoryInspector.kt @@ -6,16 +6,21 @@ internal class GitRepositoryInspector( private val nativeGit: () -> File, private val runGit: (File, File, List, Map) -> ProcessExecutionResult, ) { + fun inspectConfig(sandbox: File, currentDir: String = "."): Map { + val git = nativeGit() + val workingDir = File(sandbox, currentDir).canonicalFile + .takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) } + ?: sandbox + val repositoryRoot = repositoryRoot(sandbox, workingDir) ?: sandbox + return readConfig(git, repositoryRoot) + } + fun inspect(sandbox: File, currentDir: String = "."): RepoState { val git = nativeGit() val workingDir = File(sandbox, currentDir).canonicalFile .takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) } ?: sandbox - val repositoryRoot = generateSequence(workingDir) { directory -> - directory.parentFile?.takeIf { - it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) - } - }.firstOrNull { File(it, ".git").exists() } + val repositoryRoot = repositoryRoot(sandbox, workingDir) val inspectionRoot = repositoryRoot ?: sandbox val filesOnDisk = inspectionRoot.walkTopDown() .filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") } @@ -54,8 +59,6 @@ internal class GitRepositoryInspector( val remoteResult = run(git, inspectionRoot, listOf("remote", "-v")) val headResult = run(git, inspectionRoot, listOf("branch", "--show-current")) val exactTagResult = run(git, inspectionRoot, listOf("describe", "--tags", "--exact-match")) - val userNameResult = run(git, inspectionRoot, listOf("config", "--get", "user.name")) - val userEmailResult = run(git, inspectionRoot, listOf("config", "--get", "user.email")) val stashResult = run(git, inspectionRoot, listOf("stash", "list", "--format=%gd")) val submodules = inspectSubmodules(git, inspectionRoot) val maintenanceActions = buildSet { @@ -68,14 +71,7 @@ internal class GitRepositoryInspector( ?.readLines() ?.count { it.isNotBlank() } ?: 0 - val config = buildMap { - userNameResult.outputLines.firstOrNull() - ?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() } - ?.let { put("user.name", it) } - userEmailResult.outputLines.firstOrNull() - ?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() } - ?.let { put("user.email", it) } - } + val config = readConfig(git, inspectionRoot) val commits = if (logResult.exitCode == 0) { logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line -> @@ -185,6 +181,28 @@ internal class GitRepositoryInspector( environment: Map = emptyMap(), ): ProcessExecutionResult = runGit(binary, workingDir, arguments, environment) + private fun repositoryRoot(sandbox: File, workingDir: File): File? { + val sandboxPath = sandbox.canonicalPath + return generateSequence(workingDir) { directory -> + directory.parentFile?.takeIf { + it.path == sandboxPath || it.path.startsWith(sandboxPath + File.separator) + } + }.firstOrNull { File(it, ".git").exists() } + } + + private fun readConfig(git: File, inspectionRoot: File): Map { + val userNameResult = run(git, inspectionRoot, listOf("config", "--get", "user.name")) + val userEmailResult = run(git, inspectionRoot, listOf("config", "--get", "user.email")) + return buildMap { + userNameResult.outputLines.firstOrNull() + ?.takeIf { userNameResult.exitCode == 0 && it.isNotBlank() } + ?.let { put("user.name", it) } + userEmailResult.outputLines.firstOrNull() + ?.takeIf { userEmailResult.exitCode == 0 && it.isNotBlank() } + ?.let { put("user.email", it) } + } + } + private fun inspectRemoteRefs(git: File, inspectionRoot: File, remoteLines: List): RemoteRefs { val remotes = remoteLines.mapNotNull { line -> val parts = line.trim().split(Regex("\\s+")) diff --git a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt index 653f671..dd21424 100644 --- a/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt +++ b/app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt @@ -128,8 +128,8 @@ class GitRepositoryRuntime private constructor( ?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens) } - val inspectedRepo = inspectSandbox(level, result.first.currentDir).copy(currentDir = result.first.currentDir) - return inspectedRepo to result.second + val refreshedRepo = refreshRepoAfterCommand(level, currentRepo, result.first, expandedTokens) + return refreshedRepo to result.second } fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List { @@ -236,6 +236,72 @@ class GitRepositoryRuntime private constructor( private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState = repositoryInspector.inspect(sandboxDir(level), currentDir) + private fun refreshRepoAfterCommand( + level: Level, + previousRepo: RepoState, + commandRepo: RepoState, + tokens: List, + ): RepoState { + if (tokens.isEmpty()) return previousRepo + if (!commandNeedsStateRefresh(tokens)) return commandRepo + if (tokens.isGitConfigCommand()) { + return commandRepo.copy( + config = repositoryInspector.inspectConfig(sandboxDir(level), commandRepo.currentDir), + ) + } + return inspectSandbox(level, commandRepo.currentDir).copy(currentDir = commandRepo.currentDir) + } + + private fun commandNeedsStateRefresh(tokens: List): Boolean { + return when (tokens.firstOrNull()) { + "git" -> tokens.gitCommandNeedsStateRefresh() + "sh" -> true + "touch", "mkdir", "md", "rm", "del" -> true + "echo" -> tokens.any { it == ">" || it == ">>" } + else -> tokens.firstOrNull()?.startsWith("./") == true + } + } + + private fun List.gitCommandNeedsStateRefresh(): Boolean { + val command = drop(1).firstOrNull { it != "-C" && !it.startsWith("--git-dir=") && !it.startsWith("--work-tree=") } + ?: return false + return when (command) { + "init", + "config", + "add", + "stage", + "rm", + "mv", + "commit", + "commit-tree", + "checkout", + "switch", + "restore", + "branch", + "tag", + "remote", + "fetch", + "pull", + "push", + "merge", + "rebase", + "reset", + "revert", + "stash", + "cherry-pick", + "repack", + "submodule", + "update-ref", + "read-tree", + -> true + else -> false + } + } + + private fun List.isGitConfigCommand(): Boolean { + return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config" + } + private fun nativeGitBinary(): File? { return nativeGitOverride ?.takeIf { it.exists() && it.canExecute() } diff --git a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt index 06435a5..6c6108e 100644 --- a/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt +++ b/app/src/test/java/solutions/tretter/githugandroid/GitSandboxEngineTest.kt @@ -258,6 +258,48 @@ class GitSandboxEngineTest { } } + @Test + fun nativeConfigLevelSolvesAfterSettingNameAndEmail() { + val git = testGitBinary() + assumeTrue(git.exists() && git.canExecute()) + val root = Files.createTempDirectory("githug-config-level").toFile() + try { + val runtime = GitRepositoryRuntime(root, git) + val level = configLevel() + val repo = runtime.prepareLevel(level) + + val (namedRepo, _) = runtime.execute(level, repo, "git config user.name GitHug") + val (configuredRepo, _) = runtime.execute(level, namedRepo, "git config user.email githug@example.com") + + assertEquals("GitHug", configuredRepo.config["user.name"]) + assertEquals("githug@example.com", configuredRepo.config["user.email"]) + assertTrue(level.validator(configuredRepo, "git config user.email githug@example.com")) + } finally { + root.deleteRecursively() + } + } + + @Test + fun nativeReadOnlyHelperCommandsDoNotRefreshAwayTransientState() { + val git = testGitBinary() + assumeTrue(git.exists() && git.canExecute()) + val root = Files.createTempDirectory("githug-read-only-helper").toFile() + try { + val runtime = GitRepositoryRuntime(root, git) + val level = configLevel() + val repo = runtime.prepareLevel(level).copy( + maintenanceActions = setOf("transient-marker"), + ) + + val (listedRepo, output) = runtime.execute(level, repo, "ls") + + assertTrue(".git" in output) + assertEquals(setOf("transient-marker"), listedRepo.maintenanceActions) + } finally { + root.deleteRecursively() + } + } + @Test fun nativeGitExecAliasesRefreshWhenBinaryChanges() { val git = testGitBinary()