Speed up runtime state refresh

This commit is contained in:
Joe Tretter
2026-06-24 17:44:53 -05:00
parent 0554c736e4
commit 1c15297ae6
7 changed files with 180 additions and 22 deletions

View File

@@ -679,6 +679,7 @@ maybe_run_operation() {
local should_compile_android_git="false" local should_compile_android_git="false"
local should_bundle_manpages="false" local should_bundle_manpages="false"
local should_run_emulator="false" local should_run_emulator="false"
local should_clean_gradle="false"
case "$mode" in case "$mode" in
--build) --build)
@@ -706,6 +707,12 @@ maybe_run_operation() {
artifact_label="debug unit tests" artifact_label="debug unit tests"
should_compile_host_git="true" 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) --test-emulator)
gradle_task="connectedDebugAndroidTest" gradle_task="connectedDebugAndroidTest"
artifact_label="debug instrumentation tests on Android emulator" artifact_label="debug instrumentation tests on Android emulator"
@@ -755,7 +762,11 @@ maybe_run_operation() {
fi fi
log "Running $artifact_label with --no-daemon" log "Running $artifact_label with --no-daemon"
if [ "$should_clean_gradle" = "true" ]; then
"$PROJECT_DIR/gradlew" --no-daemon clean "$gradle_task"
else
"$PROJECT_DIR/gradlew" --no-daemon "$gradle_task" "$PROJECT_DIR/gradlew" --no-daemon "$gradle_task"
fi
if [ -n "$artifact_source" ] && [ -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")"
@@ -777,11 +788,12 @@ maybe_run_operation() {
print_usage() { print_usage() {
cat <<EOF_USAGE cat <<EOF_USAGE
Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test | --test-emulator | --compile-git] Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test | --clean-test | --test-emulator | --compile-git]
--build Set up the environment and build the debug APK --build Set up the environment and build the debug APK
--build-release-aab Set up 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, compile host Git, and run the debug JVM unit tests with it --test Set up the environment, compile host Git, and run the debug JVM unit tests with it
--clean-test Set up the environment, clean Gradle outputs, and run the debug JVM unit tests
--test-emulator Set up an Android emulator and run debug instrumentation tests on it --test-emulator Set up an Android emulator and run debug instrumentation tests on it
--compile-git Compile Git for the development host and all Android target ABIs --compile-git Compile Git for the development host and all Android target ABIs
EOF_USAGE EOF_USAGE
@@ -789,7 +801,7 @@ EOF_USAGE
validate_args() { validate_args() {
case "${1:-}" in case "${1:-}" in
""|--build|--build-release-aab|--test|--test-emulator|--compile-git) ""|--build|--build-release-aab|--test|--clean-test|--test-emulator|--compile-git)
;; ;;
*) *)
echo "Unknown argument: $1" >&2 echo "Unknown argument: $1" >&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 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 build a release AAB in one step: bash ./AndroidProjectTooling.sh --build-release-aab"
log "To set up and run unit tests with compiled host Git: bash ./AndroidProjectTooling.sh --test" log "To set up and run 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" log "To set up and run instrumentation tests on an emulator: bash ./AndroidProjectTooling.sh --test-emulator"
} }

View File

@@ -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` | Provision or refresh the local Android/JDK toolchain only. | Toolchain under `./jdk` and `./android-sdk` |
| `bash ./AndroidProjectTooling.sh --test` | Compile host Git, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Test reports under `app/build/reports/` | | `bash ./AndroidProjectTooling.sh --test` | 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 --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<versionCode>.apk` | | `bash ./AndroidProjectTooling.sh --build` | Build the debug APK. | `app/build/outputs/apk/debug/githug-android-debug-v<versionCode>.apk` |
| `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` | | `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` |
@@ -112,6 +113,7 @@ Options:
| Command | Purpose | Output | | Command | Purpose | Output |
| --- | --- | --- | | --- | --- | --- |
| `bash ./AndroidProjectTooling.sh --test` | Ensure the host Git binary is current, then run tests with it. | `build/host-git/libgit.so` and test reports | | `bash ./AndroidProjectTooling.sh --test` | 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 --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 | | `bash ./AndroidProjectTooling.sh --compile-git` | Ensure host Git and Android ABI Git binaries are current. | Host and Android outputs |

View File

@@ -20,8 +20,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 172 versionCode = 173
versionName = "0.1.171" versionName = "0.1.172"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -22,4 +22,21 @@ class GitRepositoryRuntimeInstrumentedTest {
assertTrue(updatedRepo.initialized) assertTrue(updatedRepo.initialized)
assertTrue(level.validator(updatedRepo, "git init")) 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"))
}
} }

View File

@@ -6,16 +6,21 @@ internal class GitRepositoryInspector(
private val nativeGit: () -> File, private val nativeGit: () -> File,
private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult, private val runGit: (File, File, List<String>, Map<String, String>) -> ProcessExecutionResult,
) { ) {
fun inspectConfig(sandbox: File, currentDir: String = "."): Map<String, String> {
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 { fun inspect(sandbox: File, currentDir: String = "."): RepoState {
val git = nativeGit() val git = nativeGit()
val workingDir = File(sandbox, currentDir).canonicalFile val workingDir = File(sandbox, currentDir).canonicalFile
.takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) } .takeIf { it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator) }
?: sandbox ?: sandbox
val repositoryRoot = generateSequence(workingDir) { directory -> val repositoryRoot = repositoryRoot(sandbox, workingDir)
directory.parentFile?.takeIf {
it.path == sandbox.canonicalPath || it.path.startsWith(sandbox.canonicalPath + File.separator)
}
}.firstOrNull { File(it, ".git").exists() }
val inspectionRoot = repositoryRoot ?: sandbox val inspectionRoot = repositoryRoot ?: sandbox
val filesOnDisk = inspectionRoot.walkTopDown() val filesOnDisk = inspectionRoot.walkTopDown()
.filter { it.isFile && !it.relativeTo(inspectionRoot).path.startsWith(".git/") } .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 remoteResult = run(git, inspectionRoot, listOf("remote", "-v"))
val headResult = run(git, inspectionRoot, listOf("branch", "--show-current")) val headResult = run(git, inspectionRoot, listOf("branch", "--show-current"))
val exactTagResult = run(git, inspectionRoot, listOf("describe", "--tags", "--exact-match")) 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 stashResult = run(git, inspectionRoot, listOf("stash", "list", "--format=%gd"))
val submodules = inspectSubmodules(git, inspectionRoot) val submodules = inspectSubmodules(git, inspectionRoot)
val maintenanceActions = buildSet { val maintenanceActions = buildSet {
@@ -68,14 +71,7 @@ internal class GitRepositoryInspector(
?.readLines() ?.readLines()
?.count { it.isNotBlank() } ?.count { it.isNotBlank() }
?: 0 ?: 0
val config = buildMap { val config = readConfig(git, inspectionRoot)
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 commits = if (logResult.exitCode == 0) { val commits = if (logResult.exitCode == 0) {
logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line -> logResult.outputLines.filter { it.isNotBlank() }.mapNotNull { line ->
@@ -185,6 +181,28 @@ internal class GitRepositoryInspector(
environment: Map<String, String> = emptyMap(), environment: Map<String, String> = emptyMap(),
): ProcessExecutionResult = runGit(binary, workingDir, arguments, environment) ): 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<String, String> {
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<String>): RemoteRefs { private fun inspectRemoteRefs(git: File, inspectionRoot: File, remoteLines: List<String>): RemoteRefs {
val remotes = remoteLines.mapNotNull { line -> val remotes = remoteLines.mapNotNull { line ->
val parts = line.trim().split(Regex("\\s+")) val parts = line.trim().split(Regex("\\s+"))

View File

@@ -128,8 +128,8 @@ class GitRepositoryRuntime private constructor(
?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens) ?: helperCommands.execute(sandboxRoot, workingDir, currentRepo, expandedTokens)
} }
val inspectedRepo = inspectSandbox(level, result.first.currentDir).copy(currentDir = result.first.currentDir) val refreshedRepo = refreshRepoAfterCommand(level, currentRepo, result.first, expandedTokens)
return inspectedRepo to result.second return refreshedRepo to result.second
} }
fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> { fun completionCandidates(level: Level, currentRepo: RepoState, directoriesOnly: Boolean): List<String> {
@@ -236,6 +236,72 @@ class GitRepositoryRuntime private constructor(
private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState = private fun inspectSandbox(level: Level, currentDir: String = "."): RepoState =
repositoryInspector.inspect(sandboxDir(level), currentDir) repositoryInspector.inspect(sandboxDir(level), currentDir)
private fun refreshRepoAfterCommand(
level: Level,
previousRepo: RepoState,
commandRepo: RepoState,
tokens: List<String>,
): 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<String>): 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<String>.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<String>.isGitConfigCommand(): Boolean {
return firstOrNull() == "git" && drop(1).firstOrNull { !it.startsWith("-") } == "config"
}
private fun nativeGitBinary(): File? { private fun nativeGitBinary(): File? {
return nativeGitOverride return nativeGitOverride
?.takeIf { it.exists() && it.canExecute() } ?.takeIf { it.exists() && it.canExecute() }

View File

@@ -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 @Test
fun nativeGitExecAliasesRefreshWhenBinaryChanges() { fun nativeGitExecAliasesRefreshWhenBinaryChanges() {
val git = testGitBinary() val git = testGitBinary()